diff --git a/.backportrc.json b/.backportrc.json index e44d3ce1142993..2752768194e0f6 100644 --- a/.backportrc.json +++ b/.backportrc.json @@ -31,5 +31,7 @@ "^v8.0.0$": "master", "^v7.12.0$": "7.x", "^v(\\d+).(\\d+).\\d+$": "$1.$2" - } + }, + "autoMerge": true, + "autoMergeMethod": "squash" } diff --git a/.ci/Jenkinsfile_baseline_capture b/.ci/Jenkinsfile_baseline_capture index 791cacf7abb4c6..6993dc9e087f97 100644 --- a/.ci/Jenkinsfile_baseline_capture +++ b/.ci/Jenkinsfile_baseline_capture @@ -12,7 +12,17 @@ kibanaPipeline(timeoutMinutes: 120) { ]) { parallel([ 'oss-baseline': { - workers.ci(name: 'oss-baseline', size: 'l', ramDisk: true, runErrorReporter: false) { + workers.ci(name: 'oss-baseline', size: 'l', ramDisk: true, runErrorReporter: false, bootstrapped: false) { + // bootstrap ourselves, but with the env needed to upload the ts refs cache + withGcpServiceAccount.fromVaultSecret('secret/kibana-issues/dev/ci-artifacts-key', 'value') { + withEnv([ + 'BUILD_TS_REFS_CACHE_ENABLE=true', + 'BUILD_TS_REFS_CACHE_CAPTURE=true' + ]) { + kibanaPipeline.doSetup() + } + } + kibanaPipeline.functionalTestProcess('oss-baseline', './test/scripts/jenkins_baseline.sh')() } }, diff --git a/.ci/Jenkinsfile_coverage b/.ci/Jenkinsfile_coverage index 1c1d21024ce917..0aa962a58f53cc 100644 --- a/.ci/Jenkinsfile_coverage +++ b/.ci/Jenkinsfile_coverage @@ -3,14 +3,14 @@ library 'kibana-pipeline-library' kibanaLibrary.load() // load from the Jenkins instance -kibanaPipeline(timeoutMinutes: 240) { +kibanaPipeline(timeoutMinutes: 300) { catchErrors { def timestamp = new Date(currentBuild.startTimeInMillis).format("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("UTC")) withEnv([ "TIME_STAMP=${timestamp}", 'CODE_COVERAGE=1', // Enables coverage. Needed for multiple ci scripts, such as remote.ts, test/scripts/*.sh, schema.js, etc. ]) { - workers.base(name: 'coverage-worker', size: 'l', ramDisk: false, bootstrapped: false) { + workers.base(name: 'coverage-worker', size: 'xl', ramDisk: false, bootstrapped: false) { catchError { kibanaPipeline.bash(""" diff --git a/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index 736a71b73d14d4..f3f07d5f355be1 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -19,7 +19,7 @@ currentBuild.description = "ES: ${SNAPSHOT_VERSION}
Kibana: ${params.branch def SNAPSHOT_MANIFEST = "https://storage.googleapis.com/kibana-ci-es-snapshots-daily/${SNAPSHOT_VERSION}/archives/${SNAPSHOT_ID}/manifest.json" -kibanaPipeline(timeoutMinutes: 150) { +kibanaPipeline(timeoutMinutes: 210) { catchErrors { slackNotifications.onFailure( title: "*<${env.BUILD_URL}|[${SNAPSHOT_VERSION}] ES Snapshot Verification Failure>*", diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4b0479eedea988..b45ff51b70da3c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -74,7 +74,13 @@ #CC# /src/plugins/apm_oss/ @elastic/apm-ui #CC# /x-pack/plugins/observability/ @elastic/apm-ui -# Client Side Monitoring (lives in APM directories but owned by Uptime) +# Uptime +/x-pack/plugins/uptime @elastic/uptime +/x-pack/test/functional_with_es_ssl/apps/uptime @elastic/uptime +/x-pack/test/functional/apps/uptime @elastic/uptime +/x-pack/test/api_integration/apis/uptime @elastic/uptime + +# Client Side Monitoring / Uptime (lives in APM directories but owned by Uptime) /x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm @elastic/uptime /x-pack/plugins/apm/e2e/cypress/integration/csm_dashboard.feature @elastic/uptime /x-pack/plugins/apm/public/application/csmApp.tsx @elastic/uptime @@ -106,7 +112,6 @@ /x-pack/plugins/fleet/ @elastic/fleet /x-pack/plugins/observability/ @elastic/observability-ui /x-pack/plugins/monitoring/ @elastic/stack-monitoring-ui -/x-pack/plugins/uptime @elastic/uptime # Machine Learning /x-pack/plugins/ml/ @elastic/ml-ui diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 238a21161b1297..79571d51659d6c 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -18,34 +18,20 @@ jobs: ) runs-on: ubuntu-latest steps: - - name: 'Get backport config' - run: | - curl 'https://raw.githubusercontent.com/elastic/kibana/master/.backportrc.json' > .backportrc.json - - - name: Use Node.js 14.x - uses: actions/setup-node@v1 + - name: Checkout Actions + uses: actions/checkout@v2 with: - node-version: 14.x - - - name: Install backport CLI - run: npm install -g backport@5.6.4 + repository: 'elastic/kibana-github-actions' + ref: main + path: ./actions - - name: Backport PR - run: | - git config --global user.name "kibanamachine" - git config --global user.email "42973632+kibanamachine@users.noreply.github.com" - backport --fork true --username kibanamachine --accessToken "${{ secrets.KIBANAMACHINE_TOKEN }}" --ci --pr "$PR_NUMBER" --labels backport --assignee "$PR_OWNER" | tee 'output.log' - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_OWNER: ${{ github.event.pull_request.user.login }} + - name: Install Actions + run: npm install --production --prefix ./actions - - name: Report backport status - run: | - COMMENT="Backport result - \`\`\` - $(cat output.log) - \`\`\`" - - GITHUB_TOKEN="${{ secrets.KIBANAMACHINE_TOKEN }}" gh api -X POST repos/elastic/kibana/issues/$PR_NUMBER/comments -F body="$COMMENT" - env: - PR_NUMBER: ${{ github.event.pull_request.number }} + - name: Run Backport + uses: ./actions/backport + with: + branch: master + github_token: ${{secrets.KIBANAMACHINE_TOKEN}} + commit_user: kibanamachine + commit_email: 42973632+kibanamachine@users.noreply.github.com diff --git a/.gitignore b/.gitignore index c7d7e37732ca04..fbe28b8f1e77cc 100644 --- a/.gitignore +++ b/.gitignore @@ -61,9 +61,6 @@ npm-debug.log* .ci/bash_standard_lib.sh .gradle -# apm plugin -/x-pack/plugins/apm/tsconfig.json -apm.tsconfig.json ## @cypress/snapshot from apm plugin snapshots.js diff --git a/Jenkinsfile b/Jenkinsfile index 3b68cde206573a..8ab3fecb07a1ba 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,7 +3,7 @@ library 'kibana-pipeline-library' kibanaLibrary.load() -kibanaPipeline(timeoutMinutes: 155, checkPrChanges: true, setCommitStatus: true) { +kibanaPipeline(timeoutMinutes: 210, checkPrChanges: true, setCommitStatus: true) { slackNotifications.onFailure(disabled: !params.NOTIFY_ON_FAILURE) { githubPr.withDefaultPrComments { ciStats.trackBuild { diff --git a/docs/api/actions-and-connectors.asciidoc b/docs/api/actions-and-connectors.asciidoc new file mode 100644 index 00000000000000..17e7ea1b7672a3 --- /dev/null +++ b/docs/api/actions-and-connectors.asciidoc @@ -0,0 +1,30 @@ +[[actions-and-connectors-api]] +== Action and connector APIs + +Manage Actions and Connectors. + +The following action APIs are available: + +* <> to retrieve a single action by ID + +* <> to retrieve all actions + +* <> to retrieve a list of all action types + +* <> to create actions + +* <> to update the attributes for an existing action + +* <> to execute an action by ID + +* <> to delete an action by ID + +For information about the actions and connectors that {kib} supports, refer to <>. + +include::actions-and-connectors/get.asciidoc[] +include::actions-and-connectors/get_all.asciidoc[] +include::actions-and-connectors/list.asciidoc[] +include::actions-and-connectors/create.asciidoc[] +include::actions-and-connectors/update.asciidoc[] +include::actions-and-connectors/execute.asciidoc[] +include::actions-and-connectors/delete.asciidoc[] diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc new file mode 100644 index 00000000000000..af5ddd050e40ec --- /dev/null +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -0,0 +1,68 @@ +[[actions-and-connectors-api-create]] +=== Create action API +++++ +Create action API +++++ + +Creates an action. + +[[actions-and-connectors-api-create-request]] +==== Request + +`POST :/api/actions/action` + +[[actions-and-connectors-api-create-request-body]] +==== Request body + +`name`:: + (Required, string) The display name for the action. + +`actionTypeId`:: + (Required, string) The action type ID for the action. + +`config`:: + (Required, object) The configuration for the action. Configuration properties vary depending on + the action type. For information about the configuration properties, refer to <>. + +`secrets`:: + (Required, object) The secrets configuration for the action. Secrets configuration properties vary + depending on the action type. For information about the secrets configuration properties, refer to <>. + +[[actions-and-connectors-api-create-request-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[actions-and-connectors-api-create-example]] +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/actions/action -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d ' +{ + "name": "my-action", + "actionTypeId": ".index", + "config": { + "index": "test-index" + } +}' +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad", + "actionTypeId": ".index", + "name": "my-action", + "config": { + "index": "test-index", + "refresh": false, + "executionTimeField": null + }, + "isPreconfigured": false +} +-------------------------------------------------- \ No newline at end of file diff --git a/docs/api/actions-and-connectors/delete.asciidoc b/docs/api/actions-and-connectors/delete.asciidoc new file mode 100644 index 00000000000000..e90b9ae44c5bd3 --- /dev/null +++ b/docs/api/actions-and-connectors/delete.asciidoc @@ -0,0 +1,35 @@ +[[actions-and-connectors-api-delete]] +=== Delete action API +++++ +Delete action API +++++ + +Deletes an action by ID. + +WARNING: When you delete an action, _it cannot be recovered_. + +[[actions-and-connectors-api-delete-request]] +==== Request + +`DELETE :/api/actions/action/` + +[[actions-and-connectors-api-delete-path-params]] +==== Path parameters + +`id`:: + (Required, string) The ID of the action. + +[[actions-and-connectors-api-delete-response-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X DELETE api/actions/action/c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad +-------------------------------------------------- +// KIBANA + diff --git a/docs/api/actions-and-connectors/execute.asciidoc b/docs/api/actions-and-connectors/execute.asciidoc new file mode 100644 index 00000000000000..12f1405eb44560 --- /dev/null +++ b/docs/api/actions-and-connectors/execute.asciidoc @@ -0,0 +1,83 @@ +[[actions-and-connectors-api-execute]] +=== Execute action API +++++ +Execute action API +++++ + +Executes an action by ID. + +[[actions-and-connectors-api-execute-request]] +==== Request + +`POST :/api/actions/action//_execute` + +[[actions-and-connectors-api-execute-params]] +==== Path parameters + +`id`:: + (Required, string) The ID of the action. + +[[actions-and-connectors-api-execute-request-body]] +==== Request body + +`params`:: + (Required, object) The parameters of the action. Parameter properties vary depending on + the action type. For information about the parameter properties, refer to <>. + +[[actions-and-connectors-api-execute-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[actions-and-connectors-api-execute-example]] +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X POST api/actions/action/c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad/_execute -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d ' +{ + "params": { + "documents": [ + { + "id": "test_doc_id", + "name": "test_doc_name", + "message": "hello, world" + } + ] + } +}' +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "status": "ok", + "data": { + "took": 197, + "errors": false, + "items": [ + { + "index": { + "_index": "updated-index", + "_id": "iKyijHcBKCsmXNFrQe3T", + "_version": 1, + "result": "created", + "_shards": { + "total": 2, + "successful": 1, + "failed": 0 + }, + "_seq_no": 0, + "_primary_term": 1, + "status": 201 + } + } + ] + }, + "actionId": "c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad" +} +-------------------------------------------------- \ No newline at end of file diff --git a/docs/api/actions-and-connectors/get.asciidoc b/docs/api/actions-and-connectors/get.asciidoc new file mode 100644 index 00000000000000..6be554e65db049 --- /dev/null +++ b/docs/api/actions-and-connectors/get.asciidoc @@ -0,0 +1,50 @@ +[[actions-and-connectors-api-get]] +=== Get action API +++++ +Get action API +++++ + +Retrieves an action by ID. + +[[actions-and-connectors-api-get-request]] +==== Request + +`GET :/api/actions/action/` + +[[actions-and-connectors-api-get-params]] +==== Path parameters + +`id`:: + (Required, string) The ID of the action. + +[[actions-and-connectors-api-get-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[actions-and-connectors-api-get-example]] +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/actions/action/c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad", + "actionTypeId": ".index", + "name": "my-action", + "config": { + "index": "test-index", + "refresh": false, + "executionTimeField": null + }, + "isPreconfigured": false +} +-------------------------------------------------- diff --git a/docs/api/actions-and-connectors/get_all.asciidoc b/docs/api/actions-and-connectors/get_all.asciidoc new file mode 100644 index 00000000000000..9863963c8395e6 --- /dev/null +++ b/docs/api/actions-and-connectors/get_all.asciidoc @@ -0,0 +1,52 @@ +[[actions-and-connectors-api-get-all]] +=== Get all actions API +++++ +Get all actions API +++++ + +Retrieves all actions. + +[[actions-and-connectors-api-get-all-request]] +==== Request + +`GET :/api/actions` + +[[actions-and-connectors-api-get-all-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[actions-and-connectors-api-get-all-example]] +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/actions +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +[ + { + "id": "preconfigured-mail-action", + "actionTypeId": ".email", + "name": "email: preconfigured-mail-action", + "isPreconfigured": true + }, + { + "id": "c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad", + "actionTypeId": ".index", + "name": "my-action", + "config": { + "index": "test-index", + "refresh": false, + "executionTimeField": null + }, + "isPreconfigured": false + } +] +-------------------------------------------------- diff --git a/docs/api/actions-and-connectors/list.asciidoc b/docs/api/actions-and-connectors/list.asciidoc new file mode 100644 index 00000000000000..b800b7ff3b4f2f --- /dev/null +++ b/docs/api/actions-and-connectors/list.asciidoc @@ -0,0 +1,59 @@ +[[actions-and-connectors-api-list]] +=== List action types API +++++ +List all action types API +++++ + +Retrieves a list of all action types. + +[[actions-and-connectors-api-list-request]] +==== Request + +`GET :/api/actions/list_action_types` + +[[actions-and-connectors-api-list-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[actions-and-connectors-api-list-example]] +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X GET api/actions/list_action_types +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +[ + { + "id": ".email", <1> + "name": "Email", <2> + "minimumLicenseRequired": "gold", <3> + "enabled": false, <4> + "enabledInConfig": true, <5> + "enabledInLicense": false <6> + }, + { + "id": ".index", + "name": "Index", + "minimumLicenseRequired": "basic", + "enabled": true, + "enabledInConfig": true, + "enabledInLicense": true + } +] +-------------------------------------------------- + + +<1> `id` - The unique ID of the action type. +<2> `name` - The name of the action type. +<3> `minimumLicenseRequired` - The license required to use the action type. +<4> `enabled` - Specifies if the action type is enabled or disabled in {kib}. +<5> `enabledInConfig` - Specifies if the action type is enabled or enabled in the {kib} .yml file. +<6> `enabledInLicense` - Specifies if the action type is enabled or disabled in the license. diff --git a/docs/api/actions-and-connectors/update.asciidoc b/docs/api/actions-and-connectors/update.asciidoc new file mode 100644 index 00000000000000..e08ec2f8da1b67 --- /dev/null +++ b/docs/api/actions-and-connectors/update.asciidoc @@ -0,0 +1,68 @@ +[[actions-and-connectors-api-update]] +=== Update action API +++++ +Update action API +++++ + +Updates the attributes for an existing action. + +[[actions-and-connectors-api-update-request]] +==== Request + +`PUT :/api/actions/action/` + +[[actions-and-connectors-api-update-params]] +==== Path parameters + +`id`:: + (Required, string) The ID of the action. + +[[actions-and-connectors-api-update-request-body]] +==== Request body + +`name`:: + (Required, string) The new name of the action. + +`config`:: + (Required, object) The new action configuration. Configuration properties vary depending on the action type. For information about the configuration properties, refer to <>. + +`secrets`:: + (Required, object) The updated secrets configuration for the action. Secrets properties vary depending on the action type. For information about the secrets configuration properties, refer to <>. + +[[actions-and-connectors-api-update-codes]] +==== Response code + +`200`:: + Indicates a successful call. + +[[actions-and-connectors-api-update-example]] +==== Example + +[source,sh] +-------------------------------------------------- +$ curl -X PUT api/actions/action/c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d ' +{ + "name": "updated-action", + "config": { + "index": "updated-index" + } +}' +-------------------------------------------------- +// KIBANA + +The API returns the following: + +[source,sh] +-------------------------------------------------- +{ + "id": "c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad", + "actionTypeId": ".index", + "name": "updated-action", + "config": { + "index": "updated-index", + "refresh": false, + "executionTimeField": null + }, + "isPreconfigured": false +} +-------------------------------------------------- diff --git a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc index 92a624649d3c50..6361b3c921128a 100644 --- a/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc +++ b/docs/developer/plugin/migrating-legacy-plugins-examples.asciidoc @@ -800,7 +800,7 @@ However, there are some minor changes: * The `schema.isNamespaceAgnostic` property has been renamed: `SavedObjectsType.namespaceType`. It no longer accepts a boolean but -instead an enum of `single`, `multiple`, or `agnostic` (see +instead an enum of `single`, `multiple`, `multiple-isolated`, or `agnostic` (see {kib-repo}/tree/{branch}/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md[SavedObjectsNamespaceType]). * The `schema.indexPattern` was accepting either a `string` or a `(config: LegacyConfig) => string`. `SavedObjectsType.indexPattern` only diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index 5524cf328fbfe6..ba48011ef84e08 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -168,7 +168,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | | [SavedObjectsClientContract](./kibana-plugin-core-public.savedobjectsclientcontract.md) | SavedObjectsClientContract as implemented by the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) | | [SavedObjectsImportWarning](./kibana-plugin-core-public.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-public.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-public.savedobjectsimportactionrequiredwarning.md) for more details. | -| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | +| [SavedObjectsNamespaceType](./kibana-plugin-core-public.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. | | [StartServicesAccessor](./kibana-plugin-core-public.startservicesaccessor.md) | Allows plugins to get access to APIs available in start inside async handlers, such as [App.mount](./kibana-plugin-core-public.app.mount.md). Promise will not resolve until Core and plugin dependencies have completed start. | | [StringValidation](./kibana-plugin-core-public.stringvalidation.md) | Allows regex objects or a regex string | | [Toast](./kibana-plugin-core-public.toast.md) | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md index f2205d2cee4240..cf5e6cb29a5329 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## SavedObjectsNamespaceType type -The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md b/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md index 04a3cf9aff6448..52ab5f1098457c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md +++ b/docs/development/core/server/kibana-plugin-core-server.loggingservicesetup.configure.md @@ -34,7 +34,7 @@ Customize the configuration for the plugins.data.search context. core.logging.configure( of({ appenders: new Map(), - loggers: [{ context: 'search', appenders: ['default'] }] + loggers: [{ name: 'search', appenders: ['default'] }] }) ) diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 1791335d58fef9..3ec63840a67cba 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -310,7 +310,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | | [SavedObjectsImportHook](./kibana-plugin-core-server.savedobjectsimporthook.md) | A hook associated with a specific saved object type, that will be invoked during the import process. The hook will have access to the objects of the registered type.Currently, the only supported feature for import hooks is to return warnings to be displayed in the UI when the import succeeds. The only interactions the hook can have with the import process is via the hook's response. Mutating the objects inside the hook's code will have no effect. | | [SavedObjectsImportWarning](./kibana-plugin-core-server.savedobjectsimportwarning.md) | Composite type of all the possible types of import warnings.See [SavedObjectsImportSimpleWarning](./kibana-plugin-core-server.savedobjectsimportsimplewarning.md) and [SavedObjectsImportActionRequiredWarning](./kibana-plugin-core-server.savedobjectsimportactionrequiredwarning.md) for more details. | -| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. | +| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. | | [SavedObjectUnsanitizedDoc](./kibana-plugin-core-server.savedobjectunsanitizeddoc.md) | Describes Saved Object documents from Kibana < 7.0.0 which don't have a references root property defined. This type should only be used in migrations. | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md index 3a5e84ffdc3724..268dcdd77d6b47 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.core.md @@ -11,8 +11,9 @@ core: { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md index 5300c85cf94064..54d85910f823c1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.requesthandlercontext.md @@ -18,5 +18,5 @@ export interface RequestHandlerContext | Property | Type | Description | | --- | --- | --- | -| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
exporter: ISavedObjectsExporter;
importer: ISavedObjectsImporter;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | +| [core](./kibana-plugin-core-server.requesthandlercontext.core.md) | {
savedObjects: {
client: SavedObjectsClientContract;
typeRegistry: ISavedObjectTypeRegistry;
getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract;
getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter;
getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter;
};
elasticsearch: {
client: IScopedClusterClient;
legacy: {
client: ILegacyScopedClusterClient;
};
};
uiSettings: {
client: IUiSettingsClient;
};
} | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md new file mode 100644 index 00000000000000..2a30693f4da84a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) + +## SavedObjectMigrationContext.convertToMultiNamespaceTypeVersion property + +The version in which this object type is being converted to a multi-namespace type + +Signature: + +```typescript +convertToMultiNamespaceTypeVersion?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md index 901f2dde0944ce..c8a291e5028453 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.md @@ -16,5 +16,7 @@ export interface SavedObjectMigrationContext | Property | Type | Description | | --- | --- | --- | +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.converttomultinamespacetypeversion.md) | string | The version in which this object type is being converted to a multi-namespace type | | [log](./kibana-plugin-core-server.savedobjectmigrationcontext.log.md) | SavedObjectsMigrationLogger | logger instance to be used by the migration handler | +| [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) | string | The migration version that this migration function is defined for | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md new file mode 100644 index 00000000000000..7b20ae41048f66 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) > [migrationVersion](./kibana-plugin-core-server.savedobjectmigrationcontext.migrationversion.md) + +## SavedObjectMigrationContext.migrationVersion property + +The migration version that this migration function is defined for + +Signature: + +```typescript +migrationVersion: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md index 9075a780bd2c79..01a712aa89aa9a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -4,10 +4,10 @@ ## SavedObjectsNamespaceType type -The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): This type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: This type of saved object is shareable, e.g., it can exist in one or more namespaces. \* multiple-isolated: This type of saved object is namespace-isolated, e.g., it exists in only one namespace, but object IDs must be unique across all namespaces. This is intended to be an intermediate step when objects with a "single" namespace type are being converted to a "multiple" namespace type. In other words, objects with a "multiple-isolated" namespace type will be \*share-capable\*, but will not actually be shareable until the namespace type is changed to "multiple". \* agnostic: This type of saved object is global. Signature: ```typescript -export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md new file mode 100644 index 00000000000000..2e73d6ba2e1a9f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-server.savedobjectsresolveresponse.md) > [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) + +## SavedObjectsResolveResponse.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index cfb309da0a716f..ffcf15dbc80c7c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -15,6 +15,7 @@ export interface SavedObjectsResolveResponse | Property | Type | Description | | --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | | [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | | [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md index 064bd0b35699df..20346919fc652e 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md @@ -4,13 +4,13 @@ ## SavedObjectsType.convertToMultiNamespaceTypeVersion property -If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. +If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version. Requirements: -1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) +1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) -Example of a single-namespace type in 7.10: +Example of a single-namespace type in 7.12: ```ts { @@ -21,7 +21,19 @@ Example of a single-namespace type in 7.10: } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: + +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { @@ -29,11 +41,11 @@ Example after converting to a multi-namespace type in 7.11: hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index eacad53be39fe0..d882938d731c8c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -19,7 +19,7 @@ This is only internal for now, and will only be public when we expose the regist | Property | Type | Description | | --- | --- | --- | | [convertToAliasScript](./kibana-plugin-core-server.savedobjectstype.converttoaliasscript.md) | string | If defined, will be used to convert the type to an alias. | -| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to multi-namespace objects when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.10: +| [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.12: ```ts { name: 'foo', @@ -29,18 +29,29 @@ This is only internal for now, and will only be public when we expose the regist } ``` -Example after converting to a multi-namespace type in 7.11: +Example after converting to a multi-namespace (isolated) type in 8.0: +```ts +{ + name: 'foo', + hidden: false, + namespaceType: 'multiple-isolated', + mappings: {...}, + convertToMultiNamespaceTypeVersion: '8.0.0' +} + +``` +Example after converting to a multi-namespace (shareable) type in 8.1: ```ts { name: 'foo', hidden: false, namespaceType: 'multiple', mappings: {...}, - convertToMultiNamespaceTypeVersion: '7.11.0' + convertToMultiNamespaceTypeVersion: '8.0.0' } ``` -Note: a migration function can be optionally specified for the same version. | +Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. | | [hidden](./kibana-plugin-core-server.savedobjectstype.hidden.md) | boolean | Is the type hidden by default. If true, repositories will not have access to this type unless explicitly declared as an extraType when creating the repository.See [createInternalRepository](./kibana-plugin-core-server.savedobjectsservicestart.createinternalrepository.md). | | [indexPattern](./kibana-plugin-core-server.savedobjectstype.indexpattern.md) | string | If defined, the type instances will be stored in the given index instead of the default one. | | [management](./kibana-plugin-core-server.savedobjectstype.management.md) | SavedObjectsTypeManagementDefinition | An optional [saved objects management section](./kibana-plugin-core-server.savedobjectstypemanagementdefinition.md) definition for the type. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md index 6532c5251d816f..0ff07ae2804ff8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md @@ -4,7 +4,7 @@ ## SavedObjectTypeRegistry.isMultiNamespace() method -Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered +Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to `false` if the type is not registered Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md new file mode 100644 index 00000000000000..ee240268f9d67a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isShareable](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) + +## SavedObjectTypeRegistry.isShareable() method + +Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered + +Signature: + +```typescript +isShareable(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md index 55ad7ca137de0a..0f2de8c8ef9b33 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -23,8 +23,9 @@ export declare class SavedObjectTypeRegistry | [getVisibleTypes()](./kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md) | | Returns all visible [types](./kibana-plugin-core-server.savedobjectstype.md).A visible type is a type that doesn't explicitly define hidden=true during registration. | | [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the hidden property for given type, or false if the type is not registered. | | [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the management.importableAndExportable property for given type, or false if the type is not registered or does not define a management section. | -| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | +| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to false if the type is not registered | | [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns whether the type is namespace-agnostic (global); resolves to false if the type is not registered | +| [isShareable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | | [isSingleNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) | | Returns whether the type is single-namespace (isolated); resolves to true if the type is not registered | | [registerType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.registertype.md) | | Register a [type](./kibana-plugin-core-server.savedobjectstype.md) inside the registry. A type can only be registered once. subsequent calls with the same type name will throw an error. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.md index 5f940bf70a12bd..44cfb0c65e3875 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.md @@ -21,4 +21,5 @@ export interface IDataPluginServices extends Partial | [savedObjects](./kibana-plugin-plugins-data-public.idatapluginservices.savedobjects.md) | CoreStart['savedObjects'] | | | [storage](./kibana-plugin-plugins-data-public.idatapluginservices.storage.md) | IStorageWrapper | | | [uiSettings](./kibana-plugin-plugins-data-public.idatapluginservices.uisettings.md) | CoreStart['uiSettings'] | | +| [usageCollection](./kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md) | UsageCollectionStart | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md new file mode 100644 index 00000000000000..b803dca76203f4 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [IDataPluginServices](./kibana-plugin-plugins-data-public.idatapluginservices.md) > [usageCollection](./kibana-plugin-plugins-data-public.idatapluginservices.usagecollection.md) + +## IDataPluginServices.usageCollection property + +Signature: + +```typescript +usageCollection?: UsageCollectionStart; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md new file mode 100644 index 00000000000000..baf44de5088fbd --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ISearchOptions](./kibana-plugin-plugins-data-public.isearchoptions.md) > [indexPattern](./kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md) + +## ISearchOptions.indexPattern property + +Index pattern reference is used for better error messages + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md index fc2767cd0231f9..2473c9cfdde8df 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.isearchoptions.md @@ -15,6 +15,7 @@ export interface ISearchOptions | Property | Type | Description | | --- | --- | --- | | [abortSignal](./kibana-plugin-plugins-data-public.isearchoptions.abortsignal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | +| [indexPattern](./kibana-plugin-plugins-data-public.isearchoptions.indexpattern.md) | IndexPattern | Index pattern reference is used for better error messages | | [isRestore](./kibana-plugin-plugins-data-public.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-public.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-public.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md index 5f43f8477cb9f1..b8f21de3e086ec 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `PainlessError` class Signature: ```typescript -constructor(err: IEsError); +constructor(err: IEsError, indexPattern?: IndexPattern); ``` ## Parameters @@ -17,4 +17,5 @@ constructor(err: IEsError); | Parameter | Type | Description | | --- | --- | --- | | err | IEsError | | +| indexPattern | IndexPattern | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.indexpattern.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.indexpattern.md new file mode 100644 index 00000000000000..4312f2f8d0c91f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.indexpattern.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [PainlessError](./kibana-plugin-plugins-data-public.painlesserror.md) > [indexPattern](./kibana-plugin-plugins-data-public.painlesserror.indexpattern.md) + +## PainlessError.indexPattern property + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md index c77b8b259136b1..3a887d358e2155 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.painlesserror.md @@ -14,12 +14,13 @@ export declare class PainlessError extends EsError | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(err)](./kibana-plugin-plugins-data-public.painlesserror._constructor_.md) | | Constructs a new instance of the PainlessError class | +| [(constructor)(err, indexPattern)](./kibana-plugin-plugins-data-public.painlesserror._constructor_.md) | | Constructs a new instance of the PainlessError class | ## Properties | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| [indexPattern](./kibana-plugin-plugins-data-public.painlesserror.indexpattern.md) | | IndexPattern | | | [painlessStack](./kibana-plugin-plugins-data-public.painlesserror.painlessstack.md) | | string | | ## Methods diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index 83fbc00860ca5e..786ac4f9d61a90 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md index 5f266e7d8bd8c1..2247813562dc72 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.md @@ -28,6 +28,6 @@ export declare class SearchInterceptor | --- | --- | --- | | [getTimeoutMode()](./kibana-plugin-plugins-data-public.searchinterceptor.gettimeoutmode.md) | | | | [handleSearchError(e, timeoutSignal, options)](./kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md) | | | -| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when cancelPending is called, when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | +| [search(request, options)](./kibana-plugin-plugins-data-public.searchinterceptor.search.md) | | Searches using the given search method. Overrides the AbortSignal with one that will abort either when the request times out, or when the original AbortSignal is aborted. Updates pendingCount$ when the request is started/finalized. | | [showError(e)](./kibana-plugin-plugins-data-public.searchinterceptor.showerror.md) | | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md index 61f8eeb973f4c0..a54b43da4add8b 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.search.md @@ -4,7 +4,7 @@ ## SearchInterceptor.search() method -Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when `cancelPending` is called, when the request times out, or when the original `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. +Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort either when the request times out, or when the original `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. Signature: diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md new file mode 100644 index 00000000000000..cc24363c1bed5e --- /dev/null +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-server](./kibana-plugin-plugins-data-server.md) > [ISearchOptions](./kibana-plugin-plugins-data-server.isearchoptions.md) > [indexPattern](./kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md) + +## ISearchOptions.indexPattern property + +Index pattern reference is used for better error messages + +Signature: + +```typescript +indexPattern?: IndexPattern; +``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md index 9de351b2b90194..7fd4dd5b8e566c 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.isearchoptions.md @@ -15,6 +15,7 @@ export interface ISearchOptions | Property | Type | Description | | --- | --- | --- | | [abortSignal](./kibana-plugin-plugins-data-server.isearchoptions.abortsignal.md) | AbortSignal | An AbortSignal that allows the caller of search to abort a search request. | +| [indexPattern](./kibana-plugin-plugins-data-server.isearchoptions.indexpattern.md) | IndexPattern | Index pattern reference is used for better error messages | | [isRestore](./kibana-plugin-plugins-data-server.isearchoptions.isrestore.md) | boolean | Whether the session is restored (i.e. search requests should re-use the stored search IDs, rather than starting from scratch) | | [isStored](./kibana-plugin-plugins-data-server.isearchoptions.isstored.md) | boolean | Whether the session is already saved (i.e. sent to background) | | [legacyHitsTotal](./kibana-plugin-plugins-data-server.isearchoptions.legacyhitstotal.md) | boolean | Request the legacy format for the total number of hits. If sending rest_total_hits_as_int to something other than true, this should be set to false. | diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md index ea3ba28a52defc..9dc38f96df4be6 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.plugin.start.md @@ -12,7 +12,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }; @@ -31,7 +31,7 @@ start(core: CoreStart): { fieldFormatServiceFactory: (uiSettings: import("../../../core/server").IUiSettingsClient) => Promise; }; indexPatterns: { - indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; + indexPatternsServiceFactory: (savedObjectsClient: Pick, elasticsearchClient: import("../../../core/server").ElasticsearchClient) => Promise; }; search: ISearchStart>; }` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md index 034f9c70e389fe..d5a8ec311df31e 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.embeddablestatetransfer.cleareditorstate.md @@ -9,7 +9,7 @@ Clears the [editor state](./kibana-plugin-plugins-embeddable-public.embeddableed Signature: ```typescript -clearEditorState(appId: string): void; +clearEditorState(appId?: string): void; ``` ## Parameters diff --git a/docs/discover/kuery.asciidoc b/docs/discover/kuery.asciidoc index 8c0012fb6c6bf5..a92fc182f388c3 100644 --- a/docs/discover/kuery.asciidoc +++ b/docs/discover/kuery.asciidoc @@ -1,55 +1,63 @@ [[kuery-query]] === Kibana Query Language -The Kibana Query Language (KQL) makes it easy to find -the fields and syntax for your {es} query. If you have the -https://www.elastic.co/subscriptions[Basic tier] or above, -simply place your cursor in the *Search* field. As you type, you’ll get suggestions for fields, -values, and operators. +The Kibana Query Language (KQL) is a simple syntax for filtering {es} data using +free text search or field-based search. KQL is only used for filtering data, and has +no role in sorting or aggregating the data. + +KQL is able to suggest field names, values, and operators as you type. +The performance of the suggestions is controlled by <>: [role="screenshot"] image::images/kql-autocomplete.png[Autocomplete in Search bar] -If you prefer to use Kibana’s legacy query language, based on the -<>, click *KQL* next to the *Search* field, and then turn off KQL. +KQL has a different set of features than the <>. KQL is able to query +nested fields and <>. KQL does not support regular expressions +or searching with fuzzy terms. To use the legacy Lucene syntax, click *KQL* next to the *Search* field, +and then turn off KQL. [discrete] === Terms query -A terms query matches documents that contain one or more *exact* terms in a field. +A terms query uses *exact search terms*. Spaces separate each search term, and only one term +is required to match the document. Use quotation marks to indicate a *phrase match*. -To match documents where the response field is `200`: +To query using *exact search terms*, enter the field name followed by `:` and +then the values separated by spaces: [source,yaml] ------------------- -response:200 +http.response.status_code:400 401 404 ------------------- -To match documents with the phrase "quick brown fox" in the `message` field. +For text fields, this will match any value regardless of order: [source,yaml] ------------------- -message:"quick brown fox" +http.response.body.content.text:quick brown fox ------------------- -Without the quotes, -the query matches documents regardless of the order in which -they appear. Documents with "quick brown fox" match, -and so does "quick fox brown". +To query for an *exact phrase*, use quotation marks around the values: + +[source,yaml] +------------------- +http.response.body.content.text:"quick brown fox" +------------------- -NOTE: Terms without fields are matched against the default field in your index settings. -If a default field is not -set, terms are matched against all fields. For example, a query -for `response:200` searches for the value 200 -in the response field, but a query for just `200` searches for 200 -across all fields in your index. +Field names are not required by KQL. When a field name is not provided, terms +will be matched by the default fields in your index settings. To search across fields: +[source,yaml] +------------------- +"quick brown fox" +------------------- [discrete] === Boolean queries KQL supports `or`, `and`, and `not`. By default, `and` has a higher precedence than `or`. -To override the default precedence, group operators in parentheses. +To override the default precedence, group operators in parentheses. These operators can +be upper or lower case. To match documents where response is `200`, extension is `php`, or both: @@ -143,7 +151,7 @@ but in some cases you might need to search on dates. Include the date range in q [discrete] === Exist queries -An exist query matches documents that contain a value for a field, in this case, +An exist query matches documents that contain any value for a field, in this case, response: [source,yaml] @@ -151,10 +159,16 @@ response: response:* ------------------- +Existence is defined by {es} and includes all values, including empty text. + [discrete] === Wildcard queries -To match documents where machine.os starts with `win`, such +Wildcards queries can be used to *search by a term prefix* or to *search multiple fields*. +The default settings of {kib} *prevent leading wildcards* for performance reasons, +but this can be allowed with an <>. + +To match documents where `machine.os` starts with `win`, such as "windows 7" and "windows 10": [source,yaml] diff --git a/docs/discover/search.asciidoc b/docs/discover/search.asciidoc index 45f0df5bd773fc..e8faccd50661a8 100644 --- a/docs/discover/search.asciidoc +++ b/docs/discover/search.asciidoc @@ -53,36 +53,55 @@ include::kuery.asciidoc[] [[lucene-query]] === Lucene query syntax -Kibana's legacy query language was based on the Lucene query syntax. For the time being this syntax -is still available under the options menu in the Query Bar and in Advanced Settings. The following -are some tips that can help get you started. +Lucene query syntax is available to {kib} users who opt out of the <>. +Full documentation for this syntax is available as part of {es} +{ref}/query-dsl-query-string-query.html#query-string-syntax[query string syntax]. -* To perform a free text search, simply enter a text string. For example, if +The main reason to use the Lucene query syntax in {kib} is for advanced +Lucene features, such as regular expressions or fuzzy term matching. However, +Lucene syntax is not able to search nested objects or scripted fields. + +To perform a free text search, simply enter a text string. For example, if you're searching web server logs, you could enter `safari` to search all -fields for the term `safari`. +fields: + +[source,yaml] +------------------- +safari +------------------- + +To search for a value in a specific field, prefix the value with the name +of the field: -* To search for a value in a specific field, prefix the value with the name -of the field. For example, you could enter `status:200` to find all of -the entries that contain the value `200` in the `status` field. +[source,yaml] +------------------- +status:200 +------------------- -* To search for a range of values, you can use the bracketed range syntax, +To search for a range of values, use the bracketed range syntax, `[START_VALUE TO END_VALUE]`. For example, to find entries that have 4xx status codes, you could enter `status:[400 TO 499]`. -* To specify more complex search criteria, you can use the Boolean operators -`AND`, `OR`, and `NOT`. For example, to find entries that have 4xx status -codes and have an extension of `php` or `html`, you could enter `status:[400 TO -499] AND (extension:php OR extension:html)`. +[source,yaml] +------------------- +status:[400 TO 499] +------------------- + +For an open range, use a wildcard: -IMPORTANT: When you use the Lucene Query Syntax in the *KQL* search bar, {kib} is unable to search on nested objects and perform aggregations across fields that contain nested objects. -Using `include_in_parent` or `copy_to` as a workaround can cause {kib} to fail. +[source,yaml] +------------------- +status:[400 TO *] +------------------- -For more detailed information about the Lucene query syntax, see the -{ref}/query-dsl-query-string-query.html#query-string-syntax[Query String Query] -docs. +To specify more complex search criteria, use the boolean operators +`AND`, `OR`, and `NOT`. For example, to find entries that have 4xx status +codes and have an extension of `php` or `html`: -NOTE: These examples use the Lucene query syntax. When lucene is selected as your -query language you can also submit queries using the {ref}/query-dsl.html[Elasticsearch Query DSL]. +[source,yaml] +------------------- +status:[400 TO 499] AND (extension:php OR extension:html) +------------------- [[save-open-search]] diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index bf4f7d9d827047..dc0405b22942f6 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -258,8 +258,12 @@ Highlights results in *Discover* and saved searches on dashboards. Highlighting slows requests when working on big documents. [[doctable-legacy]]`doc_table:legacy`:: -Control the way the Discover's table looks and works. Set this property to `true` to revert to the legacy implementation. +Controls the way the document table looks and works. Set this property to `true` to revert to the legacy implementation. +[[discover-searchFieldsFromSource]]`discover:searchFieldsFromSource`:: +Load fields from the original JSON {ref}/mapping-source-field.html[`_source`]. +When disabled, *Discover* loads fields using the {es} search API's +{ref}/search-fields.html#search-fields-param[`fields`] parameter. [float] [[kibana-ml-settings]] diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index afcb7bc21b66b6..7ffb6b66f5a2b4 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -356,26 +356,26 @@ To enable the <>, specify wh [source,yaml] ---------------------------------------- xpack.security.audit.appender: - kind: rolling-file - path: ./audit.log + type: rolling-file + fileName: ./audit.log policy: - kind: time-interval + type: time-interval interval: 24h <1> strategy: - kind: numeric + type: numeric max: 10 <2> layout: - kind: json + type: json ---------------------------------------- <1> Rotates log files every 24 hours. <2> Keeps maximum of 10 log files before deleting older ones. -| `xpack.security.audit.appender.kind` +| `xpack.security.audit.appender.type` | Required. Specifies where audit logs should be written to. Allowed values are `console`, `file`, or `rolling-file`. Refer to <> and <> for appender specific settings. -| `xpack.security.audit.appender.layout.kind` +| `xpack.security.audit.appender.layout.type` | Required. Specifies how audit logs should be formatted. Allowed values are `json` or `pattern`. Refer to <> for layout specific settings. @@ -396,7 +396,7 @@ The `file` appender writes to a file and can be configured using the following s [cols="2*<"] |====== -| `xpack.security.audit.appender.path` +| `xpack.security.audit.appender.fileName` | Required. Full file path the log file should be written to. |====== @@ -408,14 +408,14 @@ The `rolling-file` appender writes to a file and rotates it using a rolling stra [cols="2*<"] |====== -| `xpack.security.audit.appender.path` +| `xpack.security.audit.appender.fileName` | Required. Full file path the log file should be written to. -| `xpack.security.audit.appender.policy.kind` +| `xpack.security.audit.appender.policy.type` | Specifies when a rollover should occur. Allowed values are `size-limit` and `time-interval`. *Default:* `time-interval`. Refer to <> and <> for policy specific settings. -| `xpack.security.audit.appender.strategy.kind` +| `xpack.security.audit.appender.strategy.type` | Specifies how the rollover should occur. Only allowed value is currently `numeric`. *Default:* `numeric` Refer to <> for strategy specific settings. diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index b57152646dda16..52966bf5ac8c93 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -684,3 +684,4 @@ include::secure-settings.asciidoc[] include::{kib-repo-dir}/settings/security-settings.asciidoc[] include::{kib-repo-dir}/settings/spaces-settings.asciidoc[] include::{kib-repo-dir}/settings/telemetry-settings.asciidoc[] +include::{kib-repo-dir}/settings/task-manager-settings.asciidoc[] diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index d7a9373a6e2a99..3562be1e405f6f 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -37,6 +37,7 @@ Password:: password for 'login' type authentication. password: passwordkeystorevalue -- +[[email-connector-config-properties]] `config` defines the action type specific to the configuration and contains the following properties: [cols="2*<"] diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/user/alerting/action-types/index.asciidoc index 2c6da7c7c3026b..2f459edea28f14 100644 --- a/docs/user/alerting/action-types/index.asciidoc +++ b/docs/user/alerting/action-types/index.asciidoc @@ -30,6 +30,7 @@ Execution time field:: This field will be automatically set to the time the ale executionTimeField: somedate -- +[[index-connector-config-properties]] `config` defines the action type specific to the configuration and contains the following properties: [cols="2*<"] diff --git a/docs/user/alerting/action-types/jira.asciidoc b/docs/user/alerting/action-types/jira.asciidoc index 65e5ee4fc4a013..6e47d5618d5982 100644 --- a/docs/user/alerting/action-types/jira.asciidoc +++ b/docs/user/alerting/action-types/jira.asciidoc @@ -33,6 +33,7 @@ API token (or password):: Jira API authentication token (or password) for HTTP apiToken: tokenkeystorevalue -- +[[jira-connector-config-properties]] `config` defines the action type specific to the configuration and contains the following properties: [cols="2*<"] diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index aad192dbddb30f..e1078a55ddd0d5 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -150,6 +150,7 @@ Integration Key:: A 32 character PagerDuty Integration Key for an integration routingKey: testroutingkey -- +[[pagerduty-connector-config-properties]] `config` defines the action type specific to the configuration. `config` contains `apiURL`, a string that corresponds to *API URL*. diff --git a/docs/user/alerting/action-types/resilient.asciidoc b/docs/user/alerting/action-types/resilient.asciidoc index b5ddb76d49b0cd..112246ab91162e 100644 --- a/docs/user/alerting/action-types/resilient.asciidoc +++ b/docs/user/alerting/action-types/resilient.asciidoc @@ -33,6 +33,7 @@ API key secret:: The authentication key secret for HTTP Basic authentication. apiKeySecret: tokenkeystorevalue -- +[[resilient-connector-config-properties]] `config` defines the action type specific to the configuration and contains the following properties: [cols="2*<"] diff --git a/docs/user/alerting/action-types/servicenow.asciidoc b/docs/user/alerting/action-types/servicenow.asciidoc index 0acb92bcdb5ee5..5d8782c14e5815 100644 --- a/docs/user/alerting/action-types/servicenow.asciidoc +++ b/docs/user/alerting/action-types/servicenow.asciidoc @@ -31,6 +31,7 @@ Password:: Password for HTTP Basic authentication. password: passwordkeystorevalue -- +[[servicenow-connector-config-properties]] `config` defines the action type specific to the configuration and contains the following properties: [cols="2*<"] diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc index a1fe7a2521b22a..6a38e5c827ab28 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -26,6 +26,7 @@ Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messa webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz' -- +[[slack-connector-config-properties]] `config` defines the action type specific to the configuration. `config` contains `webhookUrl`, a string that corresponds to *Webhook URL*. diff --git a/docs/user/alerting/action-types/teams.asciidoc b/docs/user/alerting/action-types/teams.asciidoc index 6706dd2e5643fd..e1ce91fc0c1231 100644 --- a/docs/user/alerting/action-types/teams.asciidoc +++ b/docs/user/alerting/action-types/teams.asciidoc @@ -26,6 +26,7 @@ Webhook URL:: The URL of the incoming webhook. See https://docs.microsoft.com/ webhookUrl: 'https://outlook.office.com/webhook/abcd@0123456/IncomingWebhook/abcdefgh/ijklmnopqrstuvwxyz' -- +[[teams-connector-config-properties]] `config` defines the action type specific to the configuration. `config` contains `webhookUrl`, a string that corresponds to *Webhook URL*. diff --git a/docs/user/alerting/action-types/webhook.asciidoc b/docs/user/alerting/action-types/webhook.asciidoc index fff6814325ea45..2d626d53d1c77e 100644 --- a/docs/user/alerting/action-types/webhook.asciidoc +++ b/docs/user/alerting/action-types/webhook.asciidoc @@ -36,6 +36,7 @@ Password:: An optional password. If set, HTTP basic authentication is used. Cur password: passwordkeystorevalue -- +[[webhook-connector-config-properties]] `config` defines the action type specific to the configuration and contains the following properties: [cols="2*<"] diff --git a/docs/user/alerting/alert-types.asciidoc b/docs/user/alerting/alert-types.asciidoc index 016ecc3167298d..0877f067eee217 100644 --- a/docs/user/alerting/alert-types.asciidoc +++ b/docs/user/alerting/alert-types.asciidoc @@ -2,7 +2,7 @@ [[alert-types]] == Standard stack alert types -{kib} supplies alert types in two ways: some are built into {kib} (these are known as stack alerts), while domain-specific alert types are registered by {kib} apps such as <>, <>, and <>. +{kib} supplies alert types in two ways: some are built into {kib} (these are known as stack alerts), while domain-specific alert types are registered by {kib} apps such as <>, <>, <>, and <>. This section covers stack alerts. For domain-specific alert types, refer to the documentation for that app. Users will need `all` access to the *Stack Alerts* feature to be able to create and edit any of the alerts listed below. diff --git a/docs/user/alerting/alerting-getting-started.asciidoc b/docs/user/alerting/alerting-getting-started.asciidoc index 06370c64aedf82..6186fce8a51c49 100644 --- a/docs/user/alerting/alerting-getting-started.asciidoc +++ b/docs/user/alerting/alerting-getting-started.asciidoc @@ -172,6 +172,7 @@ To access alerting in a space, a user must have access to one of the following f * Alerting * <> * <> +* <> * <> * <> * <> @@ -202,4 +203,4 @@ If an alert requires certain privileges to run such as index privileges, keep in For security reasons you may wish to limit the extent to which {kib} can connect to external services. <> allows you to disable certain <> and whitelist the hostnames that {kib} can connect with. --- \ No newline at end of file +-- diff --git a/docs/user/alerting/alerting-production-considerations.asciidoc b/docs/user/alerting/alerting-production-considerations.asciidoc index cc7adc87b150ef..0442b760669cc6 100644 --- a/docs/user/alerting/alerting-production-considerations.asciidoc +++ b/docs/user/alerting/alerting-production-considerations.asciidoc @@ -25,7 +25,7 @@ Because by default tasks are polled at 3 second intervals and only 10 tasks can * Many alerts or actions must be *run at once*. In this case pending tasks will queue in {es}, and be pulled 10 at a time from the queue at 3 second intervals. * *Long running tasks* occupy slots for an extended time, leaving fewer slots for other tasks. -For details on the settings that can influence the performance and throughput of Task Manager, see {task-manager-settings}. +For details on the settings that can influence the performance and throughput of Task Manager, see <>. ============================================== diff --git a/docs/user/api.asciidoc b/docs/user/api.asciidoc index 20f1fc89367f29..2ae83bee1e06c7 100644 --- a/docs/user/api.asciidoc +++ b/docs/user/api.asciidoc @@ -36,6 +36,7 @@ include::{kib-repo-dir}/api/features.asciidoc[] include::{kib-repo-dir}/api/spaces-management.asciidoc[] include::{kib-repo-dir}/api/role-management.asciidoc[] include::{kib-repo-dir}/api/saved-objects.asciidoc[] +include::{kib-repo-dir}/api/actions-and-connectors.asciidoc[] include::{kib-repo-dir}/api/dashboard-api.asciidoc[] include::{kib-repo-dir}/api/logstash-configuration-management.asciidoc[] include::{kib-repo-dir}/api/url-shortening.asciidoc[] diff --git a/docs/user/dashboard/tsvb.asciidoc b/docs/user/dashboard/tsvb.asciidoc index 440b597520032c..052e40d845fd91 100644 --- a/docs/user/dashboard/tsvb.asciidoc +++ b/docs/user/dashboard/tsvb.asciidoc @@ -24,7 +24,9 @@ When you open *TSVB*, click *Panel options*, then verify the following: ==== Visualization options Time series:: - Supports annotations based on timestamped documents in a separate {es} index. + By default, the Y axis shows the full range of data, including zero. To scale the axis from + the minimum to maximum values of the data automatically, go to *Series > Options > Fill* and set *Fill to 0*. + You can add annotations to the x-axis based on timestamped documents in a separate {es} index. All other chart types:: *Panel options > Data timerange mode* controls the timespan used for matching documents. diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index b292c1ae5e03ff..7a092b4686e2d2 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -190,7 +190,7 @@ internal {kib} navigations with carrying over current filters. | Current query string. | -| context.panel.query.lang +| context.panel.query.language | Current query language. | @@ -200,8 +200,8 @@ context.panel.timeRange.to Tip: Use in combination with <> helper to format date. | -| context.panel.timeRange.indexPatternId + -context.panel.timeRange.indexPatternIds +| context.panel.indexPatternId + +context.panel.indexPatternIds |Index pattern ids used by a panel. | @@ -256,7 +256,7 @@ Note: | *Range selection* | event.from + event.to -| `from` and `to` values of selected range. Depending on your data, could be either a date or number. + +| `from` and `to` values of the selected range as numbers. + Tip: Consider using <> helper for date formatting. | diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 88fd870fefa74d..cc384ec041a9da 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -401,7 +401,9 @@ Vega-Lite compilation. [[vega-expression-functions]] ===== (Vega only) Expression functions which can update the time range and dashboard filters -{kib} has extended the Vega expression language with these functions: +{kib} has extended the Vega expression language with these functions. +These functions will trigger new data to be fetched, which by default will reset Vega signals. +To keep signal values set `restoreSignalValuesOnRefresh: true` in the Vega config. ```js /** @@ -444,6 +446,8 @@ kibanaSetTimeFilter(start, end) hideWarnings: true // Vega renderer to use: `svg` or `canvas` (default) renderer: canvas + // Defaults to 'false', restores Vega signal values on refresh + restoreSignalValuesOnRefresh: false } } } diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index fb91f6a6a1c9a2..65a8c4f6eb1878 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -7,20 +7,19 @@ {kib} enables you to give shape to your data and navigate the Elastic Stack. With {kib}, you can: -* *Visualize and analyze your data.* -Search for hidden insights, visualize what you've found in charts, gauges, -maps and more, and combine them in a dashboard. - * *Search, observe, and protect.* From discovering documents to analyzing logs to finding security vulnerabilities, {kib} is your portal for accessing these capabilities and more. +* *Visualize and analyze your data.* +Search for hidden insights, visualize what you've found in charts, gauges, +maps and more, and combine them in a dashboard. + * *Manage, monitor, and secure the Elastic Stack.* Manage your indices and ingest pipelines, monitor the health of your Elastic Stack cluster, and control which users have access to which features. - *{kib} is for administrators, analysts, and business users.* As an admin, your role is to manage the Elastic Stack, from creating your deployment to getting {es} data into {kib}, and then @@ -55,85 +54,66 @@ hamburger icon. To keep the main menu visible at all times, click the *Dock navi image::images/kibana-main-menu.png[Kibana main menu] [float] -[[kibana-navigation-search]] -=== Search {kib} - -Using the Search field in the global header, you can -search for applications and objects, such as -dashboards and visualizations. - -Search suggestions include deep links into applications, -allowing you to directly navigate to the views you need most. - -[role="screenshot"] -image::images/app-navigation-search.png[Example of searching for apps] - -When searching for objects, you can search by type, name, and tag. -Tags are keywords or labels that you assign to {kib} objects, -so you can classify the objects in a way that is meaningful to you. -You can then quickly search for related objects based on shared tags. +[[extend-your-use-case]] +=== Search, observe, and protect -[role="screenshot"] -image::images/tags-search.png[Example of searching for tags] +Being able to search, observe, and protect your data is a requirement for any analyst. +{kib} provides solutions for each of these use cases. -To get the most from the search feature, follow these tips: +* https://www.elastic.co/guide/en/enterprise-search/current/index.html[*Enterprise Search*] enables you to create a search experience for your app, workplace, and website. -* Use the keyboard shortcut—Ctrl+/ on Windows and Linux, Command+/ on MacOS—to focus on the input at any time. +* {observability-guide}/observability-introduction.html[*Elastic Observability*] enables you to monitor and apply analytics in real time +to events happening across all your environments. You can analyze log events, monitor the performance metrics for the host or container +that it ran in, trace the transaction, and check the overall service availability. -* Use the provided syntax keywords. +* Designed for security analysts, {security-guide}/es-overview.html[*Elastic Security*] provides an overview of +the events and alerts from your environment. Elastic Security helps you defend +your organization from threats before damage and loss occur. + -[cols=2*] -|=== -|Search by type -|`type:dashboard` - -Available types: `application`, `canvas-workpad`, `dashboard`, `index-pattern`, `lens`, `maps`, `query`, `search`, `visualization` - -|Search by tag -|`tag:mytagname` + -`tag:"tag name with spaces"` - -|Search by type and name -|`type:dashboard my_dashboard_title` - -|Advanced searches -|`tag:(tagname1 or tagname2) my_dashboard_title` + -`type:lens tag:(tagname1 or tagname2)` + -`type:(dashboard or canvas-workpad) logs` + -|=== +[role="screenshot"] +image::siem/images/detections-ui.png[Detections view in Elastic Security] [float] [[visualize-and-analyze]] -=== Analyze your data +=== Visualize and analyze -Data analysis is the core functionality of {kib}. +Data analysis is a core functionality of {kib}. You can quickly search through large amounts of data, explore fields and values, and then use {kib}’s drag-and-drop interface to rapidly build charts, tables, metrics, and more. [role="screenshot"] -image::images/visualization-journey.png[User visualization journey] +image::images/visualization-journey.png[User data analysis journey] [[get-data-into-kibana]] -. *Add data.* The best way to add {es} data to {kib} is to use one of our guided processes, +[cols=2*] +|=== + +| *1* +| *Add data.* The best way to add {es} data to {kib} is to use one of our guided processes, available from the <>. You can collect data from an app or service, upload a file, or add a sample data set. -. *Explore.* With <>, you can search your data for hidden +| *2* +| *Explore.* With <>, you can search your data for hidden insights and relationships. Ask your questions, and then filter the results to just the data you want. -You can also limit your results to the most recent documents added to {es}. +You can limit your results to the most recent documents added to {es}. -. *Visualize.* {kib} provides many options to create visualizations of your data, from +| *3* +| *Visualize.* {kib} provides many options to create visualizations of your data, from aggregation-based data to time series data. <> is your starting point to create visualizations, and then pulling them together to show your data from multiple perspectives. -. *Present.* With <>, you can display your data on a visually +| *4* +| *Present.* With <>, you can display your data on a visually compelling, pixel-perfect workpad. **Canvas** can give your data the “wow” factor needed to impress your CEO and captivate coworkers with a big-screen display. -. *Share.* Ready to <> your findings with a larger audience? {kib} offers many options—embed +| *5* +| *Share.* Ready to <> your findings with a larger audience? {kib} offers many options—embed a dashboard, share a link, export to PDF, and more. +|=== [float] ==== Plot location data on a map @@ -147,7 +127,7 @@ You can also visualize and track movement over space and through time. [float] ==== Model data behavior -To model the behavior of your data, you'll want to use +To model the behavior of your data, you'll use <>. This app can help you extract insights from your data that you might otherwise miss. You can forecast unusual behavior in your time series data. @@ -164,37 +144,17 @@ can help you uncover website vulnerabilities that hackers are targeting, so you can harden your website. Or, you might provide graph-based personalized recommendations to your e-commerce customers. -[float] -[[extend-your-use-case]] -=== Search, observe, and protect - -Being able to search, observe, and protect your data is a requirement for any analyst. -{kib} provides solutions for each of these use cases. - -* https://www.elastic.co/guide/en/enterprise-search/current/index.html[*Enterprise Search*] enables you to create a search experience for your app, workplace, and website. - -* {observability-guide}/observability-introduction.html[*Elastic Observability*] enables you to monitor and apply analytics in real time -to events happening across all your environments. You can analyze log events, monitor the performance metrics for the host or container -that it ran in, trace the transaction, and check the overall service availability. - -* Designed for security analysts, {security-guide}/es-overview.html[*Elastic Security*] provides an overview of -the events and alerts from your environment. Elastic Security helps you defend -your organization from threats before damage and loss occur. -+ -[role="screenshot"] -image::siem/images/detections-ui.png[] - [float] [[manage-all-things-stack]] === Manage all things Elastic Stack -{kib}'s <> takes you under the hood, -so you can twist the levers and turn the knobs. *Stack Management* provides +{kib}'s <> UIs takes you under the hood, +so you can twist the levers and turn the knobs. You'll find guided processes for administering all things Elastic Stack, including data, indices, clusters, alerts, and security. [role="screenshot"] -image::images/intro-management.png[] +image::images/intro-management.png[Index Management view in Stack Management] [float] ==== Manage your data, indices, and clusters @@ -216,8 +176,8 @@ that exists in almost every use case. For example, you might set an alert to not * System resources, such as memory, CPU and disk space, take a dip. * An unusually high number of service requests, suspicious processes, and login attempts occurs. -An alert is triggered when a specified condition is met. For example, -an alert might trigger when the average or max of one of +An alert triggers when a specified condition is met. For example, +you can trigger an alert when the average or max of one of your metrics exceeds a threshold within a specified time frame. When the alert triggers, you can send a notification to a system that is part of @@ -240,7 +200,7 @@ Think of a space as its own mini {kib} installation—it’s isolated from a so you can tailor it to your specific needs without impacting others. [role="screenshot"] -image::images/select-your-space.png[Space selector screen] +image::images/select-your-space.png[Space selector view] Most of {kib}’s entities are space-aware, including dashboards, visualizations, index patterns, Canvas workpads, Timelion visualizations, graphs, tags, and machine learning jobs. @@ -271,7 +231,7 @@ to specific features on a per-user basis, you must configure <>. [role="screenshot"] -image::images/features-control.png[Features Controls screen] +image::images/features-control.png[Features Controls view] [float] [[intro-kibana-Security]] @@ -289,7 +249,7 @@ Kibana supports several <>, allowing you to login using {es}’s built-in realms, or by your own single sign-on provider. [role="screenshot"] -image::images/login-screen.png[Login screen] +image::images/login-screen.png[Login page] [float] ==== Secure access @@ -320,6 +280,52 @@ record of who did what, when. The {kib} audit log will record this information f which can then be correlated with {es} audit logs to gain more insights into your users’ behavior. For more information, see <>. +[float] +[[kibana-navigation-search]] +=== Quickly find apps and objects + +Using the search field in the global header, you can +search for applications and objects, such as +dashboards and visualizations. Search suggestions include deep links into applications, +allowing you to directly navigate to the views you need most. + +[role="screenshot"] +image::images/app-navigation-search.png[Example of searching for apps] + +When searching for objects, you can search by type, name, and tag. +Tags are keywords or labels that you assign to {kib} objects, +so you can classify the objects in a way that is meaningful to you. +You can then quickly search for related objects based on shared tags. + +[role="screenshot"] +image::images/tags-search.png[Example of searching for tags] + +To get the most from the search feature, follow these tips: + +* Use the keyboard shortcut—Ctrl+/ on Windows and Linux, Command+/ on MacOS—to focus on the input at any time. + +* Use the provided syntax keywords. ++ +[cols=2*] +|=== +|Search by type +|`type:dashboard` + +Available types: `application`, `canvas-workpad`, `dashboard`, `index-pattern`, `lens`, `maps`, `query`, `search`, `visualization` + +|Search by tag +|`tag:mytagname` + +`tag:"tag name with spaces"` + +|Search by type and name +|`type:dashboard my_dashboard_title` + +|Advanced searches +|`tag:(tagname1 or tagname2) my_dashboard_title` + +`type:lens tag:(tagname1 or tagname2)` + +`type:(dashboard or canvas-workpad) logs` + +|=== + [float] [[whats-the-right-app]] === What’s the right app for you? @@ -345,32 +351,6 @@ the <>. |See the full list of {kib} features |The https://www.elastic.co/kibana/features[{kib} features page on elastic.co] -2+| *Analyze and visualize your data* - -|Know what’s in your data -|<> - -|Create charts and other visualizations -|<> - -|Show your data from different perspectives -|<> - -|Work with location data -|<> - -|Create a presentation of your data -|<> - -|Generate models for your data’s behavior -|<> - -|Explore connections in your data -|<> - -|Share your data -|<>, <> - 2+|*Build a search experience* |Create a search experience for your workplace @@ -414,6 +394,32 @@ the <>. |View and manage hosts that are running Endpoint Security |{security-guide}/admin-page-ov.html[Administration] +2+| *Analyze and visualize your data* + +|Know what’s in your data +|<> + +|Create charts and other visualizations +|<> + +|Show your data from different perspectives +|<> + +|Work with location data +|<> + +|Create a presentation of your data +|<> + +|Generate models for your data’s behavior +|<> + +|Explore connections in your data +|<> + +|Share your data +|<>, <> + 2+|*Administer your Kibana instance* |Manage your Elasticsearch data @@ -435,7 +441,7 @@ the <>. [float] [[try-kibana]] -=== Getting help +=== How to get help Using our in-product guidance can help you get up and running, faster. Click the help icon image:images/intro-help-icon.png[Help icon in navigation bar] diff --git a/package.json b/package.json index 54fa710414e67b..e4eb901c6c9164 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "@elastic/datemath": "link:packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@^8.0.0-canary", "@elastic/ems-client": "7.12.0", - "@elastic/eui": "31.4.0", + "@elastic/eui": "31.7.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "^9.0.1-kibana3", "@elastic/node-crypto": "1.2.1", @@ -120,6 +120,7 @@ "@kbn/ace": "link:packages/kbn-ace", "@kbn/analytics": "link:packages/kbn-analytics", "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", + "@kbn/apm-utils": "link:packages/kbn-apm-utils", "@kbn/config": "link:packages/kbn-config", "@kbn/config-schema": "link:packages/kbn-config-schema", "@kbn/i18n": "link:packages/kbn-i18n", @@ -325,7 +326,6 @@ "wellknown": "^0.5.0", "whatwg-fetch": "^3.0.0", "xml2js": "^0.4.22", - "xregexp": "4.2.4", "yauzl": "^2.10.0" }, "devDependencies": { @@ -350,7 +350,7 @@ "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", "@elastic/apm-rum-react": "^1.2.5", - "@elastic/charts": "24.5.1", + "@elastic/charts": "25.0.1", "@elastic/eslint-config-kibana": "link:packages/elastic-eslint-config-kibana", "@elastic/eslint-plugin-eui": "0.0.2", "@elastic/github-checks-reporter": "0.0.20b3", @@ -591,7 +591,7 @@ "babel-plugin-require-context-hook": "^1.0.0", "babel-plugin-styled-components": "^1.10.7", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", - "backport": "^5.6.4", + "backport": "^5.6.6", "base64-js": "^1.3.1", "base64url": "^3.0.1", "broadcast-channel": "^3.0.3", diff --git a/packages/kbn-apm-utils/package.json b/packages/kbn-apm-utils/package.json new file mode 100644 index 00000000000000..d414b94cb39789 --- /dev/null +++ b/packages/kbn-apm-utils/package.json @@ -0,0 +1,13 @@ +{ + "name": "@kbn/apm-utils", + "main": "./target/index.js", + "types": "./target/index.d.ts", + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true, + "scripts": { + "build": "../../node_modules/.bin/tsc", + "kbn:bootstrap": "yarn build", + "kbn:watch": "yarn build --watch" + } +} diff --git a/packages/kbn-apm-utils/src/index.ts b/packages/kbn-apm-utils/src/index.ts new file mode 100644 index 00000000000000..f2f537138dad07 --- /dev/null +++ b/packages/kbn-apm-utils/src/index.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import agent from 'elastic-apm-node'; +import asyncHooks from 'async_hooks'; + +export interface SpanOptions { + name: string; + type?: string; + subtype?: string; + labels?: Record; +} + +export function parseSpanOptions(optionsOrName: SpanOptions | string) { + const options = typeof optionsOrName === 'string' ? { name: optionsOrName } : optionsOrName; + + return options; +} + +const runInNewContext = any>(cb: T): ReturnType => { + const resource = new asyncHooks.AsyncResource('fake_async'); + + return resource.runInAsyncScope(cb); +}; + +export async function withSpan( + optionsOrName: SpanOptions | string, + cb: () => Promise +): Promise { + const options = parseSpanOptions(optionsOrName); + + const { name, type, subtype, labels } = options; + + if (!agent.isStarted()) { + return cb(); + } + + // When a span starts, it's marked as the active span in its context. + // When it ends, it's not untracked, which means that if a span + // starts directly after this one ends, the newly started span is a + // child of this span, even though it should be a sibling. + // To mitigate this, we queue a microtask by awaiting a promise. + await Promise.resolve(); + + const span = agent.startSpan(name); + + if (!span) { + return cb(); + } + + // If a span is created in the same context as the span that we just + // started, it will be a sibling, not a child. E.g., the Elasticsearch span + // that is created when calling search() happens in the same context. To + // mitigate this we create a new context. + + return runInNewContext(() => { + // @ts-ignore + if (type) { + span.type = type; + } + if (subtype) { + span.subtype = subtype; + } + + if (labels) { + span.addLabels(labels); + } + + return cb() + .then((res) => { + span.outcome = 'success'; + return res; + }) + .catch((err) => { + span.outcome = 'failure'; + throw err; + }) + .finally(() => { + span.end(); + }); + }); +} diff --git a/packages/kbn-apm-utils/tsconfig.json b/packages/kbn-apm-utils/tsconfig.json new file mode 100644 index 00000000000000..e1f79b5ef394da --- /dev/null +++ b/packages/kbn-apm-utils/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "outDir": "./target", + "stripInternal": false, + "declarationMap": true, + "types": [ + "node" + ] + }, + "include": [ + "./src/**/*.ts" + ], + "exclude": [ + "target" + ] +} diff --git a/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap b/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap index 6351a227ff90b8..2801e0a0688cc6 100644 --- a/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap +++ b/packages/kbn-config/src/legacy/__snapshots__/legacy_object_to_config_adapter.test.ts.snap @@ -68,10 +68,10 @@ exports[`#get correctly handles silent logging config. 1`] = ` Object { "appenders": Object { "default": Object { - "kind": "legacy-appender", "legacyLoggingConfig": Object { "silent": true, }, + "type": "legacy-appender", }, }, "loggers": undefined, @@ -85,12 +85,12 @@ exports[`#get correctly handles verbose file logging config with json format. 1` Object { "appenders": Object { "default": Object { - "kind": "legacy-appender", "legacyLoggingConfig": Object { "dest": "/some/path.log", "json": true, "verbose": true, }, + "type": "legacy-appender", }, }, "loggers": undefined, diff --git a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts index 4d877a26b76418..8ec26ff1f8e71c 100644 --- a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts +++ b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts @@ -44,7 +44,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { const loggingConfig = { appenders: { ...appenders, - default: { kind: 'legacy-appender', legacyLoggingConfig }, + default: { type: 'legacy-appender', legacyLoggingConfig }, }, root: { level: 'info', ...root }, loggers, diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 5a33a917f8bc31..fa339500b85fa5 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -48006,6 +48006,8 @@ __webpack_require__.r(__webpack_exports__); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getBazelRepositoryCacheFolder", function() { return _get_cache_folders__WEBPACK_IMPORTED_MODULE_0__["getBazelRepositoryCacheFolder"]; }); /* harmony import */ var _install_tools__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(373); +/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "isBazelBinAvailable", function() { return _install_tools__WEBPACK_IMPORTED_MODULE_1__["isBazelBinAvailable"]; }); + /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return _install_tools__WEBPACK_IMPORTED_MODULE_1__["installBazelTools"]; }); /* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(374); @@ -48065,6 +48067,7 @@ async function getBazelRepositoryCacheFolder() { "use strict"; __webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "isBazelBinAvailable", function() { return isBazelBinAvailable; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return installBazelTools; }); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2); /* harmony import */ var dedent__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(dedent__WEBPACK_IMPORTED_MODULE_0__); @@ -54435,8 +54438,10 @@ const CleanCommand = { } // Runs Bazel soft clean - await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['clean']); - _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Soft cleaned bazel'); + if (await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["isBazelBinAvailable"])()) { + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['clean']); + _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Soft cleaned bazel'); + } if (toDelete.length === 0) { _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Nothing to delete'); @@ -59125,16 +59130,19 @@ const ResetCommand = { pattern: extraPatterns }); } - } // Runs Bazel hard clean + } // Runs Bazel hard clean and deletes Bazel Cache Folders - await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['clean', '--expunge']); - _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Hard cleaned bazel'); // Deletes Bazel Cache Folders + if (await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["isBazelBinAvailable"])()) { + // Hard cleaning bazel + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["runBazel"])(['clean', '--expunge']); + _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Hard cleaned bazel'); // Deletes Bazel Cache Folders - await del__WEBPACK_IMPORTED_MODULE_1___default()([await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["getBazelDiskCacheFolder"])(), await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["getBazelRepositoryCacheFolder"])()], { - force: true - }); - _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Removed disk caches'); + await del__WEBPACK_IMPORTED_MODULE_1___default()([await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["getBazelDiskCacheFolder"])(), await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_4__["getBazelRepositoryCacheFolder"])()], { + force: true + }); + _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].success('Removed disk caches'); + } if (toDelete.length === 0) { return; diff --git a/packages/kbn-pm/src/commands/clean.ts b/packages/kbn-pm/src/commands/clean.ts index 23a2c382fbccc7..a742d6d4e68dfe 100644 --- a/packages/kbn-pm/src/commands/clean.ts +++ b/packages/kbn-pm/src/commands/clean.ts @@ -11,7 +11,7 @@ import del from 'del'; import ora from 'ora'; import { join, relative } from 'path'; -import { runBazel } from '../utils/bazel'; +import { isBazelBinAvailable, runBazel } from '../utils/bazel'; import { isDirectory } from '../utils/fs'; import { log } from '../utils/log'; import { ICommand } from './'; @@ -53,8 +53,10 @@ export const CleanCommand: ICommand = { } // Runs Bazel soft clean - await runBazel(['clean']); - log.success('Soft cleaned bazel'); + if (await isBazelBinAvailable()) { + await runBazel(['clean']); + log.success('Soft cleaned bazel'); + } if (toDelete.length === 0) { log.success('Nothing to delete'); diff --git a/packages/kbn-pm/src/commands/reset.ts b/packages/kbn-pm/src/commands/reset.ts index 71fe7a8c7a6e3c..9eae12a15b164e 100644 --- a/packages/kbn-pm/src/commands/reset.ts +++ b/packages/kbn-pm/src/commands/reset.ts @@ -11,7 +11,12 @@ import del from 'del'; import ora from 'ora'; import { join, relative } from 'path'; -import { getBazelDiskCacheFolder, getBazelRepositoryCacheFolder, runBazel } from '../utils/bazel'; +import { + getBazelDiskCacheFolder, + getBazelRepositoryCacheFolder, + isBazelBinAvailable, + runBazel, +} from '../utils/bazel'; import { isDirectory } from '../utils/fs'; import { log } from '../utils/log'; import { ICommand } from './'; @@ -52,15 +57,18 @@ export const ResetCommand: ICommand = { } } - // Runs Bazel hard clean - await runBazel(['clean', '--expunge']); - log.success('Hard cleaned bazel'); + // Runs Bazel hard clean and deletes Bazel Cache Folders + if (await isBazelBinAvailable()) { + // Hard cleaning bazel + await runBazel(['clean', '--expunge']); + log.success('Hard cleaned bazel'); - // Deletes Bazel Cache Folders - await del([await getBazelDiskCacheFolder(), await getBazelRepositoryCacheFolder()], { - force: true, - }); - log.success('Removed disk caches'); + // Deletes Bazel Cache Folders + await del([await getBazelDiskCacheFolder(), await getBazelRepositoryCacheFolder()], { + force: true, + }); + log.success('Removed disk caches'); + } if (toDelete.length === 0) { return; diff --git a/packages/kbn-pm/src/utils/bazel/install_tools.ts b/packages/kbn-pm/src/utils/bazel/install_tools.ts index 93acbe09b4eab6..8f634726b5ab25 100644 --- a/packages/kbn-pm/src/utils/bazel/install_tools.ts +++ b/packages/kbn-pm/src/utils/bazel/install_tools.ts @@ -26,7 +26,7 @@ async function readBazelToolsVersionFile(repoRootPath: string, versionFilename: return version; } -async function isBazelBinAvailable() { +export async function isBazelBinAvailable() { try { await spawn('bazel', ['--version'], { stdio: 'pipe' }); diff --git a/packages/kbn-ui-shared-deps/scripts/build.js b/packages/kbn-ui-shared-deps/scripts/build.js index 9e1e755b3077a3..0993f785902464 100644 --- a/packages/kbn-ui-shared-deps/scripts/build.js +++ b/packages/kbn-ui-shared-deps/scripts/build.js @@ -7,9 +7,8 @@ */ const Path = require('path'); -const Fs = require('fs'); -const { run, createFailError, CiStatsReporter } = require('@kbn/dev-utils'); +const { run, createFailError } = require('@kbn/dev-utils'); const webpack = require('webpack'); const Stats = require('webpack/lib/Stats'); const del = require('del'); @@ -34,34 +33,6 @@ run( const took = Math.round((stats.endTime - stats.startTime) / 1000); if (!stats.hasErrors() && !stats.hasWarnings()) { - if (!flags.dev) { - const reporter = CiStatsReporter.fromEnv(log); - - const metrics = [ - { - group: '@kbn/ui-shared-deps asset size', - id: 'kbn-ui-shared-deps.js', - value: Fs.statSync(Path.resolve(DIST_DIR, 'kbn-ui-shared-deps.js')).size, - }, - { - group: '@kbn/ui-shared-deps asset size', - id: 'kbn-ui-shared-deps.@elastic.js', - value: Fs.statSync(Path.resolve(DIST_DIR, 'kbn-ui-shared-deps.@elastic.js')).size, - }, - { - group: '@kbn/ui-shared-deps asset size', - id: 'css', - value: - Fs.statSync(Path.resolve(DIST_DIR, 'kbn-ui-shared-deps.css')).size + - Fs.statSync(Path.resolve(DIST_DIR, 'kbn-ui-shared-deps.v7.light.css')).size, - }, - ]; - - log.debug('metrics:', metrics); - - await reporter.metrics(metrics); - } - log.success(`webpack completed in about ${took} seconds`); return; } @@ -101,6 +72,7 @@ run( return; } + log.info('running webpack'); await onCompilationComplete( await new Promise((resolve, reject) => { compiler.run((error, stats) => { diff --git a/packages/kbn-ui-shared-deps/webpack.config.js b/packages/kbn-ui-shared-deps/webpack.config.js index 7ff5978e1f2ea2..cc761dae3bfe96 100644 --- a/packages/kbn-ui-shared-deps/webpack.config.js +++ b/packages/kbn-ui-shared-deps/webpack.config.js @@ -12,6 +12,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const CompressionPlugin = require('compression-webpack-plugin'); const { REPO_ROOT } = require('@kbn/utils'); const webpack = require('webpack'); +const { RawSource } = require('webpack-sources'); const UiSharedDeps = require('./index'); @@ -145,6 +146,36 @@ exports.getWebpackConfig = ({ dev = false } = {}) => ({ test: /\.(js|css)$/, cache: false, }), + new (class MetricsPlugin { + apply(compiler) { + compiler.hooks.emit.tap('MetricsPlugin', (compilation) => { + const metrics = [ + { + group: '@kbn/ui-shared-deps asset size', + id: 'kbn-ui-shared-deps.js', + value: compilation.assets['kbn-ui-shared-deps.js'].size(), + }, + { + group: '@kbn/ui-shared-deps asset size', + id: 'kbn-ui-shared-deps.@elastic.js', + value: compilation.assets['kbn-ui-shared-deps.@elastic.js'].size(), + }, + { + group: '@kbn/ui-shared-deps asset size', + id: 'css', + value: + compilation.assets['kbn-ui-shared-deps.css'].size() + + compilation.assets['kbn-ui-shared-deps.v7.light.css'].size(), + }, + ]; + + compilation.emitAsset( + 'metrics.json', + new RawSource(JSON.stringify(metrics, null, 2)) + ); + }); + } + })(), ]), ], }); diff --git a/packages/kbn-utils/src/package_json/index.test.ts b/packages/kbn-utils/src/package_json/index.test.ts index 49aace6b4ff93e..f6d7e1f2f611b6 100644 --- a/packages/kbn-utils/src/package_json/index.test.ts +++ b/packages/kbn-utils/src/package_json/index.test.ts @@ -7,14 +7,14 @@ */ import path from 'path'; -import { kibanaPackageJSON } from './'; +import { kibanaPackageJson } from './'; it('parses package.json', () => { - expect(kibanaPackageJSON.name).toEqual('kibana'); + expect(kibanaPackageJson.name).toEqual('kibana'); }); it('includes __dirname and __filename', () => { const root = path.resolve(__dirname, '../../../../'); - expect(kibanaPackageJSON.__filename).toEqual(path.resolve(root, 'package.json')); - expect(kibanaPackageJSON.__dirname).toEqual(root); + expect(kibanaPackageJson.__filename).toEqual(path.resolve(root, 'package.json')); + expect(kibanaPackageJson.__dirname).toEqual(root); }); diff --git a/packages/kbn-utils/src/package_json/index.ts b/packages/kbn-utils/src/package_json/index.ts index 0368d883896e92..40ce353780749a 100644 --- a/packages/kbn-utils/src/package_json/index.ts +++ b/packages/kbn-utils/src/package_json/index.ts @@ -9,7 +9,7 @@ import { dirname, resolve } from 'path'; import { REPO_ROOT } from '../repo_root'; -export const kibanaPackageJSON = { +export const kibanaPackageJson = { __filename: resolve(REPO_ROOT, 'package.json'), __dirname: dirname(resolve(REPO_ROOT, 'package.json')), ...require(resolve(REPO_ROOT, 'package.json')), diff --git a/renovate.json5 b/renovate.json5 index f1e773427a1034..52d7a06c88339a 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -11,24 +11,10 @@ ], baseBranches: [ 'master', + '7.x', ], - labels: [ - 'release_note:skip', - 'Team:Operations', - 'renovate', - 'v8.0.0', - 'v7.11.0', - ], - major: { - labels: [ - 'release_note:skip', - 'Team:Operations', - 'renovate', - 'v8.0.0', - 'v7.11.0', - 'renovate:major', - ], - }, + prConcurrentLimit: 0, + prHourlyLimit: 0, separateMajorMinor: false, masterIssue: true, rangeStrategy: 'bump', @@ -51,12 +37,31 @@ groupName: '@elastic/charts', packageNames: ['@elastic/charts'], reviewers: ['markov00'], + matchBaseBranches: ['master'], + labels: ['release_note:skip', 'v8.0.0', 'v7.12.0'], + enabled: true, + }, + { + groupName: '@elastic/elasticsearch', + packageNames: ['@elastic/elasticsearch'], + reviewers: ['team:kibana-operations'], + matchBaseBranches: ['master'], + labels: ['release_note:skip', 'v8.0.0', 'Team:Operations', 'backport:skip'], + enabled: true, + }, + { + groupName: '@elastic/elasticsearch', + packageNames: ['@elastic/elasticsearch'], + reviewers: ['team:kibana-operations'], + matchBaseBranches: ['7.x'], + labels: ['release_note:skip', 'v7.12.0', 'Team:Operations', 'backport:skip'], enabled: true, }, { groupName: 'vega related modules', packageNames: ['vega', 'vega-lite', 'vega-schema-url-parser', 'vega-tooltip'], reviewers: ['team:kibana-app'], + matchBaseBranches: ['master'], labels: ['Feature:Vega', 'Team:KibanaApp'], enabled: true, }, diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 80e23a32ca5570..575a247ffeccb5 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -715,8 +715,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
Flyout content
"`; +exports[`FlyoutService openFlyout() renders a flyout to the DOM 2`] = `"
Flyout content
"`; exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 1`] = ` Array [ @@ -59,4 +59,4 @@ Array [ ] `; -exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; +exports[`FlyoutService openFlyout() with a currently active flyout replaces the current flyout with a new one 2`] = `"
Flyout content 2
"`; diff --git a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap index 7e79725c20307b..d52cc090d5d195 100644 --- a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap +++ b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap @@ -11,21 +11,19 @@ Array [ exports[`ModalService openConfirm() renders a mountpoint confirm message 1`] = ` Array [ Array [ - - - - - - - , + + + + + ,
, ], ] @@ -36,18 +34,16 @@ exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = ` exports[`ModalService openConfirm() renders a string confirm message 1`] = ` Array [ Array [ - - - - Some message - - - , + + + Some message + + ,
, ], ] @@ -58,33 +54,29 @@ exports[`ModalService openConfirm() renders a string confirm message 2`] = `" - - - confirm 1 - - - , + + + confirm 1 + + ,
, ], Array [ - - - - some confirm - - - , + + + some confirm + + ,
, ], ] @@ -93,33 +85,29 @@ Array [ exports[`ModalService openConfirm() with a currently active modal replaces the current modal with the new confirm 1`] = ` Array [ Array [ - - - - - - - , + + + + + ,
, ], Array [ - - - - some confirm - - - , + + + some confirm + + ,
, ], ] @@ -128,18 +116,16 @@ Array [ exports[`ModalService openModal() renders a modal to the DOM 1`] = ` Array [ Array [ - - - - - - - , + + + + + ,
, ], ] @@ -150,33 +136,29 @@ exports[`ModalService openModal() renders a modal to the DOM 2`] = `"
- - - confirm 1 - - - , + + + confirm 1 + + ,
, ], Array [ - - - - some confirm - - - , + + + some confirm + + ,
, ], ] @@ -185,33 +167,29 @@ Array [ exports[`ModalService openModal() with a currently active modal replaces the current modal with a new one 1`] = ` Array [ Array [ - - - - - - - , + + + + + ,
, ], Array [ - - - - - - - , + + + + + ,
, ], ] diff --git a/src/core/public/overlays/modal/modal_service.tsx b/src/core/public/overlays/modal/modal_service.tsx index 1f96e00fef0f89..7e4aee94c958ec 100644 --- a/src/core/public/overlays/modal/modal_service.tsx +++ b/src/core/public/overlays/modal/modal_service.tsx @@ -9,7 +9,7 @@ /* eslint-disable max-classes-per-file */ import { i18n as t } from '@kbn/i18n'; -import { EuiModal, EuiConfirmModal, EuiOverlayMask, EuiConfirmModalProps } from '@elastic/eui'; +import { EuiModal, EuiConfirmModal, EuiConfirmModalProps } from '@elastic/eui'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Subject } from 'rxjs'; @@ -137,13 +137,11 @@ export class ModalService { this.activeModal = modal; render( - - - modal.close()}> - - - - , + + modal.close()}> + + + , targetDomElement ); @@ -199,11 +197,9 @@ export class ModalService { }; render( - - - - - , + + + , targetDomElement ); }); diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 8ee530f5a04e87..2e23b26f636c8f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1385,7 +1385,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public (undocumented) export interface SavedObjectsStart { diff --git a/src/core/server/core_route_handler_context.ts b/src/core/server/core_route_handler_context.ts index f3e06ad8f1daa2..f5123a91e71003 100644 --- a/src/core/server/core_route_handler_context.ts +++ b/src/core/server/core_route_handler_context.ts @@ -13,8 +13,7 @@ import { SavedObjectsClientContract } from './saved_objects/types'; import { InternalSavedObjectsServiceStart, ISavedObjectTypeRegistry, - ISavedObjectsExporter, - ISavedObjectsImporter, + SavedObjectsClientProviderOptions, } from './saved_objects'; import { InternalElasticsearchServiceStart, @@ -58,8 +57,6 @@ class CoreSavedObjectsRouteHandlerContext { ) {} #scopedSavedObjectsClient?: SavedObjectsClientContract; #typeRegistry?: ISavedObjectTypeRegistry; - #exporter?: ISavedObjectsExporter; - #importer?: ISavedObjectsImporter; public get client() { if (this.#scopedSavedObjectsClient == null) { @@ -75,19 +72,18 @@ class CoreSavedObjectsRouteHandlerContext { return this.#typeRegistry; } - public get exporter() { - if (this.#exporter == null) { - this.#exporter = this.savedObjectsStart.createExporter(this.client); - } - return this.#exporter; - } + public getClient = (options?: SavedObjectsClientProviderOptions) => { + if (!options) return this.client; + return this.savedObjectsStart.getScopedClient(this.request, options); + }; - public get importer() { - if (this.#importer == null) { - this.#importer = this.savedObjectsStart.createImporter(this.client); - } - return this.#importer; - } + public getExporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createExporter(client); + }; + + public getImporter = (client: SavedObjectsClientContract) => { + return this.savedObjectsStart.createImporter(client); + }; } class CoreUiSettingsRouteHandlerContext { diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index bd5f23b1c09bc7..e57d8d90a02dcc 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -226,7 +226,7 @@ export class CoreUsageDataService implements CoreService acc.add(a.kind), new Set()) + .reduce((acc, a) => acc.add(a.type), new Set()) .values() ), loggersConfiguredCount: this.loggingConfig?.loggers.length ?? 0, diff --git a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts index d8b9b79bc381db..6239ad270d5b57 100644 --- a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts +++ b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts @@ -99,7 +99,6 @@ test('parses fully specified config', () => { "apiVersion": "v7.0.0", "hosts": Array [ Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -111,7 +110,6 @@ test('parses fully specified config', () => { "query": null, }, Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -123,7 +121,6 @@ test('parses fully specified config', () => { "query": null, }, Object { - "auth": "elastic:changeme", "headers": Object { "x-elastic-product-origin": "kibana", "xsrf": "something", @@ -135,6 +132,7 @@ test('parses fully specified config', () => { "query": null, }, ], + "httpAuth": "elastic:changeme", "keepAlive": true, "log": [Function], "pingTimeout": 12345, diff --git a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts index 728fda04a8b5ed..d68e7635c57cb4 100644 --- a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts +++ b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts @@ -106,11 +106,14 @@ export function parseElasticsearchClientConfig( esClientConfig.sniffInterval = getDurationAsMs(config.sniffInterval); } + const needsAuth = auth !== false && config.username && config.password; + if (needsAuth) { + esClientConfig.httpAuth = `${config.username}:${config.password}`; + } + if (Array.isArray(config.hosts)) { - const needsAuth = auth !== false && config.username && config.password; esClientConfig.hosts = config.hosts.map((nodeUrl: string) => { const uri = url.parse(nodeUrl); - const httpsURI = uri.protocol === 'https:'; const httpURI = uri.protocol === 'http:'; @@ -126,10 +129,6 @@ export function parseElasticsearchClientConfig( }, }; - if (needsAuth) { - host.auth = `${config.username}:${config.password}`; - } - return host; }); } diff --git a/src/core/server/http/integration_tests/logging.test.ts b/src/core/server/http/integration_tests/logging.test.ts index ba265c1ff61bc2..fcf2cd2ba3372d 100644 --- a/src/core/server/http/integration_tests/logging.test.ts +++ b/src/core/server/http/integration_tests/logging.test.ts @@ -50,16 +50,16 @@ describe('request logging', () => { silent: true, appenders: { 'test-console': { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', pattern: '%level|%logger|%message|%meta', }, }, }, loggers: [ { - context: 'http.server.response', + name: 'http.server.response', appenders: ['test-console'], level: 'debug', }, @@ -96,16 +96,16 @@ describe('request logging', () => { silent: true, appenders: { 'test-console': { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', pattern: '%level|%logger|%message|%meta', }, }, }, loggers: [ { - context: 'http.server.response', + name: 'http.server.response', appenders: ['test-console'], level: 'debug', }, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index dac2d210eb395f..8e4cdc7d59e322 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -49,6 +49,7 @@ import { SavedObjectsServiceStart, ISavedObjectsExporter, ISavedObjectsImporter, + SavedObjectsClientProviderOptions, } from './saved_objects'; import { CapabilitiesSetup, CapabilitiesStart } from './capabilities'; import { MetricsServiceSetup, MetricsServiceStart } from './metrics'; @@ -415,8 +416,9 @@ export interface RequestHandlerContext { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; diff --git a/src/core/server/legacy/integration_tests/logging.test.ts b/src/core/server/legacy/integration_tests/logging.test.ts index 321eb81708f1e7..88c45962ce4a68 100644 --- a/src/core/server/legacy/integration_tests/logging.test.ts +++ b/src/core/server/legacy/integration_tests/logging.test.ts @@ -29,16 +29,16 @@ function createRoot(legacyLoggingConfig: LegacyLoggingConfig = {}) { // platform config appenders: { 'test-console': { - kind: 'console', + type: 'console', layout: { highlight: false, - kind: 'pattern', + type: 'pattern', }, }, }, loggers: [ { - context: 'test-file', + name: 'test-file', appenders: ['test-console'], level: 'info', }, diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.test.ts b/src/core/server/legacy/logging/appenders/legacy_appender.test.ts index 1b76b6748a5bb6..9213403d72d07a 100644 --- a/src/core/server/legacy/logging/appenders/legacy_appender.test.ts +++ b/src/core/server/legacy/logging/appenders/legacy_appender.test.ts @@ -16,13 +16,13 @@ afterEach(() => (LegacyLoggingServer as any).mockClear()); test('`configSchema` creates correct schema.', () => { const appenderSchema = LegacyAppender.configSchema; - const validConfig = { kind: 'legacy-appender', legacyLoggingConfig: { verbose: true } }; + const validConfig = { type: 'legacy-appender', legacyLoggingConfig: { verbose: true } }; expect(appenderSchema.validate(validConfig)).toEqual({ - kind: 'legacy-appender', + type: 'legacy-appender', legacyLoggingConfig: { verbose: true }, }); - const wrongConfig = { kind: 'not-legacy-appender' }; + const wrongConfig = { type: 'not-legacy-appender' }; expect(() => appenderSchema.validate(wrongConfig)).toThrow(); }); diff --git a/src/core/server/legacy/logging/appenders/legacy_appender.ts b/src/core/server/legacy/logging/appenders/legacy_appender.ts index 83e43999eeebf1..a89441a5671b55 100644 --- a/src/core/server/legacy/logging/appenders/legacy_appender.ts +++ b/src/core/server/legacy/logging/appenders/legacy_appender.ts @@ -12,7 +12,7 @@ import { DisposableAppender, LogRecord } from '@kbn/logging'; import { LegacyVars } from '../../types'; export interface LegacyAppenderConfig { - kind: 'legacy-appender'; + type: 'legacy-appender'; legacyLoggingConfig?: any; } @@ -22,7 +22,7 @@ export interface LegacyAppenderConfig { */ export class LegacyAppender implements DisposableAppender { public static configSchema = schema.object({ - kind: schema.literal('legacy-appender'), + type: schema.literal('legacy-appender'), legacyLoggingConfig: schema.any(), }); diff --git a/src/core/server/logging/README.md b/src/core/server/logging/README.md index 9e3da1f3e0d715..385d1fd91a5d75 100644 --- a/src/core/server/logging/README.md +++ b/src/core/server/logging/README.md @@ -24,7 +24,7 @@ Kibana logging system has three main components: _loggers_, _appenders_ and _lay messages according to message type and level, and to control how these messages are formatted and where the final logs will be displayed or stored. -__Loggers__ define what logging settings should be applied at the particular context. +__Loggers__ define what logging settings should be applied at the particular context name. __Appenders__ define where log messages are displayed (eg. stdout or console) and stored (eg. file on the disk). @@ -33,17 +33,17 @@ __Layouts__ define how log messages are formatted and what type of information t ## Logger hierarchy -Every logger has its unique name or context that follows hierarchical naming rule. The logger is considered to be an +Every logger has its unique context name that follows hierarchical naming rule. The logger is considered to be an ancestor of another logger if its name followed by a `.` is a prefix of the descendant logger name. For example logger -with `a.b` context is an ancestor of logger with `a.b.c` context. All top-level loggers are descendants of special -logger with `root` context that resides at the top of the logger hierarchy. This logger always exists and +with `a.b` context name is an ancestor of logger with `a.b.c` context name. All top-level loggers are descendants of special +logger with `root` context name that resides at the top of the logger hierarchy. This logger always exists and fully configured. -Developer can configure _log level_ and _appenders_ that should be used within particular context. If logger configuration +Developer can configure _log level_ and _appenders_ that should be used within particular context name. If logger configuration specifies only _log level_ then _appenders_ configuration will be inherited from the ancestor logger. __Note:__ in the current implementation log messages are only forwarded to appenders configured for a particular logger -context or to appenders of the closest ancestor if current logger doesn't have any appenders configured. That means that +context name or to appenders of the closest ancestor if current logger doesn't have any appenders configured. That means that we __don't support__ so called _appender additivity_ when log messages are forwarded to _every_ distinct appender within ancestor chain including `root`. @@ -55,7 +55,7 @@ A log record is being logged by the logger if its level is higher than or equal the log record is ignored. The _all_ and _off_ levels can be used only in configuration and are just handy shortcuts that allow developer to log every -log record or disable logging entirely for the specific context. +log record or disable logging entirely for the specific context name. ## Layouts @@ -129,7 +129,7 @@ Example of `%date` output: Outputs the process ID. ### JSON layout -With `json` layout log messages will be formatted as JSON strings that include timestamp, log level, context, message +With `json` layout log messages will be formatted as JSON strings that include timestamp, log level, context name, message text and any other metadata that may be associated with the log message itself. ## Appenders @@ -153,15 +153,15 @@ This policy will rotate the file when it reaches a predetermined size. logging: appenders: rolling-file: - kind: rolling-file - path: /var/logs/kibana.log + type: rolling-file + fileName: /var/logs/kibana.log policy: - kind: size-limit + type: size-limit size: 50mb strategy: //... layout: - kind: pattern + type: pattern ``` The options are: @@ -180,16 +180,16 @@ This policy will rotate the file every given interval of time. logging: appenders: rolling-file: - kind: rolling-file - path: /var/logs/kibana.log + type: rolling-file + fileName: /var/logs/kibana.log policy: - kind: time-interval + type: time-interval interval: 10s modulate: true strategy: //... layout: - kind: pattern + type: pattern ``` The options are: @@ -225,16 +225,16 @@ and will retains a fixed amount of rolled files. logging: appenders: rolling-file: - kind: rolling-file - path: /var/logs/kibana.log + type: rolling-file + fileName: /var/logs/kibana.log policy: // ... strategy: - kind: numeric + type: numeric pattern: '-%i' max: 2 layout: - kind: pattern + type: pattern ``` For example, with this configuration: @@ -253,7 +253,7 @@ The options are: The suffix to append to the file path when rolling. Must include `%i`, as this is the value that will be converted to the file index. -for example, with `path: /var/logs/kibana.log` and `pattern: '-%i'`, the created rolling files +for example, with `fileName: /var/logs/kibana.log` and `pattern: '-%i'`, the created rolling files will be `/var/logs/kibana-1.log`, `/var/logs/kibana-2.log`, and so on. The default value is `-%i` @@ -278,49 +278,49 @@ Here is the configuration example that can be used to configure _loggers_, _appe logging: appenders: console: - kind: console + type: console layout: - kind: pattern + type: pattern highlight: true file: - kind: file - path: /var/log/kibana.log + type: file + fileName: /var/log/kibana.log layout: - kind: pattern + type: pattern custom: - kind: console + type: console layout: - kind: pattern + type: pattern pattern: "[%date][%level] %message" json-file-appender: - kind: file - path: /var/log/kibana-json.log + type: file + fileName: /var/log/kibana-json.log root: appenders: [console, file] level: error loggers: - - context: plugins + - name: plugins appenders: [custom] level: warn - - context: plugins.myPlugin + - name: plugins.myPlugin level: info - - context: server + - name: server level: fatal - - context: optimize + - name: optimize appenders: [console] - - context: telemetry + - name: telemetry level: all appenders: [json-file-appender] - - context: metrics.ops + - name: metrics.ops level: debug appenders: [console] ``` Here is what we get with the config above: -| Context | Appenders | Level | +| Context name | Appenders | Level | | ---------------- |:------------------------:| -----:| | root | console, file | error | | plugins | custom | warn | @@ -331,7 +331,7 @@ Here is what we get with the config above: | metrics.ops | console | debug | -The `root` logger has a dedicated configuration node since this context is special and should always exist. By +The `root` logger has a dedicated configuration node since this context name is special and should always exist. By default `root` is configured with `info` level and `default` appender that is also always available. This is the configuration that all custom loggers will use unless they're re-configured explicitly. @@ -391,7 +391,7 @@ The message contains some high-level information, and the corresponding log meta ## Usage -Usage is very straightforward, one should just get a logger for a specific context and use it to log messages with +Usage is very straightforward, one should just get a logger for a specific context name and use it to log messages with different log level. ```typescript @@ -409,7 +409,7 @@ loggerWithNestedContext.trace('Message with `trace` log level.'); loggerWithNestedContext.debug('Message with `debug` log level.'); ``` -And assuming logger for `server` context with `console` appender and `trace` level was used, console output will look like this: +And assuming logger for `server` name with `console` appender and `trace` level was used, console output will look like this: ```bash [2017-07-25T11:54:41.639-07:00][TRACE][server] Message with `trace` log level. [2017-07-25T11:54:41.639-07:00][DEBUG][server] Message with `debug` log level. @@ -422,7 +422,7 @@ And assuming logger for `server` context with `console` appender and `trace` lev [2017-07-25T11:54:41.639-07:00][DEBUG][server.http] Message with `debug` log level. ``` -The log will be less verbose with `warn` level for the `server` context: +The log will be less verbose with `warn` level for the `server` context name: ```bash [2017-07-25T11:54:41.639-07:00][WARN ][server] Message with `warn` log level. [2017-07-25T11:54:41.639-07:00][ERROR][server] Message with `error` log level. @@ -433,7 +433,7 @@ The log will be less verbose with `warn` level for the `server` context: Compatibility with the legacy logging system is assured until the end of the `v7` version. All log messages handled by `root` context are forwarded to the legacy logging service. If you re-write root appenders, make sure that it contains `default` appender to provide backward compatibility. -**Note**: If you define an appender for a context, the log messages aren't handled by the +**Note**: If you define an appender for a context name, the log messages aren't handled by the `root` context anymore and not forwarded to the legacy logging service. #### logging.dest @@ -442,21 +442,21 @@ define a custom one. ```yaml logging: loggers: - - context: plugins.myPlugin + - name: plugins.myPlugin appenders: [console] ``` -Logs in a *file* if given file path. You should define a custom appender with `kind: file` +Logs in a *file* if given file path. You should define a custom appender with `type: file` ```yaml logging: appenders: file: - kind: file - path: /var/log/kibana.log + type: file + fileName: /var/log/kibana.log layout: - kind: pattern + type: pattern loggers: - - context: plugins.myPlugin + - name: plugins.myPlugin appenders: [file] ``` #### logging.json @@ -468,7 +468,7 @@ Suppresses all logging output other than error messages. With new logging, confi with adjusting minimum required [logging level](#log-level). ```yaml loggers: - - context: plugins.myPlugin + - name: plugins.myPlugin appenders: [console] level: error # or for all output @@ -494,32 +494,32 @@ to [specify timezone](#date) for `layout: pattern`. Defaults to host timezone wh logging: appenders: custom-console: - kind: console + type: console layout: - kind: pattern + type: pattern highlight: true pattern: "[%level] [%date{ISO8601_TZ}{America/Los_Angeles}][%logger] %message" ``` #### logging.events -Define a custom logger for a specific context. +Define a custom logger for a specific context name. **`logging.events.ops`** outputs sample system and process information at a regular interval. -With the new logging config, these are provided by a dedicated [context](#logger-hierarchy), +With the new logging config, these are provided by a dedicated [context name](#logger-hierarchy), and you can enable them by adjusting the minimum required [logging level](#log-level) to `debug`: ```yaml loggers: - - context: metrics.ops + - name: metrics.ops appenders: [console] level: debug ``` **`logging.events.request` and `logging.events.response`** provide logs for each request handled -by the http service. With the new logging config, these are provided by a dedicated [context](#logger-hierarchy), +by the http service. With the new logging config, these are provided by a dedicated [context name](#logger-hierarchy), and you can enable them by adjusting the minimum required [logging level](#log-level) to `debug`: ```yaml loggers: - - context: http.server.response + - name: http.server.response appenders: [console] level: debug ``` @@ -532,7 +532,7 @@ TBD | Parameter | Platform log record in **pattern** format | Legacy Platform log record **text** format | | --------------- | ------------------------------------------ | ------------------------------------------ | | @timestamp | ISO8601_TZ `2012-01-31T23:33:22.011-05:00` | Absolute `23:33:22.011` | -| context | `parent.child` | `['parent', 'child']` | +| context name | `parent.child` | `['parent', 'child']` | | level | `DEBUG` | `['debug']` | | meta | stringified JSON object `{"to": "v8"}` | N/A | | pid | can be configured as `%pid` | N/A | @@ -540,9 +540,9 @@ TBD | Parameter | Platform log record in **json** format | Legacy Platform log record **json** format | | --------------- | ------------------------------------------ | -------------------------------------------- | | @timestamp | ISO8601_TZ `2012-01-31T23:33:22.011-05:00` | ISO8601 `2012-01-31T23:33:22.011Z` | -| context | `context: parent.child` | `tags: ['parent', 'child']` | -| level | `level: DEBUG` | `tags: ['debug']` | +| context name | `log.logger: parent.child` | `tags: ['parent', 'child']` | +| level | `log.level: DEBUG` | `tags: ['debug']` | | meta | separate property `"meta": {"to": "v8"}` | merged in log record `{... "to": "v8"}` | -| pid | `pid: 12345` | `pid: 12345` | +| pid | `process.pid: 12345` | `pid: 12345` | | type | N/A | `type: log` | | error | `{ message, name, stack }` | `{ message, name, stack, code, signal }` | diff --git a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap index 8013aec4a06fd3..81321a3b1fe44c 100644 --- a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap +++ b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap @@ -84,7 +84,7 @@ Object { } `; -exports[`uses \`root\` logger if context is not specified. 1`] = ` +exports[`uses \`root\` logger if context name is not specified. 1`] = ` Array [ Array [ "[2012-01-31T03:33:22.011-05:00][INFO ][root] This message goes to a root context.", diff --git a/src/core/server/logging/appenders/appenders.test.mocks.ts b/src/core/server/logging/appenders/appenders.test.mocks.ts index 85a86ff9306c7b..1427cd7220de71 100644 --- a/src/core/server/logging/appenders/appenders.test.mocks.ts +++ b/src/core/server/logging/appenders/appenders.test.mocks.ts @@ -12,7 +12,7 @@ jest.mock('../layouts/layouts', () => { const { schema } = require('@kbn/config-schema'); return { Layouts: { - configSchema: schema.object({ kind: schema.literal('mock') }), + configSchema: schema.object({ type: schema.literal('mock') }), create: mockCreateLayout, }, }; diff --git a/src/core/server/logging/appenders/appenders.test.ts b/src/core/server/logging/appenders/appenders.test.ts index 8e1c18ae3ded66..bd32e4061049bd 100644 --- a/src/core/server/logging/appenders/appenders.test.ts +++ b/src/core/server/logging/appenders/appenders.test.ts @@ -21,33 +21,33 @@ beforeEach(() => { test('`configSchema` creates correct schema.', () => { const appendersSchema = Appenders.configSchema; - const validConfig1 = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + const validConfig1 = { type: 'file', layout: { type: 'mock' }, fileName: 'path' }; expect(appendersSchema.validate(validConfig1)).toEqual({ - kind: 'file', - layout: { kind: 'mock' }, - path: 'path', + type: 'file', + layout: { type: 'mock' }, + fileName: 'path', }); - const validConfig2 = { kind: 'console', layout: { kind: 'mock' } }; + const validConfig2 = { type: 'console', layout: { type: 'mock' } }; expect(appendersSchema.validate(validConfig2)).toEqual({ - kind: 'console', - layout: { kind: 'mock' }, + type: 'console', + layout: { type: 'mock' }, }); const wrongConfig1 = { - kind: 'console', - layout: { kind: 'mock' }, - path: 'path', + type: 'console', + layout: { type: 'mock' }, + fileName: 'path', }; expect(() => appendersSchema.validate(wrongConfig1)).toThrow(); - const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' } }; + const wrongConfig2 = { type: 'file', layout: { type: 'mock' } }; expect(() => appendersSchema.validate(wrongConfig2)).toThrow(); const wrongConfig3 = { - kind: 'console', - layout: { kind: 'mock' }, - path: 'path', + type: 'console', + layout: { type: 'mock' }, + fileName: 'path', }; expect(() => appendersSchema.validate(wrongConfig3)).toThrow(); }); @@ -56,31 +56,31 @@ test('`create()` creates correct appender.', () => { mockCreateLayout.mockReturnValue({ format: () => '' }); const consoleAppender = Appenders.create({ - kind: 'console', - layout: { highlight: true, kind: 'pattern', pattern: '' }, + type: 'console', + layout: { highlight: true, type: 'pattern', pattern: '' }, }); expect(consoleAppender).toBeInstanceOf(ConsoleAppender); const fileAppender = Appenders.create({ - kind: 'file', - layout: { highlight: true, kind: 'pattern', pattern: '' }, - path: 'path', + type: 'file', + layout: { highlight: true, type: 'pattern', pattern: '' }, + fileName: 'path', }); expect(fileAppender).toBeInstanceOf(FileAppender); const legacyAppender = Appenders.create({ - kind: 'legacy-appender', + type: 'legacy-appender', legacyLoggingConfig: { verbose: true }, }); expect(legacyAppender).toBeInstanceOf(LegacyAppender); const rollingFileAppender = Appenders.create({ - kind: 'rolling-file', - path: 'path', - layout: { highlight: true, kind: 'pattern', pattern: '' }, - strategy: { kind: 'numeric', max: 5, pattern: '%i' }, - policy: { kind: 'size-limit', size: ByteSizeValue.parse('15b') }, + type: 'rolling-file', + fileName: 'path', + layout: { highlight: true, type: 'pattern', pattern: '' }, + strategy: { type: 'numeric', max: 5, pattern: '%i' }, + policy: { type: 'size-limit', size: ByteSizeValue.parse('15b') }, }); expect(rollingFileAppender).toBeInstanceOf(RollingFileAppender); }); diff --git a/src/core/server/logging/appenders/appenders.ts b/src/core/server/logging/appenders/appenders.ts index 564def5251c132..a41a6a2f68fa1b 100644 --- a/src/core/server/logging/appenders/appenders.ts +++ b/src/core/server/logging/appenders/appenders.ts @@ -52,11 +52,11 @@ export class Appenders { * @returns Fully constructed `Appender` instance. */ public static create(config: AppenderConfigType): DisposableAppender { - switch (config.kind) { + switch (config.type) { case 'console': return new ConsoleAppender(Layouts.create(config.layout)); case 'file': - return new FileAppender(Layouts.create(config.layout), config.path); + return new FileAppender(Layouts.create(config.layout), config.fileName); case 'rolling-file': return new RollingFileAppender(config); case 'legacy-appender': diff --git a/src/core/server/logging/appenders/console/console_appender.test.ts b/src/core/server/logging/appenders/console/console_appender.test.ts index f5ad853775eea3..1e8f742c1ecda6 100644 --- a/src/core/server/logging/appenders/console/console_appender.test.ts +++ b/src/core/server/logging/appenders/console/console_appender.test.ts @@ -12,7 +12,7 @@ jest.mock('../../layouts/layouts', () => { return { Layouts: { configSchema: schema.object({ - kind: schema.literal('mock'), + type: schema.literal('mock'), }), }, }; @@ -23,16 +23,16 @@ import { ConsoleAppender } from './console_appender'; test('`configSchema` creates correct schema.', () => { const appenderSchema = ConsoleAppender.configSchema; - const validConfig = { kind: 'console', layout: { kind: 'mock' } }; + const validConfig = { type: 'console', layout: { type: 'mock' } }; expect(appenderSchema.validate(validConfig)).toEqual({ - kind: 'console', - layout: { kind: 'mock' }, + type: 'console', + layout: { type: 'mock' }, }); - const wrongConfig1 = { kind: 'not-console', layout: { kind: 'mock' } }; + const wrongConfig1 = { type: 'not-console', layout: { type: 'mock' } }; expect(() => appenderSchema.validate(wrongConfig1)).toThrow(); - const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + const wrongConfig2 = { type: 'file', layout: { type: 'mock' }, fileName: 'path' }; expect(() => appenderSchema.validate(wrongConfig2)).toThrow(); }); diff --git a/src/core/server/logging/appenders/console/console_appender.ts b/src/core/server/logging/appenders/console/console_appender.ts index 00d26d0836ee38..739068ff0a126c 100644 --- a/src/core/server/logging/appenders/console/console_appender.ts +++ b/src/core/server/logging/appenders/console/console_appender.ts @@ -13,7 +13,7 @@ import { Layouts, LayoutConfigType } from '../../layouts/layouts'; const { literal, object } = schema; export interface ConsoleAppenderConfig { - kind: 'console'; + type: 'console'; layout: LayoutConfigType; } @@ -24,7 +24,7 @@ export interface ConsoleAppenderConfig { */ export class ConsoleAppender implements DisposableAppender { public static configSchema = object({ - kind: literal('console'), + type: literal('console'), layout: Layouts.configSchema, }); diff --git a/src/core/server/logging/appenders/file/file_appender.test.mocks.ts b/src/core/server/logging/appenders/file/file_appender.test.mocks.ts index 0f87829dbbaf16..2c2a2015b6fd38 100644 --- a/src/core/server/logging/appenders/file/file_appender.test.mocks.ts +++ b/src/core/server/logging/appenders/file/file_appender.test.mocks.ts @@ -12,7 +12,7 @@ jest.mock('../../layouts/layouts', () => { return { Layouts: { configSchema: schema.object({ - kind: schema.literal('mock'), + type: schema.literal('mock'), }), }, }; diff --git a/src/core/server/logging/appenders/file/file_appender.test.ts b/src/core/server/logging/appenders/file/file_appender.test.ts index 5ef91b98e92f42..081cb16afd2ff3 100644 --- a/src/core/server/logging/appenders/file/file_appender.test.ts +++ b/src/core/server/logging/appenders/file/file_appender.test.ts @@ -20,24 +20,24 @@ beforeEach(() => { test('`createConfigSchema()` creates correct schema.', () => { const appenderSchema = FileAppender.configSchema; - const validConfig = { kind: 'file', layout: { kind: 'mock' }, path: 'path' }; + const validConfig = { type: 'file', layout: { type: 'mock' }, fileName: 'path' }; expect(appenderSchema.validate(validConfig)).toEqual({ - kind: 'file', - layout: { kind: 'mock' }, - path: 'path', + type: 'file', + layout: { type: 'mock' }, + fileName: 'path', }); const wrongConfig1 = { - kind: 'not-file', - layout: { kind: 'mock' }, - path: 'path', + type: 'not-file', + layout: { type: 'mock' }, + fileName: 'path', }; expect(() => appenderSchema.validate(wrongConfig1)).toThrow(); - const wrongConfig2 = { kind: 'file', layout: { kind: 'mock' } }; + const wrongConfig2 = { type: 'file', layout: { type: 'mock' } }; expect(() => appenderSchema.validate(wrongConfig2)).toThrow(); - const wrongConfig3 = { kind: 'console', layout: { kind: 'mock' } }; + const wrongConfig3 = { type: 'console', layout: { type: 'mock' } }; expect(() => appenderSchema.validate(wrongConfig3)).toThrow(); }); diff --git a/src/core/server/logging/appenders/file/file_appender.ts b/src/core/server/logging/appenders/file/file_appender.ts index 0f1cb71c76e9fb..be46c261dc9965 100644 --- a/src/core/server/logging/appenders/file/file_appender.ts +++ b/src/core/server/logging/appenders/file/file_appender.ts @@ -13,9 +13,9 @@ import { createWriteStream, WriteStream } from 'fs'; import { Layouts, LayoutConfigType } from '../../layouts/layouts'; export interface FileAppenderConfig { - kind: 'file'; + type: 'file'; layout: LayoutConfigType; - path: string; + fileName: string; } /** @@ -24,9 +24,9 @@ export interface FileAppenderConfig { */ export class FileAppender implements DisposableAppender { public static configSchema = schema.object({ - kind: schema.literal('file'), + type: schema.literal('file'), layout: Layouts.configSchema, - path: schema.string(), + fileName: schema.string(), }); /** diff --git a/src/core/server/logging/appenders/rolling_file/policies/index.ts b/src/core/server/logging/appenders/rolling_file/policies/index.ts index 20038d31eee8bf..e3e33c6cbfdef6 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/index.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/index.ts @@ -34,7 +34,7 @@ export type TriggeringPolicyConfig = | TimeIntervalTriggeringPolicyConfig; const defaultPolicy: TimeIntervalTriggeringPolicyConfig = { - kind: 'time-interval', + type: 'time-interval', interval: moment.duration(24, 'hour'), modulate: true, }; @@ -48,7 +48,7 @@ export const createTriggeringPolicy = ( config: TriggeringPolicyConfig, context: RollingFileContext ): TriggeringPolicy => { - switch (config.kind) { + switch (config.type) { case 'size-limit': return new SizeLimitTriggeringPolicy(config, context); case 'time-interval': diff --git a/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.test.ts b/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.test.ts index 3780bb69a8341a..ee9c96de8a940c 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.test.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.test.ts @@ -15,7 +15,7 @@ describe('SizeLimitTriggeringPolicy', () => { let context: RollingFileContext; const createPolicy = (size: ByteSizeValue) => - new SizeLimitTriggeringPolicy({ kind: 'size-limit', size }, context); + new SizeLimitTriggeringPolicy({ type: 'size-limit', size }, context); const createLogRecord = (parts: Partial = {}): LogRecord => ({ timestamp: new Date(), diff --git a/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.ts b/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.ts index 77f0a60b0e95c8..82fee352da8df7 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/size_limit/size_limit_policy.ts @@ -12,7 +12,7 @@ import { RollingFileContext } from '../../rolling_file_context'; import { TriggeringPolicy } from '../policy'; export interface SizeLimitTriggeringPolicyConfig { - kind: 'size-limit'; + type: 'size-limit'; /** * The minimum size the file must have to roll over. @@ -21,7 +21,7 @@ export interface SizeLimitTriggeringPolicyConfig { } export const sizeLimitTriggeringPolicyConfigSchema = schema.object({ - kind: schema.literal('size-limit'), + type: schema.literal('size-limit'), size: schema.byteSize({ min: '1b', defaultValue: '100mb' }), }); diff --git a/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.test.ts b/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.test.ts index 25c5cef65c8851..03f457277b7926 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.test.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.test.ts @@ -42,7 +42,7 @@ describe('TimeIntervalTriggeringPolicy', () => { interval: string = '15m', modulate: boolean = false ): TimeIntervalTriggeringPolicyConfig => ({ - kind: 'time-interval', + type: 'time-interval', interval: schema.duration().validate(interval), modulate, }); diff --git a/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.ts b/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.ts index 892dd54672f146..7c4d18d929cb0d 100644 --- a/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.ts +++ b/src/core/server/logging/appenders/rolling_file/policies/time_interval/time_interval_policy.ts @@ -15,7 +15,7 @@ import { getNextRollingTime } from './get_next_rolling_time'; import { isValidRolloverInterval } from './utils'; export interface TimeIntervalTriggeringPolicyConfig { - kind: 'time-interval'; + type: 'time-interval'; /** * How often a rollover should occur. @@ -38,7 +38,7 @@ export interface TimeIntervalTriggeringPolicyConfig { } export const timeIntervalTriggeringPolicyConfigSchema = schema.object({ - kind: schema.literal('time-interval'), + type: schema.literal('time-interval'), interval: schema.duration({ defaultValue: '24h', validate: (interval) => { diff --git a/src/core/server/logging/appenders/rolling_file/rolling_file_appender.test.ts b/src/core/server/logging/appenders/rolling_file/rolling_file_appender.test.ts index bc28e9137b2fd7..a95d995885d8b2 100644 --- a/src/core/server/logging/appenders/rolling_file/rolling_file_appender.test.ts +++ b/src/core/server/logging/appenders/rolling_file/rolling_file_appender.test.ts @@ -20,20 +20,20 @@ import { LogLevel, LogRecord } from '@kbn/logging'; import { RollingFileAppender, RollingFileAppenderConfig } from './rolling_file_appender'; const config: RollingFileAppenderConfig = { - kind: 'rolling-file', - path: '/var/log/kibana.log', + type: 'rolling-file', + fileName: '/var/log/kibana.log', layout: { - kind: 'pattern', + type: 'pattern', pattern: '%message', highlight: false, }, policy: { - kind: 'time-interval', + type: 'time-interval', interval: moment.duration(4, 'hour'), modulate: true, }, strategy: { - kind: 'numeric', + type: 'numeric', max: 5, pattern: '-%i', }, @@ -99,7 +99,7 @@ describe('RollingFileAppender', () => { it('constructs its delegates with the correct parameters', () => { expect(RollingFileContextMock).toHaveBeenCalledTimes(1); - expect(RollingFileContextMock).toHaveBeenCalledWith(config.path); + expect(RollingFileContextMock).toHaveBeenCalledWith(config.fileName); expect(RollingFileManagerMock).toHaveBeenCalledTimes(1); expect(RollingFileManagerMock).toHaveBeenCalledWith(context); diff --git a/src/core/server/logging/appenders/rolling_file/rolling_file_appender.ts b/src/core/server/logging/appenders/rolling_file/rolling_file_appender.ts index 748f47504f00ad..452d9493359544 100644 --- a/src/core/server/logging/appenders/rolling_file/rolling_file_appender.ts +++ b/src/core/server/logging/appenders/rolling_file/rolling_file_appender.ts @@ -26,7 +26,7 @@ import { RollingFileManager } from './rolling_file_manager'; import { RollingFileContext } from './rolling_file_context'; export interface RollingFileAppenderConfig { - kind: 'rolling-file'; + type: 'rolling-file'; /** * The layout to use when writing log entries */ @@ -34,7 +34,7 @@ export interface RollingFileAppenderConfig { /** * The absolute path of the file to write to. */ - path: string; + fileName: string; /** * The {@link TriggeringPolicy | policy} to use to determine if a rollover should occur. */ @@ -51,9 +51,9 @@ export interface RollingFileAppenderConfig { */ export class RollingFileAppender implements DisposableAppender { public static configSchema = schema.object({ - kind: schema.literal('rolling-file'), + type: schema.literal('rolling-file'), layout: Layouts.configSchema, - path: schema.string(), + fileName: schema.string(), policy: triggeringPolicyConfigSchema, strategy: rollingStrategyConfigSchema, }); @@ -70,7 +70,7 @@ export class RollingFileAppender implements DisposableAppender { private readonly buffer: BufferAppender; constructor(config: RollingFileAppenderConfig) { - this.context = new RollingFileContext(config.path); + this.context = new RollingFileContext(config.fileName); this.context.refreshFileInfo(); this.fileManager = new RollingFileManager(this.context); this.layout = Layouts.create(config.layout); diff --git a/src/core/server/logging/appenders/rolling_file/strategies/index.ts b/src/core/server/logging/appenders/rolling_file/strategies/index.ts index f63b68e4b92af9..c8364b0e590c67 100644 --- a/src/core/server/logging/appenders/rolling_file/strategies/index.ts +++ b/src/core/server/logging/appenders/rolling_file/strategies/index.ts @@ -19,7 +19,7 @@ export { RollingStrategy } from './strategy'; export type RollingStrategyConfig = NumericRollingStrategyConfig; const defaultStrategy: NumericRollingStrategyConfig = { - kind: 'numeric', + type: 'numeric', pattern: '-%i', max: 7, }; diff --git a/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.test.ts b/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.test.ts index d2e65f3880b87f..b4ca0131156a38 100644 --- a/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.test.ts +++ b/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.test.ts @@ -27,8 +27,8 @@ describe('NumericRollingStrategy', () => { let context: ReturnType; let strategy: NumericRollingStrategy; - const createStrategy = (config: Omit) => - new NumericRollingStrategy({ ...config, kind: 'numeric' }, context); + const createStrategy = (config: Omit) => + new NumericRollingStrategy({ ...config, type: 'numeric' }, context); beforeEach(() => { context = rollingFileAppenderMocks.createContext(logFilePath); diff --git a/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.ts b/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.ts index 5ee75bf6fda52a..13a19a40fa561d 100644 --- a/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.ts +++ b/src/core/server/logging/appenders/rolling_file/strategies/numeric/numeric_strategy.ts @@ -19,10 +19,10 @@ import { } from './rolling_tasks'; export interface NumericRollingStrategyConfig { - kind: 'numeric'; + type: 'numeric'; /** * The suffix pattern to apply when renaming a file. The suffix will be applied - * after the `appender.path` file name, but before the file extension. + * after the `appender.fileName` file name, but before the file extension. * * Must include `%i`, as it is the value that will be converted to the file index * @@ -31,8 +31,8 @@ export interface NumericRollingStrategyConfig { * logging: * appenders: * rolling-file: - * kind: rolling-file - * path: /var/logs/kibana.log + * type: rolling-file + * fileName: /var/logs/kibana.log * strategy: * type: default * pattern: "-%i" @@ -52,7 +52,7 @@ export interface NumericRollingStrategyConfig { } export const numericRollingStrategyConfigSchema = schema.object({ - kind: schema.literal('numeric'), + type: schema.literal('numeric'), pattern: schema.string({ defaultValue: '-%i', validate: (pattern) => { @@ -73,8 +73,8 @@ export const numericRollingStrategyConfigSchema = schema.object({ * logging: * appenders: * rolling-file: - * kind: rolling-file - * path: /kibana.log + * type: rolling-file + * fileName: /kibana.log * strategy: * type: numeric * pattern: "-%i" diff --git a/src/core/server/logging/integration_tests/logging.test.ts b/src/core/server/logging/integration_tests/logging.test.ts index 0af6dbfc8611ec..b4eb98546de21b 100644 --- a/src/core/server/logging/integration_tests/logging.test.ts +++ b/src/core/server/logging/integration_tests/logging.test.ts @@ -17,22 +17,22 @@ function createRoot() { silent: true, // set "true" in kbnTestServer appenders: { 'test-console': { - kind: 'console', + type: 'console', layout: { highlight: false, - kind: 'pattern', + type: 'pattern', pattern: '%level|%logger|%message', }, }, }, loggers: [ { - context: 'parent', + name: 'parent', appenders: ['test-console'], level: 'warn', }, { - context: 'parent.child', + name: 'parent.child', appenders: ['test-console'], level: 'error', }, @@ -42,7 +42,7 @@ function createRoot() { } describe('logging service', () => { - describe('logs according to context hierarchy', () => { + describe('logs according to context name hierarchy', () => { let root: ReturnType; let mockConsoleLog: jest.SpyInstance; beforeAll(async () => { @@ -61,7 +61,7 @@ describe('logging service', () => { await root.shutdown(); }); - it('uses the most specific context', () => { + it('uses the most specific context name', () => { const logger = root.logger.get('parent.child'); logger.error('error from "parent.child" context'); @@ -74,7 +74,7 @@ describe('logging service', () => { ); }); - it('uses parent context', () => { + it('uses parent context name', () => { const logger = root.logger.get('parent.another-child'); logger.error('error from "parent.another-child" context'); @@ -104,31 +104,31 @@ describe('logging service', () => { }); }); - describe('custom context configuration', () => { + describe('custom context name configuration', () => { const CUSTOM_LOGGING_CONFIG: LoggerContextConfigInput = { appenders: { customJsonConsole: { - kind: 'console', + type: 'console', layout: { - kind: 'json', + type: 'json', }, }, customPatternConsole: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', pattern: 'CUSTOM - PATTERN [%logger][%level] %message', }, }, }, loggers: [ - { context: 'debug_json', appenders: ['customJsonConsole'], level: 'debug' }, - { context: 'debug_pattern', appenders: ['customPatternConsole'], level: 'debug' }, - { context: 'info_json', appenders: ['customJsonConsole'], level: 'info' }, - { context: 'info_pattern', appenders: ['customPatternConsole'], level: 'info' }, + { name: 'debug_json', appenders: ['customJsonConsole'], level: 'debug' }, + { name: 'debug_pattern', appenders: ['customPatternConsole'], level: 'debug' }, + { name: 'info_json', appenders: ['customJsonConsole'], level: 'info' }, + { name: 'info_pattern', appenders: ['customPatternConsole'], level: 'info' }, { - context: 'all', + name: 'all', appenders: ['customJsonConsole', 'customPatternConsole'], level: 'debug', }, diff --git a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts index fb2a714adb687a..b40ce7a4e7b0e3 100644 --- a/src/core/server/logging/integration_tests/rolling_file_appender.test.ts +++ b/src/core/server/logging/integration_tests/rolling_file_appender.test.ts @@ -25,7 +25,7 @@ function createRoot(appenderConfig: any) { }, loggers: [ { - context: 'test.rolling.file', + name: 'test.rolling.file', appenders: ['rolling-file'], level: 'debug', }, @@ -63,18 +63,18 @@ describe('RollingFileAppender', () => { describe('`size-limit` policy with `numeric` strategy', () => { it('rolls the log file in the correct order', async () => { root = createRoot({ - kind: 'rolling-file', - path: logFile, + type: 'rolling-file', + fileName: logFile, layout: { - kind: 'pattern', + type: 'pattern', pattern: '%message', }, policy: { - kind: 'size-limit', + type: 'size-limit', size: '100b', }, strategy: { - kind: 'numeric', + type: 'numeric', max: 5, pattern: '.%i', }, @@ -108,18 +108,18 @@ describe('RollingFileAppender', () => { it('only keep the correct number of files', async () => { root = createRoot({ - kind: 'rolling-file', - path: logFile, + type: 'rolling-file', + fileName: logFile, layout: { - kind: 'pattern', + type: 'pattern', pattern: '%message', }, policy: { - kind: 'size-limit', + type: 'size-limit', size: '60b', }, strategy: { - kind: 'numeric', + type: 'numeric', max: 2, pattern: '-%i', }, @@ -157,19 +157,19 @@ describe('RollingFileAppender', () => { describe('`time-interval` policy with `numeric` strategy', () => { it('rolls the log file at the given interval', async () => { root = createRoot({ - kind: 'rolling-file', - path: logFile, + type: 'rolling-file', + fileName: logFile, layout: { - kind: 'pattern', + type: 'pattern', pattern: '%message', }, policy: { - kind: 'time-interval', + type: 'time-interval', interval: '1s', modulate: true, }, strategy: { - kind: 'numeric', + type: 'numeric', max: 2, pattern: '-%i', }, diff --git a/src/core/server/logging/layouts/json_layout.test.ts b/src/core/server/logging/layouts/json_layout.test.ts index 2504ad476576fb..e55f69daab1100 100644 --- a/src/core/server/logging/layouts/json_layout.test.ts +++ b/src/core/server/logging/layouts/json_layout.test.ts @@ -63,7 +63,7 @@ const records: LogRecord[] = [ test('`createConfigSchema()` creates correct schema.', () => { const layoutSchema = JsonLayout.configSchema; - expect(layoutSchema.validate({ kind: 'json' })).toEqual({ kind: 'json' }); + expect(layoutSchema.validate({ type: 'json' })).toEqual({ type: 'json' }); }); test('`format()` correctly formats record.', () => { diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts index 9e81303bedea07..bb8423f8240af9 100644 --- a/src/core/server/logging/layouts/json_layout.ts +++ b/src/core/server/logging/layouts/json_layout.ts @@ -14,12 +14,12 @@ import { LogRecord, Layout } from '@kbn/logging'; const { literal, object } = schema; const jsonLayoutSchema = object({ - kind: literal('json'), + type: literal('json'), }); /** @internal */ export interface JsonLayoutConfigType { - kind: 'json'; + type: 'json'; } /** diff --git a/src/core/server/logging/layouts/layouts.test.ts b/src/core/server/logging/layouts/layouts.test.ts index df91994564da17..3ff2fe23aae343 100644 --- a/src/core/server/logging/layouts/layouts.test.ts +++ b/src/core/server/logging/layouts/layouts.test.ts @@ -12,43 +12,43 @@ import { PatternLayout } from './pattern_layout'; test('`configSchema` creates correct schema for `pattern` layout.', () => { const layoutsSchema = Layouts.configSchema; - const validConfigWithOptional = { kind: 'pattern' }; + const validConfigWithOptional = { type: 'pattern' }; expect(layoutsSchema.validate(validConfigWithOptional)).toEqual({ highlight: undefined, - kind: 'pattern', + type: 'pattern', pattern: undefined, }); const validConfig = { highlight: true, - kind: 'pattern', + type: 'pattern', pattern: '%message', }; expect(layoutsSchema.validate(validConfig)).toEqual({ highlight: true, - kind: 'pattern', + type: 'pattern', pattern: '%message', }); - const wrongConfig2 = { kind: 'pattern', pattern: 1 }; + const wrongConfig2 = { type: 'pattern', pattern: 1 }; expect(() => layoutsSchema.validate(wrongConfig2)).toThrow(); }); test('`createConfigSchema()` creates correct schema for `json` layout.', () => { const layoutsSchema = Layouts.configSchema; - const validConfig = { kind: 'json' }; - expect(layoutsSchema.validate(validConfig)).toEqual({ kind: 'json' }); + const validConfig = { type: 'json' }; + expect(layoutsSchema.validate(validConfig)).toEqual({ type: 'json' }); }); test('`create()` creates correct layout.', () => { const patternLayout = Layouts.create({ highlight: false, - kind: 'pattern', + type: 'pattern', pattern: '[%date][%level][%logger] %message', }); expect(patternLayout).toBeInstanceOf(PatternLayout); - const jsonLayout = Layouts.create({ kind: 'json' }); + const jsonLayout = Layouts.create({ type: 'json' }); expect(jsonLayout).toBeInstanceOf(JsonLayout); }); diff --git a/src/core/server/logging/layouts/layouts.ts b/src/core/server/logging/layouts/layouts.ts index d6c14f3713b2c6..9abc8cd753f97b 100644 --- a/src/core/server/logging/layouts/layouts.ts +++ b/src/core/server/logging/layouts/layouts.ts @@ -27,7 +27,7 @@ export class Layouts { * @returns Fully constructed `Layout` instance. */ public static create(config: LayoutConfigType): Layout { - switch (config.kind) { + switch (config.type) { case 'json': return new JsonLayout(); diff --git a/src/core/server/logging/layouts/pattern_layout.test.ts b/src/core/server/logging/layouts/pattern_layout.test.ts index 7dd3c7c51f833c..abdc2f4fb929cb 100644 --- a/src/core/server/logging/layouts/pattern_layout.test.ts +++ b/src/core/server/logging/layouts/pattern_layout.test.ts @@ -66,28 +66,28 @@ expect.addSnapshotSerializer(stripAnsiSnapshotSerializer); test('`createConfigSchema()` creates correct schema.', () => { const layoutSchema = PatternLayout.configSchema; - const validConfigWithOptional = { kind: 'pattern' }; + const validConfigWithOptional = { type: 'pattern' }; expect(layoutSchema.validate(validConfigWithOptional)).toEqual({ highlight: undefined, - kind: 'pattern', + type: 'pattern', pattern: undefined, }); const validConfig = { highlight: true, - kind: 'pattern', + type: 'pattern', pattern: '%message', }; expect(layoutSchema.validate(validConfig)).toEqual({ highlight: true, - kind: 'pattern', + type: 'pattern', pattern: '%message', }); - const wrongConfig1 = { kind: 'json' }; + const wrongConfig1 = { type: 'json' }; expect(() => layoutSchema.validate(wrongConfig1)).toThrow(); - const wrongConfig2 = { kind: 'pattern', pattern: 1 }; + const wrongConfig2 = { type: 'pattern', pattern: 1 }; expect(() => layoutSchema.validate(wrongConfig2)).toThrow(); }); diff --git a/src/core/server/logging/layouts/pattern_layout.ts b/src/core/server/logging/layouts/pattern_layout.ts index a5e9c0be8409bd..a5dc41786c4400 100644 --- a/src/core/server/logging/layouts/pattern_layout.ts +++ b/src/core/server/logging/layouts/pattern_layout.ts @@ -32,7 +32,7 @@ export const patternSchema = schema.string({ const patternLayoutSchema = schema.object({ highlight: schema.maybe(schema.boolean()), - kind: schema.literal('pattern'), + type: schema.literal('pattern'), pattern: schema.maybe(patternSchema), }); @@ -47,7 +47,7 @@ const conversions: Conversion[] = [ /** @internal */ export interface PatternLayoutConfigType { - kind: 'pattern'; + type: 'pattern'; highlight?: boolean; pattern?: string; } diff --git a/src/core/server/logging/logging_config.test.ts b/src/core/server/logging/logging_config.test.ts index e494ae2413229a..2cb5831a8fb4ce 100644 --- a/src/core/server/logging/logging_config.test.ts +++ b/src/core/server/logging/logging_config.test.ts @@ -51,12 +51,12 @@ test('correctly fills in default config.', () => { expect(configValue.appenders.size).toBe(2); expect(configValue.appenders.get('default')).toEqual({ - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, }); expect(configValue.appenders.get('console')).toEqual({ - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, }); }); @@ -65,8 +65,8 @@ test('correctly fills in custom `appenders` config.', () => { config.schema.validate({ appenders: { console: { - kind: 'console', - layout: { kind: 'pattern' }, + type: 'console', + layout: { type: 'pattern' }, }, }, }) @@ -75,13 +75,13 @@ test('correctly fills in custom `appenders` config.', () => { expect(configValue.appenders.size).toBe(2); expect(configValue.appenders.get('default')).toEqual({ - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, }); expect(configValue.appenders.get('console')).toEqual({ - kind: 'console', - layout: { kind: 'pattern' }, + type: 'console', + layout: { type: 'pattern' }, }); }); @@ -91,7 +91,7 @@ test('correctly fills in default `loggers` config.', () => { expect(configValue.loggers.size).toBe(1); expect(configValue.loggers.get('root')).toEqual({ appenders: ['default'], - context: 'root', + name: 'root', level: 'info', }); }); @@ -101,24 +101,24 @@ test('correctly fills in custom `loggers` config.', () => { config.schema.validate({ appenders: { file: { - kind: 'file', - layout: { kind: 'pattern' }, - path: 'path', + type: 'file', + layout: { type: 'pattern' }, + fileName: 'path', }, }, loggers: [ { appenders: ['file'], - context: 'plugins', + name: 'plugins', level: 'warn', }, { - context: 'plugins.pid', + name: 'plugins.pid', level: 'trace', }, { appenders: ['default'], - context: 'http', + name: 'http', level: 'error', }, ], @@ -128,22 +128,22 @@ test('correctly fills in custom `loggers` config.', () => { expect(configValue.loggers.size).toBe(4); expect(configValue.loggers.get('root')).toEqual({ appenders: ['default'], - context: 'root', + name: 'root', level: 'info', }); expect(configValue.loggers.get('plugins')).toEqual({ appenders: ['file'], - context: 'plugins', + name: 'plugins', level: 'warn', }); expect(configValue.loggers.get('plugins.pid')).toEqual({ appenders: ['file'], - context: 'plugins.pid', + name: 'plugins.pid', level: 'trace', }); expect(configValue.loggers.get('http')).toEqual({ appenders: ['default'], - context: 'http', + name: 'http', level: 'error', }); }); @@ -153,7 +153,7 @@ test('fails if loggers use unknown appenders.', () => { loggers: [ { appenders: ['unknown'], - context: 'some.nested.context', + name: 'some.nested.context', }, ], }); @@ -167,9 +167,9 @@ describe('extend', () => { config.schema.validate({ appenders: { file1: { - kind: 'file', - layout: { kind: 'pattern' }, - path: 'path', + type: 'file', + layout: { type: 'pattern' }, + fileName: 'path', }, }, }) @@ -179,9 +179,9 @@ describe('extend', () => { config.schema.validate({ appenders: { file2: { - kind: 'file', - layout: { kind: 'pattern' }, - path: 'path', + type: 'file', + layout: { type: 'pattern' }, + fileName: 'path', }, }, }) @@ -200,9 +200,9 @@ describe('extend', () => { config.schema.validate({ appenders: { file1: { - kind: 'file', - layout: { kind: 'pattern' }, - path: 'path', + type: 'file', + layout: { type: 'pattern' }, + fileName: 'path', }, }, }) @@ -212,18 +212,18 @@ describe('extend', () => { config.schema.validate({ appenders: { file1: { - kind: 'file', - layout: { kind: 'json' }, - path: 'updatedPath', + type: 'file', + layout: { type: 'json' }, + fileName: 'updatedPath', }, }, }) ); expect(mergedConfigValue.appenders.get('file1')).toEqual({ - kind: 'file', - layout: { kind: 'json' }, - path: 'updatedPath', + type: 'file', + layout: { type: 'json' }, + fileName: 'updatedPath', }); }); @@ -232,7 +232,7 @@ describe('extend', () => { config.schema.validate({ loggers: [ { - context: 'plugins', + name: 'plugins', level: 'warn', }, ], @@ -243,7 +243,7 @@ describe('extend', () => { config.schema.validate({ loggers: [ { - context: 'plugins.pid', + name: 'plugins.pid', level: 'trace', }, ], @@ -258,7 +258,7 @@ describe('extend', () => { config.schema.validate({ loggers: [ { - context: 'plugins', + name: 'plugins', level: 'warn', }, ], @@ -270,7 +270,7 @@ describe('extend', () => { loggers: [ { appenders: ['console'], - context: 'plugins', + name: 'plugins', level: 'trace', }, ], @@ -279,7 +279,7 @@ describe('extend', () => { expect(mergedConfigValue.loggers.get('plugins')).toEqual({ appenders: ['console'], - context: 'plugins', + name: 'plugins', level: 'trace', }); }); diff --git a/src/core/server/logging/logging_config.ts b/src/core/server/logging/logging_config.ts index 5b79b4e8e15d5b..24496289fb4c84 100644 --- a/src/core/server/logging/logging_config.ts +++ b/src/core/server/logging/logging_config.ts @@ -51,7 +51,7 @@ const levelSchema = schema.oneOf( */ export const loggerSchema = schema.object({ appenders: schema.arrayOf(schema.string(), { defaultValue: [] }), - context: schema.string(), + name: schema.string(), level: levelSchema, }); @@ -148,15 +148,15 @@ export class LoggingConfig { [ 'default', { - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, } as AppenderConfigType, ], [ 'console', { - kind: 'console', - layout: { kind: 'pattern', highlight: true }, + type: 'console', + layout: { type: 'pattern', highlight: true }, } as AppenderConfigType, ], ]); @@ -182,8 +182,8 @@ export class LoggingConfig { public extend(contextConfig: LoggerContextConfigType) { // Use a Map to de-dupe any loggers for the same context. contextConfig overrides existing config. const mergedLoggers = new Map([ - ...this.configType.loggers.map((l) => [l.context, l] as [string, LoggerConfigType]), - ...contextConfig.loggers.map((l) => [l.context, l] as [string, LoggerConfigType]), + ...this.configType.loggers.map((l) => [l.name, l] as [string, LoggerConfigType]), + ...contextConfig.loggers.map((l) => [l.name, l] as [string, LoggerConfigType]), ]); const mergedConfig: LoggingConfigType = { @@ -204,13 +204,10 @@ export class LoggingConfig { private fillLoggersConfig(loggingConfig: LoggingConfigType) { // Include `root` logger into common logger list so that it can easily be a part // of the logger hierarchy and put all the loggers in map for easier retrieval. - const loggers = [ - { context: ROOT_CONTEXT_NAME, ...loggingConfig.root }, - ...loggingConfig.loggers, - ]; + const loggers = [{ name: ROOT_CONTEXT_NAME, ...loggingConfig.root }, ...loggingConfig.loggers]; const loggerConfigByContext = new Map( - loggers.map((loggerConfig) => toTuple(loggerConfig.context, loggerConfig)) + loggers.map((loggerConfig) => toTuple(loggerConfig.name, loggerConfig)) ); for (const [loggerContext, loggerConfig] of loggerConfigByContext) { @@ -247,7 +244,7 @@ function getAppenders( loggerConfig: LoggerConfigType, loggerConfigByContext: Map ) { - let currentContext = loggerConfig.context; + let currentContext = loggerConfig.name; let appenders = loggerConfig.appenders; while (appenders.length === 0) { diff --git a/src/core/server/logging/logging_service.test.ts b/src/core/server/logging/logging_service.test.ts index 66f1c67f114024..341a04736b87a4 100644 --- a/src/core/server/logging/logging_service.test.ts +++ b/src/core/server/logging/logging_service.test.ts @@ -30,11 +30,11 @@ describe('LoggingService', () => { it('forwards configuration changes to logging system', () => { const config1: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + loggers: [{ name: 'subcontext', appenders: ['console'], level: 'warn' }], }; const config2: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['default'], level: 'all' }], + loggers: [{ name: 'subcontext', appenders: ['default'], level: 'all' }], }; setup.configure(['test', 'context'], of(config1, config2)); @@ -54,11 +54,11 @@ describe('LoggingService', () => { const updates$ = new Subject(); const config1: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + loggers: [{ name: 'subcontext', appenders: ['console'], level: 'warn' }], }; const config2: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['default'], level: 'all' }], + loggers: [{ name: 'subcontext', appenders: ['default'], level: 'all' }], }; setup.configure(['test', 'context'], updates$); @@ -78,7 +78,7 @@ describe('LoggingService', () => { const updates$ = new Subject(); const config1: LoggerContextConfigType = { appenders: new Map(), - loggers: [{ context: 'subcontext', appenders: ['console'], level: 'warn' }], + loggers: [{ name: 'subcontext', appenders: ['console'], level: 'warn' }], }; setup.configure(['test', 'context'], updates$); diff --git a/src/core/server/logging/logging_service.ts b/src/core/server/logging/logging_service.ts index f76533dadd5c84..f5a4717fdbfaf4 100644 --- a/src/core/server/logging/logging_service.ts +++ b/src/core/server/logging/logging_service.ts @@ -31,7 +31,7 @@ export interface LoggingServiceSetup { * core.logging.configure( * of({ * appenders: new Map(), - * loggers: [{ context: 'search', appenders: ['default'] }] + * loggers: [{ name: 'search', appenders: ['default'] }] * }) * ) * ``` diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index 4d4ed191e60f8e..f68d6c6a97fbc4 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -46,7 +46,7 @@ test('uses default memory buffer logger until config is provided', () => { const logger = system.get('test', 'context'); logger.trace('trace message'); - // We shouldn't create new buffer appender for another context. + // We shouldn't create new buffer appender for another context name. const anotherLogger = system.get('test', 'context2'); anotherLogger.fatal('fatal message', { some: 'value' }); @@ -69,7 +69,7 @@ test('flushes memory buffer logger and switches to real logger once config is pr // Switch to console appender with `info` level, so that `trace` message won't go through. await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -102,12 +102,12 @@ test('appends records via multiple appenders.', async () => { await system.upgrade( config.schema.validate({ appenders: { - default: { kind: 'console', layout: { kind: 'pattern' } }, - file: { kind: 'file', layout: { kind: 'pattern' }, path: 'path' }, + default: { type: 'console', layout: { type: 'pattern' } }, + file: { type: 'file', layout: { type: 'pattern' }, fileName: 'path' }, }, loggers: [ - { appenders: ['file'], context: 'tests', level: 'warn' }, - { context: 'tests.child', level: 'error' }, + { appenders: ['file'], name: 'tests', level: 'warn' }, + { name: 'tests.child', level: 'error' }, ], }) ); @@ -121,10 +121,10 @@ test('appends records via multiple appenders.', async () => { expect(mockStreamWrite.mock.calls[1][0]).toMatchSnapshot('file logs'); }); -test('uses `root` logger if context is not specified.', async () => { +test('uses `root` logger if context name is not specified.', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'pattern' } } }, + appenders: { default: { type: 'console', layout: { type: 'pattern' } } }, }) ); @@ -137,7 +137,7 @@ test('uses `root` logger if context is not specified.', async () => { test('`stop()` disposes all appenders.', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -156,7 +156,7 @@ test('asLoggerFactory() only allows to create new loggers.', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'all' }, }) ); @@ -180,7 +180,7 @@ test('setContextConfig() updates config with relative contexts', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -189,10 +189,10 @@ test('setContextConfig() updates config with relative contexts', async () => { appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], }); testsLogger.warn('tests log to default!'); @@ -235,7 +235,7 @@ test('setContextConfig() updates config for a root context', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -244,10 +244,10 @@ test('setContextConfig() updates config for a root context', async () => { appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: '', appenders: ['custom'], level: 'debug' }], + loggers: [{ name: '', appenders: ['custom'], level: 'debug' }], }); testsLogger.warn('tests log to default!'); @@ -273,21 +273,21 @@ test('setContextConfig() updates config for a root context', async () => { ); }); -test('custom context configs are applied on subsequent calls to update()', async () => { +test('custom context name configs are applied on subsequent calls to update()', async () => { await system.setContextConfig(['tests', 'child'], { appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], }); // Calling upgrade after setContextConfig should not throw away the context-specific config await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -310,10 +310,10 @@ test('custom context configs are applied on subsequent calls to update()', async ); }); -test('subsequent calls to setContextConfig() for the same context override the previous config', async () => { +test('subsequent calls to setContextConfig() for the same context name override the previous config', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -322,10 +322,10 @@ test('subsequent calls to setContextConfig() for the same context override the p appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], }); // Call again, this time with level: 'warn' and a different pattern @@ -334,12 +334,12 @@ test('subsequent calls to setContextConfig() for the same context override the p [ 'custom', { - kind: 'console', - layout: { kind: 'pattern', pattern: '[%level][%logger] second pattern! %message' }, + type: 'console', + layout: { type: 'pattern', pattern: '[%level][%logger] second pattern! %message' }, }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'warn' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'warn' }], }); const logger = system.get('tests', 'child', 'grandchild'); @@ -360,10 +360,10 @@ test('subsequent calls to setContextConfig() for the same context override the p ); }); -test('subsequent calls to setContextConfig() for the same context can disable the previous config', async () => { +test('subsequent calls to setContextConfig() for the same context name can disable the previous config', async () => { await system.upgrade( config.schema.validate({ - appenders: { default: { kind: 'console', layout: { kind: 'json' } } }, + appenders: { default: { type: 'console', layout: { type: 'json' } } }, root: { level: 'info' }, }) ); @@ -372,10 +372,10 @@ test('subsequent calls to setContextConfig() for the same context can disable th appenders: new Map([ [ 'custom', - { kind: 'console', layout: { kind: 'pattern', pattern: '[%level][%logger] %message' } }, + { type: 'console', layout: { type: 'pattern', pattern: '[%level][%logger] %message' } }, ], ]), - loggers: [{ context: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], + loggers: [{ name: 'grandchild', appenders: ['default', 'custom'], level: 'debug' }], }); // Call again, this time no customizations (effectively disabling) diff --git a/src/core/server/logging/logging_system.ts b/src/core/server/logging/logging_system.ts index 9c22cea23720b7..9ae434aff41d3c 100644 --- a/src/core/server/logging/logging_system.ts +++ b/src/core/server/logging/logging_system.ts @@ -79,7 +79,7 @@ export class LoggingSystem implements LoggerFactory { * loggingSystem.setContextConfig( * ['plugins', 'data'], * { - * loggers: [{ context: 'search', appenders: ['default'] }] + * loggers: [{ name: 'search', appenders: ['default'] }] * } * ) * ``` @@ -95,9 +95,7 @@ export class LoggingSystem implements LoggerFactory { // Automatically prepend the base context to the logger sub-contexts loggers: contextConfig.loggers.map((l) => ({ ...l, - context: LoggingConfig.getLoggerContext( - l.context.length > 0 ? [context, l.context] : [context] - ), + name: LoggingConfig.getLoggerContext(l.name.length > 0 ? [context, l.name] : [context]), })), }); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index a4ce94b1776128..19056ae1b9bc76 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -196,8 +196,9 @@ function createCoreRequestHandlerContextMock() { savedObjects: { client: savedObjectsClientMock.create(), typeRegistry: savedObjectsTypeRegistryMock.create(), - exporter: savedObjectsServiceMock.createExporter(), - importer: savedObjectsServiceMock.createImporter(), + getClient: savedObjectsClientMock.create, + getExporter: savedObjectsServiceMock.createExporter, + getImporter: savedObjectsServiceMock.createImporter, }, elasticsearch: { client: elasticsearchServiceMock.createScopedClusterClient(), diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 776c7b195922e1..f29a8b61b48858 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -143,7 +143,7 @@ describe('DocumentMigrator', () => { ).toThrow(/Migrations are not ready. Make sure prepareMigrations is called first./i); }); - it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple'`, () => { + it(`validates convertToMultiNamespaceTypeVersion can only be used with namespaceType 'multiple' or 'multiple-isolated'`, () => { const invalidDefinition = { kibanaVersion: '3.2.3', typeRegistry: createRegistry({ @@ -154,7 +154,7 @@ describe('DocumentMigrator', () => { log: mockLogger, }; expect(() => new DocumentMigrator(invalidDefinition)).toThrow( - `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple', but got 'single'.` + `Invalid convertToMultiNamespaceTypeVersion for type foo. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got 'single'.` ); }); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index b61c4cfe967e71..47f4dda75cdcd7 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -312,9 +312,9 @@ function validateMigrationDefinition( convertToMultiNamespaceTypeVersion: string, type: string ) { - if (namespaceType !== 'multiple') { + if (namespaceType !== 'multiple' && namespaceType !== 'multiple-isolated') { throw new Error( - `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple', but got '${namespaceType}'.` + `Invalid convertToMultiNamespaceTypeVersion for type ${type}. Expected namespaceType to be 'multiple' or 'multiple-isolated', but got '${namespaceType}'.` ); } else if (!Semver.valid(convertToMultiNamespaceTypeVersion)) { throw new Error( @@ -374,7 +374,7 @@ function buildActiveMigrations( const migrationTransforms = Object.entries(migrationsMap ?? {}).map( ([version, transform]) => ({ version, - transform: wrapWithTry(version, type.name, transform, log), + transform: wrapWithTry(version, type, transform, log), transformType: 'migrate', }) ); @@ -655,24 +655,28 @@ function transformComparator(a: Transform, b: Transform) { */ function wrapWithTry( version: string, - type: string, + type: SavedObjectsType, migrationFn: SavedObjectMigrationFn, log: Logger ) { return function tryTransformDoc(doc: SavedObjectUnsanitizedDoc) { try { - const context = { log: new MigrationLogger(log) }; + const context = { + log: new MigrationLogger(log), + migrationVersion: version, + convertToMultiNamespaceTypeVersion: type.convertToMultiNamespaceTypeVersion, + }; const result = migrationFn(doc, context); // A basic sanity check to help migration authors detect basic errors // (e.g. forgetting to return the transformed doc) if (!result || !result.type) { - throw new Error(`Invalid saved object returned from migration ${type}:${version}.`); + throw new Error(`Invalid saved object returned from migration ${type.name}:${version}.`); } return { transformedDoc: result, additionalDocs: [] }; } catch (error) { - const failedTransform = `${type}:${version}`; + const failedTransform = `${type.name}:${version}`; const failedDoc = JSON.stringify(doc); log.warn( `Failed to transform document ${doc?.id}. Transform: ${failedTransform}\nDoc: ${failedDoc}` diff --git a/src/core/server/saved_objects/migrations/mocks.ts b/src/core/server/saved_objects/migrations/mocks.ts index f0360ec180d6e4..4a62fcc95997bb 100644 --- a/src/core/server/saved_objects/migrations/mocks.ts +++ b/src/core/server/saved_objects/migrations/mocks.ts @@ -21,9 +21,17 @@ export const createSavedObjectsMigrationLoggerMock = (): jest.Mocked => { +const createContextMock = ({ + migrationVersion = '8.0.0', + convertToMultiNamespaceTypeVersion, +}: { + migrationVersion?: string; + convertToMultiNamespaceTypeVersion?: string; +} = {}): jest.Mocked => { const mock = { log: createSavedObjectsMigrationLoggerMock(), + migrationVersion, + convertToMultiNamespaceTypeVersion, }; return mock; }; diff --git a/src/core/server/saved_objects/migrations/types.ts b/src/core/server/saved_objects/migrations/types.ts index 630be58eb047dc..619a7f85a327b3 100644 --- a/src/core/server/saved_objects/migrations/types.ts +++ b/src/core/server/saved_objects/migrations/types.ts @@ -57,6 +57,14 @@ export interface SavedObjectMigrationContext { * logger instance to be used by the migration handler */ log: SavedObjectsMigrationLogger; + /** + * The migration version that this migration function is defined for + */ + migrationVersion: string; + /** + * The version in which this object type is being converted to a multi-namespace type + */ + convertToMultiNamespaceTypeVersion?: string; } /** diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts index 317bfe33b3a199..95a867934307a4 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration.test.ts @@ -45,16 +45,16 @@ describe('migration v2', () => { logging: { appenders: { file: { - kind: 'file', - path: join(__dirname, 'migration_test_kibana.log'), + type: 'file', + fileName: join(__dirname, 'migration_test_kibana.log'), layout: { - kind: 'json', + type: 'json', }, }, }, loggers: [ { - context: 'root', + name: 'root', appenders: ['file'], }, ], diff --git a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts index 16ba0c855867ce..c26d4593bede19 100644 --- a/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts +++ b/src/core/server/saved_objects/migrationsv2/integration_tests/migration_7.7.2_xpack_100k.test.ts @@ -47,16 +47,16 @@ describe.skip('migration from 7.7.2-xpack with 100k objects', () => { logging: { appenders: { file: { - kind: 'file', - path: join(__dirname, 'migration_test_kibana.log'), + type: 'file', + fileName: join(__dirname, 'migration_test_kibana.log'), layout: { - kind: 'json', + type: 'json', }, }, }, loggers: [ { - context: 'root', + name: 'root', appenders: ['file'], }, ], diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index 609ce2692c7770..fe08acf23fd238 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -32,11 +32,13 @@ export const registerDeleteRoute = (router: IRouter, { coreUsageData }: RouteDep catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { force } = req.query; + const { getClient } = context.core.savedObjects; const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsDelete({ request: req }).catch(() => {}); - const result = await context.core.savedObjects.client.delete(type, id, { force }); + const client = getClient(); + const result = await client.delete(type, id, { force }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index fa5517303f18f2..e0293a4522fc14 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -165,9 +165,9 @@ export const registerExportRoute = ( }, catchAndReturnBoomErrors(async (context, req, res) => { const cleaned = cleanOptions(req.body); - const supportedTypes = context.core.savedObjects.typeRegistry - .getImportableAndExportableTypes() - .map((t) => t.name); + const { typeRegistry, getExporter, getClient } = context.core.savedObjects; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((t) => t.name); + let options: EitherExportOptions; try { options = validateOptions(cleaned, { @@ -181,7 +181,12 @@ export const registerExportRoute = ( }); } - const exporter = context.core.savedObjects.exporter; + const includedHiddenTypes = supportedTypes.filter((supportedType) => + typeRegistry.isHidden(supportedType) + ); + + const client = getClient({ includedHiddenTypes }); + const exporter = getExporter(client); const usageStatsClient = coreUsageData.getClient(); usageStatsClient diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index e84c638d3ec999..6f75bcf9fd5bf2 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -63,6 +63,7 @@ export const registerImportRoute = ( }, catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite, createNewCopies } = req.query; + const { getClient, getImporter, typeRegistry } = context.core.savedObjects; const usageStatsClient = coreUsageData.getClient(); usageStatsClient @@ -84,7 +85,15 @@ export const registerImportRoute = ( }); } - const { importer } = context.core.savedObjects; + const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((t) => t.name); + + const includedHiddenTypes = supportedTypes.filter((supportedType) => + typeRegistry.isHidden(supportedType) + ); + + const client = getClient({ includedHiddenTypes }); + const importer = getImporter(client); + try { const result = await importer.import({ readStream, diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index 7b7a71b7ca858e..eaec6e16cbd8ca 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -26,7 +26,8 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); - savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient = handlerContext.savedObjects.getClient(); + handlerContext.savedObjects.getClient = jest.fn().mockImplementation(() => savedObjectsClient); const router = httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/integration_tests/export.test.ts b/src/core/server/saved_objects/routes/integration_tests/export.test.ts index 40f13064b53f08..09d475f29f3629 100644 --- a/src/core/server/saved_objects/routes/integration_tests/export.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/export.test.ts @@ -40,9 +40,13 @@ describe('POST /api/saved_objects/_export', () => { handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( allowedTypes.map(createExportableType) ); - exporter = handlerContext.savedObjects.exporter; + exporter = handlerContext.savedObjects.getExporter(); const router = httpSetup.createRouter('/api/saved_objects/'); + handlerContext.savedObjects.getExporter = jest + .fn() + .mockImplementation(() => exporter as ReturnType); + coreUsageStatsClient = coreUsageStatsClientMock.create(); coreUsageStatsClient.incrementSavedObjectsExport.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient); @@ -77,6 +81,7 @@ describe('POST /api/saved_objects/_export', () => { ], }, ]; + exporter.exportByTypes.mockResolvedValueOnce(createListStream(sortedObjects)); const result = await supertest(httpSetup.server.listener) diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index 24122c61c9f42d..be4d2160a967be 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -68,9 +68,9 @@ describe(`POST ${URL}`, () => { typeRegistry: handlerContext.savedObjects.typeRegistry, importSizeLimit: 10000, }); - handlerContext.savedObjects.importer.import.mockImplementation((options) => - importer.import(options) - ); + handlerContext.savedObjects.getImporter = jest + .fn() + .mockImplementation(() => importer as jest.Mocked); const router = httpSetup.createRouter('/internal/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); diff --git a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts index b23211aef092fc..d84b56156b5434 100644 --- a/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/resolve_import_errors.test.ts @@ -66,7 +66,7 @@ describe(`POST ${URL}`, () => { } as any) ); - savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient = handlerContext.savedObjects.getClient(); savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); const importer = new SavedObjectsImporter({ @@ -74,9 +74,10 @@ describe(`POST ${URL}`, () => { typeRegistry: handlerContext.savedObjects.typeRegistry, importSizeLimit: 10000, }); - handlerContext.savedObjects.importer.resolveImportErrors.mockImplementation((options) => - importer.resolveImportErrors(options) - ); + + handlerContext.savedObjects.getImporter = jest + .fn() + .mockImplementation(() => importer as jest.Mocked); const router = httpSetup.createRouter('/api/saved_objects/'); coreUsageStatsClient = coreUsageStatsClientMock.create(); 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 2a664328d4df29..a05c7d30b91fdc 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -9,6 +9,7 @@ import { extname } from 'path'; import { Readable } from 'stream'; import { schema } from '@kbn/config-schema'; +import { chain } from 'lodash'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; @@ -91,7 +92,18 @@ export const registerResolveImportErrorsRoute = ( }); } - const { importer } = context.core.savedObjects; + const { getClient, getImporter, typeRegistry } = context.core.savedObjects; + + const includedHiddenTypes = chain(req.body.retries) + .map('type') + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); + const importer = getImporter(client); try { const result = await importer.resolveImportErrors({ diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index 79b9c2feb1cbb4..d53a53d745c0c8 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -20,6 +20,7 @@ const createRegistryMock = (): jest.Mocked< isNamespaceAgnostic: jest.fn(), isSingleNamespace: jest.fn(), isMultiNamespace: jest.fn(), + isShareable: jest.fn(), isHidden: jest.fn(), getIndex: jest.fn(), isImportableAndExportable: jest.fn(), @@ -36,6 +37,7 @@ const createRegistryMock = (): jest.Mocked< (type: string) => type !== 'global' && type !== 'shared' ); mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); + mock.isShareable.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); return mock; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index c0eb7891cd7d43..872b61706c526a 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -239,6 +239,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); @@ -263,6 +264,7 @@ describe('SavedObjectTypeRegistry', () => { it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); }); }); @@ -277,12 +279,36 @@ describe('SavedObjectTypeRegistry', () => { expect(registry.isMultiNamespace('unknownType')).toEqual(false); }); + it(`returns true for namespaceType 'multiple' and 'multiple-isolated'`, () => { + expectResult(true, { namespaceType: 'multiple' }); + expectResult(true, { namespaceType: 'multiple-isolated' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); + }); + }); + + describe('#isShareable', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isShareable('foo')).toBe(expected); + }; + + it(`returns false when the type is not registered`, () => { + expect(registry.isShareable('unknownType')).toEqual(false); + }); + it(`returns true for namespaceType 'multiple'`, () => { expectResult(true, { namespaceType: 'multiple' }); }); it(`returns false for other namespaceType`, () => { expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'multiple-isolated' }); expectResult(false, { namespaceType: 'single' }); expectResult(false, { namespaceType: undefined }); }); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index 8a50beda83d2a0..a63837132b652e 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -86,10 +86,19 @@ export class SavedObjectTypeRegistry { } /** - * Returns whether the type is multi-namespace (shareable); + * Returns whether the type is multi-namespace (shareable *or* isolated); * resolves to `false` if the type is not registered */ public isMultiNamespace(type: string) { + const namespaceType = this.types.get(type)?.namespaceType; + return namespaceType === 'multiple' || namespaceType === 'multiple-isolated'; + } + + /** + * Returns whether the type is multi-namespace (shareable); + * resolves to `false` if the type is not registered + */ + public isShareable(type: string) { return this.types.get(type)?.namespaceType === 'multiple'; } 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 e77143d13612ff..d26d92e84925a7 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -48,9 +48,29 @@ describe('SavedObjectsRepository', () => { const KIBANA_VERSION = '2.0.0'; const CUSTOM_INDEX_TYPE = 'customIndex'; + /** This type has namespaceType: 'agnostic'. */ const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; - const MULTI_NAMESPACE_TYPE = 'shareableType'; - const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'shareableTypeCustomIndex'; + /** + * This type has namespaceType: 'multiple'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is shareable across + * namespaces. + **/ + const MULTI_NAMESPACE_TYPE = 'multiNamespaceType'; + /** + * This type has namespaceType: 'multiple-isolated'. + * + * That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable + * across namespaces. This distinction only matters when using the `addToNamespaces` and `deleteFromNamespaces` APIs, or when using the + * `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what namespaces an object + * exists in. + * + * In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases + * where `MULTI_NAMESPACE_TYPE` would also satisfy the test case. + **/ + const MULTI_NAMESPACE_ISOLATED_TYPE = 'multiNamespaceIsolatedType'; + /** This type has namespaceType: 'multiple', and it uses a custom index. */ + const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'multiNamespaceTypeCustomIndex'; const HIDDEN_TYPE = 'hiddenType'; const mappings = { @@ -93,6 +113,13 @@ describe('SavedObjectsRepository', () => { }, }, }, + [MULTI_NAMESPACE_ISOLATED_TYPE]: { + properties: { + evenYetAnotherField: { + type: 'keyword', + }, + }, + }, [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { properties: { evenYetAnotherField: { @@ -132,6 +159,10 @@ describe('SavedObjectsRepository', () => { ...createType(MULTI_NAMESPACE_TYPE), namespaceType: 'multiple', }); + registry.registerType({ + ...createType(MULTI_NAMESPACE_ISOLATED_TYPE), + namespaceType: 'multiple-isolated', + }); registry.registerType({ ...createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE), namespaceType: 'multiple', @@ -345,13 +376,14 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [newNs1, newNs2], message); expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -518,11 +550,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget.mock.calls[0][0].body).toEqual({ docs }); }); @@ -601,7 +635,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); @@ -614,7 +648,7 @@ describe('SavedObjectsRepository', () => { it(`adds namespaces to request body for any types that are multi-namespace`, async () => { const test = async (namespace) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); const namespaces = [namespace ?? 'default']; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.objectContaining({ namespaces }); @@ -706,7 +740,7 @@ describe('SavedObjectsRepository', () => { const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); @@ -753,7 +787,7 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); - it(`returns error when initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`returns error when initialNamespaces is used with a non-shareable object`, async () => { const test = async (objType) => { const obj = { ...obj3, type: objType, initialNamespaces: [] }; await bulkCreateError( @@ -767,9 +801,10 @@ describe('SavedObjectsRepository', () => { }; await test('dashboard'); await test(NAMESPACE_AGNOSTIC_TYPE); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; await bulkCreateError( obj, @@ -792,7 +827,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE }; + const obj = { ...obj3, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const response1 = { status: 200, docs: [ @@ -884,7 +919,7 @@ describe('SavedObjectsRepository', () => { it(`doesn't add namespace to body when not using single-namespace type`, async () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespace: expect.anything() }, false, 1); @@ -892,14 +927,20 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespaces: [namespace] }, true, 1); expectMigrationArgs({ namespaces: [namespace] }, true, 2); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); await bulkCreateSuccess(objects); expectMigrationArgs({ namespaces: ['default'] }, true, 1); expectMigrationArgs({ namespaces: ['default'] }, true, 2); @@ -1070,7 +1111,7 @@ describe('SavedObjectsRepository', () => { _expectClientCallArgs(objects, { getId }); client.mget.mockClear(); - objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE })); await bulkGetSuccess(objects, { namespace }); _expectClientCallArgs(objects, { getId }); }); @@ -1130,7 +1171,7 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const response = getMockMgetResponse([obj1, obj, obj2]); response.docs[1].namespaces = ['bar-namespace']; await bulkGetErrorNotFound([obj1, obj, obj2], { namespace }, response); @@ -1189,7 +1230,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkGetSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1291,12 +1332,14 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkUpdateSuccess(objects); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), + ]; expect(client.mget).toHaveBeenCalledWith( expect.objectContaining({ body: { docs } }), expect.anything() @@ -1313,7 +1356,7 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES request for any types that are multi-namespace`, async () => { - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([obj1, _obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; expect(client.bulk).toHaveBeenCalledWith( @@ -1384,8 +1427,8 @@ describe('SavedObjectsRepository', () => { it(`defaults to the version of the existing document for multi-namespace types`, async () => { // only multi-namespace documents are obtained using a pre-flight mget request const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkUpdateSuccess(objects); const overrides = { @@ -1406,7 +1449,7 @@ describe('SavedObjectsRepository', () => { // test with both non-multi-namespace and multi-namespace types const objects = [ { ...obj1, version }, - { ...obj2, type: MULTI_NAMESPACE_TYPE, version }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, version }, ]; await bulkUpdateSuccess(objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; @@ -1459,7 +1502,7 @@ describe('SavedObjectsRepository', () => { if_seq_no: expect.any(Number), }; const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; - const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess([_obj1], { namespace }); expectClientCallArgsAction([_obj1], { method: 'update', getId }); @@ -1558,19 +1601,19 @@ describe('SavedObjectsRepository', () => { }); it(`returns error when ES is unable to find the document (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; const mgetResponse = getMockMgetResponse([_obj]); await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); }); it(`returns error when ES is unable to find the index (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = { statusCode: 404 }; await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { - const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; const mgetResponse = getMockMgetResponse([_obj], 'bar-namespace'); await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); @@ -1643,7 +1686,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ @@ -1654,7 +1697,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes originId property if present in cluster call response`, async () => { - const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const obj = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj], {}, true); expect(result).toEqual({ saved_objects: [ @@ -1669,9 +1712,9 @@ describe('SavedObjectsRepository', () => { describe('#checkConflicts', () => { const obj1 = { type: 'dashboard', id: 'one' }; const obj2 = { type: 'dashboard', id: 'two' }; - const obj3 = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; - const obj4 = { type: MULTI_NAMESPACE_TYPE, id: 'four' }; - const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; + const obj3 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'three' }; + const obj4 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'four' }; + const obj5 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'five' }; const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; const namespace = 'foo-namespace'; @@ -1854,7 +1897,7 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true }); expect(client.get).toHaveBeenCalled(); expect(client.index).toHaveBeenCalled(); }); @@ -1975,10 +2018,10 @@ describe('SavedObjectsRepository', () => { }); it(`doesn't prepend namespace to the id and adds namespaces to body when using multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, body: expect.objectContaining({ namespaces: [namespace] }), }), expect.anything() @@ -2013,7 +2056,7 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when options.initialNamespaces is used with a non-multi-namespace object`, async () => { + it(`throws when options.initialNamespaces is used with a non-shareable object`, async () => { const test = async (objType) => { await expect( savedObjectsRepository.create(objType, attributes, { initialNamespaces: [namespace] }) @@ -2024,10 +2067,11 @@ describe('SavedObjectsRepository', () => { ); }; await test('dashboard'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); - it(`throws when options.initialNamespaces is used with a multi-namespace type and is empty`, async () => { + it(`throws when options.initialNamespaces is used with a shareable type and is empty`, async () => { await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { initialNamespaces: [] }) ).rejects.toThrowError( @@ -2056,17 +2100,20 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + savedObjectsRepository.create(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, overwrite: true, namespace, }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalled(); }); @@ -2105,17 +2152,17 @@ describe('SavedObjectsRepository', () => { expectMigrationArgs({ namespace: expect.anything() }, false, 1); client.create.mockClear(); - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id, namespace }); expectMigrationArgs({ namespaces: [namespace] }); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + await createSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, attributes, { id }); expectMigrationArgs({ namespaces: ['default'] }); }); @@ -2181,13 +2228,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use ES get action then delete action when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledTimes(1); }); it(`includes the version of the existing document when using a multi-namespace type`, async () => { - await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -2238,9 +2285,9 @@ describe('SavedObjectsRepository', () => { ); client.delete.mockClear(); - await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }), + expect.objectContaining({ id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}` }), expect.anything() ); }); @@ -2273,7 +2320,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -2281,27 +2328,29 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = [namespace, 'bar-namespace']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -2309,13 +2358,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace }); response._source.namespaces = ['*']; client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + savedObjectsRepository.delete(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }) ).rejects.toThrowError( 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); @@ -3200,10 +3249,10 @@ describe('SavedObjectsRepository', () => { ); client.get.mockClear(); - await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, { namespace }); expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3250,11 +3299,13 @@ describe('SavedObjectsRepository', () => { }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -3276,7 +3327,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await getSuccess(MULTI_NAMESPACE_TYPE, id); + const result = await getSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(result).toMatchObject({ namespaces: expect.any(Array), }); @@ -3451,8 +3502,12 @@ describe('SavedObjectsRepository', () => { it('but alias target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id }, // correct namespace field is added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, // correct namespace field is added by getMockMgetResponse + { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: aliasTargetId, + namespace: `not-${namespace}`, + }, // overrides namespace field that would otherwise be added by getMockMgetResponse ]; await expectExactMatchResult(objects); }); @@ -3475,6 +3530,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id: aliasTargetId }), outcome: 'aliasMatch', + aliasTargetId, }); }; @@ -3488,8 +3544,8 @@ describe('SavedObjectsRepository', () => { it('because actual target does not exist in this namespace', async () => { const objects = [ - { type: MULTI_NAMESPACE_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse - { type: MULTI_NAMESPACE_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id, namespace: `not-${namespace}` }, // overrides namespace field that would otherwise be added by getMockMgetResponse + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: aliasTargetId }, // correct namespace field is added by getMockMgetResponse ]; await expectAliasMatchResult(objects); }); @@ -3515,6 +3571,7 @@ describe('SavedObjectsRepository', () => { expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'conflict', + aliasTargetId, }); }); }); @@ -3570,7 +3627,9 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -3625,10 +3684,12 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, counterFields, { namespace }); + await incrementCounterSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, counterFields, { + namespace, + }); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ - id: `${MULTI_NAMESPACE_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`, }), expect.anything() ); @@ -3693,15 +3754,23 @@ describe('SavedObjectsRepository', () => { }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace'); + const response = getMockGetResponse( + { type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, + 'bar-namespace' + ); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); await expect( - savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, counterFields, { - namespace, - }) - ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + savedObjectsRepository.incrementCounter( + MULTI_NAMESPACE_ISOLATED_TYPE, + id, + counterFields, + { + namespace, + } + ) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_ISOLATED_TYPE, id)); expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -4009,7 +4078,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }); - it(`throws when type is not multi-namespace`, async () => { + it(`throws when type is not shareable`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [namespace1, namespace2], message); @@ -4017,6 +4086,7 @@ describe('SavedObjectsRepository', () => { expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); + await test(MULTI_NAMESPACE_ISOLATED_TYPE); await test(NAMESPACE_AGNOSTIC_TYPE); }); @@ -4181,7 +4251,7 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES get action then update action when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.get).toHaveBeenCalledTimes(1); expect(client.update).toHaveBeenCalledTimes(1); }); @@ -4245,7 +4315,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to the version of the existing document when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { references }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { references }); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, @@ -4300,15 +4370,17 @@ describe('SavedObjectsRepository', () => { ); client.update.mockClear(); - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { namespace }); expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }), + expect.objectContaining({ + id: expect.stringMatching(`${MULTI_NAMESPACE_ISOLATED_TYPE}:${id}`), + }), expect.anything() ); }); it(`includes _source_includes when type is multi-namespace`, async () => { - await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.update).toHaveBeenCalledWith( expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), expect.anything() @@ -4353,7 +4425,7 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4361,16 +4433,18 @@ describe('SavedObjectsRepository', () => { client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { - const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace); + const response = getMockGetResponse({ type: MULTI_NAMESPACE_ISOLATED_TYPE, id }, namespace); client.get.mockResolvedValueOnce( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + await expectNotFoundError(MULTI_NAMESPACE_ISOLATED_TYPE, id, { + namespace: 'bar-namespace', + }); expect(client.get).toHaveBeenCalledTimes(1); }); @@ -4407,7 +4481,7 @@ describe('SavedObjectsRepository', () => { }); it(`includes namespaces if type is multi-namespace`, async () => { - const result = await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + const result = await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(result).toMatchObject({ namespaces: expect.any(Array), }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index b8a72377b0d764..78c3cdcb91e029 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -251,7 +251,7 @@ export class SavedObjectsRepository { const namespace = normalizeNamespace(options.namespace); if (initialNamespaces) { - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( '"options.initialNamespaces" can only be used on multi-namespace types' ); @@ -340,7 +340,7 @@ export class SavedObjectsRepository { if (!this._allowedTypes.includes(object.type)) { error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); } else if (object.initialNamespaces) { - if (!this._registry.isMultiNamespace(object.type)) { + if (!this._registry.isShareable(object.type)) { error = SavedObjectsErrorHelpers.createBadRequestError( '"initialNamespaces" can only be used on multi-namespace types' ); @@ -1085,6 +1085,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, id, exactMatchDoc), outcome: 'conflict', + aliasTargetId: legacyUrlAlias.targetId, }; } else if (foundExactMatch) { return { @@ -1095,6 +1096,7 @@ export class SavedObjectsRepository { return { saved_object: this.getSavedObjectFromSource(type, legacyUrlAlias.targetId, aliasMatchDoc), outcome: 'aliasMatch', + aliasTargetId: legacyUrlAlias.targetId, }; } throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -1194,7 +1196,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); @@ -1257,7 +1259,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - if (!this._registry.isMultiNamespace(type)) { + if (!this._registry.isShareable(type)) { throw SavedObjectsErrorHelpers.createBadRequestError( `${type} doesn't support multiple namespaces` ); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index b93f3022e4236c..b078f3eef018cd 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -339,6 +339,10 @@ export interface SavedObjectsResolveResponse { * `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. */ outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + /** + * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + */ + aliasTargetId?: string; } /** diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 66110d096213f5..57a77a9ebc5257 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -213,13 +213,17 @@ export type SavedObjectsClientContract = Pick SavedObjectMigrationMap); /** - * If defined, objects of this type will be converted to multi-namespace objects when migrating to this version. + * If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this + * version. * * Requirements: * * 1. This string value must be a valid semver version * 2. This type must have previously specified {@link SavedObjectsNamespaceType | `namespaceType: 'single'`} - * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} + * 3. This type must also specify {@link SavedObjectsNamespaceType | `namespaceType: 'multiple'`} *or* + * {@link SavedObjectsNamespaceType | `namespaceType: 'multiple-isolated'`} * - * Example of a single-namespace type in 7.10: + * Example of a single-namespace type in 7.12: * * ```ts * { @@ -278,7 +284,19 @@ export interface SavedObjectsType { * } * ``` * - * Example after converting to a multi-namespace type in 7.11: + * Example after converting to a multi-namespace (isolated) type in 8.0: + * + * ```ts + * { + * name: 'foo', + * hidden: false, + * namespaceType: 'multiple-isolated', + * mappings: {...}, + * convertToMultiNamespaceTypeVersion: '8.0.0' + * } + * ``` + * + * Example after converting to a multi-namespace (shareable) type in 8.1: * * ```ts * { @@ -286,11 +304,11 @@ export interface SavedObjectsType { * hidden: false, * namespaceType: 'multiple', * mappings: {...}, - * convertToMultiNamespaceTypeVersion: '7.11.0' + * convertToMultiNamespaceTypeVersion: '8.0.0' * } * ``` * - * Note: a migration function can be optionally specified for the same version. + * Note: migration function(s) can be optionally specified for any of these versions and will not interfere with the conversion process. */ convertToMultiNamespaceTypeVersion?: string; /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b5f8b9d69abf31..377cd2bc2068a9 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1924,8 +1924,9 @@ export interface RequestHandlerContext { savedObjects: { client: SavedObjectsClientContract; typeRegistry: ISavedObjectTypeRegistry; - exporter: ISavedObjectsExporter; - importer: ISavedObjectsImporter; + getClient: (options?: SavedObjectsClientProviderOptions) => SavedObjectsClientContract; + getExporter: (client: SavedObjectsClientContract) => ISavedObjectsExporter; + getImporter: (client: SavedObjectsClientContract) => ISavedObjectsImporter; }; elasticsearch: { client: IScopedClusterClient; @@ -2094,7 +2095,9 @@ export interface SavedObjectExportBaseOptions { // @public export interface SavedObjectMigrationContext { + convertToMultiNamespaceTypeVersion?: string; log: SavedObjectsMigrationLogger; + migrationVersion: string; } // @public @@ -2758,7 +2761,7 @@ export interface SavedObjectsMigrationVersion { } // @public -export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; // @public (undocumented) export interface SavedObjectsOpenPointInTimeOptions extends SavedObjectsBaseOptions { @@ -2850,6 +2853,7 @@ export interface SavedObjectsResolveImportErrorsOptions { // @public (undocumented) export interface SavedObjectsResolveResponse { + aliasTargetId?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; // (undocumented) saved_object: SavedObject; @@ -2963,6 +2967,7 @@ export class SavedObjectTypeRegistry { isImportableAndExportable(type: string): boolean; isMultiNamespace(type: string): boolean; isNamespaceAgnostic(type: string): boolean; + isShareable(type: string): boolean; isSingleNamespace(type: string): boolean; registerType(type: SavedObjectsType): void; } diff --git a/src/dev/build/lib/integration_tests/version_info.test.ts b/src/dev/build/lib/integration_tests/version_info.test.ts index 34d537611e0c6f..e7a3a04c047345 100644 --- a/src/dev/build/lib/integration_tests/version_info.test.ts +++ b/src/dev/build/lib/integration_tests/version_info.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { kibanaPackageJSON as pkg } from '@kbn/dev-utils'; +import { kibanaPackageJson as pkg } from '@kbn/dev-utils'; import { getVersionInfo } from '../version_info'; diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index db7110d2d0875d..c4559029e5607a 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -25,7 +25,16 @@ echo "build --remote_header=x-buildbuddy-api-key=$KIBANA_BUILDBUDDY_CI_API_KEY" ### install dependencies ### echo " -- installing node.js dependencies" -yarn kbn bootstrap +yarn kbn bootstrap --verbose + +### +### upload ts-refs-cache artifacts as quickly as possible so they are available for download +### +if [[ "$BUILD_TS_REFS_CACHE_CAPTURE" == "true" ]]; then + cd "$KIBANA_DIR/target/ts_refs_cache" + gsutil cp "*.zip" 'gs://kibana-ci-ts-refs-cache/' + cd "$KIBANA_DIR" +fi ### ### Download es snapshots diff --git a/src/dev/ci_setup/setup_env.sh b/src/dev/ci_setup/setup_env.sh index 2deafaaf35a94d..b9898960135fcd 100644 --- a/src/dev/ci_setup/setup_env.sh +++ b/src/dev/ci_setup/setup_env.sh @@ -56,7 +56,13 @@ export WORKSPACE="${WORKSPACE:-$PARENT_DIR}" nodeVersion="$(cat "$dir/.node-version")" nodeDir="$cacheDir/node/$nodeVersion" nodeBin="$nodeDir/bin" -classifier="x64.tar.gz" +hostArch="$(command uname -m)" +case "${hostArch}" in + x86_64 | amd64) nodeArch="x64" ;; + aarch64) nodeArch="arm64" ;; + *) nodeArch="${hostArch}" ;; +esac +classifier="$nodeArch.tar.gz" UNAME=$(uname) OS="linux" diff --git a/src/dev/code_coverage/shell_scripts/copy_jest_report.sh b/src/dev/code_coverage/shell_scripts/copy_jest_report.sh new file mode 100755 index 00000000000000..8369d5b467c029 --- /dev/null +++ b/src/dev/code_coverage/shell_scripts/copy_jest_report.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +EXTRACT_START_DIR=tmp/extracted_coverage +EXTRACT_END_DIR=target/kibana-coverage +COMBINED_EXRACT_DIR=/${EXTRACT_START_DIR}/${EXTRACT_END_DIR} + + +echo "### Copy combined jest report" +mkdir -p $EXTRACT_END_DIR/jest-combined +cp -r $COMBINED_EXRACT_DIR/jest-combined/. $EXTRACT_END_DIR/jest-combined/ diff --git a/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh b/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh index 01003b6dc880c7..6e6ba9e1b11180 100644 --- a/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh +++ b/src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh @@ -7,12 +7,6 @@ COMBINED_EXRACT_DIR=/${EXTRACT_START_DIR}/${EXTRACT_END_DIR} PWD=$(pwd) du -sh $COMBINED_EXRACT_DIR -echo "### Jest: replacing path in json files" -for i in oss oss-integration xpack; do - sed -i "s|/dev/shm/workspace/kibana|${PWD}|g" $COMBINED_EXRACT_DIR/jest/${i}-coverage-final.json & -done -wait - echo "### Functional: replacing path in json files" for i in {1..9}; do sed -i "s|/dev/shm/workspace/kibana|${PWD}|g" $COMBINED_EXRACT_DIR/functional/${i}*.json & diff --git a/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh b/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh index caa1f1a7613670..243dbaa6197e6d 100644 --- a/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh +++ b/src/dev/code_coverage/shell_scripts/generate_team_assignments_and_ingest_coverage.sh @@ -32,11 +32,16 @@ TEAM_ASSIGN_PATH=$5 # Build team assignments dat file node scripts/generate_team_assignments.js --verbose --src .github/CODEOWNERS --dest $TEAM_ASSIGN_PATH -for x in jest functional; do +for x in functional jest; do echo "### Ingesting coverage for ${x}" COVERAGE_SUMMARY_FILE=target/kibana-coverage/${x}-combined/coverage-summary.json + if [[ $x == "jest" ]]; then + # Need to override COVERAGE_INGESTION_KIBANA_ROOT since json file has original intake worker path + export COVERAGE_INGESTION_KIBANA_ROOT=/dev/shm/workspace/kibana + fi + node scripts/ingest_coverage.js --verbose --path ${COVERAGE_SUMMARY_FILE} --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH done diff --git a/src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh b/src/dev/code_coverage/shell_scripts/merge_functional.sh old mode 100644 new mode 100755 similarity index 54% rename from src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh rename to src/dev/code_coverage/shell_scripts/merge_functional.sh index 707c6de3f88a08..5f03e5f24528a7 --- a/src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh +++ b/src/dev/code_coverage/shell_scripts/merge_functional.sh @@ -4,6 +4,4 @@ COVERAGE_TEMP_DIR=/tmp/extracted_coverage/target/kibana-coverage/ export COVERAGE_TEMP_DIR echo "### Merge coverage reports" -for x in jest functional; do - yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.${x}.config.js -done +yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.functional.config.js diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 910f2bad15dde3..c72c81f489fb9d 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -10,6 +10,7 @@ export const storybookAliases = { apm: 'x-pack/plugins/apm/.storybook', canvas: 'x-pack/plugins/canvas/storybook', codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', + url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook', dashboard: 'src/plugins/dashboard/.storybook', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', data_enhanced: 'x-pack/plugins/data_enhanced/.storybook', diff --git a/src/dev/typescript/build_ts_refs_cli.ts b/src/dev/typescript/build_ts_refs_cli.ts index 1f7bf18b5012d9..fc8911a2517733 100644 --- a/src/dev/typescript/build_ts_refs_cli.ts +++ b/src/dev/typescript/build_ts_refs_cli.ts @@ -6,28 +6,62 @@ * Side Public License, v 1. */ -import { run } from '@kbn/dev-utils'; +import Path from 'path'; + +import { run, REPO_ROOT } from '@kbn/dev-utils'; import del from 'del'; +import { RefOutputCache } from './ref_output_cache'; import { buildAllTsRefs, REF_CONFIG_PATHS } from './build_ts_refs'; import { getOutputsDeep } from './ts_configfile'; import { concurrentMap } from './concurrent_map'; +const CACHE_WORKING_DIR = Path.resolve(REPO_ROOT, 'data/ts_refs_output_cache'); + export async function runBuildRefsCli() { run( async ({ log, flags }) => { - if (flags.clean) { - const outDirs = getOutputsDeep(REF_CONFIG_PATHS); + const outDirs = getOutputsDeep(REF_CONFIG_PATHS); + + const cacheEnabled = process.env.BUILD_TS_REFS_CACHE_ENABLE === 'true' || !!flags.cache; + const doCapture = process.env.BUILD_TS_REFS_CACHE_CAPTURE === 'true'; + const doClean = !!flags.clean || doCapture; + const doInitCache = cacheEnabled && !doClean; + + if (doClean) { log.info('deleting', outDirs.length, 'ts output directories'); await concurrentMap(100, outDirs, (outDir) => del(outDir)); } + let outputCache; + if (cacheEnabled) { + outputCache = await RefOutputCache.create({ + log, + outDirs, + repoRoot: REPO_ROOT, + workingDir: CACHE_WORKING_DIR, + upstreamUrl: 'https://github.com/elastic/kibana.git', + }); + } + + if (outputCache && doInitCache) { + await outputCache.initCaches(); + } + await buildAllTsRefs(log); + + if (outputCache && doCapture) { + await outputCache.captureCache(Path.resolve(REPO_ROOT, 'target/ts_refs_cache')); + } + + if (outputCache) { + await outputCache.cleanup(); + } }, { description: 'Build TypeScript projects', flags: { - boolean: ['clean'], + boolean: ['clean', 'cache'], }, log: { defaultLevel: 'debug', diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index a5e6c4bef832c3..8b306fc9671153 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -17,6 +17,9 @@ export const PROJECTS = [ new Project(resolve(REPO_ROOT, 'x-pack/tsconfig.json')), new Project(resolve(REPO_ROOT, 'x-pack/test/tsconfig.json'), { name: 'x-pack/test' }), new Project(resolve(REPO_ROOT, 'src/core/tsconfig.json')), + new Project(resolve(REPO_ROOT, 'x-pack/plugins/drilldowns/url_drilldown/tsconfig.json'), { + name: 'security_solution/cypress', + }), new Project(resolve(REPO_ROOT, 'x-pack/plugins/security_solution/cypress/tsconfig.json'), { name: 'security_solution/cypress', }), @@ -28,10 +31,6 @@ export const PROJECTS = [ name: 'apm/ftr_e2e', disableTypeCheck: true, }), - new Project(resolve(REPO_ROOT, 'x-pack/plugins/apm/scripts/tsconfig.json'), { - name: 'apm/scripts', - disableTypeCheck: true, - }), // NOTE: using glob.sync rather than glob-all or globby // because it takes less than 10 ms, while the other modules diff --git a/src/dev/typescript/ref_output_cache/README.md b/src/dev/typescript/ref_output_cache/README.md new file mode 100644 index 00000000000000..41506a118dcb9b --- /dev/null +++ b/src/dev/typescript/ref_output_cache/README.md @@ -0,0 +1,17 @@ +# `node scripts/build_ts_refs` output cache + +This module implements the logic for caching the output of building the ts refs and extracting those caches into the source repo to speed up the execution of this script. We've implemented this as a stop-gap solution while we migrate to Bazel which will handle caching the types produced by the +scripts independently and speed things up incredibly, but in the meantime we need something to fix the 10 minute bootstrap times we're seeing. + +How it works: + + 1. traverse the TS projects referenced from `tsconfig.refs.json` and collect their `compilerOptions.outDir` setting. + 2. determine the `upstreamBranch` by reading the `branch` property out of `package.json` + 3. fetch the latest changes from `https://github.com/elastic/kibana.git` for that branch + 4. determine the merge base between `HEAD` and the latest ref from the `upstreamBranch` + 5. check in the `data/ts_refs_output_cache/archives` dir (where we keep the 10 most recent downloads) and at `https://ts-refs-cache.kibana.dev/{sha}.zip` for the cache of the merge base commit, and up to 5 commits before that in the log, stopping once we find one that is available locally or was downloaded. + 6. check for the `.ts-ref-cache-merge-base` file in each `outDir`, which records the `mergeBase` that was used to initialize that `outDir`, if the file exists and matches the `sha` that we plan to use for our cache then exclude that `outDir` from getting initialized with the cache data + 7. for each `outDir` that either hasn't been initialized with cache data or was initialized with cache data from another merge base, delete the `outDir` and replace it with the copy stored in the downloaded cache + 1. if there isn't a cached version of that `outDir` replace it with an empty directory + 8. write the current `mergeBase` to the `.ts-ref-cache-merge-base` file in each `outDir` + 9. run `tsc`, which will only build things which have changed since the cache was created \ No newline at end of file diff --git a/src/dev/typescript/ref_output_cache/archives.ts b/src/dev/typescript/ref_output_cache/archives.ts new file mode 100644 index 00000000000000..4db40221809975 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/archives.ts @@ -0,0 +1,186 @@ +/* + * 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 Fs from 'fs/promises'; +import { createWriteStream } from 'fs'; +import Path from 'path'; +import { promisify } from 'util'; +import { pipeline } from 'stream'; + +import { ToolingLog } from '@kbn/dev-utils'; +import Axios from 'axios'; +import del from 'del'; + +// https://github.com/axios/axios/tree/ffea03453f77a8176c51554d5f6c3c6829294649/lib/adapters +// @ts-expect-error untyped internal module used to prevent axios from using xhr adapter in tests +import AxiosHttpAdapter from 'axios/lib/adapters/http'; + +interface Archive { + sha: string; + path: string; + time: number; +} + +const asyncPipeline = promisify(pipeline); + +async function getCacheNames(cacheDir: string) { + try { + return await Fs.readdir(cacheDir); + } catch (error) { + if (error.code === 'ENOENT') { + return []; + } + + throw error; + } +} + +export class Archives { + static async create(log: ToolingLog, workingDir: string) { + const dir = Path.resolve(workingDir, 'archives'); + const bySha = new Map(); + + for (const name of await getCacheNames(dir)) { + const path = Path.resolve(dir, name); + + if (!name.endsWith('.zip')) { + log.debug('deleting unexpected file in archives dir', path); + await Fs.unlink(path); + continue; + } + + const sha = name.replace('.zip', ''); + log.verbose('identified archive for', sha); + const s = await Fs.stat(path); + const time = Math.max(s.atimeMs, s.mtimeMs); + bySha.set(sha, { + path, + time, + sha, + }); + } + + return new Archives(log, workingDir, bySha); + } + + protected constructor( + private readonly log: ToolingLog, + private readonly workDir: string, + private readonly bySha: Map + ) {} + + size() { + return this.bySha.size; + } + + get(sha: string) { + return this.bySha.get(sha); + } + + async delete(sha: string) { + const archive = this.get(sha); + if (archive) { + await Fs.unlink(archive.path); + this.bySha.delete(sha); + } + } + + *[Symbol.iterator]() { + yield* this.bySha.values(); + } + + /** + * Attempt to download the cache for a given sha, adding it to this.bySha + * and returning true if successful, logging and returning false otherwise. + * + * @param sha the commit sha we should try to download the cache for + */ + async attemptToDownload(sha: string) { + if (this.bySha.has(sha)) { + return true; + } + + const url = `https://ts-refs-cache.kibana.dev/${sha}.zip`; + this.log.debug('attempting to download cache for', sha, 'from', url); + + const filename = `${sha}.zip`; + const target = Path.resolve(this.workDir, 'archives', `${filename}`); + const tmpTarget = `${target}.tmp`; + + try { + const resp = await Axios.request({ + url, + responseType: 'stream', + adapter: AxiosHttpAdapter, + }); + + await Fs.mkdir(Path.dirname(target), { recursive: true }); + await asyncPipeline(resp.data, createWriteStream(tmpTarget)); + this.log.debug('download complete, renaming tmp'); + + await Fs.rename(tmpTarget, target); + this.bySha.set(sha, { + sha, + path: target, + time: Date.now(), + }); + + this.log.debug('download of cache for', sha, 'complete'); + return true; + } catch (error) { + await del(tmpTarget, { force: true }); + + if (!error.response) { + this.log.debug(`failed to download cache, ignoring error:`, error.message); + return false; + } + + if (error.response.status === 404) { + return false; + } + + this.log.debug(`failed to download cache,`, error.response.status, 'response'); + } + } + + /** + * Iterate through a list of shas, which represent commits + * on our upstreamBranch, and look for caches which are + * already downloaded, or try to download them. If the cache + * for that commit is not available for any reason the next + * sha will be tried. + * + * If we reach the end of the list without any caches being + * available undefined is returned. + * + * @param shas shas for commits to try and find caches for + */ + async getFirstAvailable(shas: string[]): Promise { + if (!shas.length) { + throw new Error('no possible shas to pick archive from'); + } + + for (const sha of shas) { + let archive = this.bySha.get(sha); + + // if we don't have one locally try to download one + if (!archive && (await this.attemptToDownload(sha))) { + archive = this.bySha.get(sha); + } + + // if we found the archive return it + if (archive) { + return archive; + } + + this.log.debug('no archive available for', sha); + } + + return undefined; + } +} diff --git a/src/dev/typescript/ref_output_cache/index.ts b/src/dev/typescript/ref_output_cache/index.ts new file mode 100644 index 00000000000000..8d55a31a1771c5 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './ref_output_cache'; diff --git a/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip new file mode 100644 index 00000000000000..07c14c13488b5f Binary files /dev/null and b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/1234.zip differ diff --git a/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/5678.zip b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/5678.zip new file mode 100644 index 00000000000000..9a30ffff55e0dd Binary files /dev/null and b/src/dev/typescript/ref_output_cache/integration_tests/__fixtures__/archives/5678.zip differ diff --git a/src/dev/typescript/ref_output_cache/integration_tests/archives.test.ts b/src/dev/typescript/ref_output_cache/integration_tests/archives.test.ts new file mode 100644 index 00000000000000..60ba3a4f659b3c --- /dev/null +++ b/src/dev/typescript/ref_output_cache/integration_tests/archives.test.ts @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; +import { Readable } from 'stream'; + +import del from 'del'; +import cpy from 'cpy'; +import { + ToolingLog, + createAbsolutePathSerializer, + createRecursiveSerializer, + ToolingLogCollectingWriter, + createStripAnsiSerializer, +} from '@kbn/dev-utils'; + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); +expect.addSnapshotSerializer(createStripAnsiSerializer()); +expect.addSnapshotSerializer( + createRecursiveSerializer( + (v) => typeof v === 'object' && v && typeof v.time === 'number', + (v) => ({ ...v, time: '' }) + ) +); + +jest.mock('axios', () => { + return { + request: jest.fn(), + }; +}); +const mockRequest: jest.Mock = jest.requireMock('axios').request; + +import { Archives } from '../archives'; + +const FIXTURE = Path.resolve(__dirname, '__fixtures__'); +const TMP = Path.resolve(__dirname, '__tmp__'); + +beforeAll(() => del(TMP, { force: true })); +beforeEach(() => cpy('.', TMP, { cwd: FIXTURE, parents: true })); +afterEach(async () => { + await del(TMP, { force: true }); + jest.resetAllMocks(); +}); + +const readArchiveDir = () => + Fs.readdirSync(Path.resolve(TMP, 'archives')).sort((a, b) => a.localeCompare(b)); + +const log = new ToolingLog(); +const logWriter = new ToolingLogCollectingWriter(); +log.setWriters([logWriter]); +afterEach(() => (logWriter.messages.length = 0)); + +it('deletes invalid files', async () => { + const path = Path.resolve(TMP, 'archives/foo.txt'); + Fs.writeFileSync(path, 'hello'); + const archives = await Archives.create(log, TMP); + + expect(archives.size()).toBe(2); + expect(Fs.existsSync(path)).toBe(false); +}); + +it('exposes archives by sha', async () => { + const archives = await Archives.create(log, TMP); + expect(archives.get('1234')).toMatchInlineSnapshot(` + Object { + "path": /src/dev/typescript/ref_output_cache/integration_tests/__tmp__/archives/1234.zip, + "sha": "1234", + "time": "", + } + `); + expect(archives.get('5678')).toMatchInlineSnapshot(` + Object { + "path": /src/dev/typescript/ref_output_cache/integration_tests/__tmp__/archives/5678.zip, + "sha": "5678", + "time": "", + } + `); + expect(archives.get('foo')).toMatchInlineSnapshot(`undefined`); +}); + +it('deletes archives', async () => { + const archives = await Archives.create(log, TMP); + expect(archives.size()).toBe(2); + await archives.delete('1234'); + expect(archives.size()).toBe(1); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "5678.zip", + ] + `); +}); + +it('returns false when attempting to download for sha without cache', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error('404!'); + }); + + await expect(archives.attemptToDownload('foobar')).resolves.toBe(false); +}); + +it('returns true when able to download an archive for a sha', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + return { + data: Readable.from('foobar zip contents'), + }; + }); + + expect(archives.size()).toBe(2); + await expect(archives.attemptToDownload('foobar')).resolves.toBe(true); + expect(archives.size()).toBe(3); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + "foobar.zip", + ] + `); + expect(Fs.readFileSync(Path.resolve(TMP, 'archives/foobar.zip'), 'utf-8')).toBe( + 'foobar zip contents' + ); +}); + +it('returns true if attempting to download a cache which is already downloaded', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error(`it shouldn't try to download anything`); + }); + + expect(archives.size()).toBe(2); + await expect(archives.attemptToDownload('1234')).resolves.toBe(true); + expect(archives.size()).toBe(2); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + ] + `); +}); + +it('returns false and deletes the zip if the download fails part way', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + let readCounter = 0; + return { + data: new Readable({ + read() { + readCounter++; + if (readCounter === 1) { + this.push('foo'); + } else { + this.emit('error', new Error('something went wrong')); + } + }, + }), + }; + }); + + await expect(archives.attemptToDownload('foo')).resolves.toBe(false); + expect(archives.size()).toBe(2); + expect(readArchiveDir()).toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + ] + `); +}); + +it('resolves to first sha if it is available locally', async () => { + const archives = await Archives.create(log, TMP); + + expect(await archives.getFirstAvailable(['1234', '5678'])).toHaveProperty('sha', '1234'); + expect(await archives.getFirstAvailable(['5678', '1234'])).toHaveProperty('sha', '5678'); +}); + +it('resolves to first local sha when it tried to reach network and gets errors', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation(() => { + throw new Error('no network available'); + }); + + expect(await archives.getFirstAvailable(['foo', 'bar', '1234'])).toHaveProperty('sha', '1234'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg attempting to download cache for foo from https://ts-refs-cache.kibana.dev/foo.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for foo", + " debg attempting to download cache for bar from https://ts-refs-cache.kibana.dev/bar.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for bar", + ] + `); +}); + +it('resolves to first remote that downloads successfully', async () => { + const archives = await Archives.create(log, TMP); + + mockRequest.mockImplementation((params) => { + if (params.url === `https://ts-refs-cache.kibana.dev/bar.zip`) { + return { + data: Readable.from('bar cache data'), + }; + } + + throw new Error('no network available'); + }); + + const archive = await archives.getFirstAvailable(['foo', 'bar', '1234']); + expect(archive).toHaveProperty('sha', 'bar'); + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg attempting to download cache for foo from https://ts-refs-cache.kibana.dev/foo.zip", + " debg failed to download cache, ignoring error: no network available", + " debg no archive available for foo", + " debg attempting to download cache for bar from https://ts-refs-cache.kibana.dev/bar.zip", + " debg download complete, renaming tmp", + " debg download of cache for bar complete", + ] + `); + + expect(Fs.readFileSync(archive!.path, 'utf-8')).toBe('bar cache data'); +}); diff --git a/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts b/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts new file mode 100644 index 00000000000000..2bc75785ee6a73 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/integration_tests/ref_output_cache.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs'; + +import del from 'del'; +import cpy from 'cpy'; +import globby from 'globby'; +import { + ToolingLog, + createAbsolutePathSerializer, + createStripAnsiSerializer, + ToolingLogCollectingWriter, +} from '@kbn/dev-utils'; + +import { RefOutputCache, OUTDIR_MERGE_BASE_FILENAME } from '../ref_output_cache'; +import { Archives } from '../archives'; +import type { RepoInfo } from '../repo_info'; + +jest.mock('../repo_info'); +const { RepoInfo: MockRepoInfo } = jest.requireMock('../repo_info'); + +jest.mock('axios'); +const { request: mockRequest } = jest.requireMock('axios'); + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); +expect.addSnapshotSerializer(createStripAnsiSerializer()); + +const FIXTURE = Path.resolve(__dirname, '__fixtures__'); +const TMP = Path.resolve(__dirname, '__tmp__'); +const repo: jest.Mocked = new MockRepoInfo(); +const log = new ToolingLog(); +const logWriter = new ToolingLogCollectingWriter(); +log.setWriters([logWriter]); + +beforeAll(() => del(TMP, { force: true })); +beforeEach(() => cpy('.', TMP, { cwd: FIXTURE, parents: true })); +afterEach(async () => { + await del(TMP, { force: true }); + jest.resetAllMocks(); + logWriter.messages.length = 0; +}); + +it('creates and extracts caches, ingoring dirs with matching merge-base file and placing merge-base files', async () => { + // setup repo mock + const HEAD = 'abcdefg'; + repo.getHeadSha.mockResolvedValue(HEAD); + repo.getRelative.mockImplementation((path) => Path.relative(TMP, path)); + repo.getRecentShasFrom.mockResolvedValue(['5678', '1234']); + + // create two fake outDirs + const outDirs = [Path.resolve(TMP, 'out/foo'), Path.resolve(TMP, 'out/bar')]; + for (const dir of outDirs) { + Fs.mkdirSync(dir, { recursive: true }); + Fs.writeFileSync(Path.resolve(dir, 'test'), 'hello world'); + } + + // init an archives instance using tmp + const archives = await Archives.create(log, TMP); + + // init the RefOutputCache with our mock data + const refOutputCache = new RefOutputCache(log, repo, archives, outDirs, HEAD); + + // create the new cache right in the archives dir + await refOutputCache.captureCache(Path.resolve(TMP)); + const cachePath = Path.resolve(TMP, `${HEAD}.zip`); + + // check that the cache was created and stored in the archives + if (!Fs.existsSync(cachePath)) { + throw new Error('zip was not created as expected'); + } + + mockRequest.mockImplementation((params: any) => { + if (params.url.endsWith(`${HEAD}.zip`)) { + return { + data: Fs.createReadStream(cachePath), + }; + } + + throw new Error(`unexpected url: ${params.url}`); + }); + + // modify the files in the outDirs so we can see which ones are restored from the cache + for (const dir of outDirs) { + Fs.writeFileSync(Path.resolve(dir, 'test'), 'not cleared by cache init'); + } + // add the mergeBase to the first outDir so that it is ignored + Fs.writeFileSync(Path.resolve(outDirs[0], OUTDIR_MERGE_BASE_FILENAME), HEAD); + + // rebuild the outDir from the refOutputCache + await refOutputCache.initCaches(); + + const files = Object.fromEntries( + globby + .sync(outDirs, { dot: true }) + .map((path) => [Path.relative(TMP, path), Fs.readFileSync(path, 'utf-8')]) + ); + + expect(files).toMatchInlineSnapshot(` + Object { + "out/bar/.ts-ref-cache-merge-base": "abcdefg", + "out/bar/test": "hello world", + "out/foo/.ts-ref-cache-merge-base": "abcdefg", + "out/foo/test": "not cleared by cache init", + } + `); + expect(logWriter.messages).toMatchInlineSnapshot(` + Array [ + " sill identified archive for 1234", + " sill identified archive for 5678", + " debg writing ts-ref cache to abcdefg.zip", + " succ wrote archive to abcdefg.zip", + " debg attempting to download cache for abcdefg from https://ts-refs-cache.kibana.dev/abcdefg.zip", + " debg download complete, renaming tmp", + " debg download of cache for abcdefg complete", + " debg extracting archives/abcdefg.zip to rebuild caches in 1 outDirs", + " debg [out/bar] clearing outDir and replacing with cache", + ] + `); +}); + +it('cleans up oldest archives when there are more than 10', async () => { + for (let i = 0; i < 100; i++) { + const time = i * 10_000; + const path = Path.resolve(TMP, `archives/${time}.zip`); + Fs.writeFileSync(path, ''); + Fs.utimesSync(path, time, time); + } + + const archives = await Archives.create(log, TMP); + const cache = new RefOutputCache(log, repo, archives, [], '1234'); + expect(cache.archives.size()).toBe(102); + await cache.cleanup(); + expect(cache.archives.size()).toBe(10); + expect(Fs.readdirSync(Path.resolve(TMP, 'archives')).sort((a, b) => a.localeCompare(b))) + .toMatchInlineSnapshot(` + Array [ + "1234.zip", + "5678.zip", + "920000.zip", + "930000.zip", + "940000.zip", + "950000.zip", + "960000.zip", + "970000.zip", + "980000.zip", + "990000.zip", + ] + `); +}); diff --git a/src/dev/typescript/ref_output_cache/ref_output_cache.ts b/src/dev/typescript/ref_output_cache/ref_output_cache.ts new file mode 100644 index 00000000000000..342470ce0c6e36 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/ref_output_cache.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fs from 'fs/promises'; + +import { ToolingLog, kibanaPackageJson } from '@kbn/dev-utils'; +import del from 'del'; +import tempy from 'tempy'; + +import { Archives } from './archives'; +import { unzip, zip } from './zip'; +import { concurrentMap } from '../concurrent_map'; +import { RepoInfo } from './repo_info'; + +export const OUTDIR_MERGE_BASE_FILENAME = '.ts-ref-cache-merge-base'; + +export async function matchMergeBase(outDir: string, sha: string) { + try { + const existing = await Fs.readFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), 'utf8'); + return existing === sha; + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +export async function isDir(path: string) { + try { + return (await Fs.stat(path)).isDirectory(); + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + + throw error; + } +} + +export class RefOutputCache { + static async create(options: { + log: ToolingLog; + workingDir: string; + outDirs: string[]; + repoRoot: string; + upstreamUrl: string; + }) { + const repoInfo = new RepoInfo(options.log, options.repoRoot, options.upstreamUrl); + const archives = await Archives.create(options.log, options.workingDir); + + const upstreamBranch: string = kibanaPackageJson.branch; + const mergeBase = await repoInfo.getMergeBase('HEAD', upstreamBranch); + + return new RefOutputCache(options.log, repoInfo, archives, options.outDirs, mergeBase); + } + + constructor( + private readonly log: ToolingLog, + private readonly repo: RepoInfo, + public readonly archives: Archives, + private readonly outDirs: string[], + private readonly mergeBase: string + ) {} + + /** + * Find the most recent cache/archive of the outDirs and replace the outDirs + * on disk with the files in the cache if the outDir has an outdated merge-base + * written to the directory. + */ + async initCaches() { + const archive = + this.archives.get(this.mergeBase) ?? + (await this.archives.getFirstAvailable([ + this.mergeBase, + ...(await this.repo.getRecentShasFrom(this.mergeBase, 5)), + ])); + + if (!archive) { + return; + } + + const outdatedOutDirs = ( + await concurrentMap(100, this.outDirs, async (outDir) => ({ + path: outDir, + outdated: !(await matchMergeBase(outDir, archive.sha)), + })) + ) + .filter((o) => o.outdated) + .map((o) => o.path); + + if (!outdatedOutDirs.length) { + this.log.debug('all outDirs have the most recent cache'); + return; + } + + const tmpDir = tempy.directory(); + this.log.debug( + 'extracting', + this.repo.getRelative(archive.path), + 'to rebuild caches in', + outdatedOutDirs.length, + 'outDirs' + ); + await unzip(archive.path, tmpDir); + + const cacheNames = await Fs.readdir(tmpDir); + + await concurrentMap(50, outdatedOutDirs, async (outDir) => { + const relative = this.repo.getRelative(outDir); + const cacheName = `${relative.split(Path.sep).join('__')}.zip`; + + if (!cacheNames.includes(cacheName)) { + this.log.debug(`[${relative}] not in cache`); + await Fs.mkdir(outDir, { recursive: true }); + await Fs.writeFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), archive.sha); + return; + } + + if (await matchMergeBase(outDir, archive.sha)) { + this.log.debug(`[${relative}] keeping outdir, created from selected sha`); + return; + } + + this.log.debug(`[${relative}] clearing outDir and replacing with cache`); + await del(outDir); + await unzip(Path.resolve(tmpDir, cacheName), outDir); + await Fs.writeFile(Path.resolve(outDir, OUTDIR_MERGE_BASE_FILENAME), archive.sha); + }); + } + + /** + * Iterate through the outDirs, zip them up, and then zip up those zips + * into a single file which we can upload/download/extract just a portion + * of the archive. + * + * @param outputDir directory that the {HEAD}.zip file should be written to + */ + async captureCache(outputDir: string) { + const tmpDir = tempy.directory(); + const currentSha = await this.repo.getHeadSha(); + const outputPath = Path.resolve(outputDir, `${currentSha}.zip`); + const relativeOutputPath = this.repo.getRelative(outputPath); + + this.log.debug('writing ts-ref cache to', relativeOutputPath); + + const subZips: Array<[string, string]> = []; + + await Promise.all( + this.outDirs.map(async (absolute) => { + const relative = this.repo.getRelative(absolute); + const subZipName = `${relative.split(Path.sep).join('__')}.zip`; + const subZipPath = Path.resolve(tmpDir, subZipName); + await zip([[absolute, '/']], [], subZipPath); + subZips.push([subZipPath, subZipName]); + }) + ); + + await zip([], subZips, outputPath); + await del(tmpDir, { force: true }); + this.log.success('wrote archive to', relativeOutputPath); + } + + /** + * Cleanup the downloaded cache files, keeping the 10 newest files. Each file + * is about 25-30MB, so 10 downloads is a a decent amount of disk space for + * caches but we could potentially increase this number in the future if we like + */ + async cleanup() { + // sort archives by time desc + const archives = [...this.archives].sort((a, b) => b.time - a.time); + + // delete the 11th+ archive + for (const { sha } of archives.slice(10)) { + await this.archives.delete(sha); + } + } +} diff --git a/src/dev/typescript/ref_output_cache/repo_info.ts b/src/dev/typescript/ref_output_cache/repo_info.ts new file mode 100644 index 00000000000000..9a51f3f75182b1 --- /dev/null +++ b/src/dev/typescript/ref_output_cache/repo_info.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; + +import execa from 'execa'; +import { ToolingLog } from '@kbn/dev-utils'; + +export class RepoInfo { + constructor( + private readonly log: ToolingLog, + private readonly dir: string, + private readonly upstreamUrl: string + ) {} + + async getRecentShasFrom(sha: string, size: number) { + return (await this.git(['log', '--pretty=%P', `-n`, `${size}`, sha])) + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + } + + async getMergeBase(ref: string, upstreamBranch: string) { + this.log.info('ensuring we have the latest changelog from upstream', upstreamBranch); + await this.git(['fetch', this.upstreamUrl, upstreamBranch]); + + this.log.info('determining merge base with upstream'); + + const mergeBase = await this.git(['merge-base', ref, 'FETCH_HEAD']); + this.log.info('merge base with', upstreamBranch, 'is', mergeBase); + + return mergeBase; + } + + async getHeadSha() { + return await this.git(['rev-parse', 'HEAD']); + } + + getRelative(path: string) { + return Path.relative(this.dir, path); + } + + private async git(args: string[]) { + const proc = await execa('git', args, { + cwd: this.dir, + }); + + return proc.stdout.trim(); + } +} diff --git a/src/dev/typescript/ref_output_cache/zip.ts b/src/dev/typescript/ref_output_cache/zip.ts new file mode 100644 index 00000000000000..b1bd8f514bb95c --- /dev/null +++ b/src/dev/typescript/ref_output_cache/zip.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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/promises'; +import { createWriteStream } from 'fs'; +import Path from 'path'; +import { pipeline } from 'stream'; +import { promisify } from 'util'; + +import extractZip from 'extract-zip'; +import archiver from 'archiver'; + +const asyncPipeline = promisify(pipeline); + +export async function zip( + dirs: Array<[string, string]>, + files: Array<[string, string]>, + outputPath: string +) { + const archive = archiver('zip', { + zlib: { + level: 9, + }, + }); + + for (const [absolute, relative] of dirs) { + archive.directory(absolute, relative); + } + + for (const [absolute, relative] of files) { + archive.file(absolute, { + name: relative, + }); + } + + // ensure output dir exists + await Fs.mkdir(Path.dirname(outputPath), { recursive: true }); + + // await the promise from the pipeline and archive.finalize() + await Promise.all([asyncPipeline(archive, createWriteStream(outputPath)), archive.finalize()]); +} + +export async function unzip(path: string, outputDir: string) { + await extractZip(path, { + dir: outputDir, + }); +} diff --git a/src/plugins/apm_oss/server/index.ts b/src/plugins/apm_oss/server/index.ts index bea9965748f27a..a02e28201a1b90 100644 --- a/src/plugins/apm_oss/server/index.ts +++ b/src/plugins/apm_oss/server/index.ts @@ -47,4 +47,5 @@ export { createGoAgentInstructions, createJavaAgentInstructions, createDotNetAgentInstructions, + createPhpAgentInstructions, } from './tutorial/instructions/apm_agent_instructions'; diff --git a/src/plugins/apm_oss/server/tutorial/envs/on_prem.ts b/src/plugins/apm_oss/server/tutorial/envs/on_prem.ts index 7d6e3431396fc7..7d261abb0cc018 100644 --- a/src/plugins/apm_oss/server/tutorial/envs/on_prem.ts +++ b/src/plugins/apm_oss/server/tutorial/envs/on_prem.ts @@ -27,6 +27,7 @@ import { createGoAgentInstructions, createJavaAgentInstructions, createDotNetAgentInstructions, + createPhpAgentInstructions, } from '../instructions/apm_agent_instructions'; export function onPremInstructions({ @@ -152,6 +153,10 @@ export function onPremInstructions({ id: INSTRUCTION_VARIANT.DOTNET, instructions: createDotNetAgentInstructions(), }, + { + id: INSTRUCTION_VARIANT.PHP, + instructions: createPhpAgentInstructions(), + }, ], statusCheck: { title: i18n.translate('apmOss.tutorial.apmAgents.statusCheck.title', { diff --git a/src/plugins/apm_oss/server/tutorial/instructions/apm_agent_instructions.ts b/src/plugins/apm_oss/server/tutorial/instructions/apm_agent_instructions.ts index ea1f961f5e2db1..8886dec12ccd6b 100644 --- a/src/plugins/apm_oss/server/tutorial/instructions/apm_agent_instructions.ts +++ b/src/plugins/apm_oss/server/tutorial/instructions/apm_agent_instructions.ts @@ -701,3 +701,54 @@ export const createDotNetAgentInstructions = (apmServerUrl = '', secretToken = ' }), }, ]; + +export const createPhpAgentInstructions = (apmServerUrl = '', secretToken = '') => [ + { + title: i18n.translate('apmOss.tutorial.phpClient.download.title', { + defaultMessage: 'Download the APM agent', + }), + textPre: i18n.translate('apmOss.tutorial.phpClient.download.textPre', { + defaultMessage: + 'Download the package corresponding to your platform from [GitHub releases]({githubReleasesLink}).', + values: { + githubReleasesLink: 'https://github.com/elastic/apm-agent-php/releases', + }, + }), + }, + { + title: i18n.translate('apmOss.tutorial.phpClient.installPackage.title', { + defaultMessage: 'Install the downloaded package', + }), + textPre: i18n.translate('apmOss.tutorial.phpClient.installPackage.textPre', { + defaultMessage: 'For example on Alpine Linux using APK package:', + }), + commands: ['apk add --allow-untrusted .apk'], + textPost: i18n.translate('apmOss.tutorial.phpClient.installPackage.textPost', { + defaultMessage: + 'See the [documentation]({documentationLink}) for installation commands on other supported platforms and advanced installation.', + values: { + documentationLink: '{config.docs.base_url}guide/en/apm/agent/php/current/setup.html', + }, + }), + }, + { + title: i18n.translate('apmOss.tutorial.phpClient.configureAgent.title', { + defaultMessage: 'Configure the agent', + }), + textPre: i18n.translate('apmOss.tutorial.phpClient.configureAgent.textPre', { + defaultMessage: + 'APM is automatically started when your app boots. Configure the agent either via `php.ini` file:', + }), + commands: `elastic_apm.server_url=http://localhost:8200 +elastic_apm.service_name="My service" +`.split('\n'), + textPost: i18n.translate('apmOss.tutorial.phpClient.configure.textPost', { + defaultMessage: + 'See the [documentation]({documentationLink}) for configuration options and advanced usage.\n\n', + values: { + documentationLink: + '{config.docs.base_url}guide/en/apm/agent/php/current/configuration.html', + }, + }), + }, +]; diff --git a/src/plugins/charts/public/static/utils/transform_click_event.ts b/src/plugins/charts/public/static/utils/transform_click_event.ts index e875967616bbdb..0c303b92bf1a1c 100644 --- a/src/plugins/charts/public/static/utils/transform_click_event.ts +++ b/src/plugins/charts/public/static/utils/transform_click_event.ts @@ -30,9 +30,6 @@ export interface BrushTriggerEvent { type AllSeriesAccessors = Array<[accessor: Accessor | AccessorFn, value: string | number]>; -// TODO: replace when exported from elastic/charts -const DEFAULT_SINGLE_PANEL_SM_VALUE = '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__'; - /** * returns accessor value from string or function accessor * @param datum @@ -97,11 +94,11 @@ function getSplitChartValue({ | string | number | undefined { - if (smHorizontalAccessorValue !== DEFAULT_SINGLE_PANEL_SM_VALUE) { + if (smHorizontalAccessorValue !== undefined) { return smHorizontalAccessorValue; } - if (smVerticalAccessorValue !== DEFAULT_SINGLE_PANEL_SM_VALUE) { + if (smVerticalAccessorValue !== undefined) { return smVerticalAccessorValue; } diff --git a/src/plugins/console/public/application/components/settings_modal.tsx b/src/plugins/console/public/application/components/settings_modal.tsx index f3c8954d01254b..161b67500b47c7 100644 --- a/src/plugins/console/public/application/components/settings_modal.tsx +++ b/src/plugins/console/public/application/components/settings_modal.tsx @@ -22,7 +22,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSwitch, } from '@elastic/eui'; @@ -151,115 +150,107 @@ export function DevToolsSettingsModal(props: Props) { ) : undefined; return ( - - - - - - - + + + + + + + + + + } + > + { + const val = parseInt(e.target.value, 10); + if (!val) return; + setFontSize(val); + }} + /> + - - + } - > - { - const val = parseInt(e.target.value, 10); - if (!val) return; - setFontSize(val); - }} - /> - + onChange={(e) => setWrapMode(e.target.checked)} + /> + - - - } - onChange={(e) => setWrapMode(e.target.checked)} + - - - + } - > - - } - onChange={(e) => setTripleQuotes(e.target.checked)} - /> - + onChange={(e) => setTripleQuotes(e.target.checked)} + /> + - - } - > - { - const { stateSetter, ...rest } = opts; - return rest; - })} - idToSelectedMap={checkboxIdToSelectedMap} - onChange={(e: any) => { - onAutocompleteChange(e as AutocompleteOptions); - }} + - + } + > + { + const { stateSetter, ...rest } = opts; + return rest; + })} + idToSelectedMap={checkboxIdToSelectedMap} + onChange={(e: any) => { + onAutocompleteChange(e as AutocompleteOptions); + }} + /> + - {pollingFields} - + {pollingFields} + - - - - + + + + - - - - - - + + + + + ); } diff --git a/src/plugins/console/public/lib/es/es.ts b/src/plugins/console/public/lib/es/es.ts index 8053fca91b7d1d..03ee218fa2e1d0 100644 --- a/src/plugins/console/public/lib/es/es.ts +++ b/src/plugins/console/public/lib/es/es.ts @@ -9,6 +9,10 @@ import $ from 'jquery'; import { stringify } from 'query-string'; +interface SendOptions { + asSystemRequest?: boolean; +} + const esVersion: string[] = []; export function getVersion() { @@ -20,13 +24,19 @@ export function getContentType(body: any) { return 'application/json'; } -export function send(method: string, path: string, data: any) { +export function send( + method: string, + path: string, + data: any, + { asSystemRequest }: SendOptions = {} +) { const wrappedDfd = $.Deferred(); const options: JQuery.AjaxSettings = { url: '../api/console/proxy?' + stringify({ path, method }, { sort: false }), headers: { 'kbn-xsrf': 'kibana', + ...(asSystemRequest && { 'kbn-system-request': 'true' }), }, data, contentType: getContentType(data), diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js index 244cc781498a7c..d4996f9fd8862a 100644 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ b/src/plugins/console/public/lib/mappings/mappings.js @@ -250,7 +250,7 @@ function retrieveSettings(settingsKey, settingsToRetrieve) { // Fetch autocomplete info if setting is set to true, and if user has made changes. if (settingsToRetrieve[settingsKey] === true) { - return es.send('GET', settingKeyToPathMap[settingsKey], null); + return es.send('GET', settingKeyToPathMap[settingsKey], null, true); } else { const settingsPromise = new $.Deferred(); if (settingsToRetrieve[settingsKey] === false) { diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index d060327563b25b..f659fa002e922b 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -67,7 +67,13 @@ export function DashboardApp({ savedDashboard, history ); - const dashboardContainer = useDashboardContainer(dashboardStateManager, history, false); + const [unsavedChanges, setUnsavedChanges] = useState(false); + const dashboardContainer = useDashboardContainer({ + timeFilter: data.query.timefilter.timefilter, + dashboardStateManager, + setUnsavedChanges, + history, + }); const searchSessionIdQuery$ = useMemo( () => createQueryParamObservable(history, DashboardConstants.SEARCH_SESSION_ID), [history] @@ -200,6 +206,7 @@ export function DashboardApp({ ); dashboardStateManager.registerChangeListener(() => { + setUnsavedChanges(dashboardStateManager?.hasUnsavedPanelState()); // we aren't checking dirty state because there are changes the container needs to know about // that won't make the dashboard "dirty" - like a view mode change. triggerRefresh$.next(); @@ -281,6 +288,7 @@ export function DashboardApp({ embedSettings, indexPatterns, savedDashboard, + unsavedChanges, dashboardContainer, dashboardStateManager, }} diff --git a/src/plugins/dashboard/public/application/dashboard_state.test.ts b/src/plugins/dashboard/public/application/dashboard_state.test.ts index 04112d10ae7e3b..c5bda98c31b700 100644 --- a/src/plugins/dashboard/public/application/dashboard_state.test.ts +++ b/src/plugins/dashboard/public/application/dashboard_state.test.ts @@ -17,6 +17,7 @@ import { createKbnUrlStateStorage } from '../services/kibana_utils'; import { InputTimeRange, TimefilterContract, TimeRange } from '../services/data'; import { embeddablePluginMock } from 'src/plugins/embeddable/public/mocks'; +import { coreMock } from '../../../../core/public/mocks'; describe('DashboardState', function () { let dashboardState: DashboardStateManager; @@ -45,6 +46,7 @@ describe('DashboardState', function () { kibanaVersion: '7.0.0', kbnUrlStateStorage: createKbnUrlStateStorage(), history: createBrowserHistory(), + toasts: coreMock.createStart().notifications.toasts, hasTaggingCapabilities: mockHasTaggingCapabilities, }); } diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts index 8494900ea79c7e..e4b2afa8a46ea3 100644 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts @@ -43,6 +43,8 @@ import { syncState, } from '../services/kibana_utils'; import { STATE_STORAGE_KEY } from '../url_generator'; +import { NotificationsStart } from '../services/core'; +import { getMigratedToastText } from '../dashboard_strings'; /** * Dashboard state manager handles connecting angular and redux state between the angular and react portions of the @@ -59,10 +61,12 @@ export class DashboardStateManager { query: Query; }; private stateDefaults: DashboardAppStateDefaults; + private toasts: NotificationsStart['toasts']; private hideWriteControls: boolean; private kibanaVersion: string; public isDirty: boolean; private changeListeners: Array<(status: { dirty: boolean }) => void>; + private hasShownMigrationToast = false; public get appState(): DashboardAppState { return this.stateContainer.get(); @@ -93,6 +97,7 @@ export class DashboardStateManager { * @param */ constructor({ + toasts, history, kibanaVersion, savedDashboard, @@ -108,11 +113,13 @@ export class DashboardStateManager { hideWriteControls: boolean; allowByValueEmbeddables: boolean; savedDashboard: DashboardSavedObject; + toasts: NotificationsStart['toasts']; usageCollection?: UsageCollectionSetup; kbnUrlStateStorage: IKbnUrlStateStorage; dashboardPanelStorage?: DashboardPanelStorage; hasTaggingCapabilities: SavedObjectTagDecoratorTypeGuard; }) { + this.toasts = toasts; this.kibanaVersion = kibanaVersion; this.savedDashboard = savedDashboard; this.hideWriteControls = hideWriteControls; @@ -283,6 +290,10 @@ export class DashboardStateManager { if (dirty) { this.stateContainer.transitions.set('panels', Object.values(convertedPanelStateMap)); if (dirtyBecauseOfInitialStateMigration) { + if (this.getIsEditMode() && !this.hasShownMigrationToast) { + this.toasts.addSuccess(getMigratedToastText()); + this.hasShownMigrationToast = true; + } this.saveState({ replace: true }); } @@ -693,6 +704,11 @@ export class DashboardStateManager { this.dashboardPanelStorage.clearPanels(this.savedDashboard?.id); } + public hasUnsavedPanelState(): boolean { + const panels = this.dashboardPanelStorage?.getPanels(this.savedDashboard?.id); + return panels !== undefined && panels.length > 0; + } + private getUnsavedPanelState(): { panels?: SavedDashboardPanel[] } { if (!this.allowByValueEmbeddables || this.getIsViewMode() || !this.dashboardPanelStorage) { return {}; diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts index 6eb1c0bf75b240..50465cc4ab58b2 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_breadcrumbs.ts @@ -45,7 +45,6 @@ export const useDashboardBreadcrumbs = ( text: getDashboardTitle( dashboardStateManager.getTitle(), dashboardStateManager.getViewMode(), - dashboardStateManager.getIsDirty(timefilter), dashboardStateManager.isNew() ), }, 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 index d14b4056a64c67..6a6dc58db78157 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.test.tsx @@ -20,6 +20,7 @@ import { DashboardCapabilities } from '../types'; import { EmbeddableFactory } from '../../../../embeddable/public'; import { HelloWorldEmbeddable } from '../../../../embeddable/public/tests/fixtures'; import { DashboardContainer } from '../embeddable'; +import { coreMock } from 'src/core/public/mocks'; const savedDashboard = getSavedDashboardMock(); @@ -32,12 +33,13 @@ const history = createBrowserHistory(); const createDashboardState = () => new DashboardStateManager({ savedDashboard, + kibanaVersion: '7.0.0', hideWriteControls: false, allowByValueEmbeddables: false, - kibanaVersion: '7.0.0', - kbnUrlStateStorage: createKbnUrlStateStorage(), history: createBrowserHistory(), + kbnUrlStateStorage: createKbnUrlStateStorage(), hasTaggingCapabilities: mockHasTaggingCapabilities, + toasts: coreMock.createStart().notifications.toasts, }); const defaultCapabilities: DashboardCapabilities = { @@ -83,9 +85,9 @@ const setupEmbeddableFactory = () => { test('container is destroyed on unmount', async () => { const { createEmbeddable, destroySpy, embeddable } = setupEmbeddableFactory(); - const state = createDashboardState(); + const dashboardStateManager = createDashboardState(); const { result, unmount, waitForNextUpdate } = renderHook( - () => useDashboardContainer(state, history, false), + () => useDashboardContainer({ dashboardStateManager, history }), { wrapper: ({ children }) => ( {children} @@ -113,7 +115,7 @@ test('old container is destroyed on new dashboardStateManager', async () => { const { result, waitForNextUpdate, rerender } = renderHook< DashboardStateManager, DashboardContainer | null - >((dashboardState) => useDashboardContainer(dashboardState, history, false), { + >((dashboardStateManager) => useDashboardContainer({ dashboardStateManager, history }), { wrapper: ({ children }) => ( {children} ), @@ -148,7 +150,7 @@ test('destroyed if rerendered before resolved', async () => { const { result, waitForNextUpdate, rerender } = renderHook< DashboardStateManager, DashboardContainer | null - >((dashboardState) => useDashboardContainer(dashboardState, history, false), { + >((dashboardStateManager) => useDashboardContainer({ dashboardStateManager, history }), { wrapper: ({ children }) => ( {children} ), 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 d12fea07bdd418..f4fe55f8774004 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_container.ts @@ -24,12 +24,21 @@ import { getDashboardContainerInput, getSearchSessionIdFromURL } from '../dashbo import { DashboardConstants, DashboardContainer, DashboardContainerInput } from '../..'; import { DashboardAppServices } from '../types'; import { DASHBOARD_CONTAINER_TYPE } from '..'; - -export const useDashboardContainer = ( - dashboardStateManager: DashboardStateManager | null, - history: History, - isEmbeddedExternally: boolean -) => { +import { TimefilterContract } from '../../services/data'; + +export const useDashboardContainer = ({ + history, + timeFilter, + setUnsavedChanges, + dashboardStateManager, + isEmbeddedExternally, +}: { + history: History; + isEmbeddedExternally?: boolean; + timeFilter?: TimefilterContract; + setUnsavedChanges?: (dirty: boolean) => void; + dashboardStateManager: DashboardStateManager | null; +}) => { const { dashboardCapabilities, data, @@ -72,15 +81,20 @@ export const useDashboardContainer = ( .getStateTransfer() .getIncomingEmbeddablePackage(DashboardConstants.DASHBOARDS_ID, true); + // when dashboard state manager initially loads, determine whether or not there are unsaved changes + setUnsavedChanges?.( + Boolean(incomingEmbeddable) || dashboardStateManager.hasUnsavedPanelState() + ); + let canceled = false; let pendingContainer: DashboardContainer | ErrorEmbeddable | null | undefined; (async function createContainer() { pendingContainer = await dashboardFactory.create( getDashboardContainerInput({ + isEmbeddedExternally: Boolean(isEmbeddedExternally), dashboardCapabilities, dashboardStateManager, incomingEmbeddable, - isEmbeddedExternally, query, searchSessionId: searchSessionIdFromURL ?? searchSession.start(), }) @@ -141,8 +155,10 @@ export const useDashboardContainer = ( dashboardCapabilities, dashboardStateManager, isEmbeddedExternally, + setUnsavedChanges, searchSession, scopedHistory, + timeFilter, embeddable, history, query, diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts index ed14223bb0a830..effd598cc3ee87 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_state_manager.ts @@ -87,6 +87,7 @@ export const useDashboardStateManager = ( }); const stateManager = new DashboardStateManager({ + toasts: core.notifications.toasts, hasTaggingCapabilities, dashboardPanelStorage, hideWriteControls, @@ -160,7 +161,6 @@ export const useDashboardStateManager = ( const dashboardTitle = getDashboardTitle( stateManager.getTitle(), stateManager.getViewMode(), - stateManager.getIsDirty(timefilter), stateManager.isNew() ); @@ -213,6 +213,7 @@ export const useDashboardStateManager = ( uiSettings, usageCollection, allowByValueEmbeddables, + core.notifications.toasts, dashboardCapabilities.storeSearchSession, ]); diff --git a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx b/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx index 41b27b4fd69260..d302bb4216bc49 100644 --- a/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx +++ b/src/plugins/dashboard/public/application/listing/confirm_overlays.tsx @@ -40,6 +40,60 @@ export const confirmDiscardUnsavedChanges = ( } }); +export type DiscardOrKeepSelection = 'cancel' | 'discard' | 'keep'; + +export const confirmDiscardOrKeepUnsavedChanges = ( + overlays: OverlayStart +): Promise => { + return new Promise((resolve) => { + const session = overlays.openModal( + toMountPoint( + <> + + {leaveConfirmStrings.getLeaveEditModeTitle()} + + + + {leaveConfirmStrings.getLeaveEditModeSubtitle()} + + + + session.close()} + > + {leaveConfirmStrings.getCancelButtonText()} + + { + session.close(); + resolve('keep'); + }} + > + {leaveConfirmStrings.getKeepChangesText()} + + { + session.close(); + resolve('discard'); + }} + > + {leaveConfirmStrings.getConfirmButtonText()} + + + + ), + { + 'data-test-subj': 'dashboardDiscardConfirmModal', + } + ); + }); +}; + export const confirmCreateWithUnsaved = ( overlays: OverlayStart, startBlankCallback: () => void, diff --git a/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap b/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap index d289d267a2fd6d..1e029e6960cdfa 100644 --- a/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap +++ b/src/plugins/dashboard/public/application/top_nav/__snapshots__/clone_modal.test.js.snap @@ -1,65 +1,63 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders DashboardCloneModal 1`] = ` - - - - - - - - - -

- -

-
- - + + + -
- - - - - + + + + +

- - - - +

+
+ + +
+ + + + + + + + +
`; diff --git a/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx b/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx index c1bcad51babf9f..3af186f841a5d0 100644 --- a/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/clone_modal.tsx @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiText, EuiCallOut, @@ -138,69 +137,67 @@ export class DashboardCloneModal extends React.Component { render() { return ( - - - - - - - - - - -

- -

-
- - - - + + + + + - {this.renderDuplicateTitleCallout()} -
- - - + + +

- - - - - - - - +

+
+ + + + + + {this.renderDuplicateTitleCallout()} +
+ + + + + + + + + + +
); } } diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index 786afc81c400cd..11fb7f0cb56ff4 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -43,9 +43,9 @@ import { showOptionsPopover } from './show_options_popover'; import { TopNavIds } from './top_nav_ids'; import { ShowShareModal } from './show_share_modal'; import { PanelToolbar } from './panel_toolbar'; -import { confirmDiscardUnsavedChanges } from '../listing/confirm_overlays'; +import { confirmDiscardOrKeepUnsavedChanges } from '../listing/confirm_overlays'; import { OverlayRef } from '../../../../../core/public'; -import { getNewDashboardTitle } from '../../dashboard_strings'; +import { getNewDashboardTitle, unsavedChangesBadge } from '../../dashboard_strings'; import { DASHBOARD_PANELS_UNSAVED_ID } from '../lib/dashboard_panel_storage'; import { DashboardContainer } from '..'; @@ -64,6 +64,7 @@ export interface DashboardTopNavProps { timefilter: TimefilterContract; indexPatterns: IndexPattern[]; redirectTo: DashboardRedirect; + unsavedChanges?: boolean; lastDashboardId?: string; viewMode: ViewMode; } @@ -72,6 +73,7 @@ export function DashboardTopNav({ dashboardStateManager, dashboardContainer, lastDashboardId, + unsavedChanges, savedDashboard, onQuerySubmit, embedSettings, @@ -152,34 +154,53 @@ export function DashboardTopNav({ } }, [state.addPanelOverlay]); - const onDiscardChanges = useCallback(() => { - function revertChangesAndExitEditMode() { - dashboardStateManager.resetState(); - dashboardStateManager.clearUnsavedPanels(); - - // We need to do a hard reset of the timepicker. appState will not reload like - // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on - // reload will cause it not to sync. - if (dashboardStateManager.getIsTimeSavedWithDashboard()) { - dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); - dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); - } - dashboardStateManager.switchViewMode(ViewMode.VIEW); - } - confirmDiscardUnsavedChanges(core.overlays, revertChangesAndExitEditMode); - }, [core.overlays, dashboardStateManager, timefilter]); - const onChangeViewMode = useCallback( (newMode: ViewMode) => { clearAddPanel(); - if (savedDashboard?.id && allowByValueEmbeddables) { - const { getFullEditPath, title, id } = savedDashboard; - chrome.recentlyAccessed.add(getFullEditPath(newMode === ViewMode.EDIT), title, id); + const isPageRefresh = newMode === dashboardStateManager.getViewMode(); + const isLeavingEditMode = !isPageRefresh && newMode === ViewMode.VIEW; + const willLoseChanges = isLeavingEditMode && dashboardStateManager.getIsDirty(timefilter); + + function switchViewMode() { + dashboardStateManager.switchViewMode(newMode); + dashboardStateManager.restorePanels(); + + if (savedDashboard?.id && allowByValueEmbeddables) { + const { getFullEditPath, title, id } = savedDashboard; + chrome.recentlyAccessed.add(getFullEditPath(newMode === ViewMode.EDIT), title, id); + } + } + + if (!willLoseChanges) { + switchViewMode(); + return; + } + + function discardChanges() { + dashboardStateManager.resetState(); + dashboardStateManager.clearUnsavedPanels(); + + // We need to do a hard reset of the timepicker. appState will not reload like + // it does on 'open' because it's been saved to the url and the getAppState.previouslyStored() check on + // reload will cause it not to sync. + if (dashboardStateManager.getIsTimeSavedWithDashboard()) { + dashboardStateManager.syncTimefilterWithDashboardTime(timefilter); + dashboardStateManager.syncTimefilterWithDashboardRefreshInterval(timefilter); + } + dashboardStateManager.switchViewMode(ViewMode.VIEW); } - dashboardStateManager.switchViewMode(newMode); - dashboardStateManager.restorePanels(); + confirmDiscardOrKeepUnsavedChanges(core.overlays).then((selection) => { + if (selection === 'discard') { + discardChanges(); + } + if (selection !== 'cancel') { + switchViewMode(); + } + }); }, [ + timefilter, + core.overlays, clearAddPanel, savedDashboard, dashboardStateManager, @@ -381,7 +402,6 @@ export function DashboardTopNav({ }, [TopNavIds.EXIT_EDIT_MODE]: () => onChangeViewMode(ViewMode.VIEW), [TopNavIds.ENTER_EDIT_MODE]: () => onChangeViewMode(ViewMode.EDIT), - [TopNavIds.DISCARD_CHANGES]: onDiscardChanges, [TopNavIds.SAVE]: runSave, [TopNavIds.QUICK_SAVE]: runQuickSave, [TopNavIds.CLONE]: runClone, @@ -417,7 +437,6 @@ export function DashboardTopNav({ }, [ dashboardCapabilities, dashboardStateManager, - onDiscardChanges, onChangeViewMode, savedDashboard, runClone, @@ -450,7 +469,18 @@ export function DashboardTopNav({ isDirty: dashboardStateManager.isDirty, }); + const badges = unsavedChanges + ? [ + { + 'data-test-subj': 'dashboardUnsavedChangesBadge', + badgeText: unsavedChangesBadge.getUnsavedChangedBadgeText(), + color: 'secondary', + }, + ] + : undefined; + return { + badges, appName: 'dashboard', config: showTopNavMenu ? topNav : undefined, className: isFullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined, diff --git a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts index abc128369017c5..26eea1b5f718de 100644 --- a/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/top_nav/get_top_nav_config.ts @@ -41,14 +41,12 @@ export function getTopNavConfig( getOptionsConfig(actions[TopNavIds.OPTIONS]), getShareConfig(actions[TopNavIds.SHARE]), getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), getSaveConfig(actions[TopNavIds.SAVE], options.isNewDashboard), ] : [ getOptionsConfig(actions[TopNavIds.OPTIONS]), getShareConfig(actions[TopNavIds.SHARE]), getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getDiscardConfig(actions[TopNavIds.DISCARD_CHANGES]), getSaveConfig(actions[TopNavIds.SAVE]), getQuickSave(actions[TopNavIds.QUICK_SAVE]), ]; @@ -154,23 +152,6 @@ function getViewConfig(action: NavAction) { }; } -/** - * @returns {kbnTopNavConfig} - */ -function getDiscardConfig(action: NavAction) { - return { - id: 'discard', - label: i18n.translate('dashboard.topNave.discardlButtonAriaLabel', { - defaultMessage: 'discard', - }), - description: i18n.translate('dashboard.topNave.discardConfigDescription', { - defaultMessage: 'Discard unsaved changes', - }), - testId: 'dashboardDiscardChanges', - run: action, - }; -} - /** * @returns {kbnTopNavConfig} */ diff --git a/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts b/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts index 92a0db6bd0ba2e..ee3d08e2330ae9 100644 --- a/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts +++ b/src/plugins/dashboard/public/application/top_nav/top_nav_ids.ts @@ -13,7 +13,6 @@ export const TopNavIds = { SAVE: 'save', EXIT_EDIT_MODE: 'exitEditMode', ENTER_EDIT_MODE: 'enterEditMode', - DISCARD_CHANGES: 'discard', CLONE: 'clone', FULL_SCREEN: 'fullScreenMode', }; diff --git a/src/plugins/dashboard/public/dashboard_strings.ts b/src/plugins/dashboard/public/dashboard_strings.ts index 96bd32088ec38b..dad347b176c7ef 100644 --- a/src/plugins/dashboard/public/dashboard_strings.ts +++ b/src/plugins/dashboard/public/dashboard_strings.ts @@ -12,36 +12,30 @@ import { ViewMode } from './services/embeddable'; /** * @param title {string} the current title of the dashboard * @param viewMode {DashboardViewMode} the current mode. If in editing state, prepends 'Editing ' to the title. - * @param isDirty {boolean} if the dashboard is in a dirty state. If in dirty state, adds (unsaved) to the - * end of the title. * @returns {string} A title to display to the user based on the above parameters. */ -export function getDashboardTitle( - title: string, - viewMode: ViewMode, - isDirty: boolean, - isNew: boolean -): string { +export function getDashboardTitle(title: string, viewMode: ViewMode, isNew: boolean): string { const isEditMode = viewMode === ViewMode.EDIT; - let displayTitle: string; const dashboardTitle = isNew ? getNewDashboardTitle() : title; + return isEditMode + ? i18n.translate('dashboard.strings.dashboardEditTitle', { + defaultMessage: 'Editing {title}', + values: { title: dashboardTitle }, + }) + : dashboardTitle; +} - if (isEditMode && isDirty) { - displayTitle = i18n.translate('dashboard.strings.dashboardUnsavedEditTitle', { - defaultMessage: 'Editing {title} (unsaved)', - values: { title: dashboardTitle }, - }); - } else if (isEditMode) { - displayTitle = i18n.translate('dashboard.strings.dashboardEditTitle', { - defaultMessage: 'Editing {title}', - values: { title: dashboardTitle }, - }); - } else { - displayTitle = dashboardTitle; - } +export const unsavedChangesBadge = { + getUnsavedChangedBadgeText: () => + i18n.translate('dashboard.unsavedChangesBadge', { + defaultMessage: 'Unsaved changes', + }), +}; - return displayTitle; -} +export const getMigratedToastText = () => + i18n.translate('dashboard.migratedChanges', { + defaultMessage: 'Some panels have been successfully updated to the latest version.', + }); /* Plugin @@ -253,6 +247,18 @@ export const leaveConfirmStrings = { i18n.translate('dashboard.appLeaveConfirmModal.unsavedChangesSubtitle', { defaultMessage: 'Leave Dashboard with unsaved work?', }), + getKeepChangesText: () => + i18n.translate('dashboard.appLeaveConfirmModal.keepUnsavedChangesButtonLabel', { + defaultMessage: 'Keep unsaved changes', + }), + getLeaveEditModeTitle: () => + i18n.translate('dashboard.changeViewModeConfirmModal.leaveEditMode', { + defaultMessage: 'Leave edit mode with unsaved work?', + }), + getLeaveEditModeSubtitle: () => + i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesOptionalDescription', { + defaultMessage: `If you discard your changes, there's no getting them back.`, + }), getDiscardTitle: () => i18n.translate('dashboard.changeViewModeConfirmModal.discardChangesTitle', { defaultMessage: 'Discard changes to dashboard?', diff --git a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts index d991c7ad23bc86..7a91acb7e5a388 100644 --- a/src/plugins/data/common/search/aggs/buckets/date_histogram.ts +++ b/src/plugins/data/common/search/aggs/buckets/date_histogram.ts @@ -11,6 +11,7 @@ import moment from 'moment-timezone'; import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES, TimeRange, TimeRangeBounds, UI_SETTINGS } from '../../../../common'; +import { IFieldType } from '../../../index_patterns'; import { intervalOptions, autoInterval, isAutoInterval } from './_interval_options'; import { createFilterDateHistogram } from './create_filter/date_histogram'; @@ -58,7 +59,7 @@ export function isDateHistogramBucketAggConfig(agg: any): agg is IBucketDateHist } export interface AggParamsDateHistogram extends BaseAggParams { - field?: string; + field?: IFieldType | string; timeRange?: TimeRange; useNormalizedEsInterval?: boolean; scaleMetricValues?: boolean; diff --git a/src/plugins/data/common/search/aggs/metrics/max.ts b/src/plugins/data/common/search/aggs/metrics/max.ts index ee2d5ad03ce3a0..5a41cdbb256c80 100644 --- a/src/plugins/data/common/search/aggs/metrics/max.ts +++ b/src/plugins/data/common/search/aggs/metrics/max.ts @@ -36,7 +36,7 @@ export const getMaxMetricAgg = () => { { name: 'field', type: 'field', - filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.HISTOGRAM], }, ], }); diff --git a/src/plugins/data/common/search/aggs/metrics/min.ts b/src/plugins/data/common/search/aggs/metrics/min.ts index f9e3c5b59d586b..1805546a9fa346 100644 --- a/src/plugins/data/common/search/aggs/metrics/min.ts +++ b/src/plugins/data/common/search/aggs/metrics/min.ts @@ -36,7 +36,7 @@ export const getMinMetricAgg = () => { { name: 'field', type: 'field', - filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE], + filterFieldTypes: [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.DATE, KBN_FIELD_TYPES.HISTOGRAM], }, ], }); diff --git a/src/plugins/data/common/search/aggs/utils/infer_time_zone.test.ts b/src/plugins/data/common/search/aggs/utils/infer_time_zone.test.ts index 5547f554299ce5..0c94769f0538ed 100644 --- a/src/plugins/data/common/search/aggs/utils/infer_time_zone.test.ts +++ b/src/plugins/data/common/search/aggs/utils/infer_time_zone.test.ts @@ -18,7 +18,7 @@ jest.mock('moment', () => { return moment; }); -import { IndexPattern } from '../../../index_patterns'; +import { IndexPattern, IndexPatternField } from '../../../index_patterns'; import { AggParamsDateHistogram } from '../buckets'; import { inferTimeZone } from './infer_time_zone'; @@ -51,6 +51,31 @@ describe('inferTimeZone', () => { ).toEqual('UTC'); }); + it('reads time zone from index pattern type meta if available when the field is not a string', () => { + expect( + inferTimeZone( + { + field: { + name: 'mydatefield', + } as IndexPatternField, + }, + ({ + typeMeta: { + aggs: { + date_histogram: { + mydatefield: { + time_zone: 'UTC', + }, + }, + }, + }, + } as unknown) as IndexPattern, + () => false, + jest.fn() + ) + ).toEqual('UTC'); + }); + it('reads time zone from moment if set to default', () => { expect(inferTimeZone({}, {} as IndexPattern, () => true, jest.fn())).toEqual('CET'); }); diff --git a/src/plugins/data/common/search/aggs/utils/infer_time_zone.ts b/src/plugins/data/common/search/aggs/utils/infer_time_zone.ts index a997a601e940b5..b031fb890b77cf 100644 --- a/src/plugins/data/common/search/aggs/utils/infer_time_zone.ts +++ b/src/plugins/data/common/search/aggs/utils/infer_time_zone.ts @@ -20,7 +20,8 @@ export function inferTimeZone( if (!tz && params.field) { // If a field has been configured check the index pattern's typeMeta if a date_histogram on that // field requires a specific time_zone - tz = indexPattern.typeMeta?.aggs?.date_histogram?.[params.field]?.time_zone; + const fieldName = typeof params.field === 'string' ? params.field : params.field.name; + tz = indexPattern.typeMeta?.aggs?.date_histogram?.[fieldName]?.time_zone; } if (!tz) { // If the index pattern typeMeta data, didn't had a time zone assigned for the selected field use the configured tz diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 4c64a117f8cfe0..23ad7af14b093f 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -28,6 +28,7 @@ const mockSource2 = { excludes: ['bar-*'] }; const indexPattern = ({ title: 'foo', + fields: [{ name: 'foo-bar' }, { name: 'field1' }, { name: 'field2' }], getComputedFields, getSourceFiltering: () => mockSource, } as unknown) as IndexPattern; @@ -51,6 +52,11 @@ describe('SearchSource', () => { let searchSource: SearchSource; beforeEach(() => { + const getConfigMock = jest + .fn() + .mockImplementation((param) => param === 'metaFields' && ['_type', '_source']) + .mockName('getConfig'); + mockSearchMethod = jest .fn() .mockReturnValue( @@ -61,7 +67,7 @@ describe('SearchSource', () => { ); searchSourceDependencies = { - getConfig: jest.fn(), + getConfig: getConfigMock, search: mockSearchMethod, onResponse: (req, res) => res, legacy: { @@ -339,8 +345,13 @@ describe('SearchSource', () => { }); test('allows you to override computed fields if you provide a format', async () => { + const indexPatternFields = indexPattern.fields; + indexPatternFields.getByType = (type) => { + return []; + }; searchSource.setField('index', ({ ...indexPattern, + fields: indexPatternFields, getComputedFields: () => ({ storedFields: [], scriptFields: {}, @@ -373,6 +384,11 @@ describe('SearchSource', () => { test('injects a date format for computed docvalue fields while merging other properties', async () => { searchSource.setField('index', ({ ...indexPattern, + fields: { + getByType: () => { + return []; + }, + }, getComputedFields: () => ({ storedFields: [], scriptFields: {}, @@ -518,6 +534,54 @@ describe('SearchSource', () => { expect(request.script_fields).toEqual({ hello: {} }); }); + test('request all fields except the ones specified with source filters', async () => { + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: [], + docvalueFields: [], + }), + } as unknown) as IndexPattern); + searchSource.setField('fields', ['hello', 'foo']); + + const request = await searchSource.getSearchRequestBody(); + expect(request.fields).toEqual(['hello']); + }); + + test('request all fields from index pattern except the ones specified with source filters', async () => { + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: [], + docvalueFields: [], + }), + } as unknown) as IndexPattern); + searchSource.setField('fields', ['*']); + + const request = await searchSource.getSearchRequestBody(); + expect(request.fields).toEqual([{ field: 'field1' }, { field: 'field2' }]); + }); + + test('request all fields from index pattern except the ones specified with source filters with unmapped_fields option', async () => { + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: [], + docvalueFields: [], + }), + } as unknown) as IndexPattern); + searchSource.setField('fields', [{ field: '*', include_unmapped: 'true' }]); + + const request = await searchSource.getSearchRequestBody(); + expect(request.fields).toEqual([ + { field: 'field1', include_unmapped: 'true' }, + { field: 'field2', include_unmapped: 'true' }, + ]); + }); + test('returns all scripted fields when one fields entry is *', async () => { searchSource.setField('index', ({ ...indexPattern, @@ -571,7 +635,7 @@ describe('SearchSource', () => { searchSource.setField('fields', ['hello', '@timestamp', 'foo-a', 'bar']); const request = await searchSource.getSearchRequestBody(); - expect(request.fields).toEqual(['hello', '@timestamp', 'bar']); + expect(request.fields).toEqual(['hello', '@timestamp', 'bar', 'date']); expect(request.script_fields).toEqual({ hello: {} }); expect(request.stored_fields).toEqual(['@timestamp', 'bar']); }); @@ -627,6 +691,60 @@ describe('SearchSource', () => { }); }); + describe('handling date fields', () => { + test('adds date format to any date field', async () => { + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: {}, + docvalueFields: [{ field: '@timestamp' }], + }), + fields: { + getByType: () => [{ name: '@timestamp', esTypes: ['date_nanos'] }], + }, + getSourceFiltering: () => ({ excludes: [] }), + } as unknown) as IndexPattern); + searchSource.setField('fields', ['*']); + + const request = await searchSource.getSearchRequestBody(); + expect(request.fields).toEqual([ + '*', + { field: '@timestamp', format: 'strict_date_optional_time_nanos' }, + ]); + }); + + test('adds date format to any date field except the one excluded by source filters', async () => { + const indexPatternFields = indexPattern.fields; + // @ts-ignore + indexPatternFields.getByType = (type) => { + return [ + { name: '@timestamp', esTypes: ['date_nanos'] }, + { name: 'custom_date', esTypes: ['date'] }, + ]; + }; + searchSource.setField('index', ({ + ...indexPattern, + getComputedFields: () => ({ + storedFields: [], + scriptFields: {}, + docvalueFields: [{ field: '@timestamp' }, { field: 'custom_date' }], + }), + fields: indexPatternFields, + getSourceFiltering: () => ({ excludes: ['custom_date'] }), + } as unknown) as IndexPattern); + searchSource.setField('fields', ['*']); + + const request = await searchSource.getSearchRequestBody(); + expect(request.fields).toEqual([ + { field: 'foo-bar' }, + { field: 'field1' }, + { field: 'field2' }, + { field: '@timestamp', format: 'strict_date_optional_time_nanos' }, + ]); + }); + }); + describe(`#setField('index')`, () => { describe('auto-sourceFiltering', () => { describe('new index pattern assigned', () => { @@ -836,5 +954,25 @@ describe('SearchSource', () => { expect(references[1].type).toEqual('index-pattern'); expect(JSON.parse(searchSourceJSON).filter[0].meta.indexRefName).toEqual(references[1].name); }); + + test('mvt geoshape layer test', async () => { + // @ts-expect-error TS won't like using this field name, but technically it's possible. + searchSource.setField('docvalue_fields', ['prop1']); + searchSource.setField('source', ['geometry']); + searchSource.setField('fieldsFromSource', ['geometry', 'prop1']); + searchSource.setField('index', ({ + ...indexPattern, + getSourceFiltering: () => ({ excludes: [] }), + getComputedFields: () => ({ + storedFields: ['*'], + scriptFields: {}, + docvalueFields: [], + }), + } as unknown) as IndexPattern); + const request = await searchSource.getSearchRequestBody(); + expect(request.stored_fields).toEqual(['geometry', 'prop1']); + expect(request.docvalue_fields).toEqual(['prop1']); + expect(request._source).toEqual(['geometry']); + }); }); }); diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 1c1360414cb2ed..8406c4900bef74 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -59,12 +59,13 @@ */ import { setWith } from '@elastic/safer-lodash-set'; -import { uniqueId, keyBy, pick, difference, omit, isObject, isFunction } from 'lodash'; +import { uniqueId, keyBy, pick, difference, omit, isFunction, isEqual, uniqWith } from 'lodash'; import { map, switchMap, tap } from 'rxjs/operators'; import { defer, from } from 'rxjs'; +import { isObject } from 'rxjs/internal-compatibility'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; -import { IIndexPattern } from '../../index_patterns'; +import { IIndexPattern, IndexPattern, IndexPatternField } from '../../index_patterns'; import { ISearchGeneric, ISearchOptions } from '../..'; import type { ISearchSource, @@ -296,6 +297,9 @@ export class SearchSource { switchMap(() => { const searchRequest = this.flatten(); this.history = [searchRequest]; + if (searchRequest.index) { + options.indexPattern = searchRequest.index; + } return getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES) ? from(this.legacyFetch(searchRequest, options)) @@ -500,10 +504,84 @@ export class SearchSource { } } + private readonly getFieldName = (fld: string | Record): string => + typeof fld === 'string' ? fld : fld.field; + + private getFieldsWithoutSourceFilters( + index: IndexPattern | undefined, + bodyFields: SearchFieldValue[] + ) { + if (!index) { + return bodyFields; + } + const { fields } = index; + const sourceFilters = index.getSourceFiltering(); + if (!sourceFilters || sourceFilters.excludes?.length === 0 || bodyFields.length === 0) { + return bodyFields; + } + const metaFields = this.dependencies.getConfig(UI_SETTINGS.META_FIELDS); + const sourceFiltersValues = sourceFilters.excludes; + const wildcardField = bodyFields.find( + (el: SearchFieldValue) => el === '*' || (el as Record).field === '*' + ); + const filterSourceFields = (fieldName: string) => { + return ( + fieldName && + !sourceFiltersValues.some((sourceFilter) => fieldName.match(sourceFilter)) && + !metaFields.includes(fieldName) + ); + }; + if (!wildcardField) { + // we already have an explicit list of fields, so we just remove source filters from that list + return bodyFields.filter((fld: SearchFieldValue) => + filterSourceFields(this.getFieldName(fld)) + ); + } + // we need to get the list of fields from an index pattern + return fields + .filter((fld: IndexPatternField) => filterSourceFields(fld.name)) + .map((fld: IndexPatternField) => ({ + field: fld.name, + ...((wildcardField as Record)?.include_unmapped && { + include_unmapped: (wildcardField as Record).include_unmapped, + }), + })); + } + + private getFieldFromDocValueFieldsOrIndexPattern( + docvaluesIndex: Record, + fld: SearchFieldValue, + index?: IndexPattern + ) { + if (typeof fld === 'string') { + return fld; + } + const fieldName = this.getFieldName(fld); + const field = { + ...docvaluesIndex[fieldName], + ...fld, + }; + if (!index) { + return field; + } + const { fields } = index; + const dateFields = fields.getByType('date'); + const dateField = dateFields.find((indexPatternField) => indexPatternField.name === fieldName); + if (!dateField) { + return field; + } + const { esTypes } = dateField; + if (esTypes?.includes('date_nanos')) { + field.format = 'strict_date_optional_time_nanos'; + } else if (esTypes?.includes('date')) { + field.format = 'strict_date_optional_time'; + } + return field; + } + private flatten() { const { getConfig } = this.dependencies; const searchRequest = this.mergeProps(); - searchRequest.body = searchRequest.body || {}; const { body, index, query, filters, highlightAll } = searchRequest; searchRequest.indexType = this.getIndexType(index); @@ -517,10 +595,7 @@ export class SearchSource { storedFields: ['*'], runtimeFields: {}, }; - const fieldListProvided = !!body.fields; - const getFieldName = (fld: string | Record): string => - typeof fld === 'string' ? fld : fld.field; // set defaults let fieldsFromSource = searchRequest.fieldsFromSource || []; @@ -539,26 +614,22 @@ export class SearchSource { if (!body.hasOwnProperty('_source')) { body._source = sourceFilters; } - if (body._source.excludes) { - const filter = fieldWildcardFilter( - body._source.excludes, - getConfig(UI_SETTINGS.META_FIELDS) - ); - // also apply filters to provided fields & default docvalueFields - body.fields = body.fields.filter((fld: SearchFieldValue) => filter(getFieldName(fld))); - fieldsFromSource = fieldsFromSource.filter((fld: SearchFieldValue) => - filter(getFieldName(fld)) - ); - filteredDocvalueFields = filteredDocvalueFields.filter((fld: SearchFieldValue) => - filter(getFieldName(fld)) - ); - } + + const filter = fieldWildcardFilter(body._source.excludes, getConfig(UI_SETTINGS.META_FIELDS)); + // also apply filters to provided fields & default docvalueFields + body.fields = body.fields.filter((fld: SearchFieldValue) => filter(this.getFieldName(fld))); + fieldsFromSource = fieldsFromSource.filter((fld: SearchFieldValue) => + filter(this.getFieldName(fld)) + ); + filteredDocvalueFields = filteredDocvalueFields.filter((fld: SearchFieldValue) => + filter(this.getFieldName(fld)) + ); } // specific fields were provided, so we need to exclude any others if (fieldListProvided || fieldsFromSource.length) { const bodyFieldNames = body.fields.map((field: string | Record) => - getFieldName(field) + this.getFieldName(field) ); const uniqFieldNames = [...new Set([...bodyFieldNames, ...fieldsFromSource])]; @@ -579,17 +650,20 @@ export class SearchSource { const remainingFields = difference(uniqFieldNames, [ ...Object.keys(body.script_fields), ...Object.keys(body.runtime_mappings), - ]).filter(Boolean); + ]).filter((remainingField) => { + if (!remainingField) return false; + if (!body._source || !body._source.excludes) return true; + return !body._source.excludes.includes(remainingField); + }); - // only include unique values body.stored_fields = [...new Set(remainingFields)]; - + // only include unique values if (fieldsFromSource.length) { - // include remaining fields in _source - setWith(body, '_source.includes', remainingFields, (nsValue) => - isObject(nsValue) ? {} : nsValue - ); - + if (!isEqual(remainingFields, fieldsFromSource)) { + setWith(body, '_source.includes', remainingFields, (nsValue) => + isObject(nsValue) ? {} : nsValue + ); + } // if items that are in the docvalueFields are provided, we should // make sure those are added to the fields API unless they are // already set in docvalue_fields @@ -597,10 +671,10 @@ export class SearchSource { ...body.fields, ...filteredDocvalueFields.filter((fld: SearchFieldValue) => { return ( - fieldsFromSource.includes(getFieldName(fld)) && + fieldsFromSource.includes(this.getFieldName(fld)) && !(body.docvalue_fields || []) - .map((d: string | Record) => getFieldName(d)) - .includes(getFieldName(fld)) + .map((d: string | Record) => this.getFieldName(d)) + .includes(this.getFieldName(fld)) ); }), ]; @@ -614,17 +688,22 @@ export class SearchSource { // if items that are in the docvalueFields are provided, we should // inject the format from the computed fields if one isn't given const docvaluesIndex = keyBy(filteredDocvalueFields, 'field'); - body.fields = body.fields.map((fld: SearchFieldValue) => { - const fieldName = getFieldName(fld); + const bodyFields = this.getFieldsWithoutSourceFilters(index, body.fields); + body.fields = uniqWith( + bodyFields.concat(filteredDocvalueFields), + (fld1: SearchFieldValue, fld2: SearchFieldValue) => { + const field1Name = this.getFieldName(fld1); + const field2Name = this.getFieldName(fld2); + return field1Name === field2Name; + } + ).map((fld: SearchFieldValue) => { + const fieldName = this.getFieldName(fld); if (Object.keys(docvaluesIndex).includes(fieldName)) { // either provide the field object from computed docvalues, // or merge the user-provided field with the one in docvalues return typeof fld === 'string' ? docvaluesIndex[fld] - : { - ...docvaluesIndex[fieldName], - ...fld, - }; + : this.getFieldFromDocValueFieldsOrIndexPattern(docvaluesIndex, fld, index); } return fld; }); diff --git a/src/plugins/data/common/search/types.ts b/src/plugins/data/common/search/types.ts index 4f687a396a47b9..3ac4c33091f6bb 100644 --- a/src/plugins/data/common/search/types.ts +++ b/src/plugins/data/common/search/types.ts @@ -8,6 +8,7 @@ import { Observable } from 'rxjs'; import { IEsSearchRequest, IEsSearchResponse } from './es_search'; +import { IndexPattern } from '..'; export type ISearchGeneric = < SearchStrategyRequest extends IKibanaSearchRequest = IEsSearchRequest, @@ -111,4 +112,10 @@ export interface ISearchOptions { * rather than starting from scratch) */ isRestore?: boolean; + + /** + * Index pattern reference is used for better error messages + */ + + indexPattern?: IndexPattern; } diff --git a/src/plugins/data/public/autocomplete/autocomplete_service.ts b/src/plugins/data/public/autocomplete/autocomplete_service.ts index a99943c6cd8784..6b288c4507f066 100644 --- a/src/plugins/data/public/autocomplete/autocomplete_service.ts +++ b/src/plugins/data/public/autocomplete/autocomplete_service.ts @@ -16,6 +16,8 @@ import { } from './providers/value_suggestion_provider'; import { ConfigSchema } from '../../config'; +import { UsageCollectionSetup } from '../../../usage_collection/public'; +import { createUsageCollector } from './collectors'; export class AutocompleteService { autocompleteConfig: ConfigSchema['autocomplete']; @@ -47,9 +49,17 @@ export class AutocompleteService { private hasQuerySuggestions = (language: string) => this.querySuggestionProviders.has(language); /** @public **/ - public setup(core: CoreSetup, { timefilter }: { timefilter: TimefilterSetup }) { + public setup( + core: CoreSetup, + { + timefilter, + usageCollection, + }: { timefilter: TimefilterSetup; usageCollection?: UsageCollectionSetup } + ) { + const usageCollector = createUsageCollector(core.getStartServices, usageCollection); + this.getValueSuggestions = this.autocompleteConfig.valueSuggestions.enabled - ? setupValueSuggestionProvider(core, { timefilter }) + ? setupValueSuggestionProvider(core, { timefilter, usageCollector }) : getEmptyValueSuggestions; return { diff --git a/src/plugins/data/public/autocomplete/collectors/create_usage_collector.ts b/src/plugins/data/public/autocomplete/collectors/create_usage_collector.ts new file mode 100644 index 00000000000000..fc0cea2fdbc520 --- /dev/null +++ b/src/plugins/data/public/autocomplete/collectors/create_usage_collector.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { first } from 'rxjs/operators'; +import { StartServicesAccessor } from '../../../../../core/public'; +import { METRIC_TYPE, UsageCollectionSetup } from '../../../../usage_collection/public'; +import { AUTOCOMPLETE_EVENT_TYPE, AutocompleteUsageCollector } from './types'; + +export const createUsageCollector = ( + getStartServices: StartServicesAccessor, + usageCollection?: UsageCollectionSetup +): AutocompleteUsageCollector => { + const getCurrentApp = async () => { + const [{ application }] = await getStartServices(); + return application.currentAppId$.pipe(first()).toPromise(); + }; + + return { + trackCall: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiCounter( + currentApp!, + METRIC_TYPE.LOADED, + AUTOCOMPLETE_EVENT_TYPE.CALL + ); + }, + trackRequest: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiCounter( + currentApp!, + METRIC_TYPE.LOADED, + AUTOCOMPLETE_EVENT_TYPE.REQUEST + ); + }, + trackResult: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiCounter( + currentApp!, + METRIC_TYPE.LOADED, + AUTOCOMPLETE_EVENT_TYPE.RESULT + ); + }, + trackError: async () => { + const currentApp = await getCurrentApp(); + return usageCollection?.reportUiCounter( + currentApp!, + METRIC_TYPE.LOADED, + AUTOCOMPLETE_EVENT_TYPE.ERROR + ); + }, + }; +}; diff --git a/src/plugins/data/public/autocomplete/collectors/index.ts b/src/plugins/data/public/autocomplete/collectors/index.ts new file mode 100644 index 00000000000000..5cfaab19787dab --- /dev/null +++ b/src/plugins/data/public/autocomplete/collectors/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { createUsageCollector } from './create_usage_collector'; +export { AUTOCOMPLETE_EVENT_TYPE, AutocompleteUsageCollector } from './types'; diff --git a/src/plugins/data/public/autocomplete/collectors/types.ts b/src/plugins/data/public/autocomplete/collectors/types.ts new file mode 100644 index 00000000000000..60eb9103dc44ee --- /dev/null +++ b/src/plugins/data/public/autocomplete/collectors/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export enum AUTOCOMPLETE_EVENT_TYPE { + CALL = 'autocomplete:call', + REQUEST = 'autocomplete:req', + RESULT = 'autocomplete:res', + ERROR = 'autocomplete:err', +} + +export interface AutocompleteUsageCollector { + trackCall: () => Promise; + trackRequest: () => Promise; + trackResult: () => Promise; + trackError: () => Promise; +} diff --git a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts index 23fc9f5405aea6..a7b1bd2c7839de 100644 --- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts +++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.test.ts @@ -18,7 +18,7 @@ describe('FieldSuggestions', () => { beforeEach(() => { const uiSettings = { get: (key: string) => shouldSuggestValues } as IUiSettingsClient; - http = { fetch: jest.fn() }; + http = { fetch: jest.fn().mockResolvedValue([]) }; getValueSuggestions = setupValueSuggestionProvider({ http, uiSettings } as CoreSetup, { timefilter: ({ diff --git a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts index d8c6d16174d143..b8af6ad3a99e58 100644 --- a/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/plugins/data/public/autocomplete/providers/value_suggestion_provider.ts @@ -11,12 +11,7 @@ import { memoize } from 'lodash'; import { CoreSetup } from 'src/core/public'; import { IIndexPattern, IFieldType, UI_SETTINGS, buildQueryFromFilters } from '../../../common'; import { TimefilterSetup } from '../../query'; - -function resolver(title: string, field: IFieldType, query: string, filters: any[]) { - // Only cache results for a minute - const ttl = Math.floor(Date.now() / 1000 / 60); - return [ttl, query, title, field.name, JSON.stringify(filters)].join('|'); -} +import { AutocompleteUsageCollector } from '../collectors'; export type ValueSuggestionsGetFn = (args: ValueSuggestionsGetFnArgs) => Promise; @@ -47,15 +42,31 @@ export const getEmptyValueSuggestions = (() => Promise.resolve([])) as ValueSugg export const setupValueSuggestionProvider = ( core: CoreSetup, - { timefilter }: { timefilter: TimefilterSetup } + { + timefilter, + usageCollector, + }: { timefilter: TimefilterSetup; usageCollector?: AutocompleteUsageCollector } ): ValueSuggestionsGetFn => { + function resolver(title: string, field: IFieldType, query: string, filters: any[]) { + // Only cache results for a minute + const ttl = Math.floor(Date.now() / 1000 / 60); + return [ttl, query, title, field.name, JSON.stringify(filters)].join('|'); + } + const requestSuggestions = memoize( - (index: string, field: IFieldType, query: string, filters: any = [], signal?: AbortSignal) => - core.http.fetch(`/api/kibana/suggestions/values/${index}`, { - method: 'POST', - body: JSON.stringify({ query, field: field.name, filters }), - signal, - }), + (index: string, field: IFieldType, query: string, filters: any = [], signal?: AbortSignal) => { + usageCollector?.trackRequest(); + return core.http + .fetch(`/api/kibana/suggestions/values/${index}`, { + method: 'POST', + body: JSON.stringify({ query, field: field.name, filters }), + signal, + }) + .then((r) => { + usageCollector?.trackResult(); + return r; + }); + }, resolver ); @@ -85,6 +96,16 @@ export const setupValueSuggestionProvider = ( : undefined; const filterQuery = timeFilter ? buildQueryFromFilters([timeFilter], indexPattern).filter : []; const filters = [...(boolFilter ? boolFilter : []), ...filterQuery]; - return await requestSuggestions(title, field, query, filters, signal); + try { + usageCollector?.trackCall(); + return await requestSuggestions(title, field, query, filters, signal); + } catch (e) { + if (!signal?.aborted) { + usageCollector?.trackError(); + } + // Remove rejected results from memoize cache + requestSuggestions.cache.delete(resolver(title, field, query, filters)); + return []; + } }; }; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 83a248ee2c3dee..df799ede08a310 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -389,6 +389,7 @@ export type { ISessionService, SearchSessionInfoProvider, ISessionsClient, + SearchUsageCollector, } from './search'; export { ISearchOptions, isErrorResponse, isCompleteResponse, isPartialResponse } from '../common'; diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 39d3ca57215b74..862dd63948a224 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -115,7 +115,10 @@ export class DataPublicPlugin ); return { - autocomplete: this.autocomplete.setup(core, { timefilter: queryService.timefilter }), + autocomplete: this.autocomplete.setup(core, { + timefilter: queryService.timefilter, + usageCollection, + }), search: searchService, fieldFormats: this.fieldFormatsService.setup(core), query: queryService, @@ -195,10 +198,7 @@ export class DataPublicPlugin core, data: dataServices, storage: this.storage, - trackUiMetric: this.usageCollection?.reportUiCounter.bind( - this.usageCollection, - 'data_plugin' - ), + usageCollection: this.usageCollection, }); return { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 4668ce2208610d..745f4a7d29d224 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -105,7 +105,6 @@ import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; import { UiActionsSetup } from 'src/plugins/ui_actions/public'; import { UiActionsStart } from 'src/plugins/ui_actions/public'; -import { UiCounterMetricType } from '@kbn/analytics'; import { Unit } from '@elastic/datemath'; import { UnregisterCallback } from 'history'; import { URL } from 'url'; @@ -1094,6 +1093,10 @@ export interface IDataPluginServices extends Partial { storage: IStorageWrapper; // (undocumented) uiSettings: CoreStart_2['uiSettings']; + // Warning: (ae-forgotten-export) The symbol "UsageCollectionStart" needs to be exported by the entry point index.d.ts + // + // (undocumented) + usageCollection?: UsageCollectionStart; } // Warning: (ae-missing-release-tag) "IEsSearchRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1640,6 +1643,7 @@ export type ISearchGeneric = , "query" | "isLoading" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "trackUiMetric" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "indexPatterns" | "filters" | "dataTestSubj" | "refreshInterval" | "screenTitle" | "onRefresh" | "onRefreshChange" | "showQueryInput" | "showDatePicker" | "showAutoRefreshOnly" | "dateRangeFrom" | "dateRangeTo" | "isRefreshPaused" | "customSubmitButton" | "timeHistory" | "indicateNoData" | "onFiltersUpdated" | "savedQuery" | "showSaveQuery" | "onClearSavedQuery" | "showQueryBar" | "showFilterBar" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts @@ -2329,6 +2335,8 @@ export interface SearchInterceptorDeps { toasts: ToastsSetup; // (undocumented) uiSettings: CoreSetup_2['uiSettings']; + // Warning: (ae-incompatible-release-tags) The symbol "usageCollector" is marked as @public, but its signature references "SearchUsageCollector" which is marked as @internal + // // (undocumented) usageCollector?: SearchUsageCollector; } @@ -2453,6 +2461,38 @@ export class SearchTimeoutError extends KbnError { mode: TimeoutErrorMode; } +// @internal (undocumented) +export interface SearchUsageCollector { + // (undocumented) + trackQueryTimedOut: () => Promise; + // (undocumented) + trackSessionCancelled: () => Promise; + // (undocumented) + trackSessionDeleted: () => Promise; + // (undocumented) + trackSessionExtended: () => Promise; + // (undocumented) + trackSessionIndicatorSaveDisabled: () => Promise; + // (undocumented) + trackSessionIndicatorTourLoading: () => Promise; + // (undocumented) + trackSessionIndicatorTourRestored: () => Promise; + // (undocumented) + trackSessionIsRestored: () => Promise; + // (undocumented) + trackSessionReloaded: () => Promise; + // (undocumented) + trackSessionSavedResults: () => Promise; + // (undocumented) + trackSessionSentToBackground: () => Promise; + // (undocumented) + trackSessionsListLoaded: () => Promise; + // (undocumented) + trackSessionViewRestored: () => Promise; + // (undocumented) + trackViewSessionsList: () => Promise; +} + // Warning: (ae-missing-release-tag) "SortDirection" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2607,21 +2647,21 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:399:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:409:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:416:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:420:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:424:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:398:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:400:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:401:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:410:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:411:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:412:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:413:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:417:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:418:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:421:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:422:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:425:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:34:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/search/session/session_service.ts:42:5 - (ae-forgotten-export) The symbol "UrlGeneratorStateMapping" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.test.ts b/src/plugins/data/public/search/collectors/create_usage_collector.test.ts index df9903a4683e11..145bb191fde11b 100644 --- a/src/plugins/data/public/search/collectors/create_usage_collector.test.ts +++ b/src/plugins/data/public/search/collectors/create_usage_collector.test.ts @@ -45,12 +45,66 @@ describe('Search Usage Collector', () => { ); }); - test('tracks query cancellation', async () => { - await usageCollector.trackQueriesCancelled(); + test('tracks session sent to background', async () => { + await usageCollector.trackSessionSentToBackground(); expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); - expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.LOADED); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_SENT_TO_BACKGROUND + ); + }); + + test('tracks session saved results', async () => { + await usageCollector.trackSessionSavedResults(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_SAVED_RESULTS + ); + }); + + test('tracks session view restored', async () => { + await usageCollector.trackSessionViewRestored(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_VIEW_RESTORED + ); + }); + + test('tracks session is restored', async () => { + await usageCollector.trackSessionIsRestored(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_IS_RESTORED + ); + }); + + test('tracks session reloaded', async () => { + await usageCollector.trackSessionReloaded(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_RELOADED + ); + }); + + test('tracks session extended', async () => { + await usageCollector.trackSessionExtended(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( + SEARCH_EVENT_TYPE.SESSION_EXTENDED + ); + }); + + test('tracks session cancelled', async () => { + await usageCollector.trackSessionCancelled(); + expect(mockUsageCollectionSetup.reportUiCounter).toHaveBeenCalled(); + expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][1]).toBe(METRIC_TYPE.CLICK); expect(mockUsageCollectionSetup.reportUiCounter.mock.calls[0][2]).toBe( - SEARCH_EVENT_TYPE.QUERIES_CANCELLED + SEARCH_EVENT_TYPE.SESSION_CANCELLED ); }); }); diff --git a/src/plugins/data/public/search/collectors/create_usage_collector.ts b/src/plugins/data/public/search/collectors/create_usage_collector.ts index e9a192a2710c46..3fe135ea29152a 100644 --- a/src/plugins/data/public/search/collectors/create_usage_collector.ts +++ b/src/plugins/data/public/search/collectors/create_usage_collector.ts @@ -7,6 +7,7 @@ */ import { first } from 'rxjs/operators'; +import { UiCounterMetricType } from '@kbn/analytics'; import { StartServicesAccessor } from '../../../../../core/public'; import { METRIC_TYPE, UsageCollectionSetup } from '../../../../usage_collection/public'; import { SEARCH_EVENT_TYPE, SearchUsageCollector } from './types'; @@ -20,22 +21,48 @@ export const createUsageCollector = ( return application.currentAppId$.pipe(first()).toPromise(); }; - return { - trackQueryTimedOut: async () => { - const currentApp = await getCurrentApp(); - return usageCollection?.reportUiCounter( - currentApp!, - METRIC_TYPE.LOADED, - SEARCH_EVENT_TYPE.QUERY_TIMED_OUT - ); - }, - trackQueriesCancelled: async () => { + const getCollector = (metricType: UiCounterMetricType, eventType: SEARCH_EVENT_TYPE) => { + return async () => { const currentApp = await getCurrentApp(); - return usageCollection?.reportUiCounter( - currentApp!, - METRIC_TYPE.LOADED, - SEARCH_EVENT_TYPE.QUERIES_CANCELLED - ); - }, + return usageCollection?.reportUiCounter(currentApp!, metricType, eventType); + }; + }; + + return { + trackQueryTimedOut: getCollector(METRIC_TYPE.LOADED, SEARCH_EVENT_TYPE.QUERY_TIMED_OUT), + trackSessionIndicatorTourLoading: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_TOUR_LOADING + ), + trackSessionIndicatorTourRestored: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_TOUR_RESTORED + ), + trackSessionIndicatorSaveDisabled: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSION_INDICATOR_SAVE_DISABLED + ), + trackSessionSentToBackground: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_SENT_TO_BACKGROUND + ), + trackSessionSavedResults: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_SAVED_RESULTS + ), + trackSessionViewRestored: getCollector( + METRIC_TYPE.CLICK, + SEARCH_EVENT_TYPE.SESSION_VIEW_RESTORED + ), + trackSessionIsRestored: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_IS_RESTORED), + trackSessionReloaded: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_RELOADED), + trackSessionExtended: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_EXTENDED), + trackSessionCancelled: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_CANCELLED), + trackSessionDeleted: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_DELETED), + trackViewSessionsList: getCollector(METRIC_TYPE.CLICK, SEARCH_EVENT_TYPE.SESSION_VIEW_LIST), + trackSessionsListLoaded: getCollector( + METRIC_TYPE.LOADED, + SEARCH_EVENT_TYPE.SESSIONS_LIST_LOADED + ), }; }; diff --git a/src/plugins/data/public/search/collectors/mocks.ts b/src/plugins/data/public/search/collectors/mocks.ts new file mode 100644 index 00000000000000..2a546d6310d7f7 --- /dev/null +++ b/src/plugins/data/public/search/collectors/mocks.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SearchUsageCollector } from './types'; + +export function createSearchUsageCollectorMock(): jest.Mocked { + return { + trackQueryTimedOut: jest.fn(), + trackSessionIndicatorTourLoading: jest.fn(), + trackSessionIndicatorTourRestored: jest.fn(), + trackSessionIndicatorSaveDisabled: jest.fn(), + trackSessionSentToBackground: jest.fn(), + trackSessionSavedResults: jest.fn(), + trackSessionViewRestored: jest.fn(), + trackSessionIsRestored: jest.fn(), + trackSessionReloaded: jest.fn(), + trackSessionExtended: jest.fn(), + trackSessionCancelled: jest.fn(), + trackSessionDeleted: jest.fn(), + trackViewSessionsList: jest.fn(), + trackSessionsListLoaded: jest.fn(), + }; +} diff --git a/src/plugins/data/public/search/collectors/types.ts b/src/plugins/data/public/search/collectors/types.ts index 9668b4dcbefa2d..49c240d1ccb16a 100644 --- a/src/plugins/data/public/search/collectors/types.ts +++ b/src/plugins/data/public/search/collectors/types.ts @@ -6,12 +6,84 @@ * Side Public License, v 1. */ +/** + * @internal + */ export enum SEARCH_EVENT_TYPE { + /** + * A search reached the timeout configured in UI setting search:timeout + */ QUERY_TIMED_OUT = 'queryTimedOut', - QUERIES_CANCELLED = 'queriesCancelled', + /** + * The session indicator was automatically brought up because of a long running query + */ + SESSION_INDICATOR_TOUR_LOADING = 'sessionIndicatorTourLoading', + /** + * The session indicator was automatically brought up because of a restored session + */ + SESSION_INDICATOR_TOUR_RESTORED = 'sessionIndicatorTourRestored', + /** + * The session indicator was disabled because of a completion timeout + */ + SESSION_INDICATOR_SAVE_DISABLED = 'sessionIndicatorSaveDisabled', + /** + * The user clicked to continue a session in the background (prior to results completing) + */ + SESSION_SENT_TO_BACKGROUND = 'sessionSentToBackground', + /** + * The user clicked to save the session (after results completing) + */ + SESSION_SAVED_RESULTS = 'sessionSavedResults', + /** + * The user clicked to view a completed session + */ + SESSION_VIEW_RESTORED = 'sessionViewRestored', + /** + * The session was successfully restored upon a user navigating + */ + SESSION_IS_RESTORED = 'sessionIsRestored', + /** + * The user clicked to reload an expired/cancelled session + */ + SESSION_RELOADED = 'sessionReloaded', + /** + * The user clicked to extend the expiration of a session + */ + SESSION_EXTENDED = 'sessionExtended', + /** + * The user clicked to cancel a session + */ + SESSION_CANCELLED = 'sessionCancelled', + /** + * The user clicked to delete a session + */ + SESSION_DELETED = 'sessionDeleted', + /** + * The user clicked a link to view the list of sessions + */ + SESSION_VIEW_LIST = 'sessionViewList', + /** + * The user landed on the sessions management page + */ + SESSIONS_LIST_LOADED = 'sessionsListLoaded', } +/** + * @internal + */ export interface SearchUsageCollector { trackQueryTimedOut: () => Promise; - trackQueriesCancelled: () => Promise; + trackSessionIndicatorTourLoading: () => Promise; + trackSessionIndicatorTourRestored: () => Promise; + trackSessionIndicatorSaveDisabled: () => Promise; + trackSessionSentToBackground: () => Promise; + trackSessionSavedResults: () => Promise; + trackSessionViewRestored: () => Promise; + trackSessionIsRestored: () => Promise; + trackSessionReloaded: () => Promise; + trackSessionExtended: () => Promise; + trackSessionCancelled: () => Promise; + trackSessionDeleted: () => Promise; + trackViewSessionsList: () => Promise; + trackSessionsListLoaded: () => Promise; } diff --git a/src/plugins/data/public/search/errors/painless_error.test.tsx b/src/plugins/data/public/search/errors/painless_error.test.tsx index aca1f350ac013e..f07f078ea03a3f 100644 --- a/src/plugins/data/public/search/errors/painless_error.test.tsx +++ b/src/plugins/data/public/search/errors/painless_error.test.tsx @@ -27,16 +27,18 @@ describe('PainlessError', () => { }); const component = mount(e.getErrorMessage(startMock.application)); - const scriptElem = findTestSubject(component, 'painlessScript').getDOMNode(); - const failedShards = e.attributes?.failed_shards![0]; - const script = failedShards!.reason.script; - expect(scriptElem.textContent).toBe(`Error executing Painless script: '${script}'`); const stackTraceElem = findTestSubject(component, 'painlessStackTrace').getDOMNode(); - const stackTrace = failedShards!.reason.script_stack!.join('\n'); + const stackTrace = failedShards!.reason.script_stack!.splice(-2).join('\n'); expect(stackTraceElem.textContent).toBe(stackTrace); + const humanReadableError = findTestSubject( + component, + 'painlessHumanReadableError' + ).getDOMNode(); + expect(humanReadableError.textContent).toBe(failedShards?.reason.caused_by?.reason); + expect(component.find('EuiButton').length).toBe(1); }); }); diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index a73d112a8de48b..bad4567024d00f 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -14,40 +14,59 @@ import { ApplicationStart } from 'kibana/public'; import { IEsError, isEsError } from './types'; import { EsError } from './es_error'; import { getRootCause } from './utils'; +import { IndexPattern } from '../..'; export class PainlessError extends EsError { painlessStack?: string; - constructor(err: IEsError) { + indexPattern?: IndexPattern; + constructor(err: IEsError, indexPattern?: IndexPattern) { super(err); + this.indexPattern = indexPattern; } public getErrorMessage(application: ApplicationStart) { - function onClick() { + function onClick(indexPatternId?: string) { application.navigateToApp('management', { - path: `/kibana/indexPatterns`, + path: `/kibana/indexPatterns${indexPatternId ? `/patterns/${indexPatternId}` : ''}`, }); } const rootCause = getRootCause(this.err); + const scriptFromStackTrace = rootCause?.script_stack + ? rootCause?.script_stack?.slice(-2).join('\n') + : undefined; + // if the error has been properly processed it will highlight where it occurred. + const hasScript = rootCause?.script_stack?.slice(-1)[0]?.indexOf('HERE') || -1 >= 0; + const humanReadableError = rootCause?.caused_by?.reason; + // fallback, show ES stacktrace const painlessStack = rootCause?.script_stack ? rootCause?.script_stack.join('\n') : undefined; + const indexPatternId = this?.indexPattern?.id; return ( <> - + {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', { - defaultMessage: "Error executing Painless script: '{script}'", - values: { script: rootCause?.script }, + defaultMessage: + 'Error executing runtime field or scripted field on index pattern {indexPatternName}', + values: { + indexPatternName: this?.indexPattern?.title, + }, })} - {painlessStack ? ( + {scriptFromStackTrace || painlessStack ? ( - {painlessStack} + {hasScript ? scriptFromStackTrace : painlessStack} ) : null} + {humanReadableError ? ( + {humanReadableError} + ) : null} + + - + onClick(indexPatternId)} size="s"> diff --git a/src/plugins/data/public/search/index.ts b/src/plugins/data/public/search/index.ts index b1e0bc490823a3..fded4c46992c04 100644 --- a/src/plugins/data/public/search/index.ts +++ b/src/plugins/data/public/search/index.ts @@ -8,7 +8,13 @@ export * from './expressions'; -export { ISearchSetup, ISearchStart, ISearchStartSearchSource, SearchEnhancements } from './types'; +export { + ISearchSetup, + ISearchStart, + ISearchStartSearchSource, + SearchEnhancements, + SearchUsageCollector, +} from './types'; export { ES_SEARCH_STRATEGY, diff --git a/src/plugins/data/public/search/mocks.ts b/src/plugins/data/public/search/mocks.ts index b16468120d95ab..273bbfe9e7b081 100644 --- a/src/plugins/data/public/search/mocks.ts +++ b/src/plugins/data/public/search/mocks.ts @@ -10,6 +10,7 @@ import { searchAggsSetupMock, searchAggsStartMock } from './aggs/mocks'; import { searchSourceMock } from './search_source/mocks'; import { ISearchSetup, ISearchStart } from './types'; import { getSessionsClientMock, getSessionServiceMock } from './session/mocks'; +import { createSearchUsageCollectorMock } from './collectors/mocks'; function createSetupContract(): jest.Mocked { return { @@ -17,6 +18,7 @@ function createSetupContract(): jest.Mocked { __enhance: jest.fn(), session: getSessionServiceMock(), sessionsClient: getSessionsClientMock(), + usageCollector: createSearchUsageCollectorMock(), }; } diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index f33740cc45bf98..ec4b628a6bd3af 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -109,7 +109,7 @@ export class SearchInterceptor { return e; } else if (isEsError(e)) { if (isPainlessError(e)) { - return new PainlessError(e); + return new PainlessError(e, options?.indexPattern); } else { return new EsError(e); } @@ -155,13 +155,14 @@ export class SearchInterceptor { const { signal: timeoutSignal } = timeoutController; const timeout$ = timeout ? timer(timeout) : NEVER; const subscription = timeout$.subscribe(() => { + this.deps.usageCollector?.trackQueryTimedOut(); timeoutController.abort(); }); const selfAbortController = new AbortController(); // Get a combined `AbortSignal` that will be aborted whenever the first of the following occurs: - // 1. The user manually aborts (via `cancelPending`) + // 1. The internal abort controller aborts // 2. The request times out // 3. abort() is called on `selfAbortController`. This is used by session service to abort all pending searches that it tracks // in the current session @@ -221,8 +222,8 @@ export class SearchInterceptor { /** * Searches using the given `search` method. Overrides the `AbortSignal` with one that will abort - * either when `cancelPending` is called, when the request times out, or when the original - * `AbortSignal` is aborted. Updates `pendingCount$` when the request is started/finalized. + * either when the request times out, or when the original `AbortSignal` is aborted. Updates + * `pendingCount$` when the request is started/finalized. * * @param request * @options diff --git a/src/plugins/data/public/search/types.ts b/src/plugins/data/public/search/types.ts index 01f5cf3de38bd8..391be8e0537468 100644 --- a/src/plugins/data/public/search/types.ts +++ b/src/plugins/data/public/search/types.ts @@ -15,7 +15,7 @@ import { IndexPatternsContract } from '../../common/index_patterns/index_pattern import { UsageCollectionSetup } from '../../../usage_collection/public'; import { ISessionsClient, ISessionService } from './session'; -export { ISearchStartSearchSource }; +export { ISearchStartSearchSource, SearchUsageCollector }; export interface SearchEnhancements { searchInterceptor: ISearchInterceptor; diff --git a/src/plugins/data/public/types.ts b/src/plugins/data/public/types.ts index acf9a4b084c0f3..8686823ef05683 100644 --- a/src/plugins/data/public/types.ts +++ b/src/plugins/data/public/types.ts @@ -19,7 +19,7 @@ import { ISearchSetup, ISearchStart, SearchEnhancements } from './search'; import { QuerySetup, QueryStart } from './query'; import { IndexPatternsContract } from './index_patterns'; import { IndexPatternSelectProps, StatefulSearchBarProps } from './ui'; -import { UsageCollectionSetup } from '../../usage_collection/public'; +import { UsageCollectionSetup, UsageCollectionStart } from '../../usage_collection/public'; import { Setup as InspectorSetup } from '../../inspector/public'; import { NowProviderPublicContract } from './now_provider'; @@ -120,4 +120,5 @@ export interface IDataPluginServices extends Partial { http: CoreStart['http']; storage: IStorageWrapper; data: DataPublicPluginStart; + usageCollection?: UsageCollectionStart; } diff --git a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx index 5b52a529ae7aa7..9605eba9c1c282 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx @@ -11,12 +11,12 @@ import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import classNames from 'classnames'; import React, { useState } from 'react'; -import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import { METRIC_TYPE } from '@kbn/analytics'; import { FilterEditor } from './filter_editor'; import { FILTER_EDITOR_WIDTH, FilterItem } from './filter_item'; import { FilterOptions } from './filter_options'; import { useKibana } from '../../../../kibana_react/public'; -import { IIndexPattern } from '../..'; +import { IDataPluginServices, IIndexPattern } from '../..'; import { buildEmptyFilter, Filter, @@ -36,17 +36,16 @@ interface Props { indexPatterns: IIndexPattern[]; intl: InjectedIntl; appName: string; - // Track UI Metrics - trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; } function FilterBarUI(props: Props) { const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); - const kibana = useKibana(); - - const uiSettings = kibana.services.uiSettings; + const kibana = useKibana(); + const { appName, usageCollection, uiSettings } = kibana.services; if (!uiSettings) return null; + const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); + function onFiltersUpdated(filters: Filter[]) { if (props.onFiltersUpdated) { props.onFiltersUpdated(filters); @@ -119,66 +118,65 @@ function FilterBarUI(props: Props) { } function onAdd(filter: Filter) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:added`); setIsAddFilterPopoverOpen(false); - if (props.trackUiMetric) { - props.trackUiMetric(METRIC_TYPE.CLICK, `${props.appName}:filter_added`); - } + const filters = [...props.filters, filter]; onFiltersUpdated(filters); } function onRemove(i: number) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:removed`); const filters = [...props.filters]; filters.splice(i, 1); onFiltersUpdated(filters); } function onUpdate(i: number, filter: Filter) { - if (props.trackUiMetric) { - props.trackUiMetric(METRIC_TYPE.CLICK, `${props.appName}:filter_edited`); - } + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:edited`); const filters = [...props.filters]; filters[i] = filter; onFiltersUpdated(filters); } function onEnableAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:enable_all`); const filters = props.filters.map(enableFilter); onFiltersUpdated(filters); } function onDisableAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:disable_all`); const filters = props.filters.map(disableFilter); onFiltersUpdated(filters); } function onPinAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:pin_all`); const filters = props.filters.map(pinFilter); onFiltersUpdated(filters); } function onUnpinAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:unpin_all`); const filters = props.filters.map(unpinFilter); onFiltersUpdated(filters); } function onToggleAllNegated() { - if (props.trackUiMetric) { - props.trackUiMetric(METRIC_TYPE.CLICK, `${props.appName}:filter_invertInclusion`); - } + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:invert_all`); const filters = props.filters.map(toggleFilterNegated); onFiltersUpdated(filters); } function onToggleAllDisabled() { - if (props.trackUiMetric) { - props.trackUiMetric(METRIC_TYPE.CLICK, `${props.appName}:filter_toggleAllDisabled`); - } + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:toggle_all`); const filters = props.filters.map(toggleFilterDisabled); onFiltersUpdated(filters); } function onRemoveAll() { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:remove_all`); onFiltersUpdated([]); } diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index aa42e11d318543..aa2fc9e6314361 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -26,6 +26,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { debounce, compact, isEqual, isFunction } from 'lodash'; import { Toast } from 'src/core/public'; +import { METRIC_TYPE } from '@kbn/analytics'; import { IDataPluginServices, IIndexPattern, Query } from '../..'; import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; @@ -105,6 +106,10 @@ export default class QueryStringInputUI extends Component { private abortController?: AbortController; private fetchIndexPatternsAbortController?: AbortController; private services = this.props.kibana.services; + private reportUiCounter = this.services.usageCollection?.reportUiCounter.bind( + this.services.usageCollection, + this.services.appName + ); private componentIsUnmounting = false; private queryBarInputDivRefInstance: RefObject = createRef(); @@ -178,12 +183,14 @@ export default class QueryStringInputUI extends Component { selectionEnd, signal: this.abortController.signal, })) || []; - return [...suggestions, ...recentSearchSuggestions]; } catch (e) { // TODO: Waiting on https://github.com/elastic/kibana/issues/51406 for a properly typed error // Ignore aborted requests if (e.message === 'The user aborted a request.') return; + + this.reportUiCounter?.(METRIC_TYPE.LOADED, `query_string:suggestions_error`); + throw e; } }; @@ -302,7 +309,7 @@ export default class QueryStringInputUI extends Component { } if (isSuggestionsVisible && index !== null && this.state.suggestions[index]) { event.preventDefault(); - this.selectSuggestion(this.state.suggestions[index]); + this.selectSuggestion(this.state.suggestions[index], index); } else { this.onSubmit(this.props.query); this.setState({ @@ -335,7 +342,7 @@ export default class QueryStringInputUI extends Component { } }; - private selectSuggestion = (suggestion: QuerySuggestion) => { + private selectSuggestion = (suggestion: QuerySuggestion, listIndex: number) => { if (!this.inputRef) { return; } @@ -352,6 +359,17 @@ export default class QueryStringInputUI extends Component { const value = query.substr(0, selectionStart) + query.substr(selectionEnd); const newQueryString = value.substr(0, start) + text + value.substr(end); + this.reportUiCounter?.( + METRIC_TYPE.LOADED, + `query_string:${type}:suggestions_select_position`, + listIndex + ); + this.reportUiCounter?.( + METRIC_TYPE.LOADED, + `query_string:${type}:suggestions_select_q_length`, + end - start + ); + this.onQueryStringChange(newQueryString); this.setState({ @@ -458,6 +476,7 @@ export default class QueryStringInputUI extends Component { const newQuery = { query: '', language }; this.onChange(newQuery); this.onSubmit(newQuery); + this.reportUiCounter?.(METRIC_TYPE.LOADED, `query_string:language:${language}`); }; private onOutsideClick = () => { @@ -480,11 +499,11 @@ export default class QueryStringInputUI extends Component { } }; - private onClickSuggestion = (suggestion: QuerySuggestion) => { + private onClickSuggestion = (suggestion: QuerySuggestion, index: number) => { if (!this.inputRef) { return; } - this.selectSuggestion(suggestion); + this.selectSuggestion(suggestion, index); this.inputRef.focus(); }; @@ -588,6 +607,7 @@ export default class QueryStringInputUI extends Component { if (this.props.onChangeQueryInputFocus) { this.props.onChangeQueryInputFocus(true); } + requestAnimationFrame(() => { this.handleAutoHeight(); }); diff --git a/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx b/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx index 7873886432cbec..077b9ac47286d9 100644 --- a/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx +++ b/src/plugins/data/public/ui/saved_query_form/save_query_form.tsx @@ -9,7 +9,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { EuiButtonEmpty, - EuiOverlayMask, EuiModal, EuiButton, EuiModalHeader, @@ -208,37 +207,35 @@ export function SaveQueryForm({ ); return ( - - - - - {i18n.translate('data.search.searchBar.savedQueryFormTitle', { - defaultMessage: 'Save query', - })} - - - - {saveQueryForm} - - - - {i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', { - defaultMessage: 'Cancel', - })} - - - - {i18n.translate('data.search.searchBar.savedQueryFormSaveButtonText', { - defaultMessage: 'Save', - })} - - - - + + + + {i18n.translate('data.search.searchBar.savedQueryFormTitle', { + defaultMessage: 'Save query', + })} + + + + {saveQueryForm} + + + + {i18n.translate('data.search.searchBar.savedQueryFormCancelButtonText', { + defaultMessage: 'Cancel', + })} + + + + {i18n.translate('data.search.searchBar.savedQueryFormSaveButtonText', { + defaultMessage: 'Save', + })} + + + ); } diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx index 47a2d050a9bfa3..b7ba3215eb5aa3 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_list_item.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiListGroupItem, EuiConfirmModal, EuiOverlayMask, EuiIconTip } from '@elastic/eui'; +import { EuiListGroupItem, EuiConfirmModal, EuiIconTip } from '@elastic/eui'; import React, { Fragment, useState } from 'react'; import classNames from 'classnames'; @@ -114,36 +114,34 @@ export const SavedQueryListItem = ({ /> {showDeletionConfirmationModal && ( - - { - onDelete(savedQuery); - setShowDeletionConfirmationModal(false); - }} - buttonColor="danger" - onCancel={() => { - setShowDeletionConfirmationModal(false); - }} - /> - + { + onDelete(savedQuery); + setShowDeletionConfirmationModal(false); + }} + buttonColor="danger" + onCancel={() => { + setShowDeletionConfirmationModal(false); + }} + /> )} ); diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index 8f34ec1912cb44..4aba59442d204f 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -10,7 +10,6 @@ import _ from 'lodash'; import React, { useEffect, useRef } from 'react'; import { CoreStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; -import { UiCounterMetricType } from '@kbn/analytics'; import { KibanaContextProvider } from '../../../../kibana_react/public'; import { QueryStart, SavedQuery } from '../../query'; import { SearchBar, SearchBarOwnProps } from './'; @@ -20,12 +19,13 @@ import { useSavedQuery } from './lib/use_saved_query'; import { DataPublicPluginStart } from '../../types'; import { Filter, Query, TimeRange } from '../../../common'; import { useQueryStringManager } from './lib/use_query_string_manager'; +import { UsageCollectionSetup } from '../../../../usage_collection/public'; interface StatefulSearchBarDeps { core: CoreStart; data: Omit; storage: IStorageWrapper; - trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; + usageCollection?: UsageCollectionSetup; } export type StatefulSearchBarProps = SearchBarOwnProps & { @@ -110,7 +110,7 @@ const overrideDefaultBehaviors = (props: StatefulSearchBarProps) => { return props.useDefaultBehaviors ? {} : props; }; -export function createSearchBar({ core, storage, data, trackUiMetric }: StatefulSearchBarDeps) { +export function createSearchBar({ core, storage, data, usageCollection }: StatefulSearchBarDeps) { // App name should come from the core application service. // Until it's available, we'll ask the user to provide it for the pre-wired component. return (props: StatefulSearchBarProps) => { @@ -161,6 +161,7 @@ export function createSearchBar({ core, storage, data, trackUiMetric }: Stateful appName: props.appName, data, storage, + usageCollection, ...core, }} > @@ -188,7 +189,6 @@ export function createSearchBar({ core, storage, data, trackUiMetric }: Stateful onClearSavedQuery={defaultOnClearSavedQuery(props, clearSavedQuery)} onSavedQueryUpdated={defaultOnSavedQueryUpdated(props, setSavedQuery)} onSaved={defaultOnSavedQueryUpdated(props, setSavedQuery)} - trackUiMetric={trackUiMetric} {...overrideDefaultBehaviors(props)} /> diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index c87fcc3d950410..fe8165a12714a6 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -13,7 +13,7 @@ import React, { Component } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; import { get, isEqual } from 'lodash'; -import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; +import { METRIC_TYPE } from '@kbn/analytics'; import { withKibana, KibanaReactContextValue } from '../../../../kibana_react/public'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; @@ -68,8 +68,6 @@ export interface SearchBarOwnProps { onRefresh?: (payload: { dateRange: TimeRange }) => void; indicateNoData?: boolean; - // Track UI Metrics - trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -323,9 +321,11 @@ class SearchBarUI extends Component { }, }); } - if (this.props.trackUiMetric) { - this.props.trackUiMetric(METRIC_TYPE.CLICK, `${this.services.appName}:query_submitted`); - } + this.services.usageCollection?.reportUiCounter( + this.services.appName, + METRIC_TYPE.CLICK, + 'query_submitted' + ); } ); }; @@ -428,7 +428,6 @@ class SearchBarUI extends Component { onFiltersUpdated={this.props.onFiltersUpdated} indexPatterns={this.props.indexPatterns!} appName={this.services.appName} - trackUiMetric={this.props.trackUiMetric} />
diff --git a/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap b/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap index 2fa7834872f6b1..9185e6a77d1026 100644 --- a/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap +++ b/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap @@ -22,6 +22,7 @@ exports[`SuggestionsComponent Passing the index should control which suggestion > { it('Should display the suggestion and use the provided ariaId', () => { const component = shallow( { it('Should make the element active if the selected prop is true', () => { const component = shallow( { mount( { const component = shallow( { component.simulate('click'); expect(mockHandler).toHaveBeenCalledTimes(1); - expect(mockHandler).toHaveBeenCalledWith(mockSuggestion); + expect(mockHandler).toHaveBeenCalledWith(mockSuggestion, 0); }); it('Should call onMouseEnter when user mouses over the element', () => { @@ -100,6 +104,7 @@ describe('SuggestionComponent', () => { const component = shallow( void; + onClick: SuggestionOnClick; onMouseEnter: () => void; selected: boolean; + index: number; suggestion: QuerySuggestion; innerRef: (node: HTMLDivElement) => void; ariaId: string; @@ -48,7 +50,7 @@ export function SuggestionComponent(props: Props) { active: props.selected, })} role="option" - onClick={() => props.onClick(props.suggestion)} + onClick={() => props.onClick(props.suggestion, props.index)} onMouseEnter={props.onMouseEnter} ref={props.innerRef} id={props.ariaId} diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx index ebbdc7fc55e3af..dce8d5bdcfcd14 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx @@ -115,7 +115,7 @@ describe('SuggestionsComponent', () => { component.find(SuggestionComponent).at(1).simulate('click'); expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1]); + expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1], 1); }); it('Should call onMouseEnter with the index of the suggestion that was entered', () => { diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx index fa1f4aa6a8ce8a..6bc91619fe8683 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx @@ -17,11 +17,12 @@ import { SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET, SUGGESTIONS_LIST_REQUIRED_WIDTH, } from './constants'; +import { SuggestionOnClick } from './types'; // @internal export interface SuggestionsComponentProps { index: number | null; - onClick: (suggestion: QuerySuggestion) => void; + onClick: SuggestionOnClick; onMouseEnter: (index: number) => void; show: boolean; suggestions: QuerySuggestion[]; @@ -50,6 +51,7 @@ export default class SuggestionsComponent extends Component (this.childNodes[index] = node)} selected={index === this.props.index} + index={index} suggestion={suggestion} onClick={this.props.onClick} onMouseEnter={() => this.props.onMouseEnter(index)} diff --git a/src/plugins/data/public/ui/typeahead/types.ts b/src/plugins/data/public/ui/typeahead/types.ts new file mode 100644 index 00000000000000..d0be717b2bf9b9 --- /dev/null +++ b/src/plugins/data/public/ui/typeahead/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { QuerySuggestion } from '../../autocomplete'; + +export type SuggestionOnClick = (suggestion: QuerySuggestion, index: number) => void; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index ab8f6c9ed39511..23aaab36e79055 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -912,6 +912,7 @@ export class IndexPatternsService implements Plugin_3 { + volatileSearchSource.setField('filter', () => { return timefilter.createFilter($scope.indexPattern); }); } - $scope.searchSource.setParent(timeRangeSearchSource); + volatileSearchSource.setParent(persistentSearchSource); + $scope.volatileSearchSource = volatileSearchSource; const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; chrome.docTitle.change(`Discover${pageTitleSuffix}`); @@ -403,7 +397,8 @@ function discoverController($route, $scope, Promise) { } function getStateDefaults() { - const query = $scope.searchSource.getField('query') || data.query.queryString.getDefaultQuery(); + const query = + persistentSearchSource.getField('query') || data.query.queryString.getDefaultQuery(); const sort = getSortArray(savedSearch.sort, $scope.indexPattern); const columns = getDefaultColumns(); @@ -415,7 +410,7 @@ function discoverController($route, $scope, Promise) { columns, index: $scope.indexPattern.id, interval: 'auto', - filters: _.cloneDeep($scope.searchSource.getOwnField('filter')), + filters: _.cloneDeep(persistentSearchSource.getOwnField('filter')), }; if (savedSearch.grid) { defaultState.grid = savedSearch.grid; @@ -556,7 +551,7 @@ function discoverController($route, $scope, Promise) { .then(function () { $scope.fetchStatus = fetchStatuses.LOADING; logInspectorRequest({ searchSessionId }); - return $scope.searchSource.fetch({ + return $scope.volatileSearchSource.fetch({ abortSignal: abortController.signal, sessionId: searchSessionId, }); @@ -603,11 +598,13 @@ function discoverController($route, $scope, Promise) { } function onResults(resp) { - inspectorRequest.stats(getResponseInspectorStats(resp, $scope.searchSource)).ok({ json: resp }); + inspectorRequest + .stats(getResponseInspectorStats(resp, $scope.volatileSearchSource)) + .ok({ json: resp }); if (getTimeField() && !$scope.state.hideChart) { const tabifiedData = tabifyAggResponse($scope.opts.chartAggConfigs, resp); - $scope.searchSource.rawResponse = resp; + $scope.volatileSearchSource.rawResponse = resp; $scope.histogramData = discoverResponseHandler( tabifiedData, getDimensions($scope.opts.chartAggConfigs.aggs, $scope.timeRange) @@ -635,8 +632,8 @@ function discoverController($route, $scope, Promise) { defaultMessage: 'This request queries Elasticsearch to fetch the data for the search.', }); inspectorRequest = inspectorAdapters.requests.start(title, { description, searchSessionId }); - inspectorRequest.stats(getRequestInspectorStats($scope.searchSource)); - $scope.searchSource.getSearchRequestBody().then((body) => { + inspectorRequest.stats(getRequestInspectorStats($scope.volatileSearchSource)); + $scope.volatileSearchSource.getSearchRequestBody().then((body) => { inspectorRequest.json(body); }); } @@ -693,9 +690,11 @@ function discoverController($route, $scope, Promise) { }; $scope.updateDataSource = () => { - const { indexPattern, searchSource, useNewFieldsApi } = $scope; + const { indexPattern, useNewFieldsApi } = $scope; const { columns, sort } = $scope.state; - updateSearchSource(searchSource, { + updateSearchSource({ + persistentSearchSource, + volatileSearchSource: $scope.volatileSearchSource, indexPattern, services, sort, @@ -731,12 +730,12 @@ function discoverController($route, $scope, Promise) { visStateAggs ); - $scope.searchSource.onRequestStart((searchSource, options) => { + $scope.volatileSearchSource.onRequestStart((searchSource, options) => { if (!$scope.opts.chartAggConfigs) return; return $scope.opts.chartAggConfigs.onSearchRequestStart(searchSource, options); }); - $scope.searchSource.setField('aggs', function () { + $scope.volatileSearchSource.setField('aggs', function () { if (!$scope.opts.chartAggConfigs) return; return $scope.opts.chartAggConfigs.toDsl(); }); diff --git a/src/plugins/discover/public/application/angular/discover_datagrid.html b/src/plugins/discover/public/application/angular/discover_datagrid.html index e59ebbb0fafd03..42218568a838db 100644 --- a/src/plugins/discover/public/application/angular/discover_datagrid.html +++ b/src/plugins/discover/public/application/angular/discover_datagrid.html @@ -17,7 +17,7 @@ reset-query="resetQuery" result-state="resultState" rows="rows" - search-source="searchSource" + search-source="volatileSearchSource" set-index-pattern="setIndexPattern" show-save-query="showSaveQuery" state="state" diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover/public/application/angular/discover_legacy.html index 501496494106af..a01f285b1a1506 100644 --- a/src/plugins/discover/public/application/angular/discover_legacy.html +++ b/src/plugins/discover/public/application/angular/discover_legacy.html @@ -13,7 +13,7 @@ reset-query="resetQuery" result-state="resultState" rows="rows" - search-source="searchSource" + search-source="volatileSearchSource" state="state" time-range="timeRange" top-nav-menu="topNavMenu" diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 71650a4a38472e..1d183aa75cf3a5 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -206,6 +206,7 @@ export function Discover({ query={state.query} savedQuery={state.savedQuery} updateQuery={updateQuery} + searchSource={searchSource} />

diff --git a/src/plugins/discover/public/application/components/discover_grid/constants.ts b/src/plugins/discover/public/application/components/discover_grid/constants.ts index 795cdbc9c48f72..015d0b65246f2e 100644 --- a/src/plugins/discover/public/application/components/discover_grid/constants.ts +++ b/src/plugins/discover/public/application/components/discover_grid/constants.ts @@ -8,8 +8,6 @@ // data types export const kibanaJSON = 'kibana-json'; -export const geoPoint = 'geo-point'; -export const unknownType = 'unknown'; export const gridStyle = { border: 'all', fontSize: 's', diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx index 2857001b2443e3..1a721a400803e6 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.test.tsx @@ -27,8 +27,8 @@ describe('Discover grid columns ', function () { "cellActions": undefined, "display": undefined, "id": "extension", - "isSortable": undefined, - "schema": "unknown", + "isSortable": false, + "schema": "kibana-json", }, Object { "actions": Object { @@ -42,8 +42,8 @@ describe('Discover grid columns ', function () { "cellActions": undefined, "display": undefined, "id": "message", - "isSortable": undefined, - "schema": "unknown", + "isSortable": false, + "schema": "kibana-json", }, ] `); @@ -67,8 +67,8 @@ describe('Discover grid columns ', function () { "cellActions": undefined, "display": undefined, "id": "extension", - "isSortable": undefined, - "schema": "unknown", + "isSortable": false, + "schema": "kibana-json", }, Object { "actions": Object { @@ -79,8 +79,8 @@ describe('Discover grid columns ', function () { "cellActions": undefined, "display": undefined, "id": "message", - "isSortable": undefined, - "schema": "unknown", + "isSortable": false, + "schema": "kibana-json", }, ] `); @@ -105,8 +105,8 @@ describe('Discover grid columns ', function () { "display": "Time (timestamp)", "id": "timestamp", "initialWidth": 180, - "isSortable": undefined, - "schema": "unknown", + "isSortable": false, + "schema": "kibana-json", }, Object { "actions": Object { @@ -120,8 +120,8 @@ describe('Discover grid columns ', function () { "cellActions": undefined, "display": undefined, "id": "extension", - "isSortable": undefined, - "schema": "unknown", + "isSortable": false, + "schema": "kibana-json", }, Object { "actions": Object { @@ -135,8 +135,8 @@ describe('Discover grid columns ', function () { "cellActions": undefined, "display": undefined, "id": "message", - "isSortable": undefined, - "schema": "unknown", + "isSortable": false, + "schema": "kibana-json", }, ] `); diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx index 2e9bd33c606593..c245b402137a01 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx @@ -47,7 +47,7 @@ export function buildEuiGridColumn( const column: EuiDataGridColumn = { id: columnName, schema: getSchemaByKbnType(indexPatternField?.type), - isSortable: indexPatternField?.sortable, + isSortable: indexPatternField?.sortable === true, display: columnName === '_source' ? i18n.translate('discover.grid.documentHeader', { diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx index 945cbbcb8a8321..ca5b2c9f199189 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_schema.tsx @@ -6,9 +6,9 @@ * Side Public License, v 1. */ -import React, { ReactNode } from 'react'; -import { EuiCodeBlock } from '@elastic/eui'; -import { geoPoint, kibanaJSON, unknownType } from './constants'; +import React from 'react'; +import { EuiCodeBlock, EuiDataGridPopoverContents } from '@elastic/eui'; +import { kibanaJSON } from './constants'; import { KBN_FIELD_TYPES } from '../../../../../data/common'; export function getSchemaByKbnType(kbnType: string | undefined) { @@ -24,12 +24,8 @@ export function getSchemaByKbnType(kbnType: string | undefined) { return 'string'; case KBN_FIELD_TYPES.DATE: return 'datetime'; - case KBN_FIELD_TYPES._SOURCE: - return kibanaJSON; - case KBN_FIELD_TYPES.GEO_POINT: - return geoPoint; default: - return unknownType; + return kibanaJSON; } } @@ -45,44 +41,15 @@ export function getSchemaDetectors() { icon: '', color: '', }, - { - type: unknownType, - detector() { - return 0; // this schema is always explicitly defined - }, - sortTextAsc: '', - sortTextDesc: '', - icon: '', - color: '', - }, - { - type: geoPoint, - detector() { - return 0; // this schema is always explicitly defined - }, - sortTextAsc: '', - sortTextDesc: '', - icon: 'tokenGeo', - }, ]; } /** * Returns custom popover content for certain schemas */ -export function getPopoverContents() { +export function getPopoverContents(): EuiDataGridPopoverContents { return { - [geoPoint]: ({ children }: { children: ReactNode }) => { - return {children}; - }, - [unknownType]: ({ children }: { children: ReactNode }) => { - return ( - - {children} - - ); - }, - [kibanaJSON]: ({ children }: { children: ReactNode }) => { + [kibanaJSON]: ({ children }) => { return ( {children} diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx index 594aaac2168d4a..786d7bc74bf6b9 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.test.tsx @@ -78,7 +78,13 @@ describe('Discover grid cell rendering', function () { ); expect(component.html()).toMatchInlineSnapshot(` "{ - "bytes": 100 + "_id": "1", + "_index": "test", + "_type": "test", + "_score": 1, + "_source": { + "bytes": 100 + } }" `); }); diff --git a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx index 6ed19813830c85..cfcdbec475eda4 100644 --- a/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/get_render_cell_value.tsx @@ -7,7 +7,6 @@ */ import React, { Fragment, useContext, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; import themeLight from '@elastic/eui/dist/eui_theme_light.json'; import themeDark from '@elastic/eui/dist/eui_theme_dark.json'; @@ -55,7 +54,7 @@ export const getRenderCellValueFn = ( if (field && field.type === '_source') { if (isDetails) { // nicely formatted JSON for the expanded view - return {JSON.stringify(row[columnId], null, 2)}; + return {JSON.stringify(row, null, 2)}; } const formatted = indexPattern.formatHit(row); @@ -83,21 +82,6 @@ export const getRenderCellValueFn = ( return {JSON.stringify(rowFlattened[columnId])}; } - if (field?.type === 'geo_point' && rowFlattened && rowFlattened[columnId]) { - const valueFormatted = rowFlattened[columnId] as { lat: number; lon: number }; - return ( -
- {i18n.translate('discover.latitudeAndLongitude', { - defaultMessage: 'Lat: {lat} Lon: {lon}', - values: { - lat: valueFormatted?.lat, - lon: valueFormatted?.lon, - }, - })} -
- ); - } - const valueFormatted = indexPattern.formatField(row, columnId); if (typeof valueFormatted === 'undefined') { return -; diff --git a/src/plugins/discover/public/application/components/discover_topnav.test.tsx b/src/plugins/discover/public/application/components/discover_topnav.test.tsx index 50c9bcfb5d2557..891dc63c92c7c6 100644 --- a/src/plugins/discover/public/application/components/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/components/discover_topnav.test.tsx @@ -20,7 +20,7 @@ import { SavedObject } from '../../../../../core/types'; import { DiscoverTopNav, DiscoverTopNavProps } from './discover_topnav'; import { RequestAdapter } from '../../../../inspector/common/adapters/request'; import { TopNavMenu } from '../../../../navigation/public'; -import { Query } from '../../../../data/common'; +import { ISearchSource, Query } from '../../../../data/common'; import { DiscoverSearchSessionManager } from '../angular/discover_search_session'; import { Subject } from 'rxjs'; @@ -61,6 +61,7 @@ function getProps(): DiscoverTopNavProps { savedQuery: '', updateQuery: jest.fn(), onOpenInspector: jest.fn(), + searchSource: {} as ISearchSource, }; } diff --git a/src/plugins/discover/public/application/components/discover_topnav.tsx b/src/plugins/discover/public/application/components/discover_topnav.tsx index fd2aba22aa41db..ee59ee13583bdd 100644 --- a/src/plugins/discover/public/application/components/discover_topnav.tsx +++ b/src/plugins/discover/public/application/components/discover_topnav.tsx @@ -10,7 +10,7 @@ import { DiscoverProps } from './types'; import { getTopNavLinks } from './top_nav/get_top_nav_links'; import { Query, TimeRange } from '../../../../data/common/query'; -export type DiscoverTopNavProps = Pick & { +export type DiscoverTopNavProps = Pick & { onOpenInspector: () => void; query?: Query; savedQuery?: string; @@ -24,6 +24,7 @@ export const DiscoverTopNav = ({ query, savedQuery, updateQuery, + searchSource, }: DiscoverTopNavProps) => { const showDatePicker = useMemo(() => indexPattern.isTimeBased(), [indexPattern]); const { TopNavMenu } = opts.services.navigation.ui; @@ -38,8 +39,9 @@ export const DiscoverTopNav = ({ services: opts.services, state: opts.stateContainer, onOpenInspector, + searchSource, }), - [indexPattern, opts, onOpenInspector] + [indexPattern, opts, onOpenInspector, searchSource] ); const updateSavedQueryId = (newSavedQueryId: string | undefined) => { diff --git a/src/plugins/discover/public/application/components/doc/doc.test.tsx b/src/plugins/discover/public/application/components/doc/doc.test.tsx index 623d30d5f53ecf..deaaa1853ae9da 100644 --- a/src/plugins/discover/public/application/components/doc/doc.test.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.test.tsx @@ -13,6 +13,7 @@ import { mountWithIntl } from '@kbn/test/jest'; import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; +import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../../../common'; const mockSearchApi = jest.fn(); @@ -36,6 +37,13 @@ jest.mock('../../../kibana_services', () => { }, }, }, + uiSettings: { + get: (key: string) => { + if (key === mockSearchFieldsFromSource) { + return false; + } + }, + }, }), getDocViewsRegistry: () => ({ addDocView(view: any) { diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx index 6cb0bf1288d3a8..ef2619070a6d8e 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.test.tsx @@ -10,6 +10,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { buildSearchBody, useEsDocSearch, ElasticRequestState } from './use_es_doc_search'; import { DocProps } from './doc'; import { Observable } from 'rxjs'; +import { SEARCH_FIELDS_FROM_SOURCE as mockSearchFieldsFromSource } from '../../../../common'; const mockSearchResult = new Observable(); @@ -22,19 +23,52 @@ jest.mock('../../../kibana_services', () => ({ }), }, }, + uiSettings: { + get: (key: string) => { + if (key === mockSearchFieldsFromSource) { + return false; + } + }, + }, }), })); describe('Test of helper / hook', () => { - test('buildSearchBody', () => { + test('buildSearchBody given useNewFieldsApi is false', () => { const indexPattern = { getComputedFields: () => ({ storedFields: [], scriptFields: [], docvalueFields: [] }), } as any; - const actual = buildSearchBody('1', indexPattern); + const actual = buildSearchBody('1', indexPattern, false); expect(actual).toMatchInlineSnapshot(` Object { "_source": true, "docvalue_fields": Array [], + "fields": undefined, + "query": Object { + "ids": Object { + "values": Array [ + "1", + ], + }, + }, + "script_fields": Array [], + "stored_fields": Array [], + } + `); + }); + + test('buildSearchBody useNewFieldsApi is true', () => { + const indexPattern = { + getComputedFields: () => ({ storedFields: [], scriptFields: [], docvalueFields: [] }), + } as any; + const actual = buildSearchBody('1', indexPattern, true); + expect(actual).toMatchInlineSnapshot(` + Object { + "_source": false, + "docvalue_fields": Array [], + "fields": Array [ + "*", + ], "query": Object { "ids": Object { "values": Array [ diff --git a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts index 2a63a62650ca90..295b2ab3831194 100644 --- a/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts +++ b/src/plugins/discover/public/application/components/doc/use_es_doc_search.ts @@ -6,10 +6,11 @@ * Side Public License, v 1. */ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { IndexPattern, getServices } from '../../../kibana_services'; import { DocProps } from './doc'; import { ElasticSearchHit } from '../../doc_views/doc_views_types'; +import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; export enum ElasticRequestState { Loading, @@ -23,7 +24,11 @@ export enum ElasticRequestState { * helper function to build a query body for Elasticsearch * https://www.elastic.co/guide/en/elasticsearch/reference/current//query-dsl-ids-query.html */ -export function buildSearchBody(id: string, indexPattern: IndexPattern): Record { +export function buildSearchBody( + id: string, + indexPattern: IndexPattern, + useNewFieldsApi: boolean +): Record { const computedFields = indexPattern.getComputedFields(); return { @@ -33,7 +38,8 @@ export function buildSearchBody(id: string, indexPattern: IndexPattern): Record< }, }, stored_fields: computedFields.storedFields, - _source: true, + _source: !useNewFieldsApi, + fields: useNewFieldsApi ? ['*'] : undefined, script_fields: computedFields.scriptFields, docvalue_fields: computedFields.docvalueFields, }; @@ -51,6 +57,8 @@ export function useEsDocSearch({ const [indexPattern, setIndexPattern] = useState(null); const [status, setStatus] = useState(ElasticRequestState.Loading); const [hit, setHit] = useState(null); + const { data, uiSettings } = useMemo(() => getServices(), []); + const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); useEffect(() => { async function requestData() { @@ -58,11 +66,11 @@ export function useEsDocSearch({ const indexPatternEntity = await indexPatternService.get(indexPatternId); setIndexPattern(indexPatternEntity); - const { rawResponse } = await getServices() - .data.search.search({ + const { rawResponse } = await data.search + .search({ params: { index, - body: buildSearchBody(id, indexPatternEntity), + body: buildSearchBody(id, indexPatternEntity, useNewFieldsApi), }, }) .toPromise(); @@ -86,6 +94,6 @@ export function useEsDocSearch({ } } requestData(); - }, [id, index, indexPatternId, indexPatternService]); + }, [id, index, indexPatternId, indexPatternService, data.search, useNewFieldsApi]); return [status, hit, indexPattern]; } diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx index 62e19b83b016e4..6afa7f89371f98 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx @@ -16,6 +16,11 @@ import { DocViewRenderProps } from '../../doc_views/doc_views_types'; jest.mock('../../../kibana_services', () => { let registry: any[] = []; return { + getServices: () => ({ + uiSettings: { + get: jest.fn(), + }, + }), getDocViewsRegistry: () => ({ addDocView(view: any) { registry.push(view); diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_error.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_error.tsx index 571af94f293728..b9b068ce4bd073 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_error.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_error.tsx @@ -7,20 +7,18 @@ */ import React from 'react'; -import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; -import { formatMsg, formatStack } from '../../../../../kibana_legacy/public'; +import { EuiErrorBoundary } from '@elastic/eui'; interface Props { error: Error | string; } -export function DocViewerError({ error }: Props) { - const errMsg = formatMsg(error); - const errStack = typeof error === 'object' ? formatStack(error) : ''; +const DocViewerErrorWrapper = ({ error }: Props) => { + throw error; +}; - return ( - - {errStack && {errStack}} - - ); -} +export const DocViewerError = ({ error }: Props) => ( + + + +); diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx index 1da75b45239105..25454a3bad38ab 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx @@ -11,6 +11,8 @@ import { I18nProvider } from '@kbn/i18n/react'; import { DocViewRenderTab } from './doc_viewer_render_tab'; import { DocViewerError } from './doc_viewer_render_error'; import { DocViewRenderFn, DocViewRenderProps } from '../../doc_views/doc_views_types'; +import { getServices } from '../../../kibana_services'; +import { KibanaContextProvider } from '../../../../../kibana_react/public'; interface Props { component?: React.ComponentType; @@ -72,7 +74,9 @@ export class DocViewerTab extends React.Component { const Component = component as any; return ( - + + + ); } diff --git a/src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap b/src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap deleted file mode 100644 index d6f48a9b3c7745..00000000000000 --- a/src/plugins/discover/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap +++ /dev/null @@ -1,20 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`returns the \`JsonCodeEditor\` component 1`] = ` - - { - "_index": "test", - "_type": "doc", - "_id": "foo", - "_score": 1, - "_source": { - "test": 123 - } -} - -`; diff --git a/src/plugins/discover/public/application/components/json_code_block/json_code_block.tsx b/src/plugins/discover/public/application/components/json_code_block/json_code_block.tsx deleted file mode 100644 index 7dab2b199b018f..00000000000000 --- a/src/plugins/discover/public/application/components/json_code_block/json_code_block.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React from 'react'; -import { EuiCodeBlock } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { DocViewRenderProps } from '../../doc_views/doc_views_types'; - -export function JsonCodeBlock({ hit }: DocViewRenderProps) { - const label = i18n.translate('discover.docViews.json.codeEditorAriaLabel', { - defaultMessage: 'Read only JSON view of an elasticsearch document', - }); - return ( - - {JSON.stringify(hit, null, 2)} - - ); -} diff --git a/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap new file mode 100644 index 00000000000000..4f27158eee04f1 --- /dev/null +++ b/src/plugins/discover/public/application/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`returns the \`JsonCodeEditor\` component 1`] = ` + + + +
+ + + +
+
+ + + +
+`; diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss new file mode 100644 index 00000000000000..5521df5b363acd --- /dev/null +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.scss @@ -0,0 +1,3 @@ +.dscJsonCodeEditor { + width: 100% +} diff --git a/src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.test.tsx similarity index 53% rename from src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx rename to src/plugins/discover/public/application/components/json_code_editor/json_code_editor.test.tsx index dd56a1077f1ac6..4ccb3010d5a2b9 100644 --- a/src/plugins/discover/public/application/components/json_code_block/json_code_block.test.tsx +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.test.tsx @@ -8,17 +8,15 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { JsonCodeBlock } from './json_code_block'; -import { IndexPattern } from '../../../../../data/public'; +import { JsonCodeEditor } from './json_code_editor'; it('returns the `JsonCodeEditor` component', () => { - const props = { - hit: { _index: 'test', _type: 'doc', _id: 'foo', _score: 1, _source: { test: 123 } }, - columns: [], - indexPattern: {} as IndexPattern, - filter: jest.fn(), - onAddColumn: jest.fn(), - onRemoveColumn: jest.fn(), + const value = { + _index: 'test', + _type: 'doc', + _id: 'foo', + _score: 1, + _source: { test: 123 }, }; - expect(shallow()).toMatchSnapshot(); + expect(shallow()).toMatchSnapshot(); }); diff --git a/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx new file mode 100644 index 00000000000000..85d6aad7552502 --- /dev/null +++ b/src/plugins/discover/public/application/components/json_code_editor/json_code_editor.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 './json_code_editor.scss'; + +import React, { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { monaco, XJsonLang } from '@kbn/monaco'; +import { EuiButtonEmpty, EuiCopy, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { CodeEditor } from '../../../../../kibana_react/public'; +import { DocViewRenderProps } from '../../../application/doc_views/doc_views_types'; + +const codeEditorAriaLabel = i18n.translate('discover.json.codeEditorAriaLabel', { + defaultMessage: 'Read only JSON view of an elasticsearch document', +}); +const copyToClipboardLabel = i18n.translate('discover.json.copyToClipboardLabel', { + defaultMessage: 'Copy to clipboard', +}); + +export const JsonCodeEditor = ({ hit }: DocViewRenderProps) => { + const jsonValue = JSON.stringify(hit, null, 2); + + // setting editor height based on lines height and count to stretch and fit its content + const setEditorCalculatedHeight = useCallback((editor) => { + const editorElement = editor.getDomNode(); + + if (!editorElement) { + return; + } + + const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); + const lineCount = editor.getModel()?.getLineCount() || 1; + const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight; + + editorElement.style.height = `${height}px`; + editor.layout(); + }, []); + + return ( + + + +
+ + {(copy) => ( + + {copyToClipboardLabel} + + )} + +
+
+ + {}} + editorDidMount={setEditorCalculatedHeight} + aria-label={codeEditorAriaLabel} + options={{ + automaticLayout: true, + fontSize: 12, + minimap: { + enabled: false, + }, + overviewRulerBorder: false, + readOnly: true, + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + }} + /> + +
+ ); +}; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index c16dab618b284c..54a2de14e2e26e 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -114,4 +114,20 @@ describe('discover sidebar field', function () { findTestSubject(comp, 'field-_source-showDetails').simulate('click'); expect(props.getDetails).not.toHaveBeenCalled(); }); + it('displays warning for conflicting fields', function () { + const field = new IndexPatternField({ + name: 'troubled_field', + type: 'conflict', + esTypes: ['integer', 'text'], + searchable: true, + aggregatable: true, + readFromDocValues: false, + }); + const { comp } = getComponent({ + selected: true, + field, + }); + const dscField = findTestSubject(comp, 'field-troubled_field-showDetails'); + expect(dscField.find('.kbnFieldButton__infoIcon').length).toEqual(1); + }); }); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index fa857bbfcbbe44..8cd63f09e0d2cf 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -9,7 +9,14 @@ import './discover_field.scss'; import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiButtonIcon, EuiToolTip, EuiTitle } from '@elastic/eui'; +import { + EuiPopover, + EuiPopoverTitle, + EuiButtonIcon, + EuiToolTip, + EuiTitle, + EuiIcon, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UiCounterMetricType } from '@kbn/analytics'; import classNames from 'classnames'; @@ -217,6 +224,33 @@ export function DiscoverField({ ); } + const getFieldInfoIcon = () => { + if (field.type !== 'conflict') { + return null; + } + return ( + + + + ); + }; + + const fieldInfoIcon = getFieldInfoIcon(); + const shouldRenderMultiFields = !!multiFields; const renderMultiFields = () => { if (!multiFields) { @@ -263,6 +297,7 @@ export function DiscoverField({ fieldIcon={dscFieldIcon} fieldAction={actionButton} fieldName={fieldName} + fieldInfoIcon={fieldInfoIcon} /> } isOpen={infoIsOpen} diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index 684a7d4fd467c3..093b445267241b 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { DocViewTableRow } from './table_row'; import { trimAngularSpan } from './table_helper'; @@ -54,6 +54,20 @@ export function DocViewTable({ setFieldsWithParents(arr); }, [indexPattern, hit]); + const toggleColumn = useCallback( + (field: string) => { + if (!onRemoveColumn || !onAddColumn || !columns) { + return; + } + if (columns.includes(field)) { + onRemoveColumn(field); + } else { + onAddColumn(field); + } + }, + [onRemoveColumn, onAddColumn, columns] + ); + if (!indexPattern) { return null; } @@ -65,6 +79,7 @@ export function DocViewTable({ fieldRowOpen[field] = !fieldRowOpen[field]; setFieldRowOpen({ ...fieldRowOpen }); } + return ( @@ -85,16 +100,6 @@ export function DocViewTable({ const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; const isCollapsed = isCollapsible && !fieldRowOpen[field]; - const toggleColumn = - onRemoveColumn && onAddColumn && Array.isArray(columns) - ? () => { - if (columns.includes(field)) { - onRemoveColumn(field); - } else { - onAddColumn(field); - } - } - : undefined; const displayUnderscoreWarning = !mapping(field) && field.indexOf('_') === 0; const fieldType = isNestedFieldParent(field, indexPattern) @@ -109,10 +114,10 @@ export function DocViewTable({ displayUnderscoreWarning={displayUnderscoreWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} - isColumnActive={Array.isArray(columns) && columns.includes(field)} + isColumnActive={!!columns?.includes(field)} onFilter={filter} onToggleCollapse={() => toggleValueCollapse(field)} - onToggleColumn={toggleColumn} + onToggleColumn={() => toggleColumn(field)} value={value} valueRaw={valueRaw} /> @@ -123,7 +128,7 @@ export function DocViewTable({ data-test-subj={`tableDocViewRow-multifieldsTitle-${field}`} > - - + {columns} ); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js index cb9baaa4112bb9..fbec412c30f48d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_with_splits.js @@ -7,6 +7,7 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { getDisplayName } from './lib/get_display_name'; import { labelDateFormatter } from './lib/label_date_formatter'; import { findIndex, first } from 'lodash'; @@ -22,7 +23,7 @@ export function visWithSplits(WrappedComponent) { const splitsVisData = visData[model.id].series.reduce((acc, series) => { const [seriesId, splitId] = series.id.split(':'); const seriesModel = model.series.find((s) => s.id === seriesId); - if (!seriesModel || !splitId) return acc; + if (!seriesModel) return acc; const label = series.splitByLabel; @@ -80,7 +81,12 @@ export function visWithSplits(WrappedComponent) { model={model} visData={newVisData} onBrush={props.onBrush} - additionalLabel={additionalLabel} + additionalLabel={ + additionalLabel || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } backgroundColor={props.backgroundColor} getConfig={props.getConfig} /> diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js index 9ca53edc50ce92..4ec60661ffed29 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/timeseries/index.js @@ -9,6 +9,7 @@ import React, { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { i18n } from '@kbn/i18n'; import { labelDateFormatter } from '../../../components/lib/label_date_formatter'; import { @@ -188,7 +189,12 @@ export const TimeSeries = ({ key={key} seriesId={id} seriesGroupId={groupId} - name={seriesName} + name={ + seriesName || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } data={data} hideInLegend={hideInLegend} bars={bars} @@ -213,7 +219,12 @@ export const TimeSeries = ({ key={key} seriesId={id} seriesGroupId={groupId} - name={seriesName} + name={ + seriesName || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + }) + } data={data} hideInLegend={hideInLegend} lines={lines} diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js index 42d6d095d06480..542ee0871fdcb7 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js @@ -8,6 +8,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; import { getLastValue } from '../../../../common/get_last_value'; import { labelDateFormatter } from '../../components/lib/label_date_formatter'; import reactcss from 'reactcss'; @@ -103,6 +104,7 @@ export class TopN extends Component { // if both are 0, the division returns NaN causing unexpected behavior. // For this it defaults to 0 const width = 100 * (Math.abs(lastValue) / intervalLength) || 0; + const label = item.labelFormatted ? labelDateFormatter(item.labelFormatted) : item.label; const styles = reactcss( { @@ -128,7 +130,10 @@ export class TopN extends Component { return (
  + {i18n.translate('discover.fieldChooser.discoverField.multiFields', { defaultMessage: 'Multi fields', @@ -142,10 +147,12 @@ export function DocViewTable({ displayUnderscoreWarning={displayUnderscoreWarning} isCollapsed={isCollapsed} isCollapsible={isCollapsible} - isColumnActive={Array.isArray(columns) && columns.includes(field)} + isColumnActive={Array.isArray(columns) && columns.includes(multiField)} onFilter={filter} - onToggleCollapse={() => toggleValueCollapse(field)} - onToggleColumn={toggleColumn} + onToggleCollapse={() => { + toggleValueCollapse(multiField); + }} + onToggleColumn={() => toggleColumn(multiField)} value={value} valueRaw={valueRaw} /> diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts index 89cb60700074d1..30edb102c420aa 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { ISearchSource } from 'src/plugins/data/public'; import { getTopNavLinks } from './get_top_nav_links'; import { inspectorPluginMock } from '../../../../../inspector/public/mocks'; import { indexPatternMock } from '../../../__mocks__/index_pattern'; @@ -33,6 +34,7 @@ test('getTopNavLinks result', () => { savedSearch: savedSearchMock, services, state, + searchSource: {} as ISearchSource, }); expect(topNavLinks).toMatchInlineSnapshot(` Array [ 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 513508c478aa95..a1215836f9c5fc 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 @@ -15,7 +15,7 @@ import { Adapters } from '../../../../../inspector/common/adapters'; import { SavedSearch } from '../../../saved_searches'; import { onSaveSearch } from './on_save_search'; import { GetStateReturn } from '../../angular/discover_state'; -import { IndexPattern } from '../../../kibana_services'; +import { IndexPattern, ISearchSource } from '../../../kibana_services'; /** * Helper function to build the top nav links @@ -29,6 +29,7 @@ export const getTopNavLinks = ({ services, state, onOpenInspector, + searchSource, }: { getFieldCounts: () => Promise>; indexPattern: IndexPattern; @@ -38,6 +39,7 @@ export const getTopNavLinks = ({ services: DiscoverServices; state: GetStateReturn; onOpenInspector: () => void; + searchSource: ISearchSource; }) => { const newSearch = { id: 'new', @@ -93,7 +95,7 @@ export const getTopNavLinks = ({ return; } const sharingData = await getSharingData( - savedSearch.searchSource, + searchSource, state.appStateContainer.getState(), services.uiSettings, getFieldCounts diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts index 85acd575138f71..31de1f2f6ed66a 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts @@ -9,7 +9,7 @@ import { Capabilities, IUiSettingsClient } from 'kibana/public'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; import { getSortForSearchSource } from '../angular/doc_table'; -import { SearchSource } from '../../../../data/common'; +import { ISearchSource } from '../../../../data/common'; import { AppState } from '../angular/discover_state'; import { SortOrder } from '../../saved_searches/types'; @@ -39,7 +39,7 @@ const getSharingDataFields = async ( * Preparing data to share the current state as link or CSV/Report */ export async function getSharingData( - currentSearchSource: SearchSource, + currentSearchSource: ISearchSource, state: AppState, config: IUiSettingsClient, getFieldCounts: () => Promise> diff --git a/src/plugins/discover/public/application/helpers/persist_saved_search.ts b/src/plugins/discover/public/application/helpers/persist_saved_search.ts index 06e90c93bc77c9..2e4ab90ee58e54 100644 --- a/src/plugins/discover/public/application/helpers/persist_saved_search.ts +++ b/src/plugins/discover/public/application/helpers/persist_saved_search.ts @@ -35,7 +35,8 @@ export async function persistSavedSearch( state: AppState; } ) { - updateSearchSource(savedSearch.searchSource, { + updateSearchSource({ + persistentSearchSource: savedSearch.searchSource, indexPattern, services, sort: state.sort as SortOrder[], diff --git a/src/plugins/discover/public/application/helpers/update_search_source.test.ts b/src/plugins/discover/public/application/helpers/update_search_source.test.ts index 51586a6bccc234..97e2de3541d354 100644 --- a/src/plugins/discover/public/application/helpers/update_search_source.test.ts +++ b/src/plugins/discover/public/application/helpers/update_search_source.test.ts @@ -17,9 +17,12 @@ import { SortOrder } from '../../saved_searches/types'; describe('updateSearchSource', () => { test('updates a given search source', async () => { - const searchSourceMock = createSearchSourceMock({}); + const persistentSearchSourceMock = createSearchSourceMock({}); + const volatileSearchSourceMock = createSearchSourceMock({}); const sampleSize = 250; - const result = updateSearchSource(searchSourceMock, { + updateSearchSource({ + persistentSearchSource: persistentSearchSourceMock, + volatileSearchSource: volatileSearchSourceMock, indexPattern: indexPatternMock, services: ({ data: dataPluginMock.createStartContract(), @@ -36,15 +39,18 @@ describe('updateSearchSource', () => { columns: [], useNewFieldsApi: false, }); - expect(result.getField('index')).toEqual(indexPatternMock); - expect(result.getField('size')).toEqual(sampleSize); - expect(result.getField('fields')).toBe(undefined); + expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); + expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); + expect(volatileSearchSourceMock.getField('fields')).toBe(undefined); }); test('updates a given search source with the usage of the new fields api', async () => { - const searchSourceMock = createSearchSourceMock({}); + const persistentSearchSourceMock = createSearchSourceMock({}); + const volatileSearchSourceMock = createSearchSourceMock({}); const sampleSize = 250; - const result = updateSearchSource(searchSourceMock, { + updateSearchSource({ + persistentSearchSource: persistentSearchSourceMock, + volatileSearchSource: volatileSearchSourceMock, indexPattern: indexPatternMock, services: ({ data: dataPluginMock.createStartContract(), @@ -61,16 +67,19 @@ describe('updateSearchSource', () => { columns: [], useNewFieldsApi: true, }); - expect(result.getField('index')).toEqual(indexPatternMock); - expect(result.getField('size')).toEqual(sampleSize); - expect(result.getField('fields')).toEqual([{ field: '*' }]); - expect(result.getField('fieldsFromSource')).toBe(undefined); + expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); + expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); + expect(volatileSearchSourceMock.getField('fields')).toEqual([{ field: '*' }]); + expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined); }); test('requests unmapped fields when the flag is provided, using the new fields api', async () => { - const searchSourceMock = createSearchSourceMock({}); + const persistentSearchSourceMock = createSearchSourceMock({}); + const volatileSearchSourceMock = createSearchSourceMock({}); const sampleSize = 250; - const result = updateSearchSource(searchSourceMock, { + updateSearchSource({ + persistentSearchSource: persistentSearchSourceMock, + volatileSearchSource: volatileSearchSourceMock, indexPattern: indexPatternMock, services: ({ data: dataPluginMock.createStartContract(), @@ -88,16 +97,21 @@ describe('updateSearchSource', () => { useNewFieldsApi: true, showUnmappedFields: true, }); - expect(result.getField('index')).toEqual(indexPatternMock); - expect(result.getField('size')).toEqual(sampleSize); - expect(result.getField('fields')).toEqual([{ field: '*', include_unmapped: 'true' }]); - expect(result.getField('fieldsFromSource')).toBe(undefined); + expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); + expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); + expect(volatileSearchSourceMock.getField('fields')).toEqual([ + { field: '*', include_unmapped: 'true' }, + ]); + expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined); }); test('updates a given search source when showUnmappedFields option is set to true', async () => { - const searchSourceMock = createSearchSourceMock({}); + const persistentSearchSourceMock = createSearchSourceMock({}); + const volatileSearchSourceMock = createSearchSourceMock({}); const sampleSize = 250; - const result = updateSearchSource(searchSourceMock, { + updateSearchSource({ + persistentSearchSource: persistentSearchSourceMock, + volatileSearchSource: volatileSearchSourceMock, indexPattern: indexPatternMock, services: ({ data: dataPluginMock.createStartContract(), @@ -115,9 +129,11 @@ describe('updateSearchSource', () => { useNewFieldsApi: true, showUnmappedFields: true, }); - expect(result.getField('index')).toEqual(indexPatternMock); - expect(result.getField('size')).toEqual(sampleSize); - expect(result.getField('fields')).toEqual([{ field: '*', include_unmapped: 'true' }]); - expect(result.getField('fieldsFromSource')).toBe(undefined); + expect(persistentSearchSourceMock.getField('index')).toEqual(indexPatternMock); + expect(volatileSearchSourceMock.getField('size')).toEqual(sampleSize); + expect(volatileSearchSourceMock.getField('fields')).toEqual([ + { field: '*', include_unmapped: 'true' }, + ]); + expect(volatileSearchSourceMock.getField('fieldsFromSource')).toBe(undefined); }); }); diff --git a/src/plugins/discover/public/application/helpers/update_search_source.ts b/src/plugins/discover/public/application/helpers/update_search_source.ts index 55d2b05a29b690..ba5ac0e8227965 100644 --- a/src/plugins/discover/public/application/helpers/update_search_source.ts +++ b/src/plugins/discover/public/application/helpers/update_search_source.ts @@ -15,24 +15,25 @@ import { DiscoverServices } from '../../build_services'; /** * Helper function to update the given searchSource before fetching/sharing/persisting */ -export function updateSearchSource( - searchSource: ISearchSource, - { - indexPattern, - services, - sort, - columns, - useNewFieldsApi, - showUnmappedFields, - }: { - indexPattern: IndexPattern; - services: DiscoverServices; - sort: SortOrder[]; - columns: string[]; - useNewFieldsApi: boolean; - showUnmappedFields?: boolean; - } -) { +export function updateSearchSource({ + indexPattern, + services, + sort, + columns, + useNewFieldsApi, + showUnmappedFields, + persistentSearchSource, + volatileSearchSource, +}: { + indexPattern: IndexPattern; + services: DiscoverServices; + sort: SortOrder[]; + columns: string[]; + useNewFieldsApi: boolean; + showUnmappedFields?: boolean; + persistentSearchSource: ISearchSource; + volatileSearchSource?: ISearchSource; +}) { const { uiSettings, data } = services; const usedSort = getSortForSearchSource( sort, @@ -40,23 +41,32 @@ export function updateSearchSource( uiSettings.get(SORT_DEFAULT_ORDER_SETTING) ); - searchSource + persistentSearchSource .setField('index', indexPattern) - .setField('size', uiSettings.get(SAMPLE_SIZE_SETTING)) - .setField('sort', usedSort) .setField('query', data.query.queryString.getQuery() || null) .setField('filter', data.query.filterManager.getFilters()); - if (useNewFieldsApi) { - searchSource.removeField('fieldsFromSource'); - const fields: Record = { field: '*' }; - if (showUnmappedFields) { - fields.include_unmapped = 'true'; + + if (volatileSearchSource) { + volatileSearchSource + .setField('size', uiSettings.get(SAMPLE_SIZE_SETTING)) + .setField('sort', usedSort) + .setField('highlightAll', true) + .setField('version', true) + // Even when searching rollups, we want to use the default strategy so that we get back a + // document-like response. + .setPreferredSearchStrategyId('default'); + + if (useNewFieldsApi) { + volatileSearchSource.removeField('fieldsFromSource'); + const fields: Record = { field: '*' }; + if (showUnmappedFields) { + fields.include_unmapped = 'true'; + } + volatileSearchSource.setField('fields', [fields]); + } else { + volatileSearchSource.removeField('fields'); + const fieldNames = indexPattern.fields.map((field) => field.name); + volatileSearchSource.setField('fieldsFromSource', fieldNames); } - searchSource.setField('fields', [fields]); - } else { - searchSource.removeField('fields'); - const fieldNames = indexPattern.fields.map((field) => field.name); - searchSource.setField('fieldsFromSource', fieldNames); } - return searchSource; } diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index c53dfaff24112f..47161c2b8298e1 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -36,7 +36,7 @@ import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; import { DocViewTable } from './application/components/table/table'; -import { JsonCodeBlock } from './application/components/json_code_block/json_code_block'; +import { JsonCodeEditor } from './application/components/json_code_editor/json_code_editor'; import { setDocViewsRegistry, setUrlTracker, @@ -187,7 +187,7 @@ export class DiscoverPlugin defaultMessage: 'JSON', }), order: 20, - component: JsonCodeBlock, + component: JsonCodeEditor, }); const { diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts index a8ecb384f782b4..2dda0df1a85c5c 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -44,8 +44,6 @@ describe('embeddable state transfer', () => { const testAppId = 'testApp'; - const buildKey = (appId: string, key: string) => `${appId}-${key}`; - beforeEach(() => { currentAppId$ = new Subject(); currentAppId$.next(originatingApp); @@ -86,8 +84,10 @@ describe('embeddable state transfer', () => { it('can send an outgoing editor state', async () => { await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp } }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(destinationApp, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [destinationApp]: { + originatingApp: 'superUltraTestDashboard', + }, }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { @@ -104,8 +104,10 @@ describe('embeddable state transfer', () => { }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { kibanaIsNowForSports: 'extremeSportsKibana', - [buildKey(destinationApp, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [destinationApp]: { + originatingApp: 'superUltraTestDashboard', + }, }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { @@ -125,9 +127,11 @@ describe('embeddable state transfer', () => { state: { type: 'coolestType', input: { savedObjectId: '150' } }, }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(destinationApp, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'coolestType', - input: { savedObjectId: '150' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [destinationApp]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { @@ -144,9 +148,11 @@ describe('embeddable state transfer', () => { }); expect(store.set).toHaveBeenCalledWith(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { kibanaIsNowForSports: 'extremeSportsKibana', - [buildKey(destinationApp, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'coolestType', - input: { savedObjectId: '150' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [destinationApp]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, }, }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { @@ -165,8 +171,10 @@ describe('embeddable state transfer', () => { it('can fetch an incoming editor state', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [testAppId]: { + originatingApp: 'superUltraTestDashboard', + }, }, }); const fetchedState = stateTransfer.getIncomingEditorState(testAppId); @@ -175,14 +183,16 @@ describe('embeddable state transfer', () => { it('can fetch an incoming editor state and ignore state for other apps', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey('otherApp1', EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'whoops not me', - }, - [buildKey('otherApp2', EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'otherTestDashboard', - }, - [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + otherApp1: { + originatingApp: 'whoops not me', + }, + otherApp2: { + originatingApp: 'otherTestDashboard', + }, + [testAppId]: { + originatingApp: 'superUltraTestDashboard', + }, }, }); const fetchedState = stateTransfer.getIncomingEditorState(testAppId); @@ -194,8 +204,10 @@ describe('embeddable state transfer', () => { it('incoming editor state returns undefined when state is not in the right shape', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { - helloSportsKibana: 'superUltraTestDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [testAppId]: { + helloSportsKibana: 'superUltraTestDashboard', + }, }, }); const fetchedState = stateTransfer.getIncomingEditorState(testAppId); @@ -204,9 +216,11 @@ describe('embeddable state transfer', () => { it('can fetch an incoming embeddable package state', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'skisEmbeddable', - input: { savedObjectId: '123' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [testAppId]: { + type: 'skisEmbeddable', + input: { savedObjectId: '123' }, + }, }, }); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); @@ -215,13 +229,15 @@ describe('embeddable state transfer', () => { it('can fetch an incoming embeddable package state and ignore state for other apps', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'skisEmbeddable', - input: { savedObjectId: '123' }, - }, - [buildKey('testApp2', EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'crossCountryEmbeddable', - input: { savedObjectId: '456' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [testAppId]: { + type: 'skisEmbeddable', + input: { savedObjectId: '123' }, + }, + testApp2: { + type: 'crossCountryEmbeddable', + input: { savedObjectId: '456' }, + }, }, }); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); @@ -236,7 +252,11 @@ describe('embeddable state transfer', () => { it('embeddable package state returns undefined when state is not in the right shape', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { kibanaIsFor: 'sports' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [testAppId]: { + kibanaIsFor: 'sports', + }, + }, }); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(testAppId); expect(fetchedState).toBeUndefined(); @@ -244,9 +264,11 @@ describe('embeddable state transfer', () => { it('removes embeddable package key when removeAfterFetch is true', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_PACKAGE_STATE_KEY)]: { - type: 'coolestType', - input: { savedObjectId: '150' }, + [EMBEDDABLE_PACKAGE_STATE_KEY]: { + [testAppId]: { + type: 'coolestType', + input: { savedObjectId: '150' }, + }, }, iSHouldStillbeHere: 'doing the sports thing', }); @@ -258,8 +280,10 @@ describe('embeddable state transfer', () => { it('removes editor state key when removeAfterFetch is true', async () => { store.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, { - [buildKey(testAppId, EMBEDDABLE_EDITOR_STATE_KEY)]: { - originatingApp: 'superCoolFootballDashboard', + [EMBEDDABLE_EDITOR_STATE_KEY]: { + [testAppId]: { + originatingApp: 'superCoolFootballDashboard', + }, }, iSHouldStillbeHere: 'doing the sports thing', }); diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts index 8664a5aae7345f..52a5eccac99105 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts @@ -75,10 +75,14 @@ export class EmbeddableStateTransfer { * @param appId - The app to fetch incomingEditorState for * @param removeAfterFetch - Whether to remove the package state after fetch to prevent duplicates. */ - public clearEditorState(appId: string) { + public clearEditorState(appId?: string) { const currentState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY); if (currentState) { - delete currentState[this.buildKey(appId, EMBEDDABLE_EDITOR_STATE_KEY)]; + if (appId) { + delete currentState[EMBEDDABLE_EDITOR_STATE_KEY]?.[appId]; + } else { + delete currentState[EMBEDDABLE_EDITOR_STATE_KEY]; + } this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, currentState); } } @@ -117,7 +121,6 @@ export class EmbeddableStateTransfer { this.isTransferInProgress = true; await this.navigateToWithState(appId, EMBEDDABLE_EDITOR_STATE_KEY, { ...options, - appendToExistingState: true, }); } @@ -132,14 +135,9 @@ export class EmbeddableStateTransfer { this.isTransferInProgress = true; await this.navigateToWithState(appId, EMBEDDABLE_PACKAGE_STATE_KEY, { ...options, - appendToExistingState: true, }); } - private buildKey(appId: string, key: string) { - return `${appId}-${key}`; - } - private getIncomingState( guard: (state: unknown) => state is IncomingStateType, appId: string, @@ -148,15 +146,13 @@ export class EmbeddableStateTransfer { keysToRemoveAfterFetch?: string[]; } ): IncomingStateType | undefined { - const incomingState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)?.[ - this.buildKey(appId, key) - ]; + const incomingState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)?.[key]?.[appId]; const castState = !guard || guard(incomingState) ? (cloneDeep(incomingState) as IncomingStateType) : undefined; if (castState && options?.keysToRemoveAfterFetch) { const stateReplace = { ...this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY) }; options.keysToRemoveAfterFetch.forEach((keyToRemove: string) => { - delete stateReplace[this.buildKey(appId, keyToRemove)]; + delete stateReplace[keyToRemove]; }); this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, stateReplace); } @@ -166,14 +162,16 @@ export class EmbeddableStateTransfer { private async navigateToWithState( appId: string, key: string, - options?: { path?: string; state?: OutgoingStateType; appendToExistingState?: boolean } + options?: { path?: string; state?: OutgoingStateType } ): Promise { - const stateObject = options?.appendToExistingState - ? { - ...this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY), - [this.buildKey(appId, key)]: options.state, - } - : { [this.buildKey(appId, key)]: options?.state }; + const existingAppState = this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY)?.[key] || {}; + const stateObject = { + ...this.storage.get(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY), + [key]: { + ...existingAppState, + [appId]: options?.state, + }, + }; this.storage.set(EMBEDDABLE_STATE_TRANSFER_STORAGE_KEY, stateObject); await this.navigateToApp(appId, { path: options?.path }); } diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 3e7014d54958de..189f71b85206bc 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -590,7 +590,7 @@ export class EmbeddableStateTransfer { // Warning: (ae-forgotten-export) The symbol "ApplicationStart" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "PublicAppInfo" needs to be exported by the entry point index.d.ts constructor(navigateToApp: ApplicationStart['navigateToApp'], currentAppId$: ApplicationStart['currentAppId$'], appList?: ReadonlyMap | undefined, customStorage?: Storage); - clearEditorState(appId: string): void; + clearEditorState(appId?: string): void; getAppNameFromId: (appId: string) => string | undefined; getIncomingEditorState(appId: string, removeAfterFetch?: boolean): EmbeddableEditorState | undefined; getIncomingEmbeddablePackage(appId: string, removeAfterFetch?: boolean): EmbeddablePackageState | undefined; diff --git a/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts b/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts index 5244a6c1e8bf17..3ef33b651f4d26 100644 --- a/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts +++ b/src/plugins/es_ui_shared/public/request/send_request.test.helpers.ts @@ -41,20 +41,26 @@ export const createSendRequestHelpers = (): SendRequestHelpers => { // Set up successful request helpers. sendRequestSpy - .withArgs(successRequest.path, { - body: JSON.stringify(successRequest.body), - query: undefined, - }) + .withArgs( + successRequest.path, + sinon.match({ + body: JSON.stringify(successRequest.body), + query: undefined, + }) + ) .resolves(successResponse); const sendSuccessRequest = () => sendRequest({ ...successRequest }); const getSuccessResponse = () => ({ data: successResponse.data, error: null }); // Set up failed request helpers. sendRequestSpy - .withArgs(errorRequest.path, { - body: JSON.stringify(errorRequest.body), - query: undefined, - }) + .withArgs( + errorRequest.path, + sinon.match({ + body: JSON.stringify(errorRequest.body), + query: undefined, + }) + ) .rejects(errorResponse); const sendErrorRequest = () => sendRequest({ ...errorRequest }); const getErrorResponse = () => ({ diff --git a/src/plugins/es_ui_shared/public/request/send_request.ts b/src/plugins/es_ui_shared/public/request/send_request.ts index 32703f21a4668a..11ab99cfb69786 100644 --- a/src/plugins/es_ui_shared/public/request/send_request.ts +++ b/src/plugins/es_ui_shared/public/request/send_request.ts @@ -13,6 +13,11 @@ export interface SendRequestConfig { method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'; query?: HttpFetchQuery; body?: any; + /** + * If set, flags this as a "system request" to indicate that this is not a user-initiated request. For more information, see + * HttpFetchOptions#asSystemRequest. + */ + asSystemRequest?: boolean; } export interface SendRequestResponse { @@ -22,11 +27,15 @@ export interface SendRequestResponse { export const sendRequest = async ( httpClient: HttpSetup, - { path, method, body, query }: SendRequestConfig + { path, method, body, query, asSystemRequest }: SendRequestConfig ): Promise> => { try { const stringifiedBody = typeof body === 'string' ? body : JSON.stringify(body); - const response = await httpClient[method](path, { body: stringifiedBody, query }); + const response = await httpClient[method](path, { + body: stringifiedBody, + query, + asSystemRequest, + }); return { data: response.data ? response.data : response, diff --git a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx index 9f41d13112bc88..82d3764dbf72ab 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx +++ b/src/plugins/es_ui_shared/public/request/use_request.test.helpers.tsx @@ -123,10 +123,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up successful request helpers. sendRequestSpy - .withArgs(successRequest.path, { - body: JSON.stringify(successRequest.body), - query: undefined, - }) + .withArgs( + successRequest.path, + sinon.match({ + body: JSON.stringify(successRequest.body), + query: undefined, + }) + ) .resolves(successResponse); const setupSuccessRequest = (overrides = {}, requestTimings?: number[]) => setupUseRequest({ ...successRequest, ...overrides }, requestTimings); @@ -134,10 +137,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up failed request helpers. sendRequestSpy - .withArgs(errorRequest.path, { - body: JSON.stringify(errorRequest.body), - query: undefined, - }) + .withArgs( + errorRequest.path, + sinon.match({ + body: JSON.stringify(errorRequest.body), + query: undefined, + }) + ) .rejects(errorResponse); const setupErrorRequest = (overrides = {}, requestTimings?: number[]) => setupUseRequest({ ...errorRequest, ...overrides }, requestTimings); @@ -152,10 +158,13 @@ export const createUseRequestHelpers = (): UseRequestHelpers => { // Set up failed request helpers with the alternative error shape. sendRequestSpy - .withArgs(errorWithBodyRequest.path, { - body: JSON.stringify(errorWithBodyRequest.body), - query: undefined, - }) + .withArgs( + errorWithBodyRequest.path, + sinon.match({ + body: JSON.stringify(errorWithBodyRequest.body), + query: undefined, + }) + ) .rejects(errorWithBodyResponse); const setupErrorWithBodyRequest = (overrides = {}) => setupUseRequest({ ...errorWithBodyRequest, ...overrides }); diff --git a/src/plugins/es_ui_shared/public/request/use_request.ts b/src/plugins/es_ui_shared/public/request/use_request.ts index 99eb38ff6023fa..33085bdbf4478d 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.ts @@ -65,49 +65,59 @@ export const useRequest = ( /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [path, method, queryStringified, bodyStringified]); - const resendRequest = useCallback(async () => { - // If we're on an interval, this allows us to reset it if the user has manually requested the - // data, to avoid doubled-up requests. - clearPollInterval(); - - const requestId = ++requestCountRef.current; - - // We don't clear error or data, so it's up to the consumer to decide whether to display the - // "old" error/data or loading state when a new request is in-flight. - setIsLoading(true); - - const response = await sendRequest(httpClient, requestBody); - const { data: serializedResponseData, error: responseError } = response; - - const isOutdatedRequest = requestId !== requestCountRef.current; - const isUnmounted = isMounted.current === false; - - // Ignore outdated or irrelevant data. - if (isOutdatedRequest || isUnmounted) { - return; - } - - // Surface to consumers that at least one request has resolved. - isInitialRequestRef.current = false; + const resendRequest = useCallback( + async (asSystemRequest?: boolean) => { + // If we're on an interval, this allows us to reset it if the user has manually requested the + // data, to avoid doubled-up requests. + clearPollInterval(); - setError(responseError); - // If there's an error, keep the data from the last request in case it's still useful to the user. - if (!responseError) { - const responseData = deserializer - ? deserializer(serializedResponseData) - : serializedResponseData; - setData(responseData); - } - // Setting isLoading to false also acts as a signal for scheduling the next poll request. - setIsLoading(false); - }, [requestBody, httpClient, deserializer, clearPollInterval]); + const requestId = ++requestCountRef.current; + + // We don't clear error or data, so it's up to the consumer to decide whether to display the + // "old" error/data or loading state when a new request is in-flight. + setIsLoading(true); + + // Any requests that are sent in the background (without user interaction) should be flagged as "system requests". This should not be + // confused with any terminology in Elasticsearch. This is a Kibana-specific construct that allows the server to differentiate between + // user-initiated and requests "system"-initiated requests, for purposes like security features. + const requestPayload = { ...requestBody, asSystemRequest }; + const response = await sendRequest(httpClient, requestPayload); + const { data: serializedResponseData, error: responseError } = response; + + const isOutdatedRequest = requestId !== requestCountRef.current; + const isUnmounted = isMounted.current === false; + + // Ignore outdated or irrelevant data. + if (isOutdatedRequest || isUnmounted) { + return; + } + + // Surface to consumers that at least one request has resolved. + isInitialRequestRef.current = false; + + setError(responseError); + // If there's an error, keep the data from the last request in case it's still useful to the user. + if (!responseError) { + const responseData = deserializer + ? deserializer(serializedResponseData) + : serializedResponseData; + setData(responseData); + } + // Setting isLoading to false also acts as a signal for scheduling the next poll request. + setIsLoading(false); + }, + [requestBody, httpClient, deserializer, clearPollInterval] + ); const scheduleRequest = useCallback(() => { // If there's a scheduled poll request, this new one will supersede it. clearPollInterval(); if (pollIntervalMs) { - pollIntervalIdRef.current = setTimeout(resendRequest, pollIntervalMs); + pollIntervalIdRef.current = setTimeout( + () => resendRequest(true), // This is happening on an interval in the background, so we flag it as a "system request". + pollIntervalMs + ); } }, [pollIntervalMs, resendRequest, clearPollInterval]); @@ -137,11 +147,15 @@ export const useRequest = ( }; }, [clearPollInterval]); + const resendRequestForConsumer = useCallback(() => { + return resendRequest(); + }, [resendRequest]); + return { isInitialRequest: isInitialRequestRef.current, isLoading, error, data, - resendRequest, // Gives the user the ability to manually request data + resendRequest: resendRequestForConsumer, // Gives the user the ability to manually request data }; }; diff --git a/src/plugins/expressions/common/execution/execution.abortion.test.ts b/src/plugins/expressions/common/execution/execution.abortion.test.ts index 7f6141b60e9a78..33bb7826917473 100644 --- a/src/plugins/expressions/common/execution/execution.abortion.test.ts +++ b/src/plugins/expressions/common/execution/execution.abortion.test.ts @@ -6,9 +6,11 @@ * Side Public License, v 1. */ +import { waitFor } from '@testing-library/react'; import { Execution } from './execution'; import { parseExpression } from '../ast'; import { createUnitTestExecutor } from '../test_helpers'; +import { ExpressionFunctionDefinition } from '../expression_functions'; jest.useFakeTimers(); @@ -81,4 +83,73 @@ describe('Execution abortion tests', () => { jest.useFakeTimers(); }); + + test('nested expressions are aborted when parent aborted', async () => { + jest.useRealTimers(); + const started = jest.fn(); + const completed = jest.fn(); + const aborted = jest.fn(); + + const defer: ExpressionFunctionDefinition<'defer', any, { time: number }, any> = { + name: 'defer', + args: { + time: { + aliases: ['_'], + help: 'Calls function from a context after delay unless aborted', + types: ['number'], + }, + }, + help: '', + fn: async (input, args, { abortSignal }) => { + started(); + await new Promise((r) => { + const timeout = setTimeout(() => { + if (!abortSignal.aborted) { + completed(); + } + r(undefined); + }, args.time); + + abortSignal.addEventListener('abort', () => { + aborted(); + clearTimeout(timeout); + r(undefined); + }); + }); + + return args.time; + }, + }; + + const expression = 'defer time={defer time={defer time=300}}'; + const executor = createUnitTestExecutor(); + executor.registerFunction(defer); + const execution = new Execution({ + executor, + ast: parseExpression(expression), + params: {}, + }); + + execution.start(); + + await waitFor(() => expect(started).toHaveBeenCalledTimes(1)); + + execution.cancel(); + const result = await execution.result; + expect(result).toMatchObject({ + type: 'error', + error: { + message: 'The expression was aborted.', + name: 'AbortError', + }, + }); + + await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1)); + + expect(started).toHaveBeenCalledTimes(1); + expect(aborted).toHaveBeenCalledTimes(1); + expect(completed).toHaveBeenCalledTimes(0); + + jest.useFakeTimers(); + }); }); diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index e555258a0cf998..bf545a0075bed7 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -120,6 +120,13 @@ export class Execution< */ private readonly firstResultFuture = new Defer(); + /** + * Keeping track of any child executions + * Needed to cancel child executions in case parent execution is canceled + * @private + */ + private readonly childExecutions: Execution[] = []; + /** * Contract is a public representation of `Execution` instances. Contract we * can return to other plugins for their consumption. @@ -203,8 +210,10 @@ export class Execution< const chainPromise = this.invokeChain(this.state.get().ast.chain, input); this.race(chainPromise).then(resolve, (error) => { - if (this.abortController.signal.aborted) resolve(createAbortErrorValue()); - else reject(error); + if (this.abortController.signal.aborted) { + this.childExecutions.forEach((ex) => ex.cancel()); + resolve(createAbortErrorValue()); + } else reject(error); }); this.firstResultFuture.promise @@ -460,6 +469,7 @@ export class Execution< ast as ExpressionAstExpression, this.execution.params ); + this.childExecutions.push(execution); execution.start(input); return await execution.result; case 'string': diff --git a/src/plugins/home/common/instruction_variant.ts b/src/plugins/home/common/instruction_variant.ts index ae6735568851c8..310ee23460a084 100644 --- a/src/plugins/home/common/instruction_variant.ts +++ b/src/plugins/home/common/instruction_variant.ts @@ -23,6 +23,7 @@ export const INSTRUCTION_VARIANT = { JAVA: 'java', DOTNET: 'dotnet', LINUX: 'linux', + PHP: 'php', }; const DISPLAY_MAP = { @@ -42,6 +43,7 @@ const DISPLAY_MAP = { [INSTRUCTION_VARIANT.JAVA]: 'Java', [INSTRUCTION_VARIANT.DOTNET]: '.NET', [INSTRUCTION_VARIANT.LINUX]: 'Linux', + [INSTRUCTION_VARIANT.PHP]: 'PHP', }; /** diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz index d47426df12ddfc..0ab2b67c72dbae 100644 Binary files a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz and b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/ecommerce.json.gz differ diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index d32a854f2bc4b2..7c00a46602e26e 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -143,7 +143,15 @@ export function createInstallRoute( let createResults; try { - createResults = await context.core.savedObjects.client.bulkCreate( + const { getClient, typeRegistry } = context.core.savedObjects; + + const includedHiddenTypes = sampleDataset.savedObjects + .map((object) => object.type) + .filter((supportedType) => typeRegistry.isHidden(supportedType)); + + const client = getClient({ includedHiddenTypes }); + + createResults = await client.bulkCreate( sampleDataset.savedObjects.map(({ version, ...savedObject }) => savedObject), { overwrite: true } ); @@ -156,8 +164,8 @@ export function createInstallRoute( return Boolean(savedObjectCreateResult.error); }); if (errors.length > 0) { - const errMsg = `sample_data install errors while loading saved objects. Errors: ${errors.join( - ',' + const errMsg = `sample_data install errors while loading saved objects. Errors: ${JSON.stringify( + errors )}`; logger.warn(errMsg); return res.customError({ body: errMsg, statusCode: 403 }); diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index 54e6fa0936abcf..aa8ed67cf840a2 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -33,7 +33,7 @@ export function createUninstallRoute( client: { callAsCurrentUser }, }, }, - savedObjects: { client: savedObjectsClient }, + savedObjects: { getClient: getSavedObjectsClient, typeRegistry }, }, }, request, @@ -61,6 +61,12 @@ export function createUninstallRoute( } } + const includedHiddenTypes = sampleDataset.savedObjects + .map((object) => object.type) + .filter((supportedType) => typeRegistry.isHidden(supportedType)); + + const savedObjectsClient = getSavedObjectsClient({ includedHiddenTypes }); + const deletePromises = sampleDataset.savedObjects.map(({ type, id }) => savedObjectsClient.delete(type, id) ); diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap index 2b320782cb1634..eaaccdb499b0b4 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap @@ -1,14 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DeleteScritpedFieldConfirmationModal should render normally 1`] = ` - - - + `; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx index 5fbd3118b800bb..36069f408f3543 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/confirmation_modal/confirmation_modal.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal } from '@elastic/eui'; import { ScriptedFieldItem } from '../../types'; @@ -42,15 +42,13 @@ export const DeleteScritpedFieldConfirmationModal = ({ ); return ( - - - + ); }; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap index 9d92a3689b6983..736dbb611dbbdf 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/__snapshots__/confirmation_modal.test.tsx.snap @@ -1,37 +1,35 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Header should render normally 1`] = ` - - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + - } - /> - + } + /> + } +/> `; diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx index 6715d7a6780ae4..fb8d4a38bfe63e 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/source_filters_table/components/confirmation_modal/confirmation_modal.tsx @@ -10,7 +10,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; interface DeleteFilterConfirmationModalProps { filterToDeleteValue: string; @@ -26,35 +26,33 @@ export const DeleteFilterConfirmationModal = ({ onDeleteFilter, }: DeleteFilterConfirmationModalProps) => { return ( - - - } - onCancel={onCancelConfirmationModal} - onConfirm={onDeleteFilter} - cancelButtonText={ - - } - buttonColor="danger" - confirmButtonText={ - - } - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - /> - + + } + onCancel={onCancelConfirmationModal} + onConfirm={onDeleteFilter} + cancelButtonText={ + + } + buttonColor="danger" + confirmButtonText={ + + } + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + /> ); }; diff --git a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx index f22981de857497..829536063a26c9 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx @@ -25,7 +25,6 @@ import { EuiFormRow, EuiIcon, EuiLink, - EuiOverlayMask, EuiSelect, EuiSpacer, EuiText, @@ -643,42 +642,40 @@ export class FieldEditor extends PureComponent - { - this.hideDeleteModal(); - this.deleteField(); - }} - cancelButtonText={i18n.translate('indexPatternManagement.deleteField.cancelButton', { - defaultMessage: 'Cancel', - })} - confirmButtonText={i18n.translate('indexPatternManagement.deleteField.deleteButton', { - defaultMessage: 'Delete', - })} - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - > -

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

-
- + { + this.hideDeleteModal(); + this.deleteField(); + }} + cancelButtonText={i18n.translate('indexPatternManagement.deleteField.cancelButton', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('indexPatternManagement.deleteField.deleteButton', { + defaultMessage: 'Delete', + })} + buttonColor="danger" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + > +

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

+
) : null; }; diff --git a/src/plugins/kibana_react/public/code_editor/editor_theme.ts b/src/plugins/kibana_react/public/code_editor/editor_theme.ts index e3ea509c4349a5..b5d4627a5d89a3 100644 --- a/src/plugins/kibana_react/public/code_editor/editor_theme.ts +++ b/src/plugins/kibana_react/public/code_editor/editor_theme.ts @@ -41,7 +41,7 @@ export function createTheme( { token: 'annotation', foreground: euiTheme.euiColorMediumShade }, { token: 'type', foreground: euiTheme.euiColorVis0 }, - { token: 'delimiter', foreground: euiTheme.euiColorDarkestShade }, + { token: 'delimiter', foreground: euiTheme.euiTextSubduedColor }, { token: 'delimiter.html', foreground: euiTheme.euiColorDarkShade }, { token: 'delimiter.xml', foreground: euiTheme.euiColorPrimary }, @@ -81,6 +81,9 @@ export function createTheme( { token: 'operator.sql', foreground: euiTheme.euiColorMediumShade }, { token: 'operator.swift', foreground: euiTheme.euiColorMediumShade }, { token: 'predefined.sql', foreground: euiTheme.euiColorMediumShade }, + + { token: 'text', foreground: euiTheme.euiTitleColor }, + { token: 'label', foreground: euiTheme.euiColorVis9 }, ], colors: { 'editor.foreground': euiTheme.euiColorDarkestShade, diff --git a/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap b/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap index cde6a625ac8e86..07697fc036d1f9 100644 --- a/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap +++ b/src/plugins/kibana_react/public/field_icon/__snapshots__/field_icon.test.tsx.snap @@ -47,8 +47,9 @@ exports[`FieldIcon renders known field types conflict is rendered 1`] = ` diff --git a/src/plugins/kibana_react/public/field_icon/field_icon.tsx b/src/plugins/kibana_react/public/field_icon/field_icon.tsx index b466568037d941..9e3178c6b5b057 100644 --- a/src/plugins/kibana_react/public/field_icon/field_icon.tsx +++ b/src/plugins/kibana_react/public/field_icon/field_icon.tsx @@ -34,7 +34,7 @@ const defaultIcon = { iconType: 'questionInCircle', color: 'gray' }; export const typeToEuiIconMap: Partial> = { boolean: { iconType: 'tokenBoolean' }, // icon for an index pattern mapping conflict in discover - conflict: { iconType: 'alert', color: 'euiVisColor9' }, + conflict: { iconType: 'alert', color: 'euiColorVis9', shape: 'square' }, date: { iconType: 'tokenDate' }, geo_point: { iconType: 'tokenGeo' }, geo_shape: { iconType: 'tokenGeo' }, diff --git a/src/plugins/kibana_react/public/index.ts b/src/plugins/kibana_react/public/index.ts index c453a2cbe11f48..f2c2c263da5cd8 100644 --- a/src/plugins/kibana_react/public/index.ts +++ b/src/plugins/kibana_react/public/index.ts @@ -7,6 +7,7 @@ */ export * from './code_editor'; +export * from './url_template_editor'; export * from './exit_full_screen_button'; export * from './context'; export * from './overview_page'; diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index 7b3322f5f6c2d0..fa0a32fc3d542a 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -21,7 +21,6 @@ import { EuiFlexItem, EuiButton, EuiSpacer, - EuiOverlayMask, EuiConfirmModal, EuiCallOut, EuiBasicTableColumn, @@ -238,42 +237,40 @@ class TableListView extends React.Component - - } - buttonColor="danger" - onCancel={this.closeDeleteModal} - onConfirm={this.deleteSelectedItems} - cancelButtonText={ - - } - confirmButtonText={deleteButton} - defaultFocusedButton="cancel" - > -

- -

-
- + + } + buttonColor="danger" + onCancel={this.closeDeleteModal} + onConfirm={this.deleteSelectedItems} + cancelButtonText={ + + } + confirmButtonText={deleteButton} + defaultFocusedButton="cancel" + > +

+ +

+
); } diff --git a/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js b/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js new file mode 100644 index 00000000000000..742239e638b8ac --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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/kibana_react/public/url_template_editor/constants.ts b/src/plugins/kibana_react/public/url_template_editor/constants.ts new file mode 100644 index 00000000000000..6c1a1dbce5d674 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const LANG = 'handlebars_url'; diff --git a/src/plugins/kibana_react/public/url_template_editor/index.ts b/src/plugins/kibana_react/public/url_template_editor/index.ts new file mode 100644 index 00000000000000..0b0ef85ad427bf --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './url_template_editor'; diff --git a/src/plugins/kibana_react/public/url_template_editor/language.ts b/src/plugins/kibana_react/public/url_template_editor/language.ts new file mode 100644 index 00000000000000..278a7130ad1fa6 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/language.ts @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * This file is adapted from: https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts + * License: https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md + */ + +import { monaco } from '@kbn/monaco'; + +export const conf: monaco.languages.LanguageConfiguration = { + wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g, + + comments: { + blockComment: ['{{!--', '--}}'], + }, + + brackets: [ + ['<', '>'], + ['{{', '}}'], + ['{', '}'], + ['(', ')'], + ], + + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + + surroundingPairs: [ + { open: '<', close: '>' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], +}; + +export const language: monaco.languages.IMonarchLanguage = { + // Set defaultToken to invalid to see what you do not tokenize yet. + defaultToken: 'invalid', + tokenPostfix: '', + brackets: [ + { + token: 'constant.delimiter.double', + open: '{{', + close: '}}', + }, + { + token: 'constant.delimiter.triple', + open: '{{{', + close: '}}}', + }, + ], + + tokenizer: { + root: [ + { include: '@maybeHandlebars' }, + { include: '@whitespace' }, + { include: '@urlScheme' }, + { include: '@urlAuthority' }, + { include: '@urlSlash' }, + { include: '@urlParamKey' }, + { include: '@urlParamValue' }, + { include: '@text' }, + ], + + maybeHandlebars: [ + [ + /\{\{/, + { + token: '@rematch', + switchTo: '@handlebars.root', + }, + ], + ], + + whitespace: [[/[ \t\r\n]+/, '']], + + text: [ + [ + /[^<{\?\&\/]+/, + { + token: 'text', + next: '@popall', + }, + ], + ], + + rematchAsRoot: [ + [ + /.+/, + { + token: '@rematch', + switchTo: '@root', + }, + ], + ], + + urlScheme: [ + [ + /([a-zA-Z0-9\+\.\-]{1,10})(:)/, + [ + { + token: 'text.keyword.scheme.url', + }, + { + token: 'delimiter', + }, + ], + ], + ], + + urlAuthority: [ + [ + /(\/\/)([a-zA-Z0-9\.\-_]+)/, + [ + { + token: 'delimiter', + }, + { + token: 'metatag.keyword.authority.url', + }, + ], + ], + ], + + urlSlash: [ + [ + /\/+/, + { + token: 'delimiter', + }, + ], + ], + + urlParamKey: [ + [ + /([\?\&\#])([a-zA-Z0-9_\-]+)/, + [ + { + token: 'delimiter.key.query.url', + }, + { + token: 'label.label.key.query.url', + }, + ], + ], + ], + + urlParamValue: [ + [ + /(\=)([^\?\&\{}]+)/, + [ + { + token: 'text.separator.value.query.url', + }, + { + token: 'text.value.query.url', + }, + ], + ], + ], + + handlebars: [ + [ + /\{\{\{?/, + { + token: '@brackets', + bracket: '@open', + }, + ], + [ + /\}\}\}?/, + { + token: '@brackets', + bracket: '@close', + switchTo: '@$S2.$S3', + }, + ], + { include: 'handlebarsExpression' }, + ], + + handlebarsExpression: [ + [/"[^"]*"/, 'string.handlebars'], + [/[#/][^\s}]+/, 'keyword.helper.handlebars'], + [/else\b/, 'keyword.helper.handlebars'], + [/[\s]+/], + [/[^}]/, 'variable.parameter.handlebars'], + ], + }, +} as monaco.languages.IMonarchLanguage; diff --git a/src/plugins/kibana_react/public/url_template_editor/styles.scss b/src/plugins/kibana_react/public/url_template_editor/styles.scss new file mode 100644 index 00000000000000..99379b21454ec1 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/styles.scss @@ -0,0 +1,5 @@ +.urlTemplateEditor__container { + .monaco-editor .lines-content.monaco-editor-background { + margin: $euiSizeS; + } +} diff --git a/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx new file mode 100644 index 00000000000000..67f34e6eeb14f7 --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.stories.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import React from 'react'; +import { UrlTemplateEditor } from './url_template_editor'; +import { CodeEditor } from '../code_editor/code_editor'; + +storiesOf('UrlTemplateEditor', module) + .add('default', () => ( + + )) + .add('with variables', () => ( + + )); diff --git a/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx new file mode 100644 index 00000000000000..f830b4012976ae --- /dev/null +++ b/src/plugins/kibana_react/public/url_template_editor/url_template_editor.tsx @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 * as React from 'react'; +import { monaco } from '@kbn/monaco'; +import { Props as CodeEditorProps } from '../code_editor/code_editor'; +import { CodeEditor } from '../code_editor'; +import { LANG } from './constants'; +import { language, conf } from './language'; + +import './styles.scss'; + +monaco.languages.register({ + id: LANG, +}); +monaco.languages.setMonarchTokensProvider(LANG, language); +monaco.languages.setLanguageConfiguration(LANG, conf); + +export interface UrlTemplateEditorVariable { + label: string; + title?: string; + documentation?: string; + kind?: monaco.languages.CompletionItemKind; + sortText?: string; +} +export interface UrlTemplateEditorProps { + value: string; + height?: CodeEditorProps['height']; + variables?: UrlTemplateEditorVariable[]; + onChange: CodeEditorProps['onChange']; + onEditor?: (editor: monaco.editor.IStandaloneCodeEditor) => void; + Editor?: React.ComponentType; +} + +export const UrlTemplateEditor: React.FC = ({ + height = 105, + value, + variables, + onChange, + onEditor, + Editor = CodeEditor, +}) => { + const refEditor = React.useRef(null); + const handleEditor = React.useCallback((editor: monaco.editor.IStandaloneCodeEditor) => { + refEditor.current = editor; + + if (onEditor) { + onEditor(editor); + } + }, []); + + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + const editor = refEditor.current; + if (!editor) return; + + if (event.key === 'Escape') { + if (editor.hasWidgetFocus()) { + // Don't propagate Escape click if Monaco editor is focused, this allows + // user to close the autocomplete widget with Escape button without + // closing the EUI flyout. + event.stopPropagation(); + editor.trigger('editor', 'hideSuggestWidget', []); + } + } + }, []); + + React.useEffect(() => { + if (!variables) { + return; + } + + const { dispose } = monaco.languages.registerCompletionItemProvider(LANG, { + triggerCharacters: ['{', '/', '?', '&', '='], + provideCompletionItems(model, position, context, token) { + const { lineNumber } = position; + const line = model.getLineContent(lineNumber); + const wordUntil = model.getWordUntilPosition(position); + const word = model.getWordAtPosition(position) || wordUntil; + const { startColumn, endColumn } = word; + const range = { + startLineNumber: lineNumber, + endLineNumber: lineNumber, + startColumn, + endColumn, + }; + + const leadingMustacheCount = + 0 + + (line[range.startColumn - 2] === '{' ? 1 : 0) + + (line[range.startColumn - 3] === '{' ? 1 : 0); + + const trailingMustacheCount = + 0 + + (line[range.endColumn - 1] === '}' ? 1 : 0) + + (line[range.endColumn + 0] === '}' ? 1 : 0); + + return { + suggestions: variables.map( + ({ + label, + title = '', + documentation = '', + kind = monaco.languages.CompletionItemKind.Variable, + sortText, + }) => ({ + kind, + label, + insertText: + (leadingMustacheCount === 2 ? '' : leadingMustacheCount === 1 ? '{' : '{{') + + label + + (trailingMustacheCount === 2 ? '' : trailingMustacheCount === 1 ? '}' : '}}'), + detail: title, + documentation, + range, + sortText, + }) + ), + }; + }, + }); + + return () => { + dispose(); + }; + }, [variables]); + + return ( +
+ +
+ ); +}; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 5d611c75cdb992..d632c3ad61a800 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -127,5 +127,4 @@ export const stackManagementSchema: MakeSchemaFrom = { 'securitySolution:rulesTableRefresh': { type: 'text' }, 'apm:enableSignificantTerms': { type: 'boolean' }, 'apm:enableServiceOverview': { type: 'boolean' }, - 'apm:enableCorrelations': { type: 'boolean' }, }; diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index ee602fd51c32e3..698e7e7115295c 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -30,7 +30,6 @@ export interface UsageStats { 'securitySolution:rulesTableRefresh': string; 'apm:enableSignificantTerms': boolean; 'apm:enableServiceOverview': boolean; - 'apm:enableCorrelations': boolean; 'visualize:enableLabs': boolean; 'visualization:heatmap:maxBuckets': number; 'visualization:colorMapping': string; diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 230be399febda3..bc27cf061eb68a 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,3 +1,12 @@ .kbnTopNavMenu { margin-right: $euiSizeXS; } + +.kbnTopNavMenu__badgeWrapper { + display: flex; + align-items: baseline; +} + +.kbnTopNavMenu__badgeGroup { + margin-right: $euiSizeM; +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 70bc3b10b30adc..22edf9c454466c 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -7,7 +7,7 @@ */ import React, { ReactElement } from 'react'; -import { EuiHeaderLinks } from '@elastic/eui'; +import { EuiBadge, EuiBadgeGroup, EuiBadgeProps, EuiHeaderLinks } from '@elastic/eui'; import classNames from 'classnames'; import { MountPoint } from '../../../../core/public'; @@ -23,6 +23,7 @@ import { TopNavMenuItem } from './top_nav_menu_item'; export type TopNavMenuProps = StatefulSearchBarProps & Omit & { config?: TopNavMenuData[]; + badges?: Array; showSearchBar?: boolean; showQueryBar?: boolean; showQueryInput?: boolean; @@ -61,12 +62,28 @@ export type TopNavMenuProps = StatefulSearchBarProps & **/ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { - const { config, showSearchBar, ...searchBarProps } = props; + const { config, badges, showSearchBar, ...searchBarProps } = props; if ((!config || config.length === 0) && (!showSearchBar || !props.data)) { return null; } + function renderBadges(): ReactElement | null { + if (!badges || badges.length === 0) return null; + return ( + + {badges.map((badge: EuiBadgeProps & { badgeText: string }, i: number) => { + const { badgeText, ...badgeProps } = badge; + return ( + + {badgeText} + + ); + })} + + ); + } + function renderItems(): ReactElement[] | null { if (!config || config.length === 0) return null; return config.map((menuItem: TopNavMenuData, i: number) => { @@ -98,7 +115,10 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { return ( <> - {renderMenu(menuClassName)} + + {renderBadges()} + {renderMenu(menuClassName)} + {renderSearchBar()} diff --git a/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap b/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap index f88039fbda9bad..1f05ed6b944051 100644 --- a/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap +++ b/src/plugins/saved_objects/public/save_modal/__snapshots__/saved_object_save_modal.test.tsx.snap @@ -1,407 +1,399 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SavedObjectSaveModal should render matching snapshot 1`] = ` - +
- - - - - - - - - + + + - + + + + + - - + - } - labelType="label" - > - + + - - - - - - - - - Save - - - + + + + + + + + + Save + +
-
+ `; exports[`SavedObjectSaveModal should render matching snapshot when custom isValid is set 1`] = ` - +
- - - - + + - - - - - - } - labelType="label" - > - + + + + + - - + - } - labelType="label" - > - + + - - - - - - - - - Save - - - + + + + + + + + + Save + +
-
+ `; exports[`SavedObjectSaveModal should render matching snapshot when custom isValid is set 2`] = ` - +
- - - - + + - - - - - - } - labelType="label" - > - + + + + + - - + - } - labelType="label" - > - + + - - - - - - - - - Save - - - + + + + + + + + + Save + +
-
+ `; exports[`SavedObjectSaveModal should render matching snapshot when given options 1`] = ` - +
- - - - + + - - - - - - - - } - labelType="label" - > - + + + + + + + - - + - } - labelType="label" - > - + + - -
- Hello! Main options -
-
- -
- Hey there! Options on the right -
-
-
-
-
- - - - - - Save - - -
+ } + labelType="label" + > + + +
+ Hello! Main options +
+ + +
+ Hey there! Options on the right +
+
+ + + + + + + + + Save + + -
+ `; diff --git a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx index 39c87c9da60c2b..e476d62a0e793b 100644 --- a/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/saved_object_save_modal.tsx @@ -21,7 +21,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiSwitch, EuiSwitchEvent, @@ -123,52 +122,48 @@ export class SavedObjectSaveModal extends React.Component ); return ( - +
- - - - - - - - - {this.renderDuplicateTitleCallout(duplicateWarningId)} - - - {!this.props.showDescription && this.props.description && ( - - {this.props.description} - - )} - {formBody} - {this.renderCopyOnSave()} - - - - - - - - - {this.renderConfirmButton()} - - + + + + + + + + {this.renderDuplicateTitleCallout(duplicateWarningId)} + + + {!this.props.showDescription && this.props.description && ( + + {this.props.description} + + )} + {formBody} + {this.renderCopyOnSave()} + + + + + + + + + {this.renderConfirmButton()} +
-
+ ); } diff --git a/src/plugins/saved_objects_management/kibana.json b/src/plugins/saved_objects_management/kibana.json index f062433605c537..6c6d11d053c0f9 100644 --- a/src/plugins/saved_objects_management/kibana.json +++ b/src/plugins/saved_objects_management/kibana.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "requiredPlugins": ["management", "data"], - "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss"], + "optionalPlugins": ["dashboard", "visualizations", "discover", "home", "savedObjectsTaggingOss", "spacesOss"], "extraPublicDirs": ["public/lib"], "requiredBundles": ["kibanaReact", "home"] } diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index d6cebd491b6e37..b855850ed185d4 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -37,7 +37,11 @@ export const mountManagementSection = async ({ mountParams, serviceRegistry, }: MountParams) => { - const [coreStart, { data, savedObjectsTaggingOss }, pluginStart] = await core.getStartServices(); + const [ + coreStart, + { data, savedObjectsTaggingOss, spacesOss }, + pluginStart, + ] = await core.getStartServices(); const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); @@ -57,6 +61,8 @@ export const mountManagementSection = async ({ return children! as React.ReactElement; }; + const spacesApi = spacesOss?.isSpacesAvailable ? spacesOss : undefined; + ReactDOM.render( @@ -79,6 +85,7 @@ export const mountManagementSection = async ({ = ({ // can't use `EuiConfirmModal` here as the confirm modal body is wrapped // inside a `

` element, causing UI glitches with the table. return ( - - - - - - - - -

- -

- - ( - - - - ), - }, - { - field: 'id', - name: i18n.translate( - 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName', - { defaultMessage: 'Id' } - ), - }, - { - field: 'meta.title', - name: i18n.translate( - 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName', - { defaultMessage: 'Title' } - ), - }, - ]} - pagination={true} - sorting={false} + + + + - - - - - - - - - - - - - - - - - - - - - + + + +

+ +

+ + ( + + + + ), + }, + { + field: 'id', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName', + { defaultMessage: 'Id' } + ), + }, + { + field: 'meta.title', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName', + { defaultMessage: 'Title' } + ), + }, + ]} + pagination={true} + sorting={false} + /> +
+ + + + + + + + + + + + + + + + + + + ); }; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/export_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/export_modal.tsx index 693fe00ffedccb..0699f77f575219 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/export_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/export_modal.tsx @@ -8,7 +8,6 @@ import React, { FC } from 'react'; import { - EuiOverlayMask, EuiModal, EuiModalHeader, EuiModalHeaderTitle, @@ -47,80 +46,78 @@ export const ExportModal: FC = ({ onIncludeReferenceChange, }) => { return ( - - - - + + + + + + + + - - - - - } - labelType="legend" - > - { - onSelectedOptionsChange({ - ...selectedOptions, - ...{ - [optionId]: !selectedOptions[optionId], - }, - }); - }} + id="savedObjectsManagement.objectsTable.exportObjectsConfirmModalDescription" + defaultMessage="Select which types to export" /> - - - - } - checked={includeReferences} - onChange={() => onIncludeReferenceChange(!includeReferences)} + } + labelType="legend" + > + { + onSelectedOptionsChange({ + ...selectedOptions, + ...{ + [optionId]: !selectedOptions[optionId], + }, + }); + }} /> - - - - - - - - - - - - - - - - - - - - - + + + + } + checked={includeReferences} + onChange={() => onIncludeReferenceChange(!includeReferences)} + /> + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx index b1570bb1fff0d9..8b07351f6c2c20 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.tsx @@ -123,7 +123,10 @@ const CountIndicators: FC<{ importItems: ImportItem[] }> = ({ importItems }) => {errorCount && ( -

+

{ } ); return ( - - onFinish(false)} - onConfirm={() => onFinish(true, destinationId)} - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - maxWidth="500px" - > -

{bodyText}

- {selectControl} -
-
+ onFinish(false)} + onConfirm={() => onFinish(true, destinationId)} + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + maxWidth="500px" + > +

{bodyText}

+ {selectControl} +
); }; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 2d4386f95eb32a..ad61b0b692ea7d 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -70,7 +70,6 @@ interface TableState { isExportPopoverOpen: boolean; isIncludeReferencesDeepChecked: boolean; activeAction?: SavedObjectsManagementAction; - isColumnDataLoaded: boolean; } export class Table extends PureComponent { @@ -80,22 +79,12 @@ export class Table extends PureComponent { isExportPopoverOpen: false, isIncludeReferencesDeepChecked: true, activeAction: undefined, - isColumnDataLoaded: false, }; constructor(props: TableProps) { super(props); } - componentDidMount() { - this.loadColumnData(); - } - - loadColumnData = async () => { - await Promise.all(this.props.columnRegistry.getAll().map((column) => column.loadData())); - this.setState({ isColumnDataLoaded: true }); - }; - onChange = ({ query, error }: any) => { if (error) { this.setState({ diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index 8049b8adfdf1ce..c5ae2127ac0305 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -15,6 +15,7 @@ import { i18n } from '@kbn/i18n'; import { CoreStart, ChromeBreadcrumb } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; import { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; +import type { SpacesAvailableStartContract } from '../../../spaces_oss/public'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, @@ -22,10 +23,13 @@ import { } from '../services'; import { SavedObjectsTable } from './objects_table'; +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; + const SavedObjectsTablePage = ({ coreStart, dataStart, taggingApi, + spacesApi, allowedTypes, serviceRegistry, actionRegistry, @@ -35,6 +39,7 @@ const SavedObjectsTablePage = ({ coreStart: CoreStart; dataStart: DataPublicPluginStart; taggingApi?: SavedObjectsTaggingApi; + spacesApi?: SpacesAvailableStartContract; allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; actionRegistry: SavedObjectsManagementActionServiceStart; @@ -65,35 +70,42 @@ const SavedObjectsTablePage = ({ ]); }, [setBreadcrumbs]); + const ContextWrapper = useMemo( + () => spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent, + [spacesApi] + ); + return ( - { - const { editUrl } = savedObject.meta; - if (editUrl) { - return coreStart.application.navigateToUrl( - coreStart.http.basePath.prepend(`/app${editUrl}`) - ); - } - }} - canGoInApp={(savedObject) => { - const { inAppUrl } = savedObject.meta; - return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; - }} - /> + + { + const { editUrl } = savedObject.meta; + if (editUrl) { + return coreStart.application.navigateToUrl( + coreStart.http.basePath.prepend(`/app${editUrl}`) + ); + } + }} + canGoInApp={(savedObject) => { + const { inAppUrl } = savedObject.meta; + return inAppUrl ? Boolean(get(capabilities, inAppUrl.uiCapabilitiesPath)) : false; + }} + /> + ); }; // eslint-disable-next-line import/no-default-export diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index a4c7a84b419baa..f4578c4c4b8e10 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -15,6 +15,7 @@ import { DiscoverStart } from '../../discover/public'; import { HomePublicPluginSetup, FeatureCatalogueCategory } from '../../home/public'; import { VisualizationsStart } from '../../visualizations/public'; import { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; +import type { SpacesOssPluginStart } from '../../spaces_oss/public'; import { SavedObjectsManagementActionService, SavedObjectsManagementActionServiceSetup, @@ -49,6 +50,7 @@ export interface StartDependencies { visualizations?: VisualizationsStart; discover?: DiscoverStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + spacesOss?: SpacesOssPluginStart; } export class SavedObjectsManagementPlugin diff --git a/src/plugins/saved_objects_management/public/services/types/column.ts b/src/plugins/saved_objects_management/public/services/types/column.ts index 6103f1bd3d5c0c..1be279db912051 100644 --- a/src/plugins/saved_objects_management/public/services/types/column.ts +++ b/src/plugins/saved_objects_management/public/services/types/column.ts @@ -12,7 +12,4 @@ import { SavedObjectsManagementRecord } from '.'; export interface SavedObjectsManagementColumn { id: string; euiColumn: Omit, 'sortable'>; - - data?: T; - loadData: () => Promise; } diff --git a/src/plugins/saved_objects_management/server/routes/find.ts b/src/plugins/saved_objects_management/server/routes/find.ts index 4fe20f8bc615e0..45fbd311c2ee0f 100644 --- a/src/plugins/saved_objects_management/server/routes/find.ts +++ b/src/plugins/saved_objects_management/server/routes/find.ts @@ -45,17 +45,24 @@ export const registerFindRoute = ( }, }, router.handleLegacyErrors(async (context, req, res) => { + const { query } = req; const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; - const searchTypes = Array.isArray(req.query.type) ? req.query.type : [req.query.type]; - const includedFields = Array.isArray(req.query.fields) - ? req.query.fields - : [req.query.fields]; + const { getClient, typeRegistry } = context.core.savedObjects; + + const searchTypes = Array.isArray(query.type) ? query.type : [query.type]; + const includedFields = Array.isArray(query.fields) ? query.fields : [query.fields]; + const importAndExportableTypes = searchTypes.filter((type) => - managementService.isImportAndExportable(type) + typeRegistry.isImportableAndExportable(type) ); + const includedHiddenTypes = importAndExportableTypes.filter((type) => + typeRegistry.isHidden(type) + ); + + const client = getClient({ includedHiddenTypes }); const searchFields = new Set(); + importAndExportableTypes.forEach((type) => { const searchField = managementService.getDefaultSearchField(type); if (searchField) { @@ -64,7 +71,7 @@ export const registerFindRoute = ( }); const findResponse = await client.find({ - ...req.query, + ...query, fields: undefined, searchFields: [...searchFields], }); diff --git a/src/plugins/saved_objects_management/server/routes/get.ts b/src/plugins/saved_objects_management/server/routes/get.ts index 1e0115db9e43c0..5a48f2f2affa78 100644 --- a/src/plugins/saved_objects_management/server/routes/get.ts +++ b/src/plugins/saved_objects_management/server/routes/get.ts @@ -26,10 +26,14 @@ export const registerGetRoute = ( }, }, router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; + const includedHiddenTypes = [type].filter( + (entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry) + ); - const { type, id } = req.params; + const client = getClient({ includedHiddenTypes }); const findResponse = await client.get(type, id); const enhancedSavedObject = injectMetaAttributes(findResponse, managementService); diff --git a/src/plugins/saved_objects_management/server/routes/relationships.ts b/src/plugins/saved_objects_management/server/routes/relationships.ts index 5e30f49cde67fb..9e2c2031d8abda 100644 --- a/src/plugins/saved_objects_management/server/routes/relationships.ts +++ b/src/plugins/saved_objects_management/server/routes/relationships.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; +import { chain } from 'lodash'; import { findRelationships } from '../lib'; import { ISavedObjectsManagement } from '../services'; @@ -31,12 +32,21 @@ export const registerRelationshipsRoute = ( }, router.handleLegacyErrors(async (context, req, res) => { const managementService = await managementServicePromise; - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; const { type, id } = req.params; - const { size } = req.query; - const savedObjectTypes = Array.isArray(req.query.savedObjectTypes) - ? req.query.savedObjectTypes - : [req.query.savedObjectTypes]; + const { size, savedObjectTypes: maybeArraySavedObjectTypes } = req.query; + const savedObjectTypes = Array.isArray(maybeArraySavedObjectTypes) + ? maybeArraySavedObjectTypes + : [maybeArraySavedObjectTypes]; + + const includedHiddenTypes = chain(maybeArraySavedObjectTypes) + .uniq() + .filter( + (entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); const findRelationsResponse = await findRelationships({ type, diff --git a/src/plugins/saved_objects_management/server/routes/scroll_count.ts b/src/plugins/saved_objects_management/server/routes/scroll_count.ts index dfe361e7b96499..89a895adf60084 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_count.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_count.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter, SavedObjectsFindOptions } from 'src/core/server'; +import { chain } from 'lodash'; import { findAll } from '../lib'; export const registerScrollForCountRoute = (router: IRouter) => { @@ -30,18 +31,27 @@ export const registerScrollForCountRoute = (router: IRouter) => { }, }, router.handleLegacyErrors(async (context, req, res) => { - const { client } = context.core.savedObjects; + const { getClient, typeRegistry } = context.core.savedObjects; + const { typesToInclude, searchString, references } = req.body; + const includedHiddenTypes = chain(typesToInclude) + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); const findOptions: SavedObjectsFindOptions = { - type: req.body.typesToInclude, + type: typesToInclude, perPage: 1000, }; - if (req.body.searchString) { - findOptions.search = `${req.body.searchString}*`; + if (searchString) { + findOptions.search = `${searchString}*`; findOptions.searchFields = ['title']; } - if (req.body.references) { - findOptions.hasReference = req.body.references; + if (references) { + findOptions.hasReference = references; findOptions.hasReferenceOperator = 'OR'; } @@ -54,7 +64,7 @@ export const registerScrollForCountRoute = (router: IRouter) => { return accum; }, {} as Record); - for (const type of req.body.typesToInclude) { + for (const type of typesToInclude) { if (!counts[type]) { counts[type] = 0; } diff --git a/src/plugins/saved_objects_management/server/routes/scroll_export.ts b/src/plugins/saved_objects_management/server/routes/scroll_export.ts index 3417efa709e5f5..8d11437af661b9 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_export.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_export.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from 'src/core/server'; +import { chain } from 'lodash'; import { findAll } from '../lib'; export const registerScrollForExportRoute = (router: IRouter) => { @@ -21,10 +22,20 @@ export const registerScrollForExportRoute = (router: IRouter) => { }, }, router.handleLegacyErrors(async (context, req, res) => { - const { client } = context.core.savedObjects; + const { typesToInclude } = req.body; + const { getClient, typeRegistry } = context.core.savedObjects; + const includedHiddenTypes = chain(typesToInclude) + .uniq() + .filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) + ) + .value(); + + const client = getClient({ includedHiddenTypes }); + const objects = await findAll(client, { perPage: 1000, - type: req.body.typesToInclude, + type: typesToInclude, }); return res.ok({ diff --git a/src/plugins/saved_objects_management/tsconfig.json b/src/plugins/saved_objects_management/tsconfig.json index eb047c346714ca..99849dea386181 100644 --- a/src/plugins/saved_objects_management/tsconfig.json +++ b/src/plugins/saved_objects_management/tsconfig.json @@ -21,5 +21,6 @@ { "path": "../kibana_react/tsconfig.json" }, { "path": "../management/tsconfig.json" }, { "path": "../visualizations/tsconfig.json" }, + { "path": "../spaces_oss/tsconfig.json" }, ] } diff --git a/src/plugins/saved_objects_tagging_oss/common/index.ts b/src/plugins/saved_objects_tagging_oss/common/index.ts index 231bec46f57ab1..a892f41c69314c 100644 --- a/src/plugins/saved_objects_tagging_oss/common/index.ts +++ b/src/plugins/saved_objects_tagging_oss/common/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { Tag, TagAttributes, ITagsClient } from './types'; +export { Tag, TagAttributes, GetAllTagsOptions, ITagsClient } from './types'; diff --git a/src/plugins/saved_objects_tagging_oss/common/types.ts b/src/plugins/saved_objects_tagging_oss/common/types.ts index 344e18a5fd76d2..205f6984ed618e 100644 --- a/src/plugins/saved_objects_tagging_oss/common/types.ts +++ b/src/plugins/saved_objects_tagging_oss/common/types.ts @@ -19,10 +19,14 @@ export interface TagAttributes { color: string; } +export interface GetAllTagsOptions { + asSystemRequest?: boolean; +} + export interface ITagsClient { create(attributes: TagAttributes): Promise; get(id: string): Promise; - getAll(): Promise; + getAll(options?: GetAllTagsOptions): Promise; delete(id: string): Promise; update(id: string, attributes: TagAttributes): Promise; } diff --git a/src/plugins/spaces_oss/public/api.mock.ts b/src/plugins/spaces_oss/public/api.mock.ts index 4c6b8bb6ee3381..c4a410c76e7962 100644 --- a/src/plugins/spaces_oss/public/api.mock.ts +++ b/src/plugins/spaces_oss/public/api.mock.ts @@ -7,13 +7,40 @@ */ import { of } from 'rxjs'; -import { SpacesApi } from './api'; +import { SpacesApi, SpacesApiUi, SpacesApiUiComponent } from './api'; const createApiMock = (): jest.Mocked => ({ activeSpace$: of(), getActiveSpace: jest.fn(), + ui: createApiUiMock(), }); +type SpacesApiUiMock = Omit, 'components'> & { + components: SpacesApiUiComponentMock; +}; + +const createApiUiMock = () => { + const mock: SpacesApiUiMock = { + components: createApiUiComponentsMock(), + redirectLegacyUrl: jest.fn(), + }; + + return mock; +}; + +type SpacesApiUiComponentMock = jest.Mocked; + +const createApiUiComponentsMock = () => { + const mock: SpacesApiUiComponentMock = { + SpacesContext: jest.fn(), + ShareToSpaceFlyout: jest.fn(), + SpaceList: jest.fn(), + LegacyUrlConflict: jest.fn(), + }; + + return mock; +}; + export const spacesApiMock = { create: createApiMock, }; diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index 5fa8b4fc29719a..2d5e144158d78b 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -7,6 +7,7 @@ */ import { Observable } from 'rxjs'; +import type { FunctionComponent } from 'react'; import { Space } from '../common'; /** @@ -15,4 +16,238 @@ import { Space } from '../common'; export interface SpacesApi { readonly activeSpace$: Observable; getActiveSpace(): Promise; + /** + * UI API to use to add spaces capabilities to an application + */ + ui: SpacesApiUi; +} + +/** + * @public + */ +export interface SpacesApiUi { + /** + * {@link SpacesApiUiComponent | React components} to support the spaces feature. + */ + components: SpacesApiUiComponent; + /** + * Redirect the user from a legacy URL to a new URL. This needs to be used if a call to `SavedObjectsClient.resolve()` results in an + * `"aliasMatch"` outcome, which indicates that the user has loaded the page using a legacy URL. Calling this function will trigger a + * client-side redirect to the new URL, and it will display a toast to the user. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (old ID) and the object ID in the result (new ID). For example... + * + * The old object ID is `workpad-123` and the new object ID is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` + * + * The protocol, hostname, port, base path, and app path are automatically included. + * + * @param path The path to use for the new URL, optionally including `search` and/or `hash` URL components. + * @param objectNoun The string that is used to describe the object in the toast, e.g., _The **object** you're looking for has a new + * location_. Default value is 'object'. + */ + redirectLegacyUrl: (path: string, objectNoun?: string) => Promise; +} + +/** + * React UI components to be used to display the spaces feature in any application. + * + * @public + */ +export interface SpacesApiUiComponent { + /** + * Provides a context that is required to render some Spaces components. + */ + SpacesContext: FunctionComponent; + /** + * Displays a flyout to edit the spaces that an object is shared to. + * + * Note: must be rendered inside of a SpacesContext. + */ + ShareToSpaceFlyout: FunctionComponent; + /** + * Displays a corresponding list of spaces for a given list of saved object namespaces. It shows up to five spaces (and an indicator for + * any number of spaces that the user is not authorized to see) by default. If more than five named spaces would be displayed, the extras + * (along with the unauthorized spaces indicator, if present) are hidden behind a button. If '*' (aka "All spaces") is present, it + * supersedes all of the above and just displays a single badge without a button. + * + * Note: must be rendered inside of a SpacesContext. + */ + SpaceList: FunctionComponent; + /** + * Displays a callout that needs to be used if a call to `SavedObjectsClient.resolve()` results in an `"conflict"` outcome, which + * indicates that the user has loaded the page which is associated directly with one object (A), *and* with a legacy URL that points to a + * different object (B). + * + * In this case, `SavedObjectsClient.resolve()` has returned object A. This component displays a warning callout to the user explaining + * that there is a conflict, and it includes a button that will redirect the user to object B when clicked. + * + * Consumers need to determine the local path for the new URL on their own, based on the object ID that was used to call + * `SavedObjectsClient.resolve()` (A) and the `aliasTargetId` value in the response (B). For example... + * + * A is `workpad-123` and B is `workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e`. + * + * Full legacy URL: `https://localhost:5601/app/canvas#/workpad/workpad-123/page/1` + * + * New URL path: `#/workpad/workpad-e08b9bdb-ec14-4339-94c4-063bddfd610e/page/1` + */ + LegacyUrlConflict: FunctionComponent; +} + +/** + * @public + */ +export interface SpacesContextProps { + /** + * If a feature is specified, all Spaces components will treat it appropriately if the feature is disabled in a given Space. + */ + feature?: string; +} + +/** + * @public + */ +export interface ShareToSpaceFlyoutProps { + /** + * The object to render the flyout for. + */ + savedObjectTarget: ShareToSpaceSavedObjectTarget; + /** + * The EUI icon that is rendered in the flyout's title. + * + * Default is 'share'. + */ + flyoutIcon?: string; + /** + * The string that is rendered in the flyout's title. + * + * Default is 'Edit spaces for object'. + */ + flyoutTitle?: string; + /** + * When enabled, if the object is not yet shared to multiple spaces, a callout will be displayed that suggests the user might want to + * create a copy instead. + * + * Default value is false. + */ + enableCreateCopyCallout?: boolean; + /** + * When enabled, if no other spaces exist _and_ the user has the appropriate privileges, a sentence will be displayed that suggests the + * user might want to create a space. + * + * Default value is false. + */ + enableCreateNewSpaceLink?: boolean; + /** + * When set to 'within-space' (default), the flyout behaves like it is running on a page within the active space, and it will prevent the + * user from removing the object from the active space. + * + * Conversely, when set to 'outside-space', the flyout behaves like it is running on a page outside of any space, so it will allow the + * user to remove the object from the active space. + */ + behaviorContext?: 'within-space' | 'outside-space'; + /** + * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If + * this is not defined, a default handler will be used that calls `/api/spaces/_share_saved_object_add` and/or + * `/api/spaces/_share_saved_object_remove` and displays toast(s) indicating what occurred. + */ + changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise; + /** + * Optional callback when the target object is updated. + */ + onUpdate?: () => void; + /** + * Optional callback when the flyout is closed. + */ + onClose?: () => void; +} + +/** + * @public + */ +export interface ShareToSpaceSavedObjectTarget { + /** + * The object's type. + */ + type: string; + /** + * The object's ID. + */ + id: string; + /** + * The namespaces that the object currently exists in. + */ + namespaces: string[]; + /** + * The EUI icon that is rendered in the flyout's subtitle. + * + * Default is 'empty'. + */ + icon?: string; + /** + * The string that is rendered in the flyout's subtitle. + * + * Default is `${type} [id=${id}]`. + */ + title?: string; + /** + * The string that is used to describe the object in several places, e.g., _Make **object** available in selected spaces only_. + * + * Default value is 'object'. + */ + noun?: string; +} + +/** + * @public + */ +export interface SpaceListProps { + /** + * The namespaces of a saved object to render into a corresponding list of spaces. + */ + namespaces: string[]; + /** + * Optional limit to the number of spaces that can be displayed in the list. If the number of spaces exceeds this limit, they will be + * hidden behind a "show more" button. Set to 0 to disable. + * + * Default value is 5. + */ + displayLimit?: number; + /** + * When set to 'within-space' (default), the space list behaves like it is running on a page within the active space, and it will omit the + * active space (e.g., it displays a list of all the _other_ spaces that an object is shared to). + * + * Conversely, when set to 'outside-space', the space list behaves like it is running on a page outside of any space, so it will not omit + * the active space. + */ + behaviorContext?: 'within-space' | 'outside-space'; +} + +/** + * @public + */ +export interface LegacyUrlConflictProps { + /** + * The string that is used to describe the object in the callout, e.g., _There is a legacy URL for this page that points to a different + * **object**_. + * + * Default value is 'object'. + */ + objectNoun?: string; + /** + * The ID of the object that is currently shown on the page. + */ + currentObjectId: string; + /** + * The ID of the other object that the legacy URL alias points to. + */ + otherObjectId: string; + /** + * The path to use for the new URL, optionally including `search` and/or `hash` URL components. + */ + otherObjectPath: string; } diff --git a/src/plugins/spaces_oss/public/index.ts b/src/plugins/spaces_oss/public/index.ts index 70172f620d0435..be42bd9a899b10 100644 --- a/src/plugins/spaces_oss/public/index.ts +++ b/src/plugins/spaces_oss/public/index.ts @@ -8,8 +8,22 @@ import { SpacesOssPlugin } from './plugin'; -export { SpacesOssPluginSetup, SpacesOssPluginStart } from './types'; +export { + SpacesOssPluginSetup, + SpacesOssPluginStart, + SpacesAvailableStartContract, + SpacesUnavailableStartContract, +} from './types'; -export { SpacesApi } from './api'; +export { + SpacesApi, + SpacesApiUi, + SpacesApiUiComponent, + SpacesContextProps, + ShareToSpaceFlyoutProps, + ShareToSpaceSavedObjectTarget, + SpaceListProps, + LegacyUrlConflictProps, +} from './api'; export const plugin = () => new SpacesOssPlugin(); diff --git a/src/plugins/spaces_oss/public/types.ts b/src/plugins/spaces_oss/public/types.ts index 80b1f7aa840bbc..831aaa2c459439 100644 --- a/src/plugins/spaces_oss/public/types.ts +++ b/src/plugins/spaces_oss/public/types.ts @@ -8,11 +8,11 @@ import { SpacesApi } from './api'; -interface SpacesAvailableStartContract extends SpacesApi { +export interface SpacesAvailableStartContract extends SpacesApi { isSpacesAvailable: true; } -interface SpacesUnavailableStartContract { +export interface SpacesUnavailableStartContract { isSpacesAvailable: false; } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index c129fd006ae156..566d10182b5444 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -4384,9 +4384,6 @@ }, "apm:enableServiceOverview": { "type": "boolean" - }, - "apm:enableCorrelations": { - "type": "boolean" } } }, diff --git a/src/plugins/usage_collection/public/mocks.tsx b/src/plugins/usage_collection/public/mocks.tsx index ff8740ea48f7b9..72cefaf0264907 100644 --- a/src/plugins/usage_collection/public/mocks.tsx +++ b/src/plugins/usage_collection/public/mocks.tsx @@ -36,9 +36,6 @@ const createSetupContract = (): Setup => { allowTrackUserAgent: jest.fn(), reportUiCounter: jest.fn(), METRIC_TYPE, - __LEGACY: { - appChanged: jest.fn(), - }, }; return setupContract; diff --git a/src/plugins/usage_collection/public/plugin.tsx b/src/plugins/usage_collection/public/plugin.tsx index ac566c6a0d0445..6d1eb751d907a6 100644 --- a/src/plugins/usage_collection/public/plugin.tsx +++ b/src/plugins/usage_collection/public/plugin.tsx @@ -7,17 +7,17 @@ */ import { Reporter, METRIC_TYPE, ApplicationUsageTracker } from '@kbn/analytics'; -import { Subject, merge, Subscription } from 'rxjs'; +import type { Subscription } from 'rxjs'; import React from 'react'; -import { Storage } from '../../kibana_utils/public'; -import { createReporter, trackApplicationUsageChange } from './services'; -import { +import type { PluginInitializerContext, Plugin, CoreSetup, CoreStart, HttpSetup, -} from '../../../core/public'; +} from 'src/core/public'; +import { Storage } from '../../kibana_utils/public'; +import { createReporter, trackApplicationUsageChange } from './services'; import { ApplicationUsageContext } from './components/track_application_view'; export interface PublicConfigType { @@ -39,15 +39,6 @@ export interface UsageCollectionSetup { applicationUsageTracker: IApplicationUsageTracker; reportUiCounter: Reporter['reportUiCounter']; METRIC_TYPE: typeof METRIC_TYPE; - __LEGACY: { - /** - * Legacy handler so we can report the actual app being used inside "kibana#/{appId}". - * To be removed when we get rid of the legacy world - * - * @deprecated - */ - appChanged: (appId: string) => void; - }; } export interface UsageCollectionStart { @@ -65,7 +56,6 @@ export function isUnauthenticated(http: HttpSetup) { } export class UsageCollectionPlugin implements Plugin { - private readonly legacyAppId$ = new Subject(); private applicationUsageTracker?: ApplicationUsageTracker; private trackUserAgent: boolean = true; private subscriptions: Subscription[] = []; @@ -103,9 +93,6 @@ export class UsageCollectionPlugin implements Plugin this.legacyAppId$.next(appId), - }, }; } @@ -118,7 +105,7 @@ export class UsageCollectionPlugin implements Plugin { "label": "Count", "value": "count", }, + Object { + "label": "Max", + "value": "max", + }, + Object { + "label": "Min", + "value": "min", + }, Object { "label": "Sum", "value": "sum", diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx index 0b8fad4d780f64..00d088025bf253 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx @@ -225,7 +225,7 @@ const FILTER_RATIO_AGGS = [ 'value_count', ]; -const HISTOGRAM_AGGS = ['avg', 'count', 'sum', 'value_count']; +const HISTOGRAM_AGGS = ['avg', 'count', 'sum', 'min', 'max', 'value_count']; const allAggOptions = [...metricAggs, ...pipelineAggs, ...siblingAggs, ...specialAggs]; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.test.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.test.js index dd85e2490d70a7..5648a8d3e7133a 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.test.js @@ -51,26 +51,13 @@ describe('TSVB Filter Ratio', () => { field: 'histogram_value', }; const wrapper = setup(metric); - expect(wrapper.find(EuiComboBox).at(1).props().options).toMatchInlineSnapshot(` - Array [ - Object { - "label": "Average", - "value": "avg", - }, - Object { - "label": "Count", - "value": "count", - }, - Object { - "label": "Sum", - "value": "sum", - }, - Object { - "label": "Value Count", - "value": "value_count", - }, - ] - `); + expect( + wrapper + .find(EuiComboBox) + .at(1) + .props() + .options.map(({ value }) => value) + ).toEqual(['avg', 'count', 'max', 'min', 'sum', 'value_count']); }); const shouldNotHaveHistogramField = (agg) => { it(`should not have histogram fields for ${agg}`, () => { @@ -80,23 +67,19 @@ describe('TSVB Filter Ratio', () => { field: '', }; const wrapper = setup(metric); - expect(wrapper.find(EuiComboBox).at(2).props().options).toMatchInlineSnapshot(` - Array [ - Object { - "label": "number", - "options": Array [ - Object { - "label": "system.cpu.user.pct", - "value": "system.cpu.user.pct", - }, - ], - }, - ] - `); + expect(wrapper.find(EuiComboBox).at(2).props().options).toEqual([ + { + label: 'number', + options: [ + { + label: 'system.cpu.user.pct', + value: 'system.cpu.user.pct', + }, + ], + }, + ]); }); }; - shouldNotHaveHistogramField('max'); - shouldNotHaveHistogramField('min'); shouldNotHaveHistogramField('positive_rate'); it(`should not have histogram fields for cardinality`, () => { diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js index 5a5f37db2b7b5e..c536856327f283 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js @@ -65,6 +65,8 @@ describe('Histogram Types', () => { }; shouldHaveHistogramSupport('avg'); shouldHaveHistogramSupport('sum'); + shouldHaveHistogramSupport('min'); + shouldHaveHistogramSupport('max'); shouldHaveHistogramSupport('value_count'); shouldHaveHistogramSupport('percentile'); shouldHaveHistogramSupport('percentile_rank'); @@ -81,8 +83,6 @@ describe('Histogram Types', () => { ); }; shouldNotHaveHistogramSupport('cardinality'); - shouldNotHaveHistogramSupport('max'); - shouldNotHaveHistogramSupport('min'); shouldNotHaveHistogramSupport('variance'); shouldNotHaveHistogramSupport('sum_of_squares'); shouldNotHaveHistogramSupport('std_deviation'); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js index 0d0a964f3c1b60..c6afbaaee47daf 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/series_agg.js @@ -61,6 +61,13 @@ function SeriesAggUi(props) { }), value: 'mean', }, + { + label: intl.formatMessage({ + id: 'visTypeTimeseries.seriesAgg.functionOptions.countLabel', + defaultMessage: 'Series count', + }), + value: 'count', + }, { label: intl.formatMessage({ id: 'visTypeTimeseries.seriesAgg.functionOptions.overallSumLabel', diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js index ba3b330ed8eef2..61226e4e8dcbe5 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js @@ -17,6 +17,8 @@ export function getSupportedFieldsByMetricType(type) { return Object.values(KBN_FIELD_TYPES); case METRIC_TYPES.AVERAGE: case METRIC_TYPES.SUM: + case METRIC_TYPES.MIN: + case METRIC_TYPES.MAX: return [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM]; default: return [KBN_FIELD_TYPES.NUMBER]; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js index 10258effbb8836..c009146abb7bd1 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js @@ -26,11 +26,11 @@ describe('getSupportedFieldsByMetricType', () => { shouldSupportAllFieldTypes('value_count'); shouldHaveHistogramAndNumbers('avg'); shouldHaveHistogramAndNumbers('sum'); + shouldHaveHistogramAndNumbers('min'); + shouldHaveHistogramAndNumbers('max'); shouldHaveOnlyNumbers('positive_rate'); shouldHaveOnlyNumbers('std_deviation'); - shouldHaveOnlyNumbers('max'); - shouldHaveOnlyNumbers('min'); it(`should return everything but histogram for cardinality`, () => { expect(getSupportedFieldsByMetricType('cardinality')).not.toContain('histogram'); diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss b/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss index 9c07721fa00b36..198f0f42d503c7 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/_vis_types.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; flex: 1 1 100%; + overflow: auto; .tvbVisTimeSeries { overflow: hidden; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js index e4ab4eaa0a671c..24d0ca1b588f73 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/table/vis.js @@ -8,6 +8,7 @@ import _, { isArray, last, get } from 'lodash'; import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; import PropTypes from 'prop-types'; import { RedirectAppLinks } from '../../../../../../kibana_react/public'; import { createTickFormatter } from '../../lib/tick_formatter'; @@ -88,7 +89,12 @@ class TableVis extends Component { }); return (

{rowDisplay} + {rowDisplay || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + })} +
- {item.labelFormatted ? labelDateFormatter(item.labelFormatted) : item.label} + {label || + i18n.translate('visTypeTimeseries.emptyTextValue', { + defaultMessage: '(empty)', + })}
diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.js index fd8ec0d5a439a1..9ca5ffdfd1c278 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.js @@ -49,6 +49,16 @@ export const SeriesAgg = { }); return [data]; }, + count(targetSeries) { + const data = []; + _.zip(...targetSeries).forEach((row) => { + const key = row[0][0]; + // Filter out undefined or null values + const values = row.map((r) => r && r[1]).filter((v) => v || typeof v === 'number'); + data.push([key, values.length]); + }); + return [data]; + }, overall_max: overall('max'), overall_min: overall('min'), diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.test.js index 3952ecd3edd617..6201e718d42442 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/_series_agg.test.js @@ -67,6 +67,44 @@ describe('seriesAgg', () => { ], ]); }); + + test('returns the count of series', () => { + expect(seriesAgg.count(series)).toEqual([ + [ + [0, 3], + [1, 3], + [2, 3], + ], + ]); + }); + + test('returns the count of missing series', () => { + expect( + seriesAgg.count([ + [ + [0, null], + [1, null], + [2, 0], + ], + [ + [0, 0], + [1, null], + [2, 3], + ], + [ + [0, 2], + [1, null], + [2, 3], + ], + ]) + ).toEqual([ + [ + [0, 2], + [1, 0], + [2, 3], + ], + ]); + }); }); describe('overall', () => { diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index d3647b35a5b94b..5a36390dda0a7f 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -53,6 +53,7 @@ const DEFAULT_PARSER: string = 'elasticsearch'; export class VegaParser { spec: VegaSpec; hideWarnings: boolean; + restoreSignalValuesOnRefresh: boolean; error?: string; warnings: string[]; _urlParsers: UrlParserConfig | undefined; @@ -137,6 +138,8 @@ The URL is an identifier only. Kibana and your browser will never access this UR this._config = this._parseConfig(); this.hideWarnings = !!this._config.hideWarnings; + this._parseBool('restoreSignalValuesOnRefresh', this._config, false); + this.restoreSignalValuesOnRefresh = this._config.restoreSignalValuesOnRefresh; this.useMap = this._config.type === 'map'; this.renderer = this._config.renderer === 'svg' ? 'svg' : 'canvas'; this.tooltips = this._parseTooltips(); diff --git a/src/plugins/vis_type_vega/public/lib/vega_state_restorer.test.ts b/src/plugins/vis_type_vega/public/lib/vega_state_restorer.test.ts new file mode 100644 index 00000000000000..57b352b30dd205 --- /dev/null +++ b/src/plugins/vis_type_vega/public/lib/vega_state_restorer.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { createVegaStateRestorer } from './vega_state_restorer'; + +describe('extractIndexPatternsFromSpec', () => { + test('should create vega state restorer ', async () => { + expect(createVegaStateRestorer()).toMatchInlineSnapshot(` + Object { + "clear": [Function], + "restore": [Function], + "save": [Function], + } + `); + }); + + test('should save state', async () => { + const vegaStateRestorer = createVegaStateRestorer(); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + data: { test: 'test' }, + }); + + expect(vegaStateRestorer.restore()).toMatchInlineSnapshot(` + Object { + "signals": Object { + "foo": "foo", + }, + } + `); + }); + + test('should restore of "data" if "restoreData" is true', () => { + const vegaStateRestorer = createVegaStateRestorer(); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + data: { test: 'test' }, + }); + + expect(vegaStateRestorer.restore(true)).toMatchInlineSnapshot(` + Object { + "data": Object { + "test": "test", + }, + "signals": Object { + "foo": "foo", + }, + } + `); + }); + + test('should clear saved state', () => { + const vegaStateRestorer = createVegaStateRestorer(); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + data: { test: 'test' }, + }); + vegaStateRestorer.clear(); + + expect(vegaStateRestorer.restore(true)).toMatchInlineSnapshot(`null`); + }); + + test('should omit signals', () => { + const vegaStateRestorer = createVegaStateRestorer({ omitSignals: ['foo'] }); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + }); + + expect(vegaStateRestorer.restore()).toMatchInlineSnapshot(` + Object { + "signals": Object {}, + } + `); + }); + test('should not save state if isActive is false', () => { + const vegaStateRestorer = createVegaStateRestorer({ isActive: () => false }); + + vegaStateRestorer.save({ + signals: { foo: 'foo' }, + }); + + expect(vegaStateRestorer.restore()).toMatchInlineSnapshot(`null`); + }); +}); diff --git a/src/plugins/vis_type_vega/public/lib/vega_state_restorer.ts b/src/plugins/vis_type_vega/public/lib/vega_state_restorer.ts new file mode 100644 index 00000000000000..b39f2495656a39 --- /dev/null +++ b/src/plugins/vis_type_vega/public/lib/vega_state_restorer.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { omit } from 'lodash'; + +interface VegaStateRestorerOptions { + /** + * List of excluded signals + * + * By default, all Build-in signals (width,height,padding,autosize,background) were excluded + * @see https://vega.github.io/vega/docs/signals/ + */ + omitSignals?: string[]; + /** + * Gets a value that indicates whether the VegaStateRestorer is active. + */ + isActive?: () => boolean; +} + +type State = Partial<{ + signals: Record; + data: Record; +}>; + +export const createVegaStateRestorer = ({ + omitSignals = ['width', 'height', 'padding', 'autosize', 'background'], + isActive = () => true, +}: VegaStateRestorerOptions = {}) => { + let state: State | null; + + return { + /** + * Save Vega state + * @public + * @param newState - new state value + */ + save: (newState: State) => { + if (newState && isActive()) { + state = { + signals: omit(newState.signals, omitSignals || []), + data: newState.data, + }; + } + }, + + /** + * Restore Vega state + * @public + * @param restoreData - by default, we only recover signals, + * but if the data also needs to be recovered, this option should be set to true + */ + restore: (restoreData = false) => + isActive() && state ? omit(state, restoreData ? undefined : 'data') : null, + + /** + * Clear saved Vega state + * + * @public + */ + clear: () => { + state = null; + }, + }; +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts index 73620d85100a3b..a3376705305480 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts @@ -10,6 +10,7 @@ import { DataPublicPluginStart } from 'src/plugins/data/public'; import { IInterpreterRenderHandlers } from 'src/plugins/expressions'; import { IServiceSettings } from 'src/plugins/maps_legacy/public'; import { VegaParser } from '../data_model/vega_parser'; +import { createVegaStateRestorer } from '../lib/vega_state_restorer'; interface VegaViewParams { parentEl: HTMLDivElement; @@ -18,6 +19,7 @@ interface VegaViewParams { serviceSettings: IServiceSettings; filterManager: DataPublicPluginStart['query']['filterManager']; timefilter: DataPublicPluginStart['query']['timefilter']['timefilter']; + vegaStateRestorer: ReturnType; } export class VegaBaseView { @@ -34,5 +36,6 @@ export class VegaBaseView { _$container: any; _parser: any; _vegaViewConfig: any; - _serviceSettings: any; + _serviceSettings: VegaViewParams['serviceSettings']; + _vegaStateRestorer: VegaViewParams['vegaStateRestorer']; } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index d9b1b536a6d171..14e4d6034c1c22 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -62,6 +62,7 @@ export class VegaBaseView { this._destroyHandlers = []; this._initialized = false; this._enableExternalUrls = getEnableExternalUrls(); + this._vegaStateRestorer = opts.vegaStateRestorer; } async init() { @@ -103,6 +104,10 @@ export class VegaBaseView { this._$messages = null; } if (this._view) { + const state = this._view.getState(); + if (state) { + this._vegaStateRestorer.save(state); + } this._view.finalize(); } this._view = null; @@ -262,7 +267,13 @@ export class VegaBaseView { this._addDestroyHandler(() => tthandler.hideTooltip()); } - return view.runAsync(); // Allows callers to await rendering + const state = this._vegaStateRestorer.restore(); + + if (state) { + return view.setState(state); + } else { + return view.runAsync(); + } } } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts index 21c18e15c242ca..6aac6891ae0e82 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts @@ -100,13 +100,18 @@ describe('vega_map_view/view', () => { async function createVegaMapView() { await vegaParser.parseAsync(); - return new VegaMapView({ + return new VegaMapView(({ vegaParser, filterManager: dataPluginStart.query.filterManager, timefilter: dataPluginStart.query.timefilter.timefilter, fireEvent: (event: any) => {}, parentEl: document.createElement('div'), - } as VegaViewParams); + vegaStateRestorer: { + save: jest.fn(), + restore: jest.fn(), + clear: jest.fn(), + }, + } as unknown) as VegaViewParams); } beforeEach(() => { diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts index c2112659a50ae7..ca936cb49c7e0b 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts @@ -68,11 +68,18 @@ export class VegaMapView extends VegaBaseView { private getMapParams(defaults: { maxZoom: number; minZoom: number }): Partial { const { longitude, latitude, scrollWheelZoom } = this._parser.mapConfig; - const zoomSettings = validateZoomSettings(this._parser.mapConfig, defaults, this.onWarn); + const { zoom, maxZoom, minZoom } = validateZoomSettings( + this._parser.mapConfig, + defaults, + this.onWarn + ); + const { signals } = this._vegaStateRestorer.restore() || {}; return { - ...zoomSettings, - center: [longitude, latitude], + maxZoom, + minZoom, + zoom: signals?.zoom ?? zoom, + center: [signals?.longitude ?? longitude, signals?.latitude ?? latitude], scrollZoom: scrollWheelZoom, }; } diff --git a/src/plugins/vis_type_vega/public/vega_visualization.ts b/src/plugins/vis_type_vega/public/vega_visualization.ts index 63eec674af9844..d207aadec656a6 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.ts +++ b/src/plugins/vis_type_vega/public/vega_visualization.ts @@ -12,6 +12,7 @@ import { VegaParser } from './data_model/vega_parser'; import { VegaVisualizationDependencies } from './plugin'; import { getNotifications, getData } from './services'; import type { VegaView } from './vega_view/vega_view'; +import { createVegaStateRestorer } from './lib/vega_state_restorer'; type VegaVisType = new (el: HTMLDivElement, fireEvent: IInterpreterRenderHandlers['event']) => { render(visData: VegaParser): Promise; @@ -24,6 +25,9 @@ export const createVegaVisualization = ({ class VegaVisualization { private readonly dataPlugin = getData(); private vegaView: InstanceType | null = null; + private vegaStateRestorer = createVegaStateRestorer({ + isActive: () => Boolean(this.vegaView?._parser?.restoreSignalValuesOnRefresh), + }); constructor( private el: HTMLDivElement, @@ -71,6 +75,7 @@ export const createVegaVisualization = ({ const vegaViewParams = { parentEl: this.el, fireEvent: this.fireEvent, + vegaStateRestorer: this.vegaStateRestorer, vegaParser, serviceSettings, filterManager, @@ -89,6 +94,7 @@ export const createVegaVisualization = ({ } destroy() { + this.vegaStateRestorer.clear(); this.vegaView?.destroy(); } }; diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/handler.js b/src/plugins/vis_type_vislib/public/vislib/lib/handler.js index 1f1d971a445462..f3201f60007f06 100644 --- a/src/plugins/vis_type_vislib/public/vislib/lib/handler.js +++ b/src/plugins/vis_type_vislib/public/vislib/lib/handler.js @@ -9,6 +9,7 @@ import d3 from 'd3'; import _ from 'lodash'; import MarkdownIt from 'markdown-it'; +import moment from 'moment'; import { dispatchRenderComplete } from '../../../../kibana_utils/public'; @@ -26,6 +27,10 @@ const markdownIt = new MarkdownIt({ linkify: true, }); +const convertToTimestamp = (date) => { + return parseInt(moment(date).format('x')); +}; + /** * Handles building all the components of the visualization * @@ -80,11 +85,13 @@ export class Handler { case 'brush': const xRaw = _.get(eventPayload.data, 'series[0].values[0].xRaw'); if (!xRaw) return; // not sure if this is possible? + const [start, end] = eventPayload.range; + const range = [convertToTimestamp(start), convertToTimestamp(end)]; return self.vis.emit(eventType, { name: 'brush', data: { table: xRaw.table, - range: eventPayload.range, + range, column: xRaw.column, }, }); diff --git a/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx b/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx index 0c1ab262755a73..c9ed82fcf58e55 100644 --- a/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx +++ b/src/plugins/vis_type_xy/public/components/detailed_tooltip.tsx @@ -27,9 +27,6 @@ interface TooltipData { value: string; } -// TODO: replace when exported from elastic/charts -const DEFAULT_SINGLE_PANEL_SM_VALUE = '__ECH_DEFAULT_SINGLE_PANEL_SM_VALUE__'; - export const getTooltipData = ( aspects: Aspects, header: TooltipValue | null, @@ -81,7 +78,7 @@ export const getTooltipData = ( if ( aspects.splitColumn && valueSeries.smHorizontalAccessorValue !== undefined && - valueSeries.smHorizontalAccessorValue !== DEFAULT_SINGLE_PANEL_SM_VALUE + valueSeries.smHorizontalAccessorValue !== undefined ) { data.push({ label: aspects.splitColumn.title, @@ -92,7 +89,7 @@ export const getTooltipData = ( if ( aspects.splitRow && valueSeries.smVerticalAccessorValue !== undefined && - valueSeries.smVerticalAccessorValue !== DEFAULT_SINGLE_PANEL_SM_VALUE + valueSeries.smVerticalAccessorValue !== undefined ) { data.push({ label: aspects.splitRow.title, diff --git a/src/plugins/vis_type_xy/public/utils/get_legend_actions.tsx b/src/plugins/vis_type_xy/public/utils/get_legend_actions.tsx index 9f557806cf1427..5c28ca77da0c4a 100644 --- a/src/plugins/vis_type_xy/public/utils/get_legend_actions.tsx +++ b/src/plugins/vis_type_xy/public/utils/get_legend_actions.tsx @@ -20,7 +20,7 @@ export const getLegendActions = ( onFilter: (data: ClickTriggerEvent, negate?: any) => void, getSeriesName: (series: XYChartSeriesIdentifier) => SeriesName ): LegendAction => { - return ({ series: xySeries }) => { + return ({ series: [xySeries] }) => { const [popoverOpen, setPopoverOpen] = useState(false); const [isfilterable, setIsfilterable] = useState(false); const series = xySeries as XYChartSeriesIdentifier; diff --git a/src/plugins/vis_type_xy/public/utils/use_color_picker.tsx b/src/plugins/vis_type_xy/public/utils/use_color_picker.tsx index 80e7e12adf799d..5028bc379c375d 100644 --- a/src/plugins/vis_type_xy/public/utils/use_color_picker.tsx +++ b/src/plugins/vis_type_xy/public/utils/use_color_picker.tsx @@ -36,7 +36,7 @@ export const useColorPicker = ( getSeriesName: (series: XYChartSeriesIdentifier) => SeriesName ): LegendColorPicker => useMemo( - () => ({ anchor, color, onClose, onChange, seriesIdentifier }) => { + () => ({ anchor, color, onClose, onChange, seriesIdentifiers: [seriesIdentifier] }) => { const seriesName = getSeriesName(seriesIdentifier as XYChartSeriesIdentifier); const handlChange = (newColor: string | null, event: BaseSyntheticEvent) => { if (!seriesName) { diff --git a/src/plugins/vis_type_xy/public/vis_component.tsx b/src/plugins/vis_type_xy/public/vis_component.tsx index c7ba5021196ff7..ab398101bac9d6 100644 --- a/src/plugins/vis_type_xy/public/vis_component.tsx +++ b/src/plugins/vis_type_xy/public/vis_component.tsx @@ -157,17 +157,12 @@ const VisComponent = (props: VisComponentProps) => { ( visData: Datatable, xAccessor: Accessor | AccessorFn, - splitSeriesAccessors: Array, - splitChartAccessor?: Accessor | AccessorFn + splitSeriesAccessors: Array ) => { const splitSeriesAccessorFnMap = getSplitSeriesAccessorFnMap(splitSeriesAccessors); return (series: XYChartSeriesIdentifier): ClickTriggerEvent | null => { if (xAccessor !== null) { - return getFilterFromSeriesFn(visData)( - series, - splitSeriesAccessorFnMap, - splitChartAccessor - ); + return getFilterFromSeriesFn(visData)(series, splitSeriesAccessorFnMap); } return null; @@ -373,12 +368,7 @@ const VisComponent = (props: VisComponentProps) => { config.aspects.series && (config.aspects.series?.length ?? 0) > 0 ? getLegendActions( canFilter, - getFilterEventData( - visData, - xAccessor, - splitSeriesAccessors, - splitChartColumnAccessor ?? splitChartRowAccessor - ), + getFilterEventData(visData, xAccessor, splitSeriesAccessors), handleFilterAction, getSeriesName ) diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index ff352003609a99..d36b734f75be2e 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -8,7 +8,7 @@ import React from 'react'; -import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; @@ -121,7 +121,7 @@ class NewVisModal extends React.Component ); - return {selectionModal}; + return selectionModal; } private onCloseModal = () => { diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 87660b64bab611..024752188a88b9 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -64,8 +64,8 @@ export const VisualizeListing = () => { }, [history, pathname, visualizations]); useMount(() => { - // Reset editor state if the visualize listing page is loaded. - stateTransferService.clearEditorState(VisualizeConstants.APP_ID); + // Reset editor state for all apps if the visualize listing page is loaded. + stateTransferService.clearEditorState(); chrome.setBreadcrumbs([ { text: i18n.translate('visualize.visualizeListingBreadcrumbsTitle', { diff --git a/test/accessibility/apps/dashboard.ts b/test/accessibility/apps/dashboard.ts index 0171a462b13680..08d577b3df08cd 100644 --- a/test/accessibility/apps/dashboard.ts +++ b/test/accessibility/apps/dashboard.ts @@ -110,12 +110,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('Exit out of edit mode', async () => { - await PageObjects.dashboard.clickDiscardChanges(); + await PageObjects.dashboard.clickDiscardChanges(false); await a11y.testAppSnapshot(); }); it('Discard changes', async () => { - await PageObjects.common.clickConfirmOnModal(); + await testSubjects.exists('dashboardDiscardConfirmDiscard'); + await testSubjects.click('dashboardDiscardConfirmDiscard'); await PageObjects.dashboard.getIsInViewMode(); await a11y.testAppSnapshot(); }); diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index f2f9d24488ac04..5a5158825a2248 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -440,7 +440,7 @@ export default ({ getService }: FtrProviderContext) => { }, { ...BAR_TYPE, - namespaceType: 'multiple', + namespaceType: 'multiple-isolated', convertToMultiNamespaceTypeVersion: '2.0.0', }, BAZ_TYPE, // must be registered for reference transforms to be applied to objects of this type diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index c287e73e3ace9c..2d55e224f31cee 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -10,11 +10,12 @@ import expect from '@kbn/expect'; import { ReportManager, METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; import moment from 'moment'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { SavedObject } from '../../../../src/core/server'; +import { UICounterSavedObjectAttributes } from '../../../../src/plugins/kibana_usage_collection/server/collectors/ui_counters/ui_counter_saved_object_type'; 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) => ({ eventName, @@ -23,12 +24,21 @@ export default function ({ getService }: FtrProviderContext) { count, }); - // FLAKY: https://github.com/elastic/kibana/issues/85086 - describe.skip('UI Counters API', () => { - before(async () => { - await esArchiver.emptyKibanaIndex(); - }); + const getCounterById = ( + savedObjects: Array>, + targetId: string + ): SavedObject => { + const savedObject = savedObjects.find(({ id }: { id: string }) => id === targetId); + if (!savedObject) { + throw new Error(`Unable to find savedObject id ${targetId}`); + } + + return savedObject; + }; + + describe('UI Counters API', () => { const dayDate = moment().format('DDMMYYYY'); + before(async () => await esArchiver.emptyKibanaIndex()); it('stores ui counter events in savedObjects', async () => { const reportManager = new ReportManager(); @@ -44,12 +54,18 @@ export default function ({ getService }: FtrProviderContext) { .send({ report }) .expect(200); - const { body: response } = await es.search({ index: '.kibana', q: 'type:ui-counter' }); + const { + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=ui-counter') + .set('kbn-xsrf', 'kibana') + .expect(200); - const ids = response.hits.hits.map(({ _id }: { _id: string }) => _id); - expect(ids.includes(`ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event`)).to.eql( - true + const countTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:my_event` ); + expect(countTypeEvent.attributes.count).to.eql(1); }); it('supports multiple events', async () => { @@ -70,28 +86,29 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); const { - body: { - hits: { hits }, - }, - } = await es.search({ index: '.kibana', q: 'type:ui-counter' }); - - const countTypeEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}` + body: { saved_objects: savedObjects }, + } = await supertest + .get('/api/saved_objects/_find?type=ui-counter&fields=count') + .set('kbn-xsrf', 'kibana') + .expect(200); + + const countTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}` ); - expect(countTypeEvent._source['ui-counter'].count).to.eql(1); + expect(countTypeEvent.attributes.count).to.eql(1); - const clickTypeEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}` + const clickTypeEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.CLICK}:${uniqueEventName}` ); - expect(clickTypeEvent._source['ui-counter'].count).to.eql(2); + expect(clickTypeEvent.attributes.count).to.eql(2); - const secondEvent = hits.find( - (hit: { _id: string }) => - hit._id === `ui-counter:myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2` + const secondEvent = getCounterById( + savedObjects, + `myApp:${dayDate}:${METRIC_TYPE.COUNT}:${uniqueEventName}_2` ); - expect(secondEvent._source['ui-counter'].count).to.eql(1); + expect(secondEvent.attributes.count).to.eql(1); }); }); } diff --git a/test/common/fixtures/plugins/coverage/kibana.json b/test/common/fixtures/plugins/coverage/kibana.json index d80432534d7467..d849db8d0583d9 100644 --- a/test/common/fixtures/plugins/coverage/kibana.json +++ b/test/common/fixtures/plugins/coverage/kibana.json @@ -1,5 +1,5 @@ { - "id": "coverage-fixtures", + "id": "coverageFixtures", "version": "kibana", "server": false, "ui": true diff --git a/test/functional/apps/dashboard/copy_panel_to.ts b/test/functional/apps/dashboard/copy_panel_to.ts index bb02bfee49f006..9abdc2ceffc013 100644 --- a/test/functional/apps/dashboard/copy_panel_to.ts +++ b/test/functional/apps/dashboard/copy_panel_to.ts @@ -115,7 +115,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.click('confirmCopyToButton'); await PageObjects.dashboard.waitForRenderComplete(); - await PageObjects.dashboard.expectOnDashboard(`Editing New Dashboard (unsaved)`); + await PageObjects.dashboard.expectOnDashboard(`Editing New Dashboard`); }); it('it always appends new panels instead of overwriting', async () => { diff --git a/test/functional/apps/dashboard/dashboard_listing.ts b/test/functional/apps/dashboard/dashboard_listing.ts index f89161ce8c4994..86a3aac1f32c24 100644 --- a/test/functional/apps/dashboard/dashboard_listing.ts +++ b/test/functional/apps/dashboard/dashboard_listing.ts @@ -15,7 +15,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); const listingTable = getService('listingTable'); - describe('dashboard listing page', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/86948 + describe.skip('dashboard listing page', function describeIndexTests() { const dashboardName = 'Dashboard Listing Test'; before(async function () { diff --git a/test/functional/apps/dashboard/dashboard_unsaved_state.ts b/test/functional/apps/dashboard/dashboard_unsaved_state.ts index bf0791d93fb2ce..e6cc91880010ae 100644 --- a/test/functional/apps/dashboard/dashboard_unsaved_state.ts +++ b/test/functional/apps/dashboard/dashboard_unsaved_state.ts @@ -13,13 +13,15 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header', 'visualize', 'settings', 'common']); const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); let originalPanelCount = 0; let unsavedPanelCount = 0; - describe('dashboard unsaved panels', () => { + // FLAKY: https://github.com/elastic/kibana/issues/91191 + describe.skip('dashboard unsaved panels', () => { before(async () => { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ @@ -28,10 +30,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.preserveCrossAppState(); await PageObjects.dashboard.loadSavedDashboard('few panels'); - await PageObjects.dashboard.switchToEditMode(); - + await PageObjects.header.waitUntilLoadingHasFinished(); originalPanelCount = await PageObjects.dashboard.getPanelCount(); + }); + it('does not show unsaved changes badge when there are no unsaved changes', async () => { + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); + + it('shows the unsaved changes badge after adding panels', async () => { + await PageObjects.dashboard.switchToEditMode(); // add an area chart by value await dashboardAddPanel.clickCreateNewLink(); await PageObjects.visualize.clickAggBasedVisualizations(); @@ -41,6 +49,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // add a metric by reference await dashboardAddPanel.addVisualization('Rendering-Test: metric'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); }); it('has correct number of panels', async () => { @@ -72,15 +83,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('resets to original panel count upon entering view mode', async () => { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.header.waitUntilLoadingHasFinished(); const currentPanelCount = await PageObjects.dashboard.getPanelCount(); expect(currentPanelCount).to.eql(originalPanelCount); }); + it('shows unsaved changes badge in view mode if changes have not been discarded', async () => { + await testSubjects.existOrFail('dashboardUnsavedChangesBadge'); + }); + it('retains unsaved panel count after returning to edit mode', async () => { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.switchToEditMode(); + await PageObjects.header.waitUntilLoadingHasFinished(); const currentPanelCount = await PageObjects.dashboard.getPanelCount(); expect(currentPanelCount).to.eql(unsavedPanelCount); }); + + it('does not show unsaved changes badge after saving', async () => { + await PageObjects.dashboard.saveDashboard('Unsaved State Test'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.missingOrFail('dashboardUnsavedChangesBadge'); + }); }); } diff --git a/test/functional/apps/dashboard/view_edit.ts b/test/functional/apps/dashboard/view_edit.ts index 5242e59efa0e99..6c7d60c9a15aa1 100644 --- a/test/functional/apps/dashboard/view_edit.ts +++ b/test/functional/apps/dashboard/view_edit.ts @@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dashboardAddPanel = getService('dashboardAddPanel'); + const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['dashboard', 'header', 'common', 'visualize', 'timePicker']); const dashboardName = 'dashboard with filter'; const filterBar = getService('filterBar'); @@ -74,9 +75,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); await PageObjects.dashboard.clickDiscardChanges(); - // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); - const newTime = await PageObjects.timePicker.getTimeConfig(); expect(newTime.start).to.equal(originalTime.start); @@ -90,9 +88,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickDiscardChanges(); - // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); - const query = await queryBar.getQueryString(); expect(query).to.equal(originalQuery); }); @@ -113,9 +108,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickDiscardChanges(); - // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); - hasFilter = await filterBar.hasFilter('animal', 'dog'); expect(hasFilter).to.be(true); }); @@ -133,12 +125,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { redirectToOrigin: true, }); - await PageObjects.dashboard.clickDiscardChanges(); + await PageObjects.dashboard.clickDiscardChanges(false); // for this sleep see https://github.com/elastic/kibana/issues/22299 await PageObjects.common.sleep(500); // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); + await testSubjects.exists('dashboardDiscardConfirmDiscard'); + await testSubjects.click('dashboardDiscardConfirmDiscard'); const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.eql(originalPanelCount); @@ -150,9 +143,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.addVisualization('new viz panel'); await PageObjects.dashboard.clickDiscardChanges(); - // confirm lose changes - await PageObjects.common.clickConfirmOnModal(); - const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.eql(originalPanelCount); }); @@ -171,9 +161,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sep 19, 2015 @ 06:31:44.000', 'Sep 19, 2015 @ 06:31:44.000' ); - await PageObjects.dashboard.clickDiscardChanges(); + await PageObjects.dashboard.clickDiscardChanges(false); - await PageObjects.common.clickCancelOnModal(); + await testSubjects.exists('dashboardDiscardConfirmCancel'); + await testSubjects.click('dashboardDiscardConfirmCancel'); await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true, }); @@ -200,9 +191,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); const newTime = await PageObjects.timePicker.getTimeConfig(); - await PageObjects.dashboard.clickDiscardChanges(); + await PageObjects.dashboard.clickDiscardChanges(false); - await PageObjects.common.clickCancelOnModal(); + await testSubjects.exists('dashboardDiscardConfirmCancel'); + await testSubjects.click('dashboardDiscardConfirmCancel'); await PageObjects.dashboard.saveDashboard(dashboardName, { storeTimeWithDashboard: true }); await PageObjects.dashboard.loadSavedDashboard(dashboardName); @@ -223,7 +215,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Oct 19, 2014 @ 06:31:44.000', 'Dec 19, 2014 @ 06:31:44.000' ); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickCancelOutOfEditMode(false); await PageObjects.common.expectConfirmModalOpenState(false); }); @@ -235,7 +227,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const originalQuery = await queryBar.getQueryString(); await queryBar.setQuery(`${originalQuery}extra stuff`); - await PageObjects.dashboard.clickCancelOutOfEditMode(); + await PageObjects.dashboard.clickCancelOutOfEditMode(false); await PageObjects.common.expectConfirmModalOpenState(false); diff --git a/test/functional/apps/discover/_source_filters.ts b/test/functional/apps/discover/_source_filters.ts index 273ccbd8bf5af1..4161f7f289dbfd 100644 --- a/test/functional/apps/discover/_source_filters.ts +++ b/test/functional/apps/discover/_source_filters.ts @@ -30,7 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.loadIfNeeded('logstash_functional'); await kibanaServer.uiSettings.update({ - 'discover:searchFieldsFromSource': true, + 'discover:searchFieldsFromSource': false, }); log.debug('discover'); diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index 3a59f45cef8fa8..401f33b789c859 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -15,8 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const appsMenu = getService('appsMenu'); const esArchiver = getService('esArchiver'); - // Failing: See https://github.com/elastic/kibana/issues/88826 - describe.skip('Kibana browser back navigation should work', function describeIndexTests() { + describe('Kibana browser back navigation should work', function describeIndexTests() { before(async () => { await esArchiver.loadIfNeeded('discover'); await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json new file mode 100644 index 00000000000000..6a272dc16e462f --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/data.json @@ -0,0 +1,29 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "test-hidden-importable-exportable:ff3733a0-9fty-11e7-ahb3-3dcb94193fab", + "source": { + "type": "test-hidden-importable-exportable", + "updated_at": "2021-02-11T18:51:23.794Z", + "test-hidden-importable-exportable": { + "title": "Hidden Saved object type that is importable/exportable." + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "test-hidden-non-importable-exportable:op3767a1-9rcg-53u7-jkb3-3dnb74193awc", + "source": { + "type": "test-hidden-non-importable-exportable", + "updated_at": "2021-02-11T18:51:23.794Z", + "test-hidden-non-importable-exportable": { + "title": "Hidden Saved object type that is not importable/exportable." + } + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json new file mode 100644 index 00000000000000..00d349a27795d7 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json @@ -0,0 +1,513 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "test-export-transform": { + "properties": { + "title": { "type": "text" }, + "enabled": { "type": "boolean" } + } + }, + "test-export-add": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-add-dep": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-transform-error": { + "properties": { + "title": { "type": "text" } + } + }, + "test-export-invalid-transform": { + "properties": { + "title": { "type": "text" } + } + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape", + "tree": "quadtree" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "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" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "test-hidden-non-importable-exportable": { + "properties": { + "title": { + "type": "text" + } + } + }, + "test-hidden-importable-exportable": { + "properties": { + "title": { + "type": "text" + } + } + } + } + } + } +} diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 4291d67a6bc082..9c571f0f0ef86b 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -246,14 +246,26 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide return await testSubjects.exists('dashboardEditMode'); } - public async clickCancelOutOfEditMode() { + public async clickCancelOutOfEditMode(accept = true) { log.debug('clickCancelOutOfEditMode'); await testSubjects.click('dashboardViewOnlyMode'); + if (accept) { + const confirmation = await testSubjects.exists('dashboardDiscardConfirmKeep'); + if (confirmation) { + await testSubjects.click('dashboardDiscardConfirmKeep'); + } + } } - public async clickDiscardChanges() { + public async clickDiscardChanges(accept = true) { log.debug('clickDiscardChanges'); - await testSubjects.click('dashboardDiscardChanges'); + await testSubjects.click('dashboardViewOnlyMode'); + if (accept) { + const confirmation = await testSubjects.exists('dashboardDiscardConfirmDiscard'); + if (confirmation) { + await testSubjects.click('dashboardDiscardConfirmDiscard'); + } + } } public async clickQuickSave() { diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index 971bc2d48d22d3..c28d351aa77fbb 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -315,6 +315,18 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv }) ); } + + async getImportErrorsCount() { + log.debug(`Toggling overwriteAll`); + const errorCountNode = await testSubjects.find('importSavedObjectsErrorsCount'); + const errorCountText = await errorCountNode.getVisibleText(); + const match = errorCountText.match(/(\d)+/); + if (!match) { + throw Error(`unable to parse error count from text ${errorCountText}`); + } + + return +match[1]; + } } return new SavedObjectsPage(); diff --git a/test/functional/services/remote/remote.ts b/test/functional/services/remote/remote.ts index f731ffade6efc7..5bf99b4bf1136b 100644 --- a/test/functional/services/remote/remote.ts +++ b/test/functional/services/remote/remote.ts @@ -37,14 +37,21 @@ export async function RemoteProvider({ getService }: FtrProviderContext) { }; const writeCoverage = (coverageJson: string) => { - if (!Fs.existsSync(coverageDir)) { - Fs.mkdirSync(coverageDir, { recursive: true }); + // on CI we make hard link clone and run tests from kibana${process.env.CI_GROUP} root path + const re = new RegExp(`kibana${process.env.CI_GROUP}`, 'g'); + const dir = process.env.CI ? coverageDir.replace(re, 'kibana') : coverageDir; + + if (!Fs.existsSync(dir)) { + Fs.mkdirSync(dir, { recursive: true }); } + const id = coverageCounter++; const timestamp = Date.now(); - const path = resolve(coverageDir, `${id}.${timestamp}.coverage.json`); + const path = resolve(dir, `${id}.${timestamp}.coverage.json`); log.info('writing coverage to', path); - Fs.writeFileSync(path, JSON.stringify(JSON.parse(coverageJson), null, 2)); + + const jsonString = process.env.CI ? coverageJson.replace(re, 'kibana') : coverageJson; + Fs.writeFileSync(path, JSON.stringify(JSON.parse(jsonString), null, 2)); }; const browserConfig: BrowserConfig = { diff --git a/test/plugin_functional/config.ts b/test/plugin_functional/config.ts index bd5ef814ae6c0a..fc747fcd71f17b 100644 --- a/test/plugin_functional/config.ts +++ b/test/plugin_functional/config.ts @@ -30,6 +30,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./test_suites/application_links'), require.resolve('./test_suites/data_plugin'), require.resolve('./test_suites/saved_objects_management'), + require.resolve('./test_suites/saved_objects_hidden_type'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json b/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json new file mode 100644 index 00000000000000..baef662c695d49 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "savedObjectsHiddenType", + "version": "0.0.1", + "kibanaVersion": "kibana", + "configPath": ["saved_objects_hidden_type"], + "server": true, + "ui": false +} diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/package.json b/test/plugin_functional/plugins/saved_objects_hidden_type/package.json new file mode 100644 index 00000000000000..af5212209d574a --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/package.json @@ -0,0 +1,14 @@ +{ + "name": "saved_objects_hidden_type", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/saved_objects_hidden_type", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && ../../../../node_modules/.bin/tsc" + } +} \ No newline at end of file diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts b/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts new file mode 100644 index 00000000000000..2093b6e8449a46 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SavedObjectsHiddenTypePlugin } from './plugin'; + +export const plugin = () => new SavedObjectsHiddenTypePlugin(); diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts b/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts new file mode 100644 index 00000000000000..da2a0a2def1c24 --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/server/plugin.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Plugin, CoreSetup } from 'kibana/server'; + +export class SavedObjectsHiddenTypePlugin implements Plugin { + public setup({ savedObjects }: CoreSetup, deps: {}) { + // example of a SO type that is hidden and importableAndExportable + savedObjects.registerType({ + name: 'test-hidden-importable-exportable', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + title: { type: 'text' }, + }, + }, + management: { + importableAndExportable: true, + }, + }); + + // example of a SO type that is hidden and not importableAndExportable + savedObjects.registerType({ + name: 'test-hidden-non-importable-exportable', + hidden: true, + namespaceType: 'single', + mappings: { + properties: { + title: { type: 'text' }, + }, + }, + management: { + importableAndExportable: false, + }, + }); + } + + public start() {} + public stop() {} +} diff --git a/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json b/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json new file mode 100644 index 00000000000000..da457c9ba32fcc --- /dev/null +++ b/test/plugin_functional/plugins/saved_objects_hidden_type/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "server/**/*.ts", + "../../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../../../src/core/tsconfig.json" } + ] +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts new file mode 100644 index 00000000000000..666afe1acedca6 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/delete.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('should return generic 404 when trying to delete a doc with importableAndExportable types', async () => + await supertest + .delete( + `/api/saved_objects/test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab` + ) + .set('kbn-xsrf', 'true') + .expect(404) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: + 'Saved object [test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab] not found', + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .delete( + `/api/saved_objects/test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc` + ) + .set('kbn-xsrf', 'true') + .expect(404) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: + 'Saved object [test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc] not found', + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts new file mode 100644 index 00000000000000..af25835db5a81a --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/export.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +function ndjsonToObject(input: string): string[] { + return input.split('\n').map((str) => JSON.parse(str)); +} + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('export', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('exports objects with importableAndExportable types', async () => + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-hidden-importable-exportable'], + }) + .expect(200) + .then((resp) => { + const objects = ndjsonToObject(resp.text); + expect(objects).to.have.length(2); + expect(objects[0]).to.have.property('id', 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab'); + expect(objects[0]).to.have.property('type', 'test-hidden-importable-exportable'); + })); + + it('excludes objects with non importableAndExportable types', async () => + await supertest + .post('/api/saved_objects/_export') + .set('kbn-xsrf', 'true') + .send({ + type: ['test-hidden-non-importable-exportable'], + }) + .then((resp) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'Trying to export non-exportable type(s): test-hidden-non-importable-exportable', + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts new file mode 100644 index 00000000000000..723140f5c6bf5a --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/find.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('returns empty response for importableAndExportable types', async () => + await supertest + .get('/api/saved_objects/_find?type=test-hidden-importable-exportable&fields=title') + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .get('/api/saved_objects/_find?type=test-hidden-non-importable-exportable&fields=title') + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts new file mode 100644 index 00000000000000..5de7d8375dd8ce --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/import.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('import', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('imports objects with importableAndExportable type', async () => { + const fileBuffer = Buffer.from( + '{"id":"some-id-1","type":"test-hidden-importable-exportable","attributes":{"title":"my title"},"references":[]}', + 'utf8' + ); + await supertest + .post('/api/saved_objects/_import') + .set('kbn-xsrf', 'true') + .attach('file', fileBuffer, 'export.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 1, + success: true, + warnings: [], + successResults: [ + { + type: 'test-hidden-importable-exportable', + id: 'some-id-1', + meta: { + title: 'my title', + }, + }, + ], + }); + }); + }); + + it('does not import objects with non importableAndExportable type', async () => { + const fileBuffer = Buffer.from( + '{"id":"some-id-1","type":"test-hidden-non-importable-exportable","attributes":{"title":"my title"},"references":[]}', + 'utf8' + ); + await supertest + .post('/api/saved_objects/_import') + .set('kbn-xsrf', 'true') + .attach('file', fileBuffer, 'export.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 0, + success: false, + warnings: [], + errors: [ + { + id: 'some-id-1', + type: 'test-hidden-non-importable-exportable', + title: 'my title', + meta: { + title: 'my title', + }, + error: { + type: 'unsupported_type', + }, + }, + ], + }); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts new file mode 100644 index 00000000000000..00ba74a988cf4c --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ loadTestFile }: PluginFunctionalProviderContext) { + describe('Saved objects with hidden type', function () { + loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./export')); + loadTestFile(require.resolve('./resolve_import_errors')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./interface/saved_objects_management')); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson new file mode 100644 index 00000000000000..a74585c07b8687 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_importable.ndjson @@ -0,0 +1 @@ +{"attributes": { "title": "Hidden Saved object type that is importable/exportable." }, "id":"ff3733a0-9fty-11e7-ahb3-3dcb94193fab", "references":[], "type":"test-hidden-importable-exportable", "version":1} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson new file mode 100644 index 00000000000000..25eea91b8bc435 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/exports/_import_hidden_non_importable.ndjson @@ -0,0 +1 @@ +{"attributes": { "title": "Hidden Saved object type that is not importable/exportable." },"id":"op3767a1-9rcg-53u7-jkb3-3dnb74193awc","references":[],"type":"test-hidden-non-importable-exportable","version":1} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts new file mode 100644 index 00000000000000..dfd0b9dd074769 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/interface/saved_objects_management.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import path from 'path'; +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../../services'; + +export default function ({ getPageObjects, getService }: PluginFunctionalProviderContext) { + const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); + const esArchiver = getService('esArchiver'); + const fixturePaths = { + hiddenImportable: path.join(__dirname, 'exports', '_import_hidden_importable.ndjson'), + hiddenNonImportable: path.join(__dirname, 'exports', '_import_hidden_non_importable.ndjson'), + }; + + describe('Saved objects management Interface', () => { + before(() => esArchiver.emptyKibanaIndex()); + beforeEach(async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + }); + describe('importable/exportable hidden type', () => { + it('imports objects successfully', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + }); + + it('shows test-hidden-importable-exportable in table', async () => { + await PageObjects.savedObjects.searchForObject('type:(test-hidden-importable-exportable)'); + const results = await PageObjects.savedObjects.getTableSummary(); + expect(results.length).to.be(1); + + const { title } = results[0]; + expect(title).to.be( + 'test-hidden-importable-exportable [id=ff3733a0-9fty-11e7-ahb3-3dcb94193fab]' + ); + }); + }); + + describe('non-importable/exportable hidden type', () => { + it('fails to import object', async () => { + await PageObjects.savedObjects.importFile(fixturePaths.hiddenNonImportable); + await PageObjects.savedObjects.checkImportSucceeded(); + + const errorsCount = await PageObjects.savedObjects.getImportErrorsCount(); + expect(errorsCount).to.be(1); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts b/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts new file mode 100644 index 00000000000000..dddee085ae22b0 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_hidden_type/resolve_import_errors.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('export', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('resolves objects with importableAndExportable types', async () => { + const fileBuffer = Buffer.from( + '{"id":"ff3733a0-9fty-11e7-ahb3-3dcb94193fab","type":"test-hidden-importable-exportable","attributes":{"title":"new title!"},"references":[]}', + 'utf8' + ); + + await supertest + .post('/api/saved_objects/_resolve_import_errors') + .set('kbn-xsrf', 'true') + .field( + 'retries', + JSON.stringify([ + { + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + type: 'test-hidden-importable-exportable', + overwrite: true, + }, + ]) + ) + .attach('file', fileBuffer, 'import.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 1, + success: true, + warnings: [], + successResults: [ + { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + meta: { + title: 'new title!', + }, + overwrite: true, + }, + ], + }); + }); + }); + + it('rejects objects with non importableAndExportable types', async () => { + const fileBuffer = Buffer.from( + '{"id":"op3767a1-9rcg-53u7-jkb3-3dnb74193awc","type":"test-hidden-non-importable-exportable","attributes":{"title":"new title!"},"references":[]}', + 'utf8' + ); + + await supertest + .post('/api/saved_objects/_resolve_import_errors') + .set('kbn-xsrf', 'true') + .field( + 'retries', + JSON.stringify([ + { + id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc', + type: 'test-hidden-non-importable-exportable', + overwrite: true, + }, + ]) + ) + .attach('file', fileBuffer, 'import.ndjson') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + successCount: 0, + success: false, + warnings: [], + errors: [ + { + id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc', + type: 'test-hidden-non-importable-exportable', + title: 'new title!', + meta: { + title: 'new title!', + }, + error: { + type: 'unsupported_type', + }, + overwrite: true, + }, + ], + }); + }); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/find.ts b/test/plugin_functional/test_suites/saved_objects_management/find.ts new file mode 100644 index 00000000000000..5dce8f43339a16 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/find.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + it('returns saved objects with importableAndExportable types', async () => + await supertest + .get( + '/api/kibana/management/saved_objects/_find?type=test-hidden-importable-exportable&fields=title' + ) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + attributes: { + title: 'Hidden Saved object type that is importable/exportable.', + }, + references: [], + updated_at: '2021-02-11T18:51:23.794Z', + version: 'WzIsMl0=', + namespaces: ['default'], + score: 0, + meta: { + namespaceType: 'single', + }, + }, + ], + }); + })); + + it('returns empty response for non importableAndExportable types', async () => + await supertest + .get( + '/api/kibana/management/saved_objects/_find?type=test-hidden-non-importable-exportable' + ) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/get.ts b/test/plugin_functional/test_suites/saved_objects_management/get.ts new file mode 100644 index 00000000000000..fa35983df8301a --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/get.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('get', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + const hiddenTypeExportableImportable = + 'test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab'; + const hiddenTypeNonExportableImportable = + 'test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc'; + + it('should return 200 for hidden types that are importableAndExportable', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${hiddenTypeExportableImportable}`) + .set('kbn-xsrf', 'true') + .expect(200) + .then((resp) => { + const { body } = resp; + const { type, id, meta } = body; + expect(type).to.eql('test-hidden-importable-exportable'); + expect(id).to.eql('ff3733a0-9fty-11e7-ahb3-3dcb94193fab'); + expect(meta).to.not.equal(undefined); + })); + + it('should return 404 for hidden types that are not importableAndExportable', async () => + await supertest + .get(`/api/kibana/management/saved_objects/${hiddenTypeNonExportableImportable}`) + .set('kbn-xsrf', 'true') + .expect(404)); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/index.ts b/test/plugin_functional/test_suites/saved_objects_management/index.ts index f6d383e60388db..9f2d28b582f786 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/index.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/index.ts @@ -10,6 +10,9 @@ import { PluginFunctionalProviderContext } from '../../services'; export default function ({ loadTestFile }: PluginFunctionalProviderContext) { describe('Saved Objects Management', function () { + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./scroll_count')); + loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./export_transform')); loadTestFile(require.resolve('./import_warnings')); }); diff --git a/test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts b/test/plugin_functional/test_suites/saved_objects_management/scroll_count.ts new file mode 100644 index 00000000000000..f74cd5b938447d --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/scroll_count.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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const apiUrl = '/api/kibana/management/saved_objects/scroll/counts'; + + describe('scroll_count', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + '../functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + + it('only counts hidden types that are importableAndExportable', async () => { + const res = await supertest + .post(apiUrl) + .set('kbn-xsrf', 'true') + .send({ + typesToInclude: [ + 'test-hidden-non-importable-exportable', + 'test-hidden-importable-exportable', + ], + }) + .expect(200); + + expect(res.body).to.eql({ + 'test-hidden-importable-exportable': 1, + 'test-hidden-non-importable-exportable': 0, + }); + }); + }); + }); +} diff --git a/test/scripts/jenkins_baseline.sh b/test/scripts/jenkins_baseline.sh index 60926238576c77..58d86cddf65fa7 100755 --- a/test/scripts/jenkins_baseline.sh +++ b/test/scripts/jenkins_baseline.sh @@ -7,7 +7,9 @@ echo " -> building and extracting OSS Kibana distributable for use in functional node scripts/build --debug --oss echo " -> shipping metrics from build to ci-stats" -node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json +node scripts/ship_ci_stats \ + --metrics target/optimizer_bundle_metrics.json \ + --metrics packages/kbn-ui-shared-deps/target/metrics.json linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$PARENT_DIR/install/kibana" diff --git a/test/scripts/jenkins_build_kibana.sh b/test/scripts/jenkins_build_kibana.sh index 5819a3ce6765e1..fa0c9522ef5fb1 100755 --- a/test/scripts/jenkins_build_kibana.sh +++ b/test/scripts/jenkins_build_kibana.sh @@ -18,7 +18,9 @@ if [[ -z "$CODE_COVERAGE" ]] ; then node scripts/build --debug --oss echo " -> shipping metrics from build to ci-stats" - node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json + node scripts/ship_ci_stats \ + --metrics target/optimizer_bundle_metrics.json \ + --metrics packages/kbn-ui-shared-deps/target/metrics.json mkdir -p "$WORKSPACE/kibana-build-oss" cp -pR build/oss/kibana-*-SNAPSHOT-linux-x86_64/. $WORKSPACE/kibana-build-oss/ diff --git a/test/scripts/jenkins_ci_group.sh b/test/scripts/jenkins_ci_group.sh index 4faf645975c778..58dcc9f52c0893 100755 --- a/test/scripts/jenkins_ci_group.sh +++ b/test/scripts/jenkins_ci_group.sh @@ -29,14 +29,6 @@ else echo " -> running tests from the clone folder" node scripts/functional_tests --debug --include-tag "ciGroup$CI_GROUP" --exclude-tag "skipCoverage" || true; - if [[ -d target/kibana-coverage/functional ]]; then - echo " -> replacing kibana${CI_GROUP} with kibana in json files" - sed -i "s|kibana${CI_GROUP}|kibana|g" target/kibana-coverage/functional/*.json - echo " -> copying coverage to the original folder" - mkdir -p ../kibana/target/kibana-coverage/functional - mv target/kibana-coverage/functional/* ../kibana/target/kibana-coverage/functional/ - fi - echo " -> moving junit output, silently fail in case of no report" mkdir -p ../kibana/target/junit mv target/junit/* ../kibana/target/junit/ || echo "copying junit failed" diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index 996dfdc4149173..dd9cd71fc3800b 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -29,8 +29,11 @@ if [[ -z "$CODE_COVERAGE" ]] ; then ./test/scripts/checks/test_hardening.sh else echo " -> Running jest tests with coverage" - node scripts/jest --ci --verbose --maxWorkers=6 --coverage || true; + node scripts/jest --ci --verbose --maxWorkers=8 --coverage || true; echo " -> Running jest integration tests with coverage" node scripts/jest_integration --ci --verbose --coverage || true; + + echo " -> Combine code coverage in a single report" + yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.jest.config.js fi diff --git a/test/scripts/jenkins_xpack_baseline.sh b/test/scripts/jenkins_xpack_baseline.sh index aaacdd4ea3aaec..2755a6e0a705dc 100755 --- a/test/scripts/jenkins_xpack_baseline.sh +++ b/test/scripts/jenkins_xpack_baseline.sh @@ -8,7 +8,9 @@ cd "$KIBANA_DIR" node scripts/build --debug --no-oss echo " -> shipping metrics from build to ci-stats" -node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json +node scripts/ship_ci_stats \ + --metrics target/optimizer_bundle_metrics.json \ + --metrics packages/kbn-ui-shared-deps/target/metrics.json linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$KIBANA_DIR/install/kibana" diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 36865ce7c4967a..2887a51f262833 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -34,7 +34,9 @@ if [[ -z "$CODE_COVERAGE" ]] ; then node scripts/build --debug --no-oss echo " -> shipping metrics from build to ci-stats" - node scripts/ship_ci_stats --metrics target/optimizer_bundle_metrics.json + node scripts/ship_ci_stats \ + --metrics target/optimizer_bundle_metrics.json \ + --metrics packages/kbn-ui-shared-deps/target/metrics.json linuxBuild="$(find "$KIBANA_DIR/target" -name 'kibana-*-linux-x86_64.tar.gz')" installDir="$KIBANA_DIR/install/kibana" diff --git a/test/scripts/jenkins_xpack_ci_group.sh b/test/scripts/jenkins_xpack_ci_group.sh index 648605135b3595..0198a5d0ac5fac 100755 --- a/test/scripts/jenkins_xpack_ci_group.sh +++ b/test/scripts/jenkins_xpack_ci_group.sh @@ -25,14 +25,6 @@ else echo " -> running tests from the clone folder" node scripts/functional_tests --debug --include-tag "ciGroup$CI_GROUP" --exclude-tag "skipCoverage" || true; - if [[ -d ../target/kibana-coverage/functional ]]; then - echo " -> replacing kibana${CI_GROUP} with kibana in json files" - sed -i "s|kibana${CI_GROUP}|kibana|g" ../target/kibana-coverage/functional/*.json - echo " -> copying coverage to the original folder" - mkdir -p ../../kibana/target/kibana-coverage/functional - mv ../target/kibana-coverage/functional/* ../../kibana/target/kibana-coverage/functional/ - fi - echo " -> moving junit output, silently fail in case of no report" mkdir -p ../../kibana/target/junit mv ../target/junit/* ../../kibana/target/junit/ || echo "copying junit failed" diff --git a/tsconfig.refs.json b/tsconfig.refs.json index 966ce0494b8c22..8014cb69b2f25e 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -60,6 +60,7 @@ { "path": "./src/plugins/index_pattern_management/tsconfig.json" }, { "path": "./x-pack/plugins/actions/tsconfig.json" }, { "path": "./x-pack/plugins/alerts/tsconfig.json" }, + { "path": "./x-pack/plugins/apm/tsconfig.json" }, { "path": "./x-pack/plugins/beats_management/tsconfig.json" }, { "path": "./x-pack/plugins/canvas/tsconfig.json" }, { "path": "./x-pack/plugins/cloud/tsconfig.json" }, @@ -69,6 +70,7 @@ { "path": "./x-pack/plugins/data_enhanced/tsconfig.json" }, { "path": "./x-pack/plugins/dashboard_mode/tsconfig.json" }, { "path": "./x-pack/plugins/discover_enhanced/tsconfig.json" }, + { "path": "./x-pack/plugins/drilldowns/url_drilldown/tsconfig.json" }, { "path": "./x-pack/plugins/embeddable_enhanced/tsconfig.json" }, { "path": "./x-pack/plugins/encrypted_saved_objects/tsconfig.json" }, { "path": "./x-pack/plugins/enterprise_search/tsconfig.json" }, @@ -86,9 +88,11 @@ { "path": "./x-pack/plugins/lens/tsconfig.json" }, { "path": "./x-pack/plugins/license_management/tsconfig.json" }, { "path": "./x-pack/plugins/licensing/tsconfig.json" }, + { "path": "./x-pack/plugins/logstash/tsconfig.json" }, { "path": "./x-pack/plugins/maps_legacy_licensing/tsconfig.json" }, { "path": "./x-pack/plugins/maps/tsconfig.json" }, { "path": "./x-pack/plugins/ml/tsconfig.json" }, + { "path": "./x-pack/plugins/monitoring/tsconfig.json" }, { "path": "./x-pack/plugins/observability/tsconfig.json" }, { "path": "./x-pack/plugins/osquery/tsconfig.json" }, { "path": "./x-pack/plugins/painless_lab/tsconfig.json" }, @@ -109,10 +113,11 @@ { "path": "./x-pack/plugins/runtime_fields/tsconfig.json" }, { "path": "./x-pack/plugins/index_management/tsconfig.json" }, { "path": "./x-pack/plugins/watcher/tsconfig.json" }, - { "path": "./x-pack/plugins/rollup/tsconfig.json"}, - { "path": "./x-pack/plugins/remote_clusters/tsconfig.json"}, - { "path": "./x-pack/plugins/cross_cluster_replication/tsconfig.json"}, - { "path": "./x-pack/plugins/index_lifecycle_management/tsconfig.json"}, + { "path": "./x-pack/plugins/rollup/tsconfig.json" }, + { "path": "./x-pack/plugins/remote_clusters/tsconfig.json" }, + { "path": "./x-pack/plugins/cross_cluster_replication/tsconfig.json" }, + { "path": "./x-pack/plugins/index_lifecycle_management/tsconfig.json" }, { "path": "./x-pack/plugins/uptime/tsconfig.json" }, + { "path": "./x-pack/plugins/xpack_legacy/tsconfig.json" }, ] } diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index e393f3a5d2150f..1d5fd211f830f1 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -148,9 +148,10 @@ def generateReports(title) { cd .. . src/dev/code_coverage/shell_scripts/extract_archives.sh . src/dev/code_coverage/shell_scripts/fix_html_reports_parallel.sh - . src/dev/code_coverage/shell_scripts/merge_jest_and_functional.sh - # zip combined reports - tar -czf kibana-coverage.tar.gz target/kibana-coverage/**/* + . src/dev/code_coverage/shell_scripts/merge_functional.sh + . src/dev/code_coverage/shell_scripts/copy_jest_report.sh + # zip functional combined report + tar -czf kibana-functional-coverage.tar.gz target/kibana-coverage/functional-combined/* """, title) } @@ -162,7 +163,7 @@ def uploadCombinedReports() { kibanaPipeline.uploadGcsArtifact( "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/coverage/combined", - 'kibana-coverage.tar.gz' + 'kibana-functional-coverage.tar.gz' ) } diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index 5efcea3edb9bb2..7adf755bfc5834 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -188,7 +188,7 @@ def withGcsArtifactUpload(workerName, closure) { def ARTIFACT_PATTERNS = [ 'target/junit/**/*', 'target/kibana-*', - 'target/kibana-coverage/**/*', + 'target/kibana-coverage/jest/**/*', 'target/kibana-security-solution/**/*.png', 'target/test-metrics/*', 'target/test-suites-ci-plan.json', diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 2191b23eec11ee..aab848d4555d2d 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -20,26 +20,13 @@ Table of Contents - [Example](#example) - [Role Based Access-Control](#role-based-access-control) - [Alert Navigation](#alert-navigation) - - [RESTful API](#restful-api) - - [`POST /api/alerts/alert`: Create alert](#post-apialert-create-alert) - - [`DELETE /api/alerts/alert/{id}`: Delete alert](#delete-apialertid-delete-alert) - - [`GET /api/alerts/_find`: Find alerts](#get-apialertfind-find-alerts) - - [`GET /api/alerts/alert/{id}`: Get alert](#get-apialertid-get-alert) + - [Experimental RESTful API](#restful-api) - [`GET /api/alerts/alert/{id}/state`: Get alert state](#get-apialertidstate-get-alert-state) - [`GET /api/alerts/alert/{id}/_instance_summary`: Get alert instance summary](#get-apialertidstate-get-alert-instance-summary) - - [`GET /api/alerts/list_alert_types`: List alert types](#get-apialerttypes-list-alert-types) - - [`PUT /api/alerts/alert/{id}`: Update alert](#put-apialertid-update-alert) - - [`POST /api/alerts/alert/{id}/_enable`: Enable an alert](#post-apialertidenable-enable-an-alert) - - [`POST /api/alerts/alert/{id}/_disable`: Disable an alert](#post-apialertiddisable-disable-an-alert) - - [`POST /api/alerts/alert/{id}/_mute_all`: Mute all alert instances](#post-apialertidmuteall-mute-all-alert-instances) - - [`POST /api/alerts/alert/{alert_id}/alert_instance/{alert_instance_id}/_mute`: Mute alert instance](#post-apialertalertidalertinstancealertinstanceidmute-mute-alert-instance) - - [`POST /api/alerts/alert/{id}/_unmute_all`: Unmute all alert instances](#post-apialertidunmuteall-unmute-all-alert-instances) - - [`POST /api/alerts/alert/{alertId}/alert_instance/{alertInstanceId}/_unmute`: Unmute an alert instance](#post-apialertalertidalertinstancealertinstanceidunmute-unmute-an-alert-instance) - [`POST /api/alerts/alert/{id}/_update_api_key`: Update alert API key](#post-apialertidupdateapikey-update-alert-api-key) - - [Schedule Formats](#schedule-formats) - [Alert instance factory](#alert-instance-factory) - [Templating actions](#templating-actions) - - [Examples](#examples) + - [Examples](#examples) ## Terminology @@ -61,7 +48,7 @@ A Kibana alert detects a condition and executes one or more actions when that co 1. Develop and register an alert type (see alert types -> example). 2. Configure feature level privileges using RBAC -3. Create an alert using the RESTful API (see alerts -> create). +3. Create an alert using the RESTful API [Documentation](https://www.elastic.co/guide/en/kibana/master/alerts-api-update.html) (see alerts -> create). ## Limitations @@ -96,6 +83,7 @@ The following table describes the properties of the `options` object. |validate.params|When developing an alert type, you can choose to accept a series of parameters. You may also have the parameters validated before they are passed to the `executor` function or created as an alert saved object. In order to do this, provide a `@kbn/config-schema` schema that we will use to validate the `params` attribute.|@kbn/config-schema| |executor|This is where the code of the alert type lives. This is a function to be called when executing an alert on an interval basis. For full details, see executor section below.|Function| |producer|The id of the application producing this alert type.|string| +|minimumLicenseRequired|The value of a minimum license. Most of the alerts are licensed as "basic".|string| ### Executor @@ -142,13 +130,13 @@ This example receives server and threshold as parameters. It will read the CPU u ```typescript import { schema } from '@kbn/config-schema'; +import { AlertType, AlertExecutorOptions } from '../../../alerts/server'; import { - Alert, - AlertTypeParams, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext -} from 'x-pack/plugins/alerts/common'; + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, +} from '../../../alerts/common'; ... interface MyAlertTypeParams extends AlertTypeParams { server: string; @@ -156,7 +144,7 @@ interface MyAlertTypeParams extends AlertTypeParams { } interface MyAlertTypeState extends AlertTypeState { - lastChecked: number; + lastChecked: Date; } interface MyAlertTypeInstanceState extends AlertInstanceState { @@ -257,83 +245,6 @@ const myAlertType: AlertType< server.newPlatform.setup.plugins.alerts.registerType(myAlertType); ``` -This example only receives threshold as a parameter. It will read the CPU usage of all the servers and schedule individual actions if the reading for a server is greater than the threshold. This is a better implementation than above as only one query is performed for all the servers instead of one query per server. - -```typescript -server.newPlatform.setup.plugins.alerts.registerType({ - id: 'my-alert-type', - name: 'My alert type', - validate: { - params: schema.object({ - threshold: schema.number({ min: 0, max: 1 }), - }), - }, - actionGroups: [ - { - id: 'default', - name: 'Default', - }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - actionVariables: { - context: [ - { name: 'server', description: 'the server' }, - { name: 'hasCpuUsageIncreased', description: 'boolean indicating if the cpu usage has increased' }, - ], - state: [ - { name: 'cpuUsage', description: 'CPU usage' }, - ], - }, - async executor({ - alertId, - startedAt, - previousStartedAt, - services, - params, - state, - }: AlertExecutorOptions) { - const { threshold } = params; // Let's assume params is { threshold: 0.8 } - - // Call a function to get the CPU readings on all the servers. The result will be - // an array of { server, cpuUsage }. - const cpuUsageByServer = await getCpuUsageByServer(); - - for (const { server, cpuUsage: currentCpuUsage } of cpuUsageByServer) { - // Only execute if CPU usage is greater than threshold - if (currentCpuUsage > threshold) { - // The first argument is a unique identifier the alert instance is about. In this scenario - // the provided server will be used. Also, this id will be used to make `getState()` return - // previous state, if any, on matching identifiers. - const alertInstance = services.alertInstanceFactory(server); - - // State from last execution. This will exist if an alert instance was created and executed - // in the previous execution - const { cpuUsage: previousCpuUsage } = alertInstance.getState(); - - // Replace state entirely with new values - alertInstance.replaceState({ - cpuUsage: currentCpuUsage, - }); - - // 'default' refers to the id of a group of actions to be scheduled for execution, see 'actions' in create alert section - alertInstance.scheduleActions('default', { - server, - hasCpuUsageIncreased: currentCpuUsage > previousCpuUsage, - }); - } - } - - // Single object containing state that isn't specific to a server, this will become available - // within the `state` function parameter at the next execution - return { - lastChecked: new Date(), - }; - }, - producer: 'alerting', -}); -``` - ## Role Based Access-Control Once you have registered your AlertType, you need to grant your users privileges to use it. When registering a feature in Kibana you can specify multiple types of privileges which are granted to users when they're assigned certain roles. @@ -387,29 +298,37 @@ It's important to note that any role can be granted a mix of `all` and `read` pr ```typescript features.registerKibanaFeature({ - id: 'my-application-id', - name: 'My Application', - app: [], - privileges: { - all: { - alerting: { - all: [ - 'my-application-id.my-alert-type', - 'my-application-id.my-restricted-alert-type' - ], - }, - }, - read: { - alerting: { - all: [ - 'my-application-id.my-alert-type' - ] - read: [ - 'my-application-id.my-restricted-alert-type' - ], - }, - }, - }, + id: 'my-application-id', + name: 'My Application', + app: [], + privileges: { + all: { + app: ['my-application-id', 'kibana'], + savedObject: { + all: [], + read: [], + }, + ui: [], + api: [], + }, + read: { + app: ['lens', 'kibana'], + alerting: { + all: [ + 'my-application-id.my-alert-type' + ], + read: [ + 'my-application-id.my-restricted-alert-type' + ], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + api: [], + }, + }, }); ``` @@ -494,46 +413,10 @@ The only case in which this handler will not be used to evaluate the navigation You can use the `registerNavigation` api to specify as many AlertType specific handlers as you like, but you can only use it once per AlertType as we wouldn't know which handler to use if you specified two for the same AlertType. For the same reason, you can only use `registerDefaultNavigation` once per plugin, as it covers all cases for your specific plugin. -## RESTful API - -Using an alert type requires you to create an alert that will contain parameters and actions for a given alert type. See below for CRUD operations using the API. +## Experimental RESTful API -### `POST /api/alerts/alert`: Create alert - -Payload: - -|Property|Description|Type| -|---|---|---| -|enabled|Indicate if you want the alert to start executing on an interval basis after it has been created.|boolean| -|name|A name to reference and search in the future.|string| -|tags|A list of keywords to reference and search in the future.|string[]| -|alertTypeId|The id value of the alert type you want to call when the alert is scheduled to execute.|string| -|schedule|The schedule specifying when this alert should be run, using one of the available schedule formats specified under _Schedule Formats_ below|object| -|throttle|A Duration specifying how often this alert should fire the same actions. This will prevent the alert from sending out the same notification over and over. For example, if an alert with a `schedule` of 1 minute stays in a triggered state for 90 minutes, setting a `throttle` of `10m` or `1h` will prevent it from sending 90 notifications over this period.|string| -|params|The parameters to pass in to the alert type executor `params` value. This will also validate against the alert type params validator if defined.|object| -|actions|Array of the following:
- `group` (string): We support grouping actions in the scenario of escalations or different types of alert instances. If you don't need this, feel free to use `default` as a value.
- `id` (string): The id of the action saved object to execute.
- `params` (object): The map to the `params` the action type will receive. In order to help apply context to strings, we handle them as mustache templates and pass in a default set of context. (see templating actions).|array| - -### `DELETE /api/alerts/alert/{id}`: Delete alert - -Params: - -|Property|Description|Type| -|---|---|---| -|id|The id of the alert you're trying to delete.|string| - -### `GET /api/alerts/_find`: Find alerts - -Params: - -See the saved objects API documentation for find. All the properties are the same except you cannot pass in `type`. - -### `GET /api/alerts/alert/{id}`: Get alert - -Params: - -|Property|Description|Type| -|---|---|---| -|id|The id of the alert you're trying to get.|string| +Using of the alert type requires you to create an alert that will contain parameters and actions for a given alert type. API description for CRUD operations is a part of the [user documentation](https://www.elastic.co/guide/en/kibana/master/alerts-api-update.html). +API listed below is experimental and could be changed or removed in the future. ### `GET /api/alerts/alert/{id}/state`: Get alert state @@ -560,93 +443,12 @@ Query: |---|---|---| |dateStart|The date to start looking for alert events in the event log. Either an ISO date string, or a duration string indicating time since now.|string| -### `GET /api/alerts/list_alert_types`: List alert types - -No parameters. - -### `PUT /api/alerts/alert/{id}`: Update alert - -Params: - -|Property|Description|Type| -|---|---|---| -|id|The id of the alert you're trying to update.|string| - -Payload: - -|Property|Description|Type| -|---|---|---| -|schedule|The schedule specifying when this alert should be run, using one of the available schedule formats specified under _Schedule Formats_ below|object| -|throttle|A Duration specifying how often this alert should fire the same actions. This will prevent the alert from sending out the same notification over and over. For example, if an alert with a `schedule` of 1 minute stays in a triggered state for 90 minutes, setting a `throttle` of `10m` or `1h` will prevent it from sending 90 notifications over this period.|string| -|name|A name to reference and search in the future.|string| -|tags|A list of keywords to reference and search in the future.|string[]| -|params|The parameters to pass in to the alert type executor `params` value. This will also validate against the alert type params validator if defined.|object| -|actions|Array of the following:
- `group` (string): We support grouping actions in the scenario of escalations or different types of alert instances. If you don't need this, feel free to use `default` as a value.
- `id` (string): The id of the action saved object to execute.
- `params` (object): There map to the `params` the action type will receive. In order to help apply context to strings, we handle them as mustache templates and pass in a default set of context. (see templating actions).|array| - -### `POST /api/alerts/alert/{id}/_enable`: Enable an alert - -Params: - -|Property|Description|Type| -|---|---|---| -|id|The id of the alert you're trying to enable.|string| - -### `POST /api/alerts/alert/{id}/_disable`: Disable an alert - -Params: - -|Property|Description|Type| -|---|---|---| -|id|The id of the alert you're trying to disable.|string| - -### `POST /api/alerts/alert/{id}/_mute_all`: Mute all alert instances - -Params: - -|Property|Description|Type| -|---|---|---| -|id|The id of the alert you're trying to mute all alert instances for.|string| - -### `POST /api/alerts/alert/{alert_id}/alert_instance/{alert_instance_id}/_mute`: Mute alert instance - -Params: - -|Property|Description|Type| -|---|---|---| -|alertId|The id of the alert you're trying to mute an instance for.|string| -|alertInstanceId|The instance id of the alert instance you're trying to mute.|string| - -### `POST /api/alerts/alert/{id}/_unmute_all`: Unmute all alert instances - -Params: - -|Property|Description|Type| -|---|---|---| -|id|The id of the alert you're trying to unmute all alert instances for.|string| - -### `POST /api/alerts/alert/{alertId}/alert_instance/{alertInstanceId}/_unmute`: Unmute an alert instance - -Params: - -|Property|Description|Type| -|---|---|---| -|alertId|The id of the alert you're trying to unmute an instance for.|string| -|alertInstanceId|The instance id of the alert instance you're trying to unmute.|string| - ### `POST /api/alerts/alert/{id}/_update_api_key`: Update alert API key |Property|Description|Type| |---|---|---| |id|The id of the alert you're trying to update the API key for. System will use user in request context to generate an API key for.|string| -## Schedule Formats -A schedule is structured such that the key specifies the format you wish to use and its value specifies the schedule. - -We currently support the _Interval format_ which specifies the interval in seconds, minutes, hours or days at which the alert should execute. -Example: `{ interval: "10s" }`, `{ interval: "5m" }`, `{ interval: "1h" }`, `{ interval: "1d" }`. - -There are plans to support multiple other schedule formats in the near future. - ## Alert instance factory **alertInstanceFactory(id)** @@ -694,7 +496,7 @@ When an alert instance executes, the first argument is the `group` of actions to The templating engine is [mustache]. General definition for the [mustache variable] is a double-brace {{}}. All variables are HTML-escaped by default and if there is a requirement to render unescaped HTML, it should be applied the triple mustache: `{{{name}}}`. Also, can be used `&` to unescape a variable. -## Examples +### Examples The following code would be within an alert type. As you can see `cpuUsage ` will replace the state of the alert instance and `server` is the context for the alert instance to execute. The difference between the two is `cpuUsage ` will be accessible at the next execution. diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 8dba4453d56827..5a0745d3f00b7d 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -161,6 +161,7 @@ export class AlertingPlugin { private eventLogService?: IEventLogService; private eventLogger?: IEventLogger; private readonly kibanaIndexConfig: Observable<{ kibana: { index: string } }>; + private kibanaBaseUrl: string | undefined; constructor(initializerContext: PluginInitializerContext) { this.config = initializerContext.config.create().pipe(first()).toPromise(); @@ -176,6 +177,7 @@ export class AlertingPlugin { core: CoreSetup, plugins: AlertingPluginsSetup ): PluginSetupContract { + this.kibanaBaseUrl = core.http.basePath.publicBaseUrl; this.licenseState = new LicenseState(plugins.licensing.license$); this.security = plugins.security; @@ -371,6 +373,7 @@ export class AlertingPlugin { eventLogger: this.eventLogger!, internalSavedObjectsRepository: core.savedObjects.createInternalRepository(['alert']), alertTypeRegistry: this.alertTypeRegistry!, + kibanaBaseUrl: this.kibanaBaseUrl, }); this.eventLogService!.registerSavedObjectProvider('alert', (request) => { diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts index 2f9187b1ccc6aa..36e228ead31da7 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts @@ -12,7 +12,7 @@ import { SavedObjectUnsanitizedDoc } from 'kibana/server'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { migrationMocks } from 'src/core/server/mocks'; -const { log } = migrationMocks.createContext(); +const migrationContext = migrationMocks.createContext(); const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); describe('7.10.0', () => { @@ -26,7 +26,7 @@ describe('7.10.0', () => { test('marks alerts as legacy', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; const alert = getMockData({}); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -42,7 +42,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'metrics', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -59,7 +59,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'securitySolution', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -76,7 +76,7 @@ describe('7.10.0', () => { const alert = getMockData({ consumer: 'alerting', }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -104,7 +104,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -142,7 +142,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -179,7 +179,7 @@ describe('7.10.0', () => { }, ], }); - expect(migration710(alert, { log })).toMatchObject({ + expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, attributes: { ...alert.attributes, @@ -206,7 +206,7 @@ describe('7.10.0', () => { const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; const alert = getMockData(); const dateStart = Date.now(); - const migratedAlert = migration710(alert, { log }); + const migratedAlert = migration710(alert, migrationContext); const dateStop = Date.now(); const dateExecutionStatus = Date.parse( migratedAlert.attributes.executionStatus.lastExecutionDate @@ -242,14 +242,14 @@ describe('7.10.0 migrates with failure', () => { const alert = getMockData({ consumer: 'alerting', }); - const res = migration710(alert, { log }); + const res = migration710(alert, migrationContext); expect(res).toMatchObject({ ...alert, attributes: { ...alert.attributes, }, }); - expect(log.error).toHaveBeenCalledWith( + expect(migrationContext.log.error).toHaveBeenCalledWith( `encryptedSavedObject 7.10.0 migration failed for alert ${alert.id} with error: Can't migrate!`, { alertDocument: { @@ -274,7 +274,7 @@ describe('7.11.0', () => { test('add updatedAt field to alert - set to SavedObject updated_at attribute', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}, true); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -287,7 +287,7 @@ describe('7.11.0', () => { test('add updatedAt field to alert - set to createdAt when SavedObject updated_at is not defined', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -300,7 +300,7 @@ describe('7.11.0', () => { test('add notifyWhen=onActiveAlert when throttle is null', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({}); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, @@ -313,7 +313,7 @@ describe('7.11.0', () => { test('add notifyWhen=onActiveAlert when throttle is set', () => { const migration711 = getMigrations(encryptedSavedObjectsSetup)['7.11.0']; const alert = getMockData({ throttle: '5m' }); - expect(migration711(alert, { log })).toEqual({ + expect(migration711(alert, migrationContext)).toEqual({ ...alert, attributes: { ...alert.attributes, diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index 4de53a38958f48..120ab6de296dd8 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -72,6 +72,7 @@ const createExecutionHandlerParams: jest.Mocked< alertName: 'name-of-alert', tags: ['tag-A', 'tag-B'], apiKey: 'MTIzOmFiYw==', + kibanaBaseUrl: 'http://localhost:5601', alertType, logger: loggingSystemMock.create().get(), eventLogger: mockEventLogger, diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index ad024d7ddd8841..9999ea6a4d3d7c 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -39,6 +39,7 @@ export interface CreateExecutionHandlerOptions< actions: AlertAction[]; spaceId: string; apiKey: RawAlert['apiKey']; + kibanaBaseUrl: string | undefined; alertType: NormalizedAlertType< Params, State, @@ -82,6 +83,7 @@ export function createExecutionHandler< spaceId, apiKey, alertType, + kibanaBaseUrl, eventLogger, request, alertParams, @@ -126,6 +128,7 @@ export function createExecutionHandler< context, actionParams: action.params, state, + kibanaBaseUrl, alertParams, }), }; diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index b227cd2fe53530..bb5e0e5830159d 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -37,6 +37,7 @@ import { Alert, RecoveredActionGroup } from '../../common'; import { omit } from 'lodash'; import { UntypedNormalizedAlertType } from '../alert_type_registry'; import { alertTypeRegistryMock } from '../alert_type_registry.mock'; +import uuid from 'uuid'; const alertType: jest.Mocked = { id: 'test', name: 'My test alert', @@ -96,6 +97,7 @@ describe('Task Runner', () => { eventLogger: eventLoggerMock.create(), internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), alertTypeRegistry, + kibanaBaseUrl: 'https://localhost:5601', }; const mockedAlertTypeSavedObject: Alert = { @@ -1068,6 +1070,86 @@ describe('Task Runner', () => { `); }); + test('should skip alertInstances which werent active on the previous execution', async () => { + const alertId = uuid.v4(); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); + taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + + alertType.executor.mockImplementation( + async ({ + services: executorServices, + }: AlertExecutorOptions< + AlertTypeParams, + AlertTypeState, + AlertInstanceState, + AlertInstanceContext, + string + >) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + + // create an instance, but don't schedule any actions, so it doesn't go active + executorServices.alertInstanceFactory('3'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { meta: {}, state: { bar: false } }, + '2': { meta: {}, state: { bar: false } }, + }, + }, + params: { + alertId, + }, + }, + taskRunnerFactoryInitializerParams + ); + alertsClient.get.mockResolvedValue(mockedAlertTypeSavedObject); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ + id: alertId, + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` + Object { + "1": Object { + "meta": Object { + "lastScheduledActions": Object { + "date": 1970-01-01T00:00:00.000Z, + "group": "default", + "subgroup": undefined, + }, + }, + "state": Object { + "bar": false, + }, + }, + } + `); + + const logger = taskRunnerFactoryInitializerParams.logger; + expect(logger.debug).toHaveBeenCalledWith( + `alert test:${alertId}: 'alert-name' has 1 active alert instances: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + ); + expect(logger.debug).toHaveBeenCalledWith( + `alert test:${alertId}: 'alert-name' has 1 recovered alert instances: [\"2\"]` + ); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); + expect(actionsClient.enqueueExecution).toHaveBeenCalledTimes(2); + expect(actionsClient.enqueueExecution.mock.calls[1][0].id).toEqual('1'); + expect(actionsClient.enqueueExecution.mock.calls[0][0].id).toEqual('2'); + }); + test('fire actions under a custom recovery group when specified on an alert type for alertInstances which are in the recovered state', async () => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 42708c9b7eb54b..744be164519995 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -160,6 +160,7 @@ export class TaskRunner< tags: string[] | undefined, spaceId: string, apiKey: RawAlert['apiKey'], + kibanaBaseUrl: string | undefined, actions: Alert['actions'], alertParams: Params ) { @@ -180,6 +181,7 @@ export class TaskRunner< actions, spaceId, alertType: this.alertType, + kibanaBaseUrl, eventLogger: this.context.eventLogger, request: this.getFakeKibanaRequest(spaceId, apiKey), alertParams, @@ -234,6 +236,7 @@ export class TaskRunner< (rawAlertInstance) => new AlertInstance(rawAlertInstance) ); const originalAlertInstances = cloneDeep(alertInstances); + const originalAlertInstanceIds = new Set(Object.keys(originalAlertInstances)); const eventLogger = this.context.eventLogger; const alertLabel = `${this.alertType.id}:${alertId}: '${name}'`; @@ -282,8 +285,8 @@ export class TaskRunner< ); const recoveredAlertInstances = pickBy( alertInstances, - (alertInstance: AlertInstance) => - !alertInstance.hasScheduledActions() + (alertInstance: AlertInstance, id) => + !alertInstance.hasScheduledActions() && originalAlertInstanceIds.has(id) ); logActiveAndRecoveredInstances({ @@ -387,6 +390,7 @@ export class TaskRunner< alert.tags, spaceId, apiKey, + this.context.kibanaBaseUrl, alert.actions, alert.params ); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts index 175de7384ed467..343dffa0d5e709 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts @@ -77,6 +77,7 @@ describe('Task Runner Factory', () => { eventLogger: eventLoggerMock.create(), internalSavedObjectsRepository: savedObjectsRepositoryMock.create(), alertTypeRegistry: alertTypeRegistryMock.create(), + kibanaBaseUrl: 'https://localhost:5601', }; beforeEach(() => { diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts index 6d8a3aec92636d..a023776134e9cf 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts @@ -40,6 +40,7 @@ export interface TaskRunnerContext { basePathService: IBasePath; internalSavedObjectsRepository: ISavedObjectsRepository; alertTypeRegistry: AlertTypeRegistry; + kibanaBaseUrl: string | undefined; } export class TaskRunnerFactory { diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts index 7e95fee15e700b..4ce30c46cd9f7e 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts @@ -27,6 +27,7 @@ interface TransformActionParamsOptions { actionParams: AlertActionParams; alertParams: AlertTypeParams; state: AlertInstanceState; + kibanaBaseUrl?: string; context: AlertInstanceContext; } @@ -44,6 +45,7 @@ export function transformActionParams({ context, actionParams, state, + kibanaBaseUrl, alertParams, }: TransformActionParamsOptions): AlertActionParams { // when the list of variables we pass in here changes, @@ -61,6 +63,7 @@ export function transformActionParams({ context, date: new Date().toISOString(), state, + kibanaBaseUrl, params: alertParams, }; return actionsPlugin.renderActionParameterTemplates(actionTypeId, actionParams, variables); diff --git a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts deleted file mode 100644 index 313b597e5d4090..00000000000000 --- a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { dateAsStringRt } from './index'; -import { isLeft, isRight } from 'fp-ts/lib/Either'; - -describe('dateAsStringRt', () => { - it('validates whether a string is a valid date', () => { - expect(isLeft(dateAsStringRt.decode(1566299881499))).toBe(true); - - expect(isRight(dateAsStringRt.decode('2019-08-20T11:18:31.407Z'))).toBe( - true - ); - }); - - it('returns the string it was given', () => { - const either = dateAsStringRt.decode('2019-08-20T11:18:31.407Z'); - - if (isRight(either)) { - expect(either.right).toBe('2019-08-20T11:18:31.407Z'); - } else { - fail(); - } - }); -}); diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts new file mode 100644 index 00000000000000..573bfdc83e429e --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isoToEpochRt } from './index'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('isoToEpochRt', () => { + it('validates whether its input is a valid ISO timestamp', () => { + expect(isRight(isoToEpochRt.decode(1566299881499))).toBe(false); + + expect(isRight(isoToEpochRt.decode('2019-08-20T11:18:31.407Z'))).toBe(true); + }); + + it('decodes valid ISO timestamps to epoch time', () => { + const iso = '2019-08-20T11:18:31.407Z'; + const result = isoToEpochRt.decode(iso); + + if (isRight(result)) { + expect(result.right).toBe(new Date(iso).getTime()); + } else { + fail(); + } + }); + + it('encodes epoch time to ISO string', () => { + expect(isoToEpochRt.encode(1566299911407)).toBe('2019-08-20T11:18:31.407Z'); + }); +}); diff --git a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts similarity index 57% rename from x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts rename to x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts index 182399657f6f3c..1a17f82a521413 100644 --- a/x-pack/plugins/apm/common/runtime_types/date_as_string_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts @@ -9,15 +9,20 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; // Checks whether a string is a valid ISO timestamp, -// but doesn't convert it into a Date object when decoding +// and returns an epoch timestamp -export const dateAsStringRt = new t.Type( - 'DateAsString', - t.string.is, +export const isoToEpochRt = new t.Type( + 'isoToEpochRt', + t.number.is, (input, context) => either.chain(t.string.validate(input, context), (str) => { - const date = new Date(str); - return isNaN(date.getTime()) ? t.failure(input, context) : t.success(str); + const epochDate = new Date(str).getTime(); + return isNaN(epochDate) + ? t.failure(input, context) + : t.success(epochDate); }), - t.identity + (a) => { + const d = new Date(a); + return d.toISOString(); + } ); diff --git a/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts new file mode 100644 index 00000000000000..1e6828ed4ead35 --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/to_boolean_rt/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; + +export const toBooleanRt = new t.Type( + 'ToBoolean', + t.boolean.is, + (input) => { + let value: boolean; + if (typeof input === 'string') { + value = input === 'true'; + } else { + value = !!input; + } + + return t.success(value); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts index 4103cb8837cde1..a4632680cb6e18 100644 --- a/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts +++ b/x-pack/plugins/apm/common/runtime_types/to_number_rt/index.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; export const toNumberRt = new t.Type( 'ToNumber', - t.any.is, + t.number.is, (input, context) => { const number = Number(input); return !isNaN(number) ? t.success(number) : t.failure(input, context); diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 31d319bfdbb360..303f6b02c0ea25 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -7,27 +7,34 @@ import { i18n } from '@kbn/i18n'; import cytoscape from 'cytoscape'; -import { - AGENT_NAME, - SERVICE_ENVIRONMENT, - SERVICE_NAME, - SPAN_DESTINATION_SERVICE_RESOURCE, - SPAN_SUBTYPE, - SPAN_TYPE, -} from './elasticsearch_fieldnames'; import { ServiceAnomalyStats } from './anomaly_detection'; +// These should be imported, but until TypeScript 4.2 we're inlining them here. +// All instances of "agent.name", "service.name", "service.environment", "span.type", +// "span.subtype", and "span.destination.service.resource" need to be changed +// back to using the constants. +// See https://github.com/microsoft/TypeScript/issues/37888 +// +// import { +// AGENT_NAME, +// SERVICE_ENVIRONMENT, +// SERVICE_NAME, +// SPAN_DESTINATION_SERVICE_RESOURCE, +// SPAN_SUBTYPE, +// SPAN_TYPE, +// } from './elasticsearch_fieldnames'; + export interface ServiceConnectionNode extends cytoscape.NodeDataDefinition { - [SERVICE_NAME]: string; - [SERVICE_ENVIRONMENT]: string | null; - [AGENT_NAME]: string; + 'service.name': string; + 'service.environment': string | null; + 'agent.name': string; serviceAnomalyStats?: ServiceAnomalyStats; label?: string; } export interface ExternalConnectionNode extends cytoscape.NodeDataDefinition { - [SPAN_DESTINATION_SERVICE_RESOURCE]: string; - [SPAN_TYPE]: string; - [SPAN_SUBTYPE]: string; + 'span.destination.service.resource': string; + 'span.type': string; + 'span.subtype': string; label?: string; } diff --git a/x-pack/plugins/apm/common/ui_settings_keys.ts b/x-pack/plugins/apm/common/ui_settings_keys.ts index 83d358068905e1..427c30605e71b8 100644 --- a/x-pack/plugins/apm/common/ui_settings_keys.ts +++ b/x-pack/plugins/apm/common/ui_settings_keys.ts @@ -5,5 +5,4 @@ * 2.0. */ -export const enableCorrelations = 'apm:enableCorrelations'; export const enableServiceOverview = 'apm:enableServiceOverview'; diff --git a/x-pack/plugins/apm/common/utils/queries.test.ts b/x-pack/plugins/apm/common/utils/queries.test.ts new file mode 100644 index 00000000000000..546c8627def69c --- /dev/null +++ b/x-pack/plugins/apm/common/utils/queries.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SERVICE_ENVIRONMENT } from '../elasticsearch_fieldnames'; +import { ENVIRONMENT_NOT_DEFINED } from '../environment_filter_values'; +import { environmentQuery } from './queries'; + +describe('environmentQuery', () => { + describe('when environment is undefined', () => { + it('returns an empty query', () => { + expect(environmentQuery()).toEqual([]); + }); + }); + + it('creates a query for a service environment', () => { + expect(environmentQuery('test')).toEqual([ + { + term: { [SERVICE_ENVIRONMENT]: 'test' }, + }, + ]); + }); + + it('creates a query for missing service environments', () => { + expect(environmentQuery(ENVIRONMENT_NOT_DEFINED.value)[0]).toHaveProperty( + ['bool', 'must_not', 'exists', 'field'], + SERVICE_ENVIRONMENT + ); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts b/x-pack/plugins/apm/common/utils/queries.ts similarity index 52% rename from x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts rename to x-pack/plugins/apm/common/utils/queries.ts index 3dea3344085c92..dbbbf324b964a9 100644 --- a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts +++ b/x-pack/plugins/apm/common/utils/queries.ts @@ -5,19 +5,41 @@ * 2.0. */ -import { ESFilter } from '../../../../../../typings/elasticsearch'; +import { ESFilter } from '../../../../typings/elasticsearch'; import { ENVIRONMENT_NOT_DEFINED, ENVIRONMENT_ALL, -} from '../../../../common/environment_filter_values'; -import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; +} from '../environment_filter_values'; +import { SERVICE_ENVIRONMENT } from '../elasticsearch_fieldnames'; -export function getEnvironmentUiFilterES(environment?: string): ESFilter[] { +type QueryContainer = ESFilter; + +export function environmentQuery(environment?: string): QueryContainer[] { if (!environment || environment === ENVIRONMENT_ALL.value) { return []; } + if (environment === ENVIRONMENT_NOT_DEFINED.value) { return [{ bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } } }]; } + return [{ term: { [SERVICE_ENVIRONMENT]: environment } }]; } + +export function rangeQuery( + start: number, + end: number, + field = '@timestamp' +): QueryContainer[] { + return [ + { + range: { + [field]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }, + ]; +} diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts index b7e16f71ce0a49..5b4934eac1f712 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts @@ -31,13 +31,13 @@ Then(`breakdown series should appear in chart`, () => { cy.get('.euiLoadingChart').should('not.exist'); cy.get('[data-cy=pageLoadDist]').within(() => { - cy.get('div.echLegendItem__label[title=Chrome] ', DEFAULT_TIMEOUT) + cy.get('button.echLegendItem__label[title=Chrome] ', DEFAULT_TIMEOUT) .invoke('text') .should('eq', 'Chrome'); - cy.get('div.echLegendItem__label', DEFAULT_TIMEOUT).should( + cy.get('button.echLegendItem__label', DEFAULT_TIMEOUT).should( 'have.text', - 'OverallChromeChrome Mobile WebViewSafariFirefoxMobile SafariChrome MobileChrome Mobile iOS' + 'ChromeChrome Mobile WebViewSafariFirefoxMobile SafariChrome MobileChrome Mobile iOSOverall' ); }); }); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index 8d01bfa70bc491..47154ee214dc42 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -52,12 +52,14 @@ Then(`should display percentile for page load chart`, () => { }); Then(`should display chart legend`, () => { - const chartLegend = 'div.echLegendItem__label'; + const chartLegend = 'button.echLegendItem__label'; waitForLoadingToFinish(); cy.get('.euiLoadingChart').should('not.exist'); - cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); + cy.get('[data-cy=pageLoadDist]').within(() => { + cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); + }); }); Then(`should display tooltip on hover`, () => { diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 2625fc2f3c1cd1..fe9294a48893a5 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -25,19 +25,14 @@ ], "server": true, "ui": true, - "configPath": [ - "xpack", - "apm" - ], - "extraPublicDirs": [ - "public/style/variables" - ], + "configPath": ["xpack", "apm"], + "extraPublicDirs": ["public/style/variables"], "requiredBundles": [ + "home", "kibanaReact", "kibanaUtils", - "observability", - "home", "maps", - "ml" + "ml", + "observability" ] } diff --git a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx index 0d44c08355c107..d069d4a11b4942 100644 --- a/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/alerting/transaction_duration_alert_trigger/index.stories.tsx @@ -25,7 +25,7 @@ export default { core: { http: { get: (endpoint: string) => { - if (endpoint === '/api/apm/ui_filters/environments') { + if (endpoint === '/api/apm/environments') { return Promise.resolve(['production']); } else { return Promise.resolve({ diff --git a/x-pack/plugins/apm/public/components/app/Correlations/index.tsx b/x-pack/plugins/apm/public/components/app/Correlations/index.tsx deleted file mode 100644 index c5b2f265fac8c8..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Correlations/index.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState } from 'react'; -import { - EuiButtonEmpty, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutHeader, - EuiTitle, - EuiPortal, - EuiCode, - EuiLink, - EuiCallOut, - EuiButton, -} from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; -import { EuiSpacer } from '@elastic/eui'; -import { isActivePlatinumLicense } from '../../../../common/license_check'; -import { enableCorrelations } from '../../../../common/ui_settings_keys'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; -import { LatencyCorrelations } from './LatencyCorrelations'; -import { ErrorCorrelations } from './ErrorCorrelations'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; -import { createHref } from '../../shared/Links/url_helpers'; -import { useLicenseContext } from '../../../context/license/use_license_context'; - -export function Correlations() { - const { uiSettings } = useApmPluginContext().core; - const { urlParams } = useUrlParams(); - const license = useLicenseContext(); - const history = useHistory(); - const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); - if ( - !uiSettings.get(enableCorrelations) || - !isActivePlatinumLicense(license) - ) { - return null; - } - - return ( - <> - { - setIsFlyoutVisible(true); - }} - > - View correlations - - - - - {isFlyoutVisible && ( - - setIsFlyoutVisible(false)} - > - - -

Correlations

-
-
- - {urlParams.kuery ? ( - <> - - Filtering by - {urlParams.kuery} - - Clear - - - - - ) : null} - - -

- Correlations is an experimental feature and in active - development. Bugs and surprises are to be expected but let us - know your feedback so we can improve it. -

-
- - - - - -
-
-
- )} - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index 9a8c2dffacaf7d..4cd2db43621a8f 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -38,7 +38,7 @@ const Titles = euiStyled.div` const Label = euiStyled.div` margin-bottom: ${px(units.quarter)}; font-size: ${fontSizes.small}; - color: ${({ theme }) => theme.eui.euiColorMediumShade}; + color: ${({ theme }) => theme.eui.euiColorDarkShade}; `; const Message = euiStyled.div` @@ -68,7 +68,7 @@ type ErrorGroupDetailsProps = RouteComponentProps<{ export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { const { serviceName, groupId } = match.params; const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; + const { environment, start, end } = urlParams; const { data: errorGroupData } = useFetcher( (callApmApi) => { @@ -81,6 +81,7 @@ export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { groupId, }, query: { + environment, start, end, uiFilters: JSON.stringify(uiFilters), @@ -89,7 +90,7 @@ export function ErrorGroupDetails({ location, match }: ErrorGroupDetailsProps) { }); } }, - [serviceName, start, end, groupId, uiFilters] + [environment, serviceName, start, end, groupId, uiFilters] ); const { errorDistributionData } = useErrorGroupDistributionFetcher({ diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/Metrics.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/Metrics.tsx new file mode 100644 index 00000000000000..3a9100a0712aab --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/Metrics.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as React from 'react'; +import numeral from '@elastic/numeral'; +import styled from 'styled-components'; +import { useContext, useEffect } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiToolTip, + EuiIconTip, +} from '@elastic/eui'; +import { useFetcher } from '../../../../hooks/use_fetcher'; +import { I18LABELS } from '../translations'; +import { useUxQuery } from '../hooks/useUxQuery'; +import { formatToSec } from '../UXMetrics/KeyUXMetrics'; +import { CsmSharedContext } from '../CsmSharedContext'; + +const ClFlexGroup = styled(EuiFlexGroup)` + flex-direction: row; + @media only screen and (max-width: 768px) { + flex-direction: row; + justify-content: space-between; + } +`; + +function formatTitle(unit: string, value?: number) { + if (typeof value === 'undefined') return I18LABELS.dataMissing; + return formatToSec(value, unit); +} + +function PageViewsTotalTitle({ pageViews }: { pageViews?: number }) { + if (typeof pageViews === 'undefined') { + return <>{I18LABELS.dataMissing}; + } + return pageViews < 10000 ? ( + <>{numeral(pageViews).format('0,0')} + ) : ( + + <>{numeral(pageViews).format('0 a')} + + ); +} + +export function Metrics() { + const uxQuery = useUxQuery(); + + const { data, status } = useFetcher( + (callApmApi) => { + if (uxQuery) { + return callApmApi({ + endpoint: 'GET /api/apm/rum/client-metrics', + params: { + query: { + ...uxQuery, + }, + }, + }); + } + return Promise.resolve(null); + }, + [uxQuery] + ); + + const { setSharedData } = useContext(CsmSharedContext); + + useEffect(() => { + setSharedData({ totalPageViews: data?.pageViews?.value ?? 0 }); + }, [data, setSharedData]); + + const STAT_STYLE = { minWidth: '150px', maxWidth: '250px' }; + + return ( + + + + {I18LABELS.totalPageLoad} + + + } + isLoading={status !== 'success'} + /> + + + + {I18LABELS.backEnd} + + + } + isLoading={status !== 'success'} + /> + + + + {I18LABELS.frontEnd} + + + } + isLoading={status !== 'success'} + /> + + + } + description={I18LABELS.pageViews} + isLoading={status !== 'success'} + /> + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx index 1d6ce21d198c4a..add6ac1b08b281 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx @@ -6,134 +6,36 @@ */ import * as React from 'react'; -import numeral from '@elastic/numeral'; -import styled from 'styled-components'; -import { useContext, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem, - EuiStat, - EuiToolTip, - EuiIconTip, + EuiPanel, + EuiTitle, + EuiSpacer, } from '@elastic/eui'; -import { useFetcher } from '../../../../hooks/use_fetcher'; import { I18LABELS } from '../translations'; -import { useUxQuery } from '../hooks/useUxQuery'; -import { formatToSec } from '../UXMetrics/KeyUXMetrics'; -import { CsmSharedContext } from '../CsmSharedContext'; - -const ClFlexGroup = styled(EuiFlexGroup)` - flex-direction: row; - @media only screen and (max-width: 768px) { - flex-direction: row; - justify-content: space-between; - } -`; - -function formatTitle(unit: string, value?: number) { - if (typeof value === 'undefined') return I18LABELS.dataMissing; - return formatToSec(value, unit); -} - -function PageViewsTotalTitle({ pageViews }: { pageViews?: number }) { - if (typeof pageViews === 'undefined') { - return <>{I18LABELS.dataMissing}; - } - return pageViews < 10000 ? ( - <>{numeral(pageViews).format('0,0')} - ) : ( - - <>{numeral(pageViews).format('0 a')} - - ); -} +import { getPercentileLabel } from '../UXMetrics/translations'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { Metrics } from './Metrics'; export function ClientMetrics() { - const uxQuery = useUxQuery(); - - const { data, status } = useFetcher( - (callApmApi) => { - if (uxQuery) { - return callApmApi({ - endpoint: 'GET /api/apm/rum/client-metrics', - params: { - query: { - ...uxQuery, - }, - }, - }); - } - return Promise.resolve(null); - }, - [uxQuery] - ); - - const { setSharedData } = useContext(CsmSharedContext); - - useEffect(() => { - setSharedData({ totalPageViews: data?.pageViews?.value ?? 0 }); - }, [data, setSharedData]); - - const STAT_STYLE = { width: '240px' }; + const { + urlParams: { percentile }, + } = useUrlParams(); return ( - - - - {I18LABELS.totalPageLoad} - - - } - isLoading={status !== 'success'} - /> - - - - {I18LABELS.backEnd} - - - } - isLoading={status !== 'success'} - /> - - - - {I18LABELS.frontEnd} - - - } - isLoading={status !== 'success'} - /> - - - } - description={I18LABELS.pageViews} - isLoading={status !== 'success'} - /> - - + + + + +

+ {I18LABELS.pageLoad} ({getPercentileLabel(percentile!)}) +

+
+ + +
+
+
); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx index 1a59b7d910b1f9..a558813484807e 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/Filter/FilterTitleButton.tsx @@ -5,11 +5,21 @@ * 2.0. */ -import React from 'react'; -import { EuiButtonEmpty, EuiTitle } from '@elastic/eui'; -import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { EuiButtonEmpty, EuiButtonEmptyProps, EuiTitle } from '@elastic/eui'; +import React, { FunctionComponent } from 'react'; +import { StyledComponent } from 'styled-components'; +import { + euiStyled, + EuiTheme, +} from '../../../../../../../../../src/plugins/kibana_react/common'; -const Button = euiStyled(EuiButtonEmpty).attrs(() => ({ +// The return type of this component needs to be specified because the inferred +// return type depends on types that are not exported from EUI. You get a TS4023 +// error if the return type is not specified. +const Button: StyledComponent< + FunctionComponent, + EuiTheme +> = euiStyled(EuiButtonEmpty).attrs(() => ({ contentProps: { className: 'alignLeft', }, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/index.tsx index 4afecb7623f73e..5b0b475e86e038 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/LocalUIFilters/index.tsx @@ -11,12 +11,14 @@ import { EuiSpacer, EuiHorizontalRule, EuiButtonEmpty, + EuiAccordion, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; import { Filter } from './Filter'; import { useLocalUIFilters } from '../hooks/useLocalUIFilters'; import { LocalUIFilterName } from '../../../../../common/ui_filter'; +import { useBreakPoints } from '../../../../hooks/use_break_points'; interface Props { filterNames: LocalUIFilterName[]; @@ -45,16 +47,20 @@ function LocalUIFilters({ const hasValues = filters.some((filter) => filter.value.length > 0); - return ( + const { isSmall } = useBreakPoints(); + + const title = ( + +

+ {i18n.translate('xpack.apm.localFiltersTitle', { + defaultMessage: 'Filters', + })} +

+
+ ); + + const content = ( <> - -

- {i18n.translate('xpack.apm.localFiltersTitle', { - defaultMessage: 'Filters', - })} -

-
- {children} {filters.map((filter) => { return ( @@ -90,6 +96,18 @@ function LocalUIFilters({ ) : null} ); + + return isSmall ? ( + + {content} + + ) : ( + <> + {title} + + {content} + + ); } export { LocalUIFilters }; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx index 7d34328d2653dd..e3e2a979c48d35 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Panels/MainFilters.tsx @@ -13,6 +13,7 @@ import { useFetcher } from '../../../../hooks/use_fetcher'; import { RUM_AGENT_NAMES } from '../../../../../common/agent_name'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { UserPercentile } from '../UserPercentile'; +import { useBreakPoints } from '../../../../hooks/use_break_points'; export function MainFilters() { const { @@ -37,6 +38,11 @@ export function MainFilters() { [start, end] ); + const { isSmall } = useBreakPoints(); + + // on mobile we want it to take full width + const envStyle = isSmall ? {} : { maxWidth: 200 }; + return ( <> @@ -45,7 +51,7 @@ export function MainFilters() { serviceNames={data ?? []} /> - + diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx index 50d91ca73c249d..a182de8540f584 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumDashboard.tsx @@ -5,46 +5,22 @@ * 2.0. */ -import { - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiSpacer, - EuiPanel, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; -import { ClientMetrics } from './ClientMetrics'; -import { I18LABELS } from './translations'; import { UXMetrics } from './UXMetrics'; import { ImpactfulMetrics } from './ImpactfulMetrics'; import { PageLoadAndViews } from './Panels/PageLoadAndViews'; import { VisitorBreakdownsPanel } from './Panels/VisitorBreakdowns'; import { useBreakPoints } from '../../../hooks/use_break_points'; -import { getPercentileLabel } from './UXMetrics/translations'; -import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { ClientMetrics } from './ClientMetrics'; export function RumDashboard() { - const { - urlParams: { percentile }, - } = useUrlParams(); const { isSmall } = useBreakPoints(); return ( - - - - -

- {I18LABELS.pageLoad} ({getPercentileLabel(percentile!)}) -

-
- - -
-
-
+
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx index 6e3ed54ed2e780..a0b3781a30b209 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/RumHome.tsx @@ -20,24 +20,15 @@ export const UX_LABEL = i18n.translate('xpack.apm.ux.title', { export function RumHome() { return ( - +

{UX_LABEL}

+ - - - - - - +
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx index d3a34a1df25f7a..965449b78f3e08 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx @@ -206,6 +206,7 @@ export function SelectableUrlList({ panelRef={setPopoverRef} button={search} closePopover={closePopover} + style={{ minWidth: 200 }} >
diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx index f2e761128e5cea..b8766e8b5ce67d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/UXMetrics/KeyUXMetrics.tsx @@ -36,7 +36,7 @@ export function formatToSec( } return (valueInMs / 1000).toFixed(2) + ' s'; } -const STAT_STYLE = { width: '240px' }; +const STAT_STYLE = { width: '200px' }; interface Props { data?: UXMetrics | null; @@ -71,7 +71,7 @@ export function KeyUXMetrics({ data, loading }: Props) { // Note: FCP value is in ms unit return ( - + - +

diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts index 3f366300792aca..c40f6ba2b88509 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/hooks/useLocalUIFilters.ts @@ -10,11 +10,11 @@ import { useHistory } from 'react-router-dom'; 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/rum_client/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'; +} from '../../../../../server/lib/rum_client/ui_filters/local_ui_filters/config'; import { fromQuery, toQuery, @@ -72,7 +72,7 @@ export function useLocalUIFilters({ (callApmApi) => { if (shouldFetch && urlParams.start && urlParams.end) { return callApmApi({ - endpoint: `GET /api/apm/ui_filters/local_filters/rumOverview`, + endpoint: 'GET /api/apm/rum/local_filters', params: { query: { uiFilters: JSON.stringify(uiFilters), diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 9abadf2bdb9dbe..7651dba89e27e9 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -27,7 +27,7 @@ export const CytoscapeContext = createContext( undefined ); -interface CytoscapeProps { +export interface CytoscapeProps { children?: ReactNode; elements: cytoscape.ElementDefinition[]; height: number; diff --git a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx index 6554f48ea3c2b3..081a3dbc907c5a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -6,7 +6,7 @@ */ import React, { useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { NotificationsStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { getOptionLabel } from '../../../../../../common/agent_configuration/all_option'; @@ -29,41 +29,39 @@ export function ConfirmDeleteModal({ config, onCancel, onConfirm }: Props) { const { toasts } = useApmPluginContext().core.notifications; return ( - - { + setIsDeleting(true); + await deleteConfig(config, toasts); + setIsDeleting(false); + onConfirm(); + }} + cancelButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.cancel', + { defaultMessage: `Cancel` } + )} + confirmButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.confirm', + { defaultMessage: `Delete` } + )} + confirmButtonDisabled={isDeleting} + buttonColor="danger" + defaultFocusedButton="confirm" + > +

+ {i18n.translate('xpack.apm.agentConfig.deleteModal.text', { + defaultMessage: `You are about to delete the configuration for service "{serviceName}" and environment "{environment}".`, + values: { + serviceName: getOptionLabel(config.service.name), + environment: getOptionLabel(config.service.environment), + }, })} - onCancel={onCancel} - onConfirm={async () => { - setIsDeleting(true); - await deleteConfig(config, toasts); - setIsDeleting(false); - onConfirm(); - }} - cancelButtonText={i18n.translate( - 'xpack.apm.agentConfig.deleteModal.cancel', - { defaultMessage: `Cancel` } - )} - confirmButtonText={i18n.translate( - 'xpack.apm.agentConfig.deleteModal.confirm', - { defaultMessage: `Delete` } - )} - confirmButtonDisabled={isDeleting} - buttonColor="danger" - defaultFocusedButton="confirm" - > -

- {i18n.translate('xpack.apm.agentConfig.deleteModal.text', { - defaultMessage: `You are about to delete the configuration for service "{serviceName}" and environment "{environment}".`, - values: { - serviceName: getOptionLabel(config.service.name), - environment: getOptionLabel(config.service.environment), - }, - })} -

-
-
+

+ ); } diff --git a/x-pack/plugins/apm/public/components/app/Correlations/SignificantTermsTable.tsx b/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx similarity index 51% rename from x-pack/plugins/apm/public/components/app/Correlations/SignificantTermsTable.tsx rename to x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx index f7580cc65c543f..c75c1fb6d96a6b 100644 --- a/x-pack/plugins/apm/public/components/app/Correlations/SignificantTermsTable.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx @@ -5,16 +5,23 @@ * 2.0. */ -import React from 'react'; -import { EuiIcon, EuiLink } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { debounce } from 'lodash'; +import { + EuiIcon, + EuiLink, + EuiBasicTable, + EuiBasicTableColumn, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { useHistory } from 'react-router-dom'; -import { EuiBasicTable } from '@elastic/eui'; -import { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiCode } from '@elastic/eui'; import { asInteger, asPercent } from '../../../../common/utils/formatters'; import { APIReturnType } from '../../../services/rest/createCallApmApi'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { createHref, push } from '../../shared/Links/url_helpers'; +import { ImpactBar } from '../../shared/ImpactBar'; +import { useUiTracker } from '../../../../../observability/public'; type CorrelationsApiResponse = | APIReturnType<'GET /api/apm/correlations/failed_transactions'> @@ -27,49 +34,83 @@ type SignificantTerm = NonNullable< interface Props { significantTerms?: T[]; status: FETCH_STATUS; - cardinalityColumnName: string; + percentageColumnName: string; setSelectedSignificantTerm: (term: T | null) => void; + onFilter: () => void; } -export function SignificantTermsTable({ +export function CorrelationsTable({ significantTerms, status, - cardinalityColumnName, + percentageColumnName, setSelectedSignificantTerm, + onFilter, }: Props) { + const trackApmEvent = useUiTracker({ app: 'apm' }); + const trackSelectSignificantTerm = useCallback( + () => + debounce( + () => trackApmEvent({ metric: 'select_significant_term' }), + 1000 + ), + [trackApmEvent] + ); const history = useHistory(); const columns: Array> = [ { width: '100px', - field: 'score', - name: 'Score', + field: 'impact', + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.impactLabel', + { defaultMessage: 'Impact' } + ), render: (_: any, term: T) => { - return {Math.round(term.score)}; + return ; }, }, { - field: 'cardinality', - name: cardinalityColumnName, + field: 'percentage', + name: percentageColumnName, render: (_: any, term: T) => { - const matches = asPercent(term.fgCount, term.bgCount); - return `${asInteger(term.fgCount)} (${matches})`; + return ( + + <>{asPercent(term.valueCount, term.fieldCount)} + + ); }, }, { field: 'fieldName', - name: 'Field name', + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.fieldNameLabel', + { defaultMessage: 'Field name' } + ), }, { field: 'fieldValue', - name: 'Field value', + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.fieldValueLabel', + { defaultMessage: 'Field value' } + ), render: (_: any, term: T) => String(term.fieldValue).slice(0, 50), }, { width: '100px', actions: [ { - name: 'Focus', - description: 'Focus on this term', + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.filterLabel', + { defaultMessage: 'Filter' } + ), + description: i18n.translate( + 'xpack.apm.correlations.correlationsTable.filterDescription', + { defaultMessage: 'Filter by value' } + ), icon: 'magnifyWithPlus', type: 'icon', onClick: (term: T) => { @@ -80,11 +121,19 @@ export function SignificantTermsTable({ )}"`, }, }); + onFilter(); + trackApmEvent({ metric: 'correlations_term_include_filter' }); }, }, { - name: 'Exclude', - description: 'Exclude this term', + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.excludeLabel', + { defaultMessage: 'Exclude' } + ), + description: i18n.translate( + 'xpack.apm.correlations.correlationsTable.excludeDescription', + { defaultMessage: 'Filter out value' } + ), icon: 'magnifyWithMinus', type: 'icon', onClick: (term: T) => { @@ -95,10 +144,15 @@ export function SignificantTermsTable({ )}"`, }, }); + onFilter(); + trackApmEvent({ metric: 'correlations_term_exclude_filter' }); }, }, ], - name: 'Actions', + name: i18n.translate( + 'xpack.apm.correlations.correlationsTable.actionsLabel', + { defaultMessage: 'Actions' } + ), render: (_: any, term: T) => { return ( <> @@ -134,15 +188,30 @@ export function SignificantTermsTable({ return ( { return { - onMouseEnter: () => setSelectedSignificantTerm(term), + onMouseEnter: () => { + setSelectedSignificantTerm(term); + trackSelectSignificantTerm(); + }, onMouseLeave: () => setSelectedSignificantTerm(null), }; }} /> ); } + +const loadingText = i18n.translate( + 'xpack.apm.correlations.correlationsTable.loadingText', + { defaultMessage: 'Loading' } +); + +const noDataText = i18n.translate( + 'xpack.apm.correlations.correlationsTable.noDataText', + { defaultMessage: 'No data' } +); diff --git a/x-pack/plugins/apm/public/components/app/correlations/custom_fields.tsx b/x-pack/plugins/apm/public/components/app/correlations/custom_fields.tsx new file mode 100644 index 00000000000000..9d7da4c0d30836 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/custom_fields.tsx @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiAccordion, + EuiComboBox, + EuiFormRow, + EuiLink, + EuiSelect, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useEffect, useState } from 'react'; +import { useFieldNames } from './use_field_names'; +import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; +import { useUiTracker } from '../../../../../observability/public'; + +interface Props { + fieldNames: string[]; + setFieldNames: (fieldNames: string[]) => void; + setDurationPercentile?: (value: PercentileOption) => void; + showThreshold?: boolean; + durationPercentile?: PercentileOption; +} + +export type PercentileOption = 50 | 75 | 99; +const percentilOptions: PercentileOption[] = [50, 75, 99]; + +export function CustomFields({ + fieldNames, + setFieldNames, + setDurationPercentile = () => {}, + showThreshold = false, + durationPercentile = 75, +}: Props) { + const trackApmEvent = useUiTracker({ app: 'apm' }); + const { defaultFieldNames, getSuggestions } = useFieldNames(); + const [suggestedFieldNames, setSuggestedFieldNames] = useState( + getSuggestions('') + ); + + useEffect(() => { + if (suggestedFieldNames.length) { + return; + } + setSuggestedFieldNames(getSuggestions('')); + }, [getSuggestions, suggestedFieldNames]); + + return ( + + + + {showThreshold && ( + + + ({ + value: percentile, + text: i18n.translate( + 'xpack.apm.correlations.customize.thresholdPercentile', + { + defaultMessage: '{percentile}th percentile', + values: { percentile }, + } + ), + }))} + onChange={(e) => { + setDurationPercentile( + parseInt(e.target.value, 10) as PercentileOption + ); + }} + /> + + + )} + + { + setFieldNames(defaultFieldNames); + }} + > + {i18n.translate( + 'xpack.apm.correlations.customize.fieldHelpTextReset', + { defaultMessage: 'reset' } + )} + + ), + docsLink: ( + + {i18n.translate( + 'xpack.apm.correlations.customize.fieldHelpTextDocsLink', + { + defaultMessage: + 'Learn more about the default fields.', + } + )} + + ), + }} + /> + } + > + ({ label }))} + onChange={(options) => { + const nextFieldNames = options.map((option) => option.label); + setFieldNames(nextFieldNames); + trackApmEvent({ metric: 'customize_correlations_fields' }); + }} + onCreateOption={(term) => { + const nextFieldNames = [...fieldNames, term]; + setFieldNames(nextFieldNames); + }} + onSearchChange={(searchValue) => { + setSuggestedFieldNames(getSuggestions(searchValue)); + }} + options={suggestedFieldNames.map((label) => ({ label }))} + /> + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx b/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx similarity index 60% rename from x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx rename to x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx index 533373d7e8778f..9b80ee6fc31b85 100644 --- a/x-pack/plugins/apm/public/components/app/Correlations/ErrorCorrelations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/error_correlations.tsx @@ -17,19 +17,19 @@ import { } from '@elastic/charts'; import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; -import { - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiComboBox, - EuiAccordion, -} from '@elastic/eui'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; 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 { px } from '../../../style/variables'; -import { SignificantTermsTable } from './SignificantTermsTable'; +import { CorrelationsTable } from './correlations_table'; import { ChartContainer } from '../../shared/charts/chart_container'; +import { useTheme } from '../../../hooks/use_theme'; +import { CustomFields } from './custom_fields'; +import { useFieldNames } from './use_field_names'; +import { useLocalStorage } from '../../../hooks/useLocalStorage'; +import { useUiTracker } from '../../../../../observability/public'; type CorrelationsApiResponse = NonNullable< APIReturnType<'GET /api/apm/correlations/failed_transactions'> @@ -39,29 +39,30 @@ type SignificantTerm = NonNullable< CorrelationsApiResponse['significantTerms'] >[0]; -const initialFieldNames = [ - 'transaction.name', - 'user.username', - 'user.id', - 'host.ip', - 'user_agent.name', - 'kubernetes.pod.uuid', - 'kubernetes.pod.name', - 'url.domain', - 'container.id', - 'service.node.name', -].map((label) => ({ label })); +interface Props { + onClose: () => void; +} -export function ErrorCorrelations() { +export function ErrorCorrelations({ onClose }: Props) { const [ selectedSignificantTerm, setSelectedSignificantTerm, ] = useState(null); - const [fieldNames, setFieldNames] = useState(initialFieldNames); const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); - const { transactionName, transactionType, start, end } = urlParams; + const { + environment, + transactionName, + transactionType, + start, + end, + } = urlParams; + const { defaultFieldNames } = useFieldNames(); + const [fieldNames, setFieldNames] = useLocalStorage( + `apm.correlations.errors.fields:${serviceName}`, + defaultFieldNames + ); const { data, status } = useFetcher( (callApmApi) => { @@ -70,19 +71,21 @@ export function ErrorCorrelations() { endpoint: 'GET /api/apm/correlations/failed_transactions', params: { query: { + environment, serviceName, transactionName, transactionType, start, end, uiFilters: JSON.stringify(uiFilters), - fieldNames: fieldNames.map((field) => field.label).join(','), + fieldNames: fieldNames.join(','), }, }, }); } }, [ + environment, serviceName, start, end, @@ -93,12 +96,29 @@ export function ErrorCorrelations() { ] ); + const trackApmEvent = useUiTracker({ app: 'apm' }); + trackApmEvent({ metric: 'view_errors_correlations' }); + return ( <> - -

Error rate over time

+ +

+ {i18n.translate('xpack.apm.correlations.error.description', { + defaultMessage: + 'Why are some transactions failing and returning errors? Correlations will help discover a possible culprit in a particular cohort of your data. Either by host, version, or other custom fields.', + })} +

+
+
+ + +

+ {i18n.translate('xpack.apm.correlations.error.chart.title', { + defaultMessage: 'Error rate over time', + })} +

@@ -109,26 +129,20 @@ export function ErrorCorrelations() { /> - - - setFieldNames((names) => [...names, { label: term }]) - } - /> - - - - + + +
); @@ -143,6 +157,7 @@ function ErrorTimeseriesChart({ selectedSignificantTerm: SignificantTerm | null; status: FETCH_STATUS; }) { + const theme = useTheme(); const dateFormatter = timeFormatter('HH:mm:ss'); return ( @@ -164,7 +179,10 @@ function ErrorTimeseriesChart({ /> diff --git a/x-pack/plugins/apm/public/components/app/correlations/index.tsx b/x-pack/plugins/apm/public/components/app/correlations/index.tsx new file mode 100644 index 00000000000000..eba7c42490e066 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/index.tsx @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiButtonEmpty, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiPortal, + EuiCode, + EuiLink, + EuiCallOut, + EuiButton, + EuiTab, + EuiTabs, + EuiSpacer, + EuiBetaBadge, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useHistory } from 'react-router-dom'; +import { LatencyCorrelations } from './latency_correlations'; +import { ErrorCorrelations } from './error_correlations'; +import { useUrlParams } from '../../../context/url_params_context/use_url_params'; +import { createHref } from '../../shared/Links/url_helpers'; +import { + METRIC_TYPE, + useTrackMetric, +} from '../../../../../observability/public'; +import { isActivePlatinumLicense } from '../../../../common/license_check'; +import { useLicenseContext } from '../../../context/license/use_license_context'; +import { LicensePrompt } from '../../shared/LicensePrompt'; + +const latencyTab = { + key: 'latency', + label: i18n.translate('xpack.apm.correlations.tabs.latencyLabel', { + defaultMessage: 'Latency', + }), + component: LatencyCorrelations, +}; +const errorRateTab = { + key: 'errorRate', + label: i18n.translate('xpack.apm.correlations.tabs.errorRateLabel', { + defaultMessage: 'Error rate', + }), + component: ErrorCorrelations, +}; +const tabs = [latencyTab, errorRateTab]; + +export function Correlations() { + const { urlParams } = useUrlParams(); + const history = useHistory(); + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [currentTab, setCurrentTab] = useState(latencyTab.key); + const { component: TabContent } = + tabs.find((tab) => tab.key === currentTab) ?? latencyTab; + + return ( + <> + { + setIsFlyoutVisible(true); + }} + iconType="visTagCloud" + > + {i18n.translate('xpack.apm.correlations.buttonLabel', { + defaultMessage: 'View correlations', + })} + + + {isFlyoutVisible && ( + + setIsFlyoutVisible(false)} + > + + +

+ {CORRELATIONS_TITLE} +   + +

+
+
+ + + {urlParams.kuery ? ( + <> + + + {i18n.translate( + 'xpack.apm.correlations.filteringByLabel', + { defaultMessage: 'Filtering by' } + )} + + {urlParams.kuery} + + + {i18n.translate( + 'xpack.apm.correlations.clearFiltersLabel', + { defaultMessage: 'Clear' } + )} + + + + + + ) : null} + + + + {tabs.map(({ key, label }) => ( + { + setCurrentTab(key); + }} + > + {label} + + ))} + + + setIsFlyoutVisible(false)} /> + + +
+
+ )} + + ); +} + +const CORRELATIONS_TITLE = i18n.translate('xpack.apm.correlations.title', { + defaultMessage: 'Correlations', +}); + +function CorrelationsMetricsLicenseCheck({ + children, +}: { + children: React.ReactNode; +}) { + const license = useLicenseContext(); + const hasActivePlatinumLicense = isActivePlatinumLicense(license); + + const metric = { + app: 'apm' as const, + metric: hasActivePlatinumLicense + ? 'correlations_flyout_view' + : 'correlations_license_prompt', + metricType: METRIC_TYPE.COUNT as METRIC_TYPE.COUNT, + }; + useTrackMetric(metric); + useTrackMetric({ ...metric, delay: 15000 }); + + return ( + <> + {hasActivePlatinumLicense ? ( + children + ) : ( + + )} + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Correlations/LatencyCorrelations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx similarity index 61% rename from x-pack/plugins/apm/public/components/app/Correlations/LatencyCorrelations.tsx rename to x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index 19f6248f56da61..459df99a62f5a2 100644 --- a/x-pack/plugins/apm/public/components/app/Correlations/LatencyCorrelations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -15,21 +15,19 @@ import { } from '@elastic/charts'; import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; -import { - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiComboBox, - EuiAccordion, - EuiFormRow, - EuiFieldNumber, -} from '@elastic/eui'; +import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { getDurationFormatter } from '../../../../common/utils/formatters'; 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 { SignificantTermsTable } from './SignificantTermsTable'; +import { CorrelationsTable } from './correlations_table'; import { ChartContainer } from '../../shared/charts/chart_container'; +import { useTheme } from '../../../hooks/use_theme'; +import { CustomFields, PercentileOption } from './custom_fields'; +import { useFieldNames } from './use_field_names'; +import { useLocalStorage } from '../../../hooks/useLocalStorage'; +import { useUiTracker } from '../../../../../observability/public'; type CorrelationsApiResponse = NonNullable< APIReturnType<'GET /api/apm/correlations/slow_transactions'> @@ -39,29 +37,37 @@ type SignificantTerm = NonNullable< CorrelationsApiResponse['significantTerms'] >[0]; -const initialFieldNames = [ - 'user.username', - 'user.id', - 'host.ip', - 'user_agent.name', - 'kubernetes.pod.uuid', - 'kubernetes.pod.name', - 'url.domain', - 'container.id', - 'service.node.name', -].map((label) => ({ label })); - -export function LatencyCorrelations() { +interface Props { + onClose: () => void; +} + +export function LatencyCorrelations({ onClose }: Props) { const [ selectedSignificantTerm, setSelectedSignificantTerm, ] = useState(null); - const [fieldNames, setFieldNames] = useState(initialFieldNames); - const [durationPercentile, setDurationPercentile] = useState('50'); const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); - const { transactionName, transactionType, start, end } = urlParams; + const { + environment, + transactionName, + transactionType, + start, + end, + } = urlParams; + const { defaultFieldNames } = useFieldNames(); + const [fieldNames, setFieldNames] = useLocalStorage( + `apm.correlations.latency.fields:${serviceName}`, + defaultFieldNames + ); + const [ + durationPercentile, + setDurationPercentile, + ] = useLocalStorage( + `apm.correlations.latency.threshold:${serviceName}`, + 75 + ); const { data, status } = useFetcher( (callApmApi) => { @@ -70,20 +76,22 @@ export function LatencyCorrelations() { endpoint: 'GET /api/apm/correlations/slow_transactions', params: { query: { + environment, serviceName, transactionName, transactionType, start, end, uiFilters: JSON.stringify(uiFilters), - durationPercentile, - fieldNames: fieldNames.map((field) => field.label).join(','), + durationPercentile: durationPercentile.toString(10), + fieldNames: fieldNames.join(','), }, }, }); } }, [ + environment, serviceName, start, end, @@ -95,14 +103,32 @@ export function LatencyCorrelations() { ] ); + const trackApmEvent = useUiTracker({ app: 'apm' }); + trackApmEvent({ metric: 'view_latency_correlations' }); + return ( <> + + +

+ {i18n.translate('xpack.apm.correlations.latency.description', { + defaultMessage: + 'What is slowing down my service? Correlations will help discover a slower performance in a particular cohort of your data. Either by host, version, or other custom fields.', + })} +

+
+
- -

Latency distribution

+ +

+ {i18n.translate( + 'xpack.apm.correlations.latency.chart.title', + { defaultMessage: 'Latency distribution' } + )} +

- - - - - - setDurationPercentile(e.currentTarget.value) - } - /> - - - - - { - setFieldNames((names) => [...names, { label: term }]); - }} - /> - - - - - - - + + +
@@ -181,6 +187,7 @@ function LatencyDistributionChart({ selectedSignificantTerm: SignificantTerm | null; status: FETCH_STATUS; }) { + const theme = useTheme(); const xMax = Math.max( ...(data?.overall?.distribution.map((p) => p.x ?? 0) ?? []) ); @@ -218,7 +225,10 @@ function LatencyDistributionChart({ /> `${roundFloat(d)}%`} diff --git a/x-pack/plugins/apm/public/components/app/correlations/use_field_names.ts b/x-pack/plugins/apm/public/components/app/correlations/use_field_names.ts new file mode 100644 index 00000000000000..ff88808c51d15a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/use_field_names.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { memoize } from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; +import { isRumAgentName } from '../../../../common/agent_name'; +import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; +import { useDynamicIndexPatternFetcher } from '../../../hooks/use_dynamic_index_pattern'; + +interface IndexPattern { + fields: Array<{ name: string; esTypes: string[] }>; +} + +export function useFieldNames() { + const { agentName } = useApmServiceContext(); + const isRumAgent = isRumAgentName(agentName); + const { indexPattern } = useDynamicIndexPatternFetcher(); + + const [defaultFieldNames, setDefaultFieldNames] = useState( + getDefaultFieldNames(indexPattern, isRumAgent) + ); + + const getSuggestions = useMemo( + () => + memoize((searchValue: string) => + getMatchingFieldNames(indexPattern, searchValue) + ), + [indexPattern] + ); + + useEffect(() => { + setDefaultFieldNames(getDefaultFieldNames(indexPattern, isRumAgent)); + }, [indexPattern, isRumAgent]); + + return { defaultFieldNames, getSuggestions }; +} + +function getMatchingFieldNames( + indexPattern: IndexPattern | undefined, + inputValue: string +) { + if (!indexPattern) { + return []; + } + return indexPattern.fields + .filter( + ({ name, esTypes }) => + name.startsWith(inputValue) && esTypes[0] === 'keyword' // only show fields of type 'keyword' + ) + .map(({ name }) => name); +} + +function getDefaultFieldNames( + indexPattern: IndexPattern | undefined, + isRumAgent: boolean +) { + const labelFields = getMatchingFieldNames(indexPattern, 'labels.').slice( + 0, + 6 + ); + return isRumAgent + ? [ + ...labelFields, + 'user_agent.name', + 'user_agent.os.name', + 'url.original', + ...getMatchingFieldNames(indexPattern, 'user.').slice(0, 6), + ] + : [...labelFields, 'service.version', 'service.node.name', 'host.ip']; +} diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 29bdf6467e5447..bde23eddaa44fa 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -28,7 +28,7 @@ interface ErrorGroupOverviewProps { export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { const { urlParams, uiFilters } = useUrlParams(); - const { start, end, sortField, sortDirection } = urlParams; + const { environment, start, end, sortField, sortDirection } = urlParams; const { errorDistributionData } = useErrorGroupDistributionFetcher({ serviceName, groupId: undefined, @@ -46,6 +46,7 @@ export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { serviceName, }, query: { + environment, start, end, sortField, @@ -56,7 +57,7 @@ export function ErrorGroupOverview({ serviceName }: ErrorGroupOverviewProps) { }); } }, - [serviceName, start, end, sortField, sortDirection, uiFilters] + [environment, serviceName, start, end, sortField, sortDirection, uiFilters] ); useTrackPageview({ 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 23f699b63d207f..d2d5c9f6f3a9ab 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 @@ -26,6 +26,7 @@ import { ServiceNodeOverview } from '../service_node_overview'; import { ServiceMetrics } from '../service_metrics'; import { ServiceOverview } from '../service_overview'; import { TransactionOverview } from '../transaction_overview'; +import { Correlations } from '../correlations'; interface Tab { key: string; @@ -137,6 +138,9 @@ export function ServiceDetailTabs({ serviceName, tab }: Props) { {text} ))} +
+ +
{selectedTab ? selectedTab.render() : null} 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 1cb420a8ac1949..cd17ca0ce023d2 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 @@ -39,19 +39,24 @@ function useServicesFetcher() { const { urlParams, uiFilters } = useUrlParams(); const { core } = useApmPluginContext(); const upgradeAssistantHref = useUpgradeAssistantHref(); - const { start, end } = urlParams; + const { environment, start, end } = urlParams; const { data = initialData, status } = useFetcher( (callApmApi) => { if (start && end) { return callApmApi({ endpoint: 'GET /api/apm/services', params: { - query: { start, end, uiFilters: JSON.stringify(uiFilters) }, + query: { + environment, + start, + end, + uiFilters: JSON.stringify(uiFilters), + }, }, }); } }, - [start, end, uiFilters] + [environment, start, end, uiFilters] ); useEffect(() => { @@ -118,7 +123,7 @@ export function ServiceInventory() { return ( <> - + {displayMlCallout ? ( 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 69b4149625824d..419b66da5d2220 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,10 +20,12 @@ import { MockApmPluginContextWrapper, } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; +import { clearCache } from '../../../services/rest/callApi'; 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'; import * as hook from './use_anomaly_detection_jobs_fetcher'; +import { TimeRangeComparisonType } from '../../shared/time_comparison/get_time_range_comparison'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -55,10 +57,10 @@ function wrapper({ children }: { children?: ReactNode }) { params={{ rangeFrom: 'now-15m', rangeTo: 'now', - start: 'mystart', - end: 'myend', + start: '2021-02-12T13:20:43.344Z', + end: '2021-02-12T13:20:58.344Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, }} > {children} @@ -74,6 +76,7 @@ describe('ServiceInventory', () => { beforeEach(() => { // @ts-expect-error global.sessionStorage = new SessionStorageMock(); + clearCache(); jest.spyOn(hook, 'useAnomalyDetectionJobsFetcher').mockReturnValue({ anomalyDetectionJobsStatus: FETCH_STATUS.SUCCESS, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 2214dc2a5e034f..a0ea00ae5c3ad8 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -18,7 +18,6 @@ import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { SearchBar } from '../../shared/search_bar'; -import { UserExperienceCallout } from '../transaction_overview/user_experience_callout'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewInstancesChartAndTable } from './service_overview_instances_chart_and_table'; @@ -58,14 +57,9 @@ export function ServiceOverview({ return ( - + - {isRumAgent && ( - - - - )} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx index cb77c0fa1346fb..999718e754c619 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx @@ -80,21 +80,21 @@ describe('ServiceOverview', () => { status: FETCH_STATUS.SUCCESS, }); + /* eslint-disable @typescript-eslint/naming-convention */ const calls = { - // eslint-disable-next-line @typescript-eslint/naming-convention 'GET /api/apm/services/{serviceName}/error_groups': { error_groups: [], total_error_groups: 0, }, - 'GET /api/apm/services/{serviceName}/transactions/groups/overview': { + 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics': { transactionGroups: [], totalTransactionGroups: 0, isAggregationAccurate: true, }, 'GET /api/apm/services/{serviceName}/dependencies': [], - // eslint-disable-next-line @typescript-eslint/naming-convention 'GET /api/apm/services/{serviceName}/service_overview_instances': [], }; + /* eslint-enable @typescript-eslint/naming-convention */ jest .spyOn(callApmApiModule, 'createCallApmApi') diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index baae683e2eba95..2f37e8e4238d8b 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -14,10 +14,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { - ENVIRONMENT_ALL, - getNextEnvironmentUrlParam, -} from '../../../../../common/environment_filter_values'; +import { getNextEnvironmentUrlParam } from '../../../../../common/environment_filter_values'; import { asMillisecondDuration, asPercent, @@ -182,7 +179,7 @@ export function ServiceOverviewDependenciesTable({ serviceName }: Props) { query: { start, end, - environment: environment || ENVIRONMENT_ALL.value, + environment, numBuckets: 20, }, }, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index 7f4823c13d593a..f7f5db32e986cc 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -52,7 +52,7 @@ const DEFAULT_SORT = { export function ServiceOverviewErrorsTable({ serviceName }: Props) { const { - urlParams: { start, end }, + urlParams: { environment, start, end }, uiFilters, } = useUrlParams(); const { transactionType } = useApmServiceContext(); @@ -152,6 +152,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { params: { path: { serviceName }, query: { + environment, start, end, uiFilters: JSON.stringify(uiFilters), @@ -178,6 +179,7 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { }); }, [ + environment, start, end, serviceName, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index e7c1d4442e3b7c..819d65a5d9415e 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -25,7 +25,7 @@ export function ServiceOverviewInstancesChartAndTable({ const { transactionType } = useApmServiceContext(); const { - urlParams: { start, end }, + urlParams: { environment, start, end }, uiFilters, } = useUrlParams(); @@ -43,6 +43,7 @@ export function ServiceOverviewInstancesChartAndTable({ serviceName, }, query: { + environment, start, end, transactionType, @@ -52,7 +53,7 @@ export function ServiceOverviewInstancesChartAndTable({ }, }); }, - [start, end, serviceName, transactionType, uiFilters] + [environment, start, end, serviceName, transactionType, uiFilters] ); return ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index d70dae5ae63166..2d38ce2c23ca7c 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -16,6 +16,11 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { TimeseriesChart } from '../../shared/charts/timeseries_chart'; +const INITIAL_STATE = { + currentPeriod: [], + previousPeriod: [], +}; + export function ServiceOverviewThroughputChart({ height, }: { @@ -25,9 +30,9 @@ export function ServiceOverviewThroughputChart({ const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); const { transactionType } = useApmServiceContext(); - const { start, end } = urlParams; + const { environment, start, end } = urlParams; - const { data, status } = useFetcher( + const { data = INITIAL_STATE, status } = useFetcher( (callApmApi) => { if (serviceName && transactionType && start && end) { return callApmApi({ @@ -37,6 +42,7 @@ export function ServiceOverviewThroughputChart({ serviceName, }, query: { + environment, start, end, transactionType, @@ -46,7 +52,7 @@ export function ServiceOverviewThroughputChart({ }); } }, - [serviceName, start, end, uiFilters, transactionType] + [environment, serviceName, start, end, uiFilters, transactionType] ); return ( @@ -65,7 +71,7 @@ export function ServiceOverviewThroughputChart({ fetchStatus={status} timeseries={[ { - data: data?.throughput ?? [], + data: data.currentPeriod, type: 'linemark', color: theme.eui.euiColorVis0, title: i18n.translate( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/get_columns.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/get_columns.tsx new file mode 100644 index 00000000000000..2ffc0fc9c93a3a --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/get_columns.tsx @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBasicTableColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ValuesType } from 'utility-types'; +import { + asMillisecondDuration, + asPercent, + asTransactionRate, +} from '../../../../../common/utils/formatters'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; +import { px, unit } from '../../../../style/variables'; +import { SparkPlot } from '../../../shared/charts/spark_plot'; +import { ImpactBar } from '../../../shared/ImpactBar'; +import { TransactionDetailLink } from '../../../shared/Links/apm/transaction_detail_link'; +import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; + +type TransactionGroupPrimaryStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics'>; + +type ServiceTransactionGroupItem = ValuesType< + TransactionGroupPrimaryStatistics['transactionGroups'] +>; +type TransactionGroupComparisonStatistics = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics'>; + +function getLatencyAggregationTypeLabel(latencyAggregationType?: string) { + switch (latencyAggregationType) { + case 'avg': + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.avg', + { defaultMessage: 'Latency (avg.)' } + ); + + case 'p95': + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p95', + { defaultMessage: 'Latency (95th)' } + ); + + case 'p99': + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p99', + { defaultMessage: 'Latency (99th)' } + ); + } +} + +export function getColumns({ + serviceName, + latencyAggregationType, + transactionGroupComparisonStatistics, +}: { + serviceName: string; + latencyAggregationType?: string; + transactionGroupComparisonStatistics?: TransactionGroupComparisonStatistics; +}): Array> { + return [ + { + field: 'name', + sortable: true, + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnName', + { defaultMessage: 'Name' } + ), + render: (_, { name, transactionType: type }) => { + return ( + + {name} + + } + /> + ); + }, + }, + { + field: 'latency', + sortable: true, + name: getLatencyAggregationTypeLabel(latencyAggregationType), + width: px(unit * 10), + render: (_, { latency, name }) => { + const timeseries = + transactionGroupComparisonStatistics?.[name]?.latency; + return ( + + ); + }, + }, + { + field: 'throughput', + sortable: true, + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnThroughput', + { defaultMessage: 'Throughput' } + ), + width: px(unit * 10), + render: (_, { throughput, name }) => { + const timeseries = + transactionGroupComparisonStatistics?.[name]?.throughput; + return ( + + ); + }, + }, + { + field: 'errorRate', + sortable: true, + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnErrorRate', + { defaultMessage: 'Error rate' } + ), + width: px(unit * 8), + render: (_, { errorRate, name }) => { + const timeseries = + transactionGroupComparisonStatistics?.[name]?.errorRate; + return ( + + ); + }, + }, + { + field: 'impact', + sortable: true, + name: i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnImpact', + { defaultMessage: 'Impact' } + ), + width: px(unit * 5), + render: (_, { name }) => { + const impact = + transactionGroupComparisonStatistics?.[name]?.impact ?? 0; + return ; + }, + }, + ]; +} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index 6ee82f47b9e4aa..5529f9028b9dda 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -7,86 +7,41 @@ import { EuiBasicTable, - EuiBasicTableColumn, EuiFlexGroup, EuiFlexItem, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { orderBy } from 'lodash'; import React, { useState } from 'react'; -import { ValuesType } from 'utility-types'; -import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; -import { - asMillisecondDuration, - asPercent, - asTransactionRate, -} from '../../../../../common/utils/formatters'; +import uuid from 'uuid'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; 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 { px, unit } from '../../../../style/variables'; -import { SparkPlot } from '../../../shared/charts/spark_plot'; -import { ImpactBar } from '../../../shared/ImpactBar'; -import { TransactionDetailLink } from '../../../shared/Links/apm/transaction_detail_link'; import { TransactionOverviewLink } from '../../../shared/Links/apm/transaction_overview_link'; import { TableFetchWrapper } from '../../../shared/table_fetch_wrapper'; -import { TruncateWithTooltip } from '../../../shared/truncate_with_tooltip'; import { ServiceOverviewTableContainer } from '../service_overview_table_container'; - -type ServiceTransactionGroupItem = ValuesType< - APIReturnType<'GET /api/apm/services/{serviceName}/transactions/groups/overview'>['transactionGroups'] ->; +import { getColumns } from './get_columns'; interface Props { serviceName: string; } +const INITIAL_STATE = { + transactionGroups: [], + isAggregationAccurate: true, + requestId: '', +}; + type SortField = 'name' | 'latency' | 'throughput' | 'errorRate' | 'impact'; type SortDirection = 'asc' | 'desc'; - const PAGE_SIZE = 5; const DEFAULT_SORT = { direction: 'desc' as const, field: 'impact' as const, }; -function getLatencyAggregationTypeLabel(latencyAggregationType?: string) { - switch (latencyAggregationType) { - case 'avg': - return i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnLatency.avg', - { - defaultMessage: 'Latency (avg.)', - } - ); - - case 'p95': - return i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p95', - { - defaultMessage: 'Latency (95th)', - } - ); - - case 'p99': - return i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p99', - { - defaultMessage: 'Latency (99th)', - } - ); - } -} - -export function ServiceOverviewTransactionsTable(props: Props) { - const { serviceName } = props; - const { transactionType } = useApmServiceContext(); - const { - uiFilters, - urlParams: { start, end, latencyAggregationType }, - } = useUrlParams(); - +export function ServiceOverviewTransactionsTable({ serviceName }: Props) { const [tableOptions, setTableOptions] = useState<{ pageIndex: number; sort: { @@ -98,167 +53,122 @@ export function ServiceOverviewTransactionsTable(props: Props) { sort: DEFAULT_SORT, }); + const { pageIndex, sort } = tableOptions; + + const { transactionType } = useApmServiceContext(); const { - data = { - totalItemCount: 0, - items: [], - tableOptions: { - pageIndex: 0, - sort: DEFAULT_SORT, - }, - }, - status, - } = useFetcher( + uiFilters, + urlParams: { environment, start, end, latencyAggregationType }, + } = useUrlParams(); + + const { data = INITIAL_STATE, status } = useFetcher( (callApmApi) => { if (!start || !end || !latencyAggregationType || !transactionType) { return; } - return callApmApi({ endpoint: - 'GET /api/apm/services/{serviceName}/transactions/groups/overview', + 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', params: { path: { serviceName }, query: { + environment, start, end, uiFilters: JSON.stringify(uiFilters), - size: PAGE_SIZE, - numBuckets: 20, - pageIndex: tableOptions.pageIndex, - sortField: tableOptions.sort.field, - sortDirection: tableOptions.sort.direction, transactionType, - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, }, }, }).then((response) => { return { - items: response.transactionGroups, - totalItemCount: response.totalTransactionGroups, - tableOptions: { - pageIndex: tableOptions.pageIndex, - sort: { - field: tableOptions.sort.field, - direction: tableOptions.sort.direction, - }, - }, + requestId: uuid(), + ...response, }; }); }, [ + environment, serviceName, start, end, uiFilters, - tableOptions.pageIndex, - tableOptions.sort.field, - tableOptions.sort.direction, transactionType, latencyAggregationType, ] ); - const { - items, - totalItemCount, - tableOptions: { pageIndex, sort }, - } = data; + const { transactionGroups, requestId } = data; + const currentPageTransactionGroups = orderBy( + transactionGroups, + sort.field, + sort.direction + ).slice(pageIndex * PAGE_SIZE, (pageIndex + 1) * PAGE_SIZE); - const columns: Array> = [ - { - field: 'name', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnName', - { - defaultMessage: 'Name', - } - ), - render: (_, { name, transactionType: type }) => { - return ( - - {name} - - } - /> - ); - }, - }, - { - field: 'latency', - name: getLatencyAggregationTypeLabel(latencyAggregationType), - width: px(unit * 10), - render: (_, { latency }) => { - return ( - - ); - }, - }, - { - field: 'throughput', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnThroughput', - { defaultMessage: 'Throughput' } - ), - width: px(unit * 10), - render: (_, { throughput }) => { - return ( - - ); - }, - }, - { - field: 'errorRate', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnErrorRate', - { - defaultMessage: 'Error rate', - } - ), - width: px(unit * 8), - render: (_, { errorRate }) => { - return ( - - ); - }, + const transactionNames = JSON.stringify( + currentPageTransactionGroups.map(({ name }) => name).sort() + ); + + const { + data: transactionGroupComparisonStatistics, + status: transactionGroupComparisonStatisticsStatus, + } = useFetcher( + (callApmApi) => { + if ( + currentPageTransactionGroups.length && + start && + end && + transactionType && + latencyAggregationType + ) { + return callApmApi({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', + params: { + path: { serviceName }, + query: { + environment, + start, + end, + uiFilters: JSON.stringify(uiFilters), + numBuckets: 20, + transactionType, + latencyAggregationType, + transactionNames, + }, + }, + }); + } }, - { - field: 'impact', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnImpact', - { - defaultMessage: 'Impact', - } - ), - width: px(unit * 5), - render: (_, { impact }) => { - return ; - }, + // only fetches statistics when requestId changes or transaction names changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [requestId, transactionNames], + { preservePreviousData: false } + ); + + const columns = getColumns({ + serviceName, + latencyAggregationType, + transactionGroupComparisonStatistics, + }); + + const isLoading = + status === FETCH_STATUS.LOADING || + transactionGroupComparisonStatisticsStatus === FETCH_STATUS.LOADING; + + const pagination = { + pageIndex, + pageSize: PAGE_SIZE, + totalItemCount: transactionGroups.length, + hidePerPageOptions: true, + }; + + const sorting = { + sort: { + field: sort.field, + direction: sort.direction, }, - ]; + }; return ( @@ -295,21 +205,14 @@ export function ServiceOverviewTransactionsTable(props: Props) { diff --git a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx index d29dad7a7e3dea..8fc9ac12824baa 100644 --- a/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/trace_overview/index.tsx @@ -5,14 +5,13 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiPage, EuiPanel } from '@elastic/eui'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; 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 { SearchBar } from '../../shared/search_bar'; -import { Correlations } from '../Correlations'; import { TraceList } from './TraceList'; type TracesAPIResponse = APIReturnType<'GET /api/apm/traces'>; @@ -24,7 +23,7 @@ const DEFAULT_RESPONSE: TracesAPIResponse = { export function TraceOverview() { const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; + const { environment, start, end } = urlParams; const { status, data = DEFAULT_RESPONSE } = useFetcher( (callApmApi) => { if (start && end) { @@ -32,6 +31,7 @@ export function TraceOverview() { endpoint: 'GET /api/apm/traces', params: { query: { + environment, start, end, uiFilters: JSON.stringify(uiFilters), @@ -40,7 +40,7 @@ export function TraceOverview() { }); } }, - [start, end, uiFilters] + [environment, start, end, uiFilters] ); useTrackPageview({ app: 'apm', path: 'traces_overview' }); @@ -48,14 +48,9 @@ export function TraceOverview() { return ( <> - + - - - - - ) { +export function Example({ sync }: SyncBadgeProps) { return ; } -Example.args = { sync: true } as ComponentProps; +Example.args = { sync: true } as SyncBadgeProps; diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx index 24301b2cf10fbc..b9e4c6951fa06a 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/WaterfallWithSummmary/WaterfallContainer/Waterfall/SyncBadge.tsx @@ -16,7 +16,7 @@ const SpanBadge = euiStyled(EuiBadge)` margin-right: ${px(units.quarter)}; `; -interface SyncBadgeProps { +export interface SyncBadgeProps { /** * Is the request synchronous? True will show blocking, false will show async. */ diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx index d5f5eed311de89..0a322cfc9c80b0 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/index.tsx @@ -7,7 +7,6 @@ import { EuiFlexGroup, - EuiFlexItem, EuiHorizontalRule, EuiPage, EuiPanel, @@ -27,7 +26,6 @@ import { TransactionCharts } from '../../shared/charts/transaction_charts'; import { HeightRetainer } from '../../shared/HeightRetainer'; 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'; @@ -97,15 +95,9 @@ export function TransactionDetails({

{transactionName}

- + - - - - - - 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 1f8b431d072b79..97be35ec6f5b95 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 @@ -21,7 +21,6 @@ import { Location } from 'history'; import React from 'react'; import { useLocation } from 'react-router-dom'; import { useTrackPageview } from '../../../../../observability/public'; -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 { useUrlParams } from '../../../context/url_params_context/use_url_params'; @@ -30,10 +29,8 @@ import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; 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 { useTransactionListFetcher } from './use_transaction_list'; function getRedirectLocation({ @@ -85,7 +82,7 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { return ( <> - + @@ -112,17 +109,7 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) {
- - -
- - {transactionType === TRANSACTION_PAGE_LOAD && ( - <> - - - - )} 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 7d0ada3e31bffc..e4fbd075660605 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 @@ -131,7 +131,7 @@ describe('TransactionOverview', () => { }); expect(history.location.search).toEqual( - '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=yesterday' + '?transactionType=secondType&rangeFrom=now-15m&rangeTo=now' ); expect(getByText(container, 'firstType')).toBeInTheDocument(); expect(getByText(container, 'secondType')).toBeInTheDocument(); @@ -142,7 +142,7 @@ describe('TransactionOverview', () => { expect(history.push).toHaveBeenCalled(); expect(history.location.search).toEqual( - '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now&comparisonEnabled=true&comparisonType=yesterday' + '?transactionType=firstType&rangeFrom=now-15m&rangeTo=now' ); }); }); diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts b/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts index 406ba98b79e256..a63788457b8b5d 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts +++ b/x-pack/plugins/apm/public/components/app/transaction_overview/use_transaction_list.ts @@ -21,7 +21,7 @@ const DEFAULT_RESPONSE: Partial = { export function useTransactionListFetcher() { const { urlParams, uiFilters } = useUrlParams(); const { serviceName } = useParams<{ serviceName?: string }>(); - const { transactionType, start, end } = urlParams; + const { environment, transactionType, start, end } = urlParams; const { data = DEFAULT_RESPONSE, error, status } = useFetcher( (callApmApi) => { if (serviceName && start && end && transactionType) { @@ -30,6 +30,7 @@ export function useTransactionListFetcher() { params: { path: { serviceName }, query: { + environment, start, end, transactionType, @@ -39,7 +40,7 @@ export function useTransactionListFetcher() { }); } }, - [serviceName, start, end, transactionType, uiFilters] + [environment, serviceName, start, end, transactionType, uiFilters] ); return { diff --git a/x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx b/x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx deleted file mode 100644 index 5b1ffceb8a213a..00000000000000 --- a/x-pack/plugins/apm/public/components/app/transaction_overview/user_experience_callout.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiButton, EuiCallOut, EuiSpacer, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; - -interface Props { - serviceName: string; -} -export function UserExperienceCallout({ serviceName }: Props) { - const { core } = useApmPluginContext(); - const userExperienceHref = core.http.basePath.prepend( - `/app/ux?serviceName=${serviceName}` - ); - - return ( - - - {i18n.translate( - 'xpack.apm.transactionOverview.userExperience.calloutText', - { - defaultMessage: - 'We are beyond excited to introduce a new experience for analyzing the user experience metrics specifically for your RUM services. It provides insights into the core vitals and visitor breakdown by browser and location. The app is always available in the left sidebar among the other Observability views.', - } - )} - - - - {i18n.translate( - 'xpack.apm.transactionOverview.userExperience.linkLabel', - { defaultMessage: 'Take me there' } - )} - - - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx index 414011df7f9efb..20a589f3126c45 100644 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx @@ -15,7 +15,7 @@ import { EnvironmentFilter } from '../EnvironmentFilter'; const HeaderFlexGroup = euiStyled(EuiFlexGroup)` padding: ${({ theme }) => theme.eui.gutterTypes.gutterMedium}; - border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; + background: ${({ theme }) => theme.eui.euiColorEmptyShade}; `; export function ApmHeader({ children }: { children: ReactNode }) { diff --git a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx index 714645fd749612..59c99463144cbe 100644 --- a/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/EnvironmentFilter/index.tsx @@ -64,10 +64,9 @@ export function EnvironmentFilter() { const history = useHistory(); const location = useLocation(); const { serviceName } = useParams<{ serviceName?: string }>(); - const { uiFilters, urlParams } = useUrlParams(); + const { urlParams } = useUrlParams(); - const { environment } = uiFilters; - const { start, end } = urlParams; + const { environment, start, end } = urlParams; const { environments, status = 'loading' } = useEnvironmentsFetcher({ serviceName, start, diff --git a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx index 1da6bbaad7358c..36c499f9e5ee40 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/spark_plot/index.tsx @@ -16,6 +16,7 @@ import { Settings, } from '@elastic/charts'; import { merge } from 'lodash'; +import { Coordinate } from '../../../../../typings/timeseries'; import { useChartTheme } from '../../../../../../observability/public'; import { px, unit } from '../../../../style/variables'; import { useTheme } from '../../../../hooks/use_theme'; @@ -39,7 +40,7 @@ export function SparkPlot({ compact, }: { color: Color; - series?: Array<{ x: number; y: number | null }> | null; + series?: Coordinate[] | null; valueLabel: React.ReactNode; compact?: boolean; }) { @@ -58,18 +59,18 @@ export function SparkPlot({ const colorValue = theme.eui[color]; + const chartSize = { + height: px(24), + width: compact ? px(unit * 3) : px(unit * 4), + }; + return ( {!series || series.every((point) => point.y === null) ? ( - + ) : ( - + ['anomalyTimeseries']; + customTheme?: Record; } export function TimeseriesChart({ @@ -72,13 +73,14 @@ export function TimeseriesChart({ showAnnotations = true, yDomain, anomalyTimeseries, + customTheme = {}, }: Props) { const history = useHistory(); const { annotations } = useAnnotationsContext(); - const chartTheme = useChartTheme(); const { setPointerEvent, chartRef } = useChartPointerEventContext(); const { urlParams } = useUrlParams(); const theme = useTheme(); + const chartTheme = useChartTheme(); const { start, end } = urlParams; @@ -103,6 +105,7 @@ export function TimeseriesChart({ areaSeriesStyle: { line: { visible: false }, }, + ...customTheme, }} onPointerUpdate={setPointerEvent} externalPointerEvents={{ diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts index 466f201ab3398e..293a1929ca6069 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_breakdown_chart/use_transaction_breakdown.ts @@ -13,7 +13,7 @@ import { useApmServiceContext } from '../../../../context/apm_service/use_apm_se export function useTransactionBreakdown() { const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); - const { start, end, transactionName } = urlParams; + const { environment, start, end, transactionName } = urlParams; const { transactionType } = useApmServiceContext(); const { data = { timeseries: undefined }, error, status } = useFetcher( @@ -25,6 +25,7 @@ export function useTransactionBreakdown() { params: { path: { serviceName }, query: { + environment, start, end, transactionName, @@ -35,7 +36,15 @@ export function useTransactionBreakdown() { }); } }, - [serviceName, start, end, transactionType, transactionName, uiFilters] + [ + environment, + serviceName, + start, + end, + transactionType, + transactionName, + uiFilters, + ] ); return { diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx index df18e7627faedb..a3da8812966f18 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx @@ -33,7 +33,7 @@ export function TransactionErrorRateChart({ const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); const { transactionType } = useApmServiceContext(); - const { start, end, transactionName } = urlParams; + const { environment, start, end, transactionName } = urlParams; const { data, status } = useFetcher( (callApmApi) => { @@ -46,6 +46,7 @@ export function TransactionErrorRateChart({ serviceName, }, query: { + environment, start, end, transactionType, @@ -56,7 +57,15 @@ export function TransactionErrorRateChart({ }); } }, - [serviceName, start, end, uiFilters, transactionType, transactionName] + [ + environment, + serviceName, + start, + end, + uiFilters, + transactionType, + transactionName, + ] ); const errorRates = data?.transactionErrorRate || []; diff --git a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx b/x-pack/plugins/apm/public/components/shared/main_tabs.tsx index 941ce924cff079..f60da7c3087115 100644 --- a/x-pack/plugins/apm/public/components/shared/main_tabs.tsx +++ b/x-pack/plugins/apm/public/components/shared/main_tabs.tsx @@ -15,6 +15,8 @@ import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; const StyledTabs = euiStyled(EuiTabs)` padding: ${({ theme }) => `${theme.eui.gutterTypes.gutterMedium}`}; border-bottom: ${({ theme }) => theme.eui.euiBorderThin}; + border-top: ${({ theme }) => theme.eui.euiBorderThin}; + background: ${({ theme }) => theme.eui.euiColorEmptyShade}; `; export function MainTabs({ children }: { children: ReactNode }) { 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 3285db1f49191c..2bd3fef8c0e884 100644 --- a/x-pack/plugins/apm/public/components/shared/search_bar.tsx +++ b/x-pack/plugins/apm/public/components/shared/search_bar.tsx @@ -22,17 +22,23 @@ const SearchBarFlexGroup = euiStyled(EuiFlexGroup)` interface Props { prepend?: React.ReactNode | string; showTimeComparison?: boolean; + showCorrelations?: boolean; } function getRowDirection(showColumn: boolean) { return showColumn ? 'column' : 'row'; } -export function SearchBar({ prepend, showTimeComparison = false }: Props) { +export function SearchBar({ + prepend, + showTimeComparison = false, + showCorrelations = false, +}: Props) { const { isMedium, isLarge } = useBreakPoints(); const itemsStyle = { marginBottom: isLarge ? px(unit) : 0 }; + return ( - + diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts new file mode 100644 index 00000000000000..7234e94881ce79 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { + getTimeRangeComparison, + TimeRangeComparisonType, +} from './get_time_range_comparison'; + +describe('getTimeRangeComparison', () => { + describe('return empty object', () => { + it('when start is not defined', () => { + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + start: undefined, + end, + comparisonType: TimeRangeComparisonType.DayBefore, + }); + expect(result).toEqual({}); + }); + + it('when end is not defined', () => { + const start = '2021-01-28T14:45:00.000Z'; + const result = getTimeRangeComparison({ + start, + end: undefined, + comparisonType: TimeRangeComparisonType.DayBefore, + }); + expect(result).toEqual({}); + }); + }); + + describe('Time range is between 0 - 24 hours', () => { + describe('when day before is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-01-28T14:45:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.DayBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-27T14:45:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-27T15:00:00.000Z'); + }); + }); + describe('when a week before is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-01-28T14:45:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.WeekBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-21T14:45:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + }); + }); + describe('when previous period is selected', () => { + it('returns the correct time range - 15 min', () => { + const start = '2021-02-09T14:40:01.087Z'; + const end = '2021-02-09T14:56:00.000Z'; + const result = getTimeRangeComparison({ + start, + end, + comparisonType: TimeRangeComparisonType.PeriodBefore, + }); + expect(result).toEqual({ + comparisonStart: '2021-02-09T14:24:02.174Z', + comparisonEnd: '2021-02-09T14:40:01.087Z', + }); + }); + }); + }); + + describe('Time range is between 24 hours - 1 week', () => { + describe('when a week before is selected', () => { + it('returns the correct time range - 2 days', () => { + const start = '2021-01-26T15:00:00.000Z'; + const end = '2021-01-28T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.WeekBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-19T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); + }); + }); + }); + + describe('Time range is greater than 7 days', () => { + it('uses the date difference to calculate the time range - 8 days', () => { + const start = '2021-01-10T15:00:00.000Z'; + const end = '2021-01-18T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2021-01-02T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-10T15:00:00.000Z'); + }); + + it('uses the date difference to calculate the time range - 30 days', () => { + const start = '2021-01-01T15:00:00.000Z'; + const end = '2021-01-31T15:00:00.000Z'; + const result = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + expect(result.comparisonStart).toEqual('2020-12-02T15:00:00.000Z'); + expect(result.comparisonEnd).toEqual('2021-01-01T15:00:00.000Z'); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts new file mode 100644 index 00000000000000..5dd014441a9e4f --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { EuiTheme } from 'src/plugins/kibana_react/common'; +import { getDateDifference } from '../../../../common/utils/formatters'; + +export enum TimeRangeComparisonType { + WeekBefore = 'week', + DayBefore = 'day', + PeriodBefore = 'period', +} + +export function getComparisonChartTheme(theme: EuiTheme) { + return { + areaSeriesStyle: { + area: { + fill: theme.eui.euiColorLightestShade, + visible: true, + opacity: 1, + }, + line: { + stroke: theme.eui.euiColorMediumShade, + strokeWidth: 1, + visible: true, + }, + point: { + visible: false, + }, + }, + }; +} + +const oneDayInMilliseconds = moment.duration(1, 'day').asMilliseconds(); +const oneWeekInMilliseconds = moment.duration(1, 'week').asMilliseconds(); + +export function getTimeRangeComparison({ + comparisonType, + start, + end, +}: { + comparisonType: TimeRangeComparisonType; + start?: string; + end?: string; +}) { + if (!start || !end) { + return {}; + } + + const startMoment = moment(start); + const endMoment = moment(end); + + const startEpoch = startMoment.valueOf(); + const endEpoch = endMoment.valueOf(); + + let diff: number; + + switch (comparisonType) { + case TimeRangeComparisonType.DayBefore: + diff = oneDayInMilliseconds; + break; + + case TimeRangeComparisonType.WeekBefore: + diff = oneWeekInMilliseconds; + break; + + case TimeRangeComparisonType.PeriodBefore: + diff = getDateDifference({ + start: startMoment, + end: endMoment, + unitOfTime: 'milliseconds', + precise: true, + }); + break; + + default: + throw new Error('Unknown comparisonType'); + } + + return { + comparisonStart: new Date(startEpoch - diff).toISOString(), + comparisonEnd: new Date(endEpoch - diff).toISOString(), + }; +} 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 4ace78f74ee79e..a4f44290fe777f 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 @@ -18,6 +18,7 @@ import { import { TimeComparison } from './'; import * as urlHelpers from '../../shared/Links/url_helpers'; import moment from 'moment'; +import { TimeRangeComparisonType } from './get_time_range_comparison'; function getWrapper(params?: IUrlParams) { return ({ children }: { children?: ReactNode }) => { @@ -53,22 +54,22 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, }, }); }); - it('selects yesterday and enables comparison', () => { + it('selects day before and enables comparison', () => { const Wrapper = getWrapper({ start: '2021-01-28T14:45:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -80,13 +81,13 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T10:00:00.000Z', comparisonEnabled: true, - comparisonType: 'yesterday', + comparisonType: TimeRangeComparisonType.DayBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['Yesterday', 'A week ago']); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -98,13 +99,13 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T10:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now-15m', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['28/01 11:00 - 29/01 11:00']); + expectTextsInDocument(component, ['27/01 11:00 - 28/01 11:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -118,14 +119,14 @@ describe('TimeComparison', () => { start: '2021-01-28T10:00:00.000Z', end: '2021-01-29T11:00:00.000Z', comparisonEnabled: true, - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsNotInDocument(component, ['Yesterday']); - expectTextsInDocument(component, ['A week ago']); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); }); it('sets default values', () => { const Wrapper = getWrapper({ @@ -139,7 +140,7 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, }, }); }); @@ -148,14 +149,14 @@ describe('TimeComparison', () => { start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'week', + comparisonType: TimeRangeComparisonType.WeekBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); - expectTextsNotInDocument(component, ['Yesterday']); - expectTextsInDocument(component, ['A week ago']); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -167,13 +168,13 @@ describe('TimeComparison', () => { start: '2021-01-26T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: '2021-01-28T15:00:00.000Z', }); const component = render(, { wrapper: Wrapper, }); - expectTextsInDocument(component, ['26/01 16:00 - 28/01 16:00']); + expectTextsInDocument(component, ['24/01 16:00 - 26/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -187,14 +188,14 @@ describe('TimeComparison', () => { start: '2021-01-20T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/01 16:00 - 28/01 16:00']); + expectTextsInDocument(component, ['12/01 16:00 - 20/01 16:00']); expect( (component.getByTestId('comparisonSelect') as HTMLSelectElement) .selectedIndex @@ -206,14 +207,14 @@ describe('TimeComparison', () => { start: '2020-12-20T15:00:00.000Z', end: '2021-01-28T15:00:00.000Z', comparisonEnabled: true, - comparisonType: 'previousPeriod', + comparisonType: TimeRangeComparisonType.PeriodBefore, rangeTo: 'now', }); const component = render(, { wrapper: Wrapper, }); expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/12/20 16:00 - 28/01/21 16:00']); + expectTextsInDocument(component, ['11/11/20 16:00 - 20/12/20 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 e4b03bd57377aa..0b6c1a2c52a980 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 @@ -16,6 +16,10 @@ import { useUrlParams } from '../../../context/url_params_context/use_url_params import { px, unit } from '../../../style/variables'; import * as urlHelpers from '../../shared/Links/url_helpers'; import { useBreakPoints } from '../../../hooks/use_break_points'; +import { + getTimeRangeComparison, + TimeRangeComparisonType, +} from './get_time_range_comparison'; const PrependContainer = euiStyled.div` display: flex; @@ -25,15 +29,32 @@ const PrependContainer = euiStyled.div` padding: 0 ${px(unit)}; `; -function formatPreviousPeriodDates({ - momentStart, - momentEnd, +function getDateFormat({ + previousPeriodStart, + currentPeriodEnd, }: { - momentStart: moment.Moment; - momentEnd: moment.Moment; + previousPeriodStart?: string; + currentPeriodEnd?: string; }) { - const isDifferentYears = momentStart.get('year') !== momentEnd.get('year'); - const dateFormat = isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; + const momentPreviousPeriodStart = moment(previousPeriodStart); + const momentCurrentPeriodEnd = moment(currentPeriodEnd); + const isDifferentYears = + momentPreviousPeriodStart.get('year') !== + momentCurrentPeriodEnd.get('year'); + return isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; +} + +function formatDate({ + dateFormat, + previousPeriodStart, + previousPeriodEnd, +}: { + dateFormat: string; + previousPeriodStart?: string; + previousPeriodEnd?: string; +}) { + const momentStart = moment(previousPeriodStart); + const momentEnd = moment(previousPeriodEnd); return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; } @@ -49,17 +70,17 @@ function getSelectOptions({ const momentStart = moment(start); const momentEnd = moment(end); - const yesterdayOption = { - value: 'yesterday', - text: i18n.translate('xpack.apm.timeComparison.select.yesterday', { - defaultMessage: 'Yesterday', + const dayBeforeOption = { + value: TimeRangeComparisonType.DayBefore, + text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { + defaultMessage: 'Day before', }), }; - const aWeekAgoOption = { - value: 'week', - text: i18n.translate('xpack.apm.timeComparison.select.weekAgo', { - defaultMessage: 'A week ago', + const weekBeforeOption = { + value: TimeRangeComparisonType.WeekBefore, + text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { + defaultMessage: 'Week before', }), }; @@ -69,23 +90,39 @@ function getSelectOptions({ unitOfTime: 'days', precise: true, }); + const isRangeToNow = rangeTo === 'now'; if (isRangeToNow) { // Less than or equals to one day if (dateDiff <= 1) { - return [yesterdayOption, aWeekAgoOption]; + return [dayBeforeOption, weekBeforeOption]; } // Less than or equals to one week if (dateDiff <= 7) { - return [aWeekAgoOption]; + return [weekBeforeOption]; } } + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonType.PeriodBefore, + start, + end, + }); + + const dateFormat = getDateFormat({ + previousPeriodStart: comparisonStart, + currentPeriodEnd: end, + }); + const prevPeriodOption = { - value: 'previousPeriod', - text: formatPreviousPeriodDates({ momentStart, momentEnd }), + value: TimeRangeComparisonType.PeriodBefore, + text: formatDate({ + dateFormat, + previousPeriodStart: comparisonStart, + previousPeriodEnd: comparisonEnd, + }), }; // above one week or when rangeTo is not "now" diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index 03ad24a580612e..b6e7330be30cbd 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -6,11 +6,13 @@ */ import { Location } from 'history'; +import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { pickKeys } from '../../../common/utils/pick_keys'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; +import { localUIFilterNames } from '../../../server/lib/rum_client/ui_filters/local_ui_filters/config'; import { toQuery } from '../../components/shared/Links/url_helpers'; +import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; import { getDateRange, removeUndefinedProps, @@ -65,6 +67,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { refreshInterval: refreshInterval ? toNumber(refreshInterval) : undefined, // query params + environment: toString(environment) || ENVIRONMENT_ALL.value, sortDirection, sortField, page: toNumber(page) || 0, @@ -80,14 +83,12 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { transactionType, searchTerm: toString(searchTerm), percentile: toNumber(percentile), - latencyAggregationType, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, comparisonEnabled: comparisonEnabled ? toBoolean(comparisonEnabled) : undefined, - comparisonType, - + comparisonType: comparisonType as TimeRangeComparisonType | undefined, // ui filters - environment, ...localUIFilters, }); } diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index be66f6fdd02f6a..4332019d1a1c9e 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -5,7 +5,9 @@ * 2.0. */ +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { LocalUIFilterName } from '../../../common/ui_filter'; +import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; export type IUrlParams = { detailTab?: string; @@ -29,7 +31,7 @@ export type IUrlParams = { pageSize?: number; searchTerm?: string; percentile?: number; - latencyAggregationType?: string; + latencyAggregationType?: LatencyAggregationType; comparisonEnabled?: boolean; - comparisonType?: string; + comparisonType?: TimeRangeComparisonType; } & Partial>; diff --git a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx index e29c092071894e..90245b9843b013 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx +++ b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx @@ -14,18 +14,17 @@ import React, { useState, } from 'react'; import { withRouter } from 'react-router-dom'; -import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; import { LocalUIFilterName } from '../../../common/ui_filter'; import { pickKeys } from '../../../common/utils/pick_keys'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; +import { localUIFilterNames } from '../../../server/lib/rum_client/ui_filters/local_ui_filters/config'; import { UIFilters } from '../../../typings/ui_filters'; import { useDeepObjectIdentity } from '../../hooks/useDeepObjectIdentity'; import { getDateRange } from './helpers'; import { resolveUrlParams } from './resolve_url_params'; import { IUrlParams } from './types'; -interface TimeRange { +export interface TimeRange { rangeFrom: string; rangeTo: string; } @@ -39,7 +38,6 @@ function useUiFilters(params: IUrlParams): UIFilters { return useDeepObjectIdentity({ kuery, - environment: environment || ENVIRONMENT_ALL.value, ...localUiFilters, }); } diff --git a/x-pack/plugins/apm/public/hooks/use_environments_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_environments_fetcher.tsx index f6d3e848024701..b7dc29a36170d7 100644 --- a/x-pack/plugins/apm/public/hooks/use_environments_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_environments_fetcher.tsx @@ -36,7 +36,7 @@ export function useEnvironmentsFetcher({ (callApmApi) => { if (start && end) { return callApmApi({ - endpoint: 'GET /api/apm/ui_filters/environments', + endpoint: 'GET /api/apm/environments', params: { query: { start, diff --git a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx index 834b0cc0527897..9ff179e6af6a06 100644 --- a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx @@ -16,7 +16,7 @@ export function useErrorGroupDistributionFetcher({ groupId: string | undefined; }) { const { urlParams, uiFilters } = useUrlParams(); - const { start, end } = urlParams; + const { environment, start, end } = urlParams; const { data } = useFetcher( (callApmApi) => { if (start && end) { @@ -25,6 +25,7 @@ export function useErrorGroupDistributionFetcher({ params: { path: { serviceName }, query: { + environment, start, end, groupId, @@ -34,7 +35,7 @@ export function useErrorGroupDistributionFetcher({ }); } }, - [serviceName, start, end, groupId, uiFilters] + [environment, serviceName, start, end, groupId, uiFilters] ); return { errorDistributionData: data }; diff --git a/x-pack/plugins/apm/public/hooks/use_service_metric_charts_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_service_metric_charts_fetcher.ts index ecfa5471189d20..87e10f1e8937b0 100644 --- a/x-pack/plugins/apm/public/hooks/use_service_metric_charts_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_service_metric_charts_fetcher.ts @@ -24,7 +24,7 @@ export function useServiceMetricChartsFetcher({ const { urlParams, uiFilters } = useUrlParams(); const { agentName } = useApmServiceContext(); const { serviceName } = useParams<{ serviceName?: string }>(); - const { start, end } = urlParams; + const { environment, start, end } = urlParams; const { data = INITIAL_DATA, error, status } = useFetcher( (callApmApi) => { if (serviceName && start && end && agentName) { @@ -33,6 +33,7 @@ export function useServiceMetricChartsFetcher({ params: { path: { serviceName }, query: { + environment, serviceNodeName, start, end, @@ -43,7 +44,15 @@ export function useServiceMetricChartsFetcher({ }); } }, - [serviceName, start, end, agentName, serviceNodeName, uiFilters] + [ + environment, + serviceName, + start, + end, + agentName, + serviceNodeName, + uiFilters, + ] ); return { diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts index 66446bf0dfebad..c493a30716aa5e 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_distribution_fetcher.ts @@ -25,6 +25,7 @@ export function useTransactionDistributionFetcher() { const { serviceName } = useParams<{ serviceName?: string }>(); const { urlParams, uiFilters } = useUrlParams(); const { + environment, start, end, transactionType, @@ -45,6 +46,7 @@ export function useTransactionDistributionFetcher() { serviceName, }, query: { + environment, start, end, transactionType, @@ -92,7 +94,15 @@ export function useTransactionDistributionFetcher() { }, // the histogram should not be refetched if the transactionId or traceId changes // eslint-disable-next-line react-hooks/exhaustive-deps - [serviceName, start, end, transactionType, transactionName, uiFilters] + [ + environment, + serviceName, + start, + end, + transactionType, + transactionName, + uiFilters, + ] ); return { diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts index 5522562fbeab79..cca2e99d84dfdf 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts @@ -12,14 +12,19 @@ import { useUrlParams } from '../context/url_params_context/use_url_params'; import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; import { getLatencyChartSelector } from '../selectors/latency_chart_selectors'; import { useTheme } from './use_theme'; -import { LatencyAggregationType } from '../../common/latency_aggregation_types'; export function useTransactionLatencyChartsFetcher() { const { serviceName } = useParams<{ serviceName?: string }>(); const { transactionType } = useApmServiceContext(); const theme = useTheme(); const { - urlParams: { start, end, transactionName, latencyAggregationType }, + urlParams: { + environment, + start, + end, + transactionName, + latencyAggregationType, + }, uiFilters, } = useUrlParams(); @@ -38,18 +43,20 @@ export function useTransactionLatencyChartsFetcher() { params: { path: { serviceName }, query: { + environment, start, end, transactionType, transactionName, uiFilters: JSON.stringify(uiFilters), - latencyAggregationType: latencyAggregationType as LatencyAggregationType, + latencyAggregationType, }, }, }); } }, [ + environment, serviceName, start, end, diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts index cd2dbca7512afc..55765cd40c04eb 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_throughput_chart_fetcher.ts @@ -9,7 +9,7 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useFetcher } from './use_fetcher'; import { useUrlParams } from '../context/url_params_context/use_url_params'; -import { getThrouputChartSelector } from '../selectors/throuput_chart_selectors'; +import { getThroughputChartSelector } from '../selectors/throughput_chart_selectors'; import { useTheme } from './use_theme'; import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; @@ -18,7 +18,7 @@ export function useTransactionThroughputChartsFetcher() { const { transactionType } = useApmServiceContext(); const theme = useTheme(); const { - urlParams: { start, end, transactionName }, + urlParams: { environment, start, end, transactionName }, uiFilters, } = useUrlParams(); @@ -31,6 +31,7 @@ export function useTransactionThroughputChartsFetcher() { params: { path: { serviceName }, query: { + environment, start, end, transactionType, @@ -41,11 +42,19 @@ export function useTransactionThroughputChartsFetcher() { }); } }, - [serviceName, start, end, transactionName, transactionType, uiFilters] + [ + environment, + serviceName, + start, + end, + transactionName, + transactionType, + uiFilters, + ] ); const memoizedData = useMemo( - () => getThrouputChartSelector({ throuputChart: data, theme }), + () => getThroughputChartSelector({ throughputChart: data, theme }), [data, theme] ); diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts index aac0c75dacaebb..858d44de8bb7a0 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts @@ -15,7 +15,7 @@ import { APIReturnType } from '../services/rest/createCallApmApi'; export type LatencyChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/latency'>; -interface LatencyChartData { +export interface LatencyChartData { latencyTimeseries: Array>; mlJobId?: string; anomalyTimeseries?: { boundaries: APMChartSpec[]; scores: APMChartSpec }; diff --git a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts similarity index 79% rename from x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts rename to x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts index 89e406a9014671..b76b77abaa7bde 100644 --- a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.test.ts +++ b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.test.ts @@ -7,9 +7,9 @@ import { EuiTheme } from '../../../../../src/plugins/kibana_react/common'; import { - getThrouputChartSelector, - ThrouputChartsResponse, -} from './throuput_chart_selectors'; + getThroughputChartSelector, + ThroughputChartsResponse, +} from './throughput_chart_selectors'; const theme = { eui: { @@ -30,26 +30,26 @@ const throughputData = { { key: 'HTTP 4xx', avg: 1, dataPoints: [{ x: 1, y: 2 }] }, { key: 'HTTP 5xx', avg: 1, dataPoints: [{ x: 1, y: 2 }] }, ], -} as ThrouputChartsResponse; +} as ThroughputChartsResponse; -describe('getThrouputChartSelector', () => { +describe('getThroughputChartSelector', () => { it('returns default values when data is undefined', () => { - const throughputTimeseries = getThrouputChartSelector({ theme }); + const throughputTimeseries = getThroughputChartSelector({ theme }); expect(throughputTimeseries).toEqual({ throughputTimeseries: [] }); }); it('returns default values when timeseries is empty', () => { - const throughputTimeseries = getThrouputChartSelector({ + const throughputTimeseries = getThroughputChartSelector({ theme, - throuputChart: { throughputTimeseries: [] }, + throughputChart: { throughputTimeseries: [] }, }); expect(throughputTimeseries).toEqual({ throughputTimeseries: [] }); }); it('return throughput time series', () => { - const throughputTimeseries = getThrouputChartSelector({ + const throughputTimeseries = getThroughputChartSelector({ theme, - throuputChart: throughputData, + throughputChart: throughputData, }); expect(throughputTimeseries).toEqual({ diff --git a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts similarity index 80% rename from x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts rename to x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts index daf1e69c2e5f99..f9e72bff231f4a 100644 --- a/x-pack/plugins/apm/public/selectors/throuput_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/throughput_chart_selectors.ts @@ -12,36 +12,36 @@ import { TimeSeries } from '../../typings/timeseries'; import { APIReturnType } from '../services/rest/createCallApmApi'; import { httpStatusCodeToColor } from '../utils/httpStatusCodeToColor'; -export type ThrouputChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/throughput'>; +export type ThroughputChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/throughput'>; -interface ThroughputChart { +export interface ThroughputChart { throughputTimeseries: TimeSeries[]; } -export function getThrouputChartSelector({ +export function getThroughputChartSelector({ theme, - throuputChart, + throughputChart, }: { theme: EuiTheme; - throuputChart?: ThrouputChartsResponse; + throughputChart?: ThroughputChartsResponse; }): ThroughputChart { - if (!throuputChart) { + if (!throughputChart) { return { throughputTimeseries: [] }; } return { - throughputTimeseries: getThroughputTimeseries({ throuputChart, theme }), + throughputTimeseries: getThroughputTimeseries({ throughputChart, theme }), }; } function getThroughputTimeseries({ - throuputChart, + throughputChart, theme, }: { theme: EuiTheme; - throuputChart: ThrouputChartsResponse; + throughputChart: ThroughputChartsResponse; }) { - const { throughputTimeseries } = throuputChart; + const { throughputTimeseries } = throughputChart; const bucketKeys = throughputTimeseries.map(({ key }) => key); const getColor = getColorByKey(bucketKeys, theme); diff --git a/x-pack/plugins/apm/public/utils/testHelpers.tsx b/x-pack/plugins/apm/public/utils/testHelpers.tsx index aac6196c4253c5..f7f6f7486091b7 100644 --- a/x-pack/plugins/apm/public/utils/testHelpers.tsx +++ b/x-pack/plugins/apm/public/utils/testHelpers.tsx @@ -182,8 +182,8 @@ export async function inspectSearchParams( }, } ) as APMConfig, - uiFilters: { environment: 'test' }, - esFilter: [{ term: { 'service.environment': 'test' } }], + uiFilters: {}, + esFilter: [], indices: { /* eslint-disable @typescript-eslint/naming-convention */ 'apm_oss.sourcemapIndices': 'myIndex', diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index 00d7e8e1dd5e42..9ddbd1757ad94f 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -118,7 +118,7 @@ _Note: Run the following commands from `kibana/`._ ### Typescript ``` -yarn tsc --noEmit --project x-pack/tsconfig.json --skipLibCheck +yarn tsc --noEmit --project x-pack/plugins/apm/tsconfig.json --skipLibCheck ``` ### Prettier diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js b/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js index f466b7ff72c49f..ae941c1d2de0c9 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/optimize.js @@ -16,6 +16,7 @@ const { omit } = require('lodash'); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); +const unlink = promisify(fs.unlink); const { xpackRoot, @@ -72,6 +73,10 @@ async function setIgnoreChanges() { } } +async function deleteApmTsConfig() { + await unlink(path.resolve(kibanaRoot, 'x-pack/plugins/apm', 'tsconfig.json')); +} + async function optimizeTsConfig() { await unoptimizeTsConfig(); @@ -79,6 +84,8 @@ async function optimizeTsConfig() { await addApmFilesToXpackTsConfig(); + await deleteApmTsConfig(); + await setIgnoreChanges(); // eslint-disable-next-line no-console console.log( diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js b/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js index 9788de37bdb33c..d697c073fa17a0 100644 --- a/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js +++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/paths.js @@ -16,6 +16,7 @@ const filesToIgnore = [ path.resolve(xpackRoot, 'tsconfig.json'), path.resolve(kibanaRoot, 'tsconfig.json'), path.resolve(kibanaRoot, 'tsconfig.base.json'), + path.resolve(kibanaRoot, 'x-pack/plugins/apm', 'tsconfig.json'), ]; module.exports = { diff --git a/x-pack/plugins/apm/scripts/precommit.js b/x-pack/plugins/apm/scripts/precommit.js index 71943842b673ff..42fe0734d9160d 100644 --- a/x-pack/plugins/apm/scripts/precommit.js +++ b/x-pack/plugins/apm/scripts/precommit.js @@ -11,10 +11,24 @@ const execa = require('execa'); const Listr = require('listr'); const { resolve } = require('path'); +const { argv } = require('yargs'); -const cwd = resolve(__dirname, '../../../..'); +const root = resolve(__dirname, '../../../..'); -const execaOpts = { cwd, stderr: 'pipe' }; +const execaOpts = { cwd: root, stderr: 'pipe' }; + +const useOptimizedTsConfig = !!argv.optimizeTs; + +const tsconfig = useOptimizedTsConfig + ? resolve(root, 'x-pack/tsconfig.json') + : resolve(root, 'x-pack/plugins/apm/tsconfig.json'); + +console.log( + resolve( + __dirname, + useOptimizedTsConfig ? './optimize-tsonfig.js' : './unoptimize-tsconfig.js' + ) +); const tasks = new Listr( [ @@ -36,15 +50,25 @@ const tasks = new Listr( { title: 'Typescript', task: () => - execa('node', [resolve(__dirname, 'optimize-tsconfig.js')]).then(() => + execa( + 'node', + [ + resolve( + __dirname, + useOptimizedTsConfig + ? './optimize-tsconfig.js' + : './unoptimize-tsconfig.js' + ), + ], + execaOpts + ).then(() => execa( require.resolve('typescript/bin/tsc'), [ '--project', - resolve(__dirname, '../../../tsconfig.json'), + tsconfig, '--pretty', - '--noEmit', - '--skipLibCheck', + ...(useOptimizedTsConfig ? ['--noEmit'] : []), ], execaOpts ) @@ -55,16 +79,15 @@ const tasks = new Listr( task: () => execa('node', [resolve(__dirname, 'eslint.js')], execaOpts), }, ], - { exitOnError: false, concurrent: true } + { exitOnError: true, concurrent: true } ); tasks.run().catch((error) => { // from src/dev/typescript/exec_in_projects.ts process.exitCode = 1; - const errors = error.errors || [error]; for (const e of errors) { - process.stderr.write(e.stdout); + process.stderr.write(e.stderr || e.stdout); } }); diff --git a/x-pack/plugins/apm/scripts/shared/get_es_client.ts b/x-pack/plugins/apm/scripts/shared/get_es_client.ts index ab443e081825a8..f17a55cf4e215a 100644 --- a/x-pack/plugins/apm/scripts/shared/get_es_client.ts +++ b/x-pack/plugins/apm/scripts/shared/get_es_client.ts @@ -30,17 +30,25 @@ export function getEsClient({ auth, }); - return { - ...client, - async search( - request: TSearchRequest - ) { - const response = await client.search(request as any); + async function search< + TDocument = unknown, + TSearchRequest extends ESSearchRequest = ESSearchRequest + >(request: TSearchRequest) { + const response = await client.search(request); - return { - ...response, - body: response.body as ESSearchResponse, - }; - }, + return { + ...response, + body: (response.body as unknown) as ESSearchResponse< + TDocument, + TSearchRequest + >, + }; + } + + // @ts-expect-error + client.search = search; + + return (client as unknown) as Omit & { + search: typeof search; }; } diff --git a/x-pack/plugins/apm/scripts/tsconfig.json b/x-pack/plugins/apm/scripts/tsconfig.json deleted file mode 100644 index f1643608496ad4..00000000000000 --- a/x-pack/plugins/apm/scripts/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../../../tsconfig.base.json", - "include": [ - "./**/*", - "../observability" - ], - "exclude": [], - "compilerOptions": { - "types": [ - "node" - ] - } -} diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index a7340cd2cfedf6..e4aedf452002dd 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -83,7 +83,7 @@ async function uploadData() { apmAgentConfigurationIndex: '.apm-agent-configuration', }, search: (body) => { - return unwrapEsResponse(client.search(body)); + return unwrapEsResponse(client.search(body as any)); }, indicesStats: (body) => { return unwrapEsResponse(client.indices.stats(body)); diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts index 67cd33415f28f2..3457207eeee3c4 100644 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts @@ -13,82 +13,84 @@ import { TRANSACTION_TYPE, } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { AlertParams } from '../../../routes/alerts/chart_preview'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -export async function getTransactionDurationChartPreview({ +export function getTransactionDurationChartPreview({ alertParams, setup, }: { alertParams: AlertParams; setup: Setup & SetupTimeRange; }) { - const { apmEventClient, start, end } = setup; - const { - aggregationType, - environment, - serviceName, - transactionType, - } = alertParams; + return withApmSpan('get_transaction_duration_chart_preview', async () => { + const { apmEventClient, start, end } = setup; + const { + aggregationType, + environment, + serviceName, + transactionType, + } = alertParams; - const query = { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), - ...(transactionType - ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] - : []), - ...getEnvironmentUiFilterES(environment), - ], - }, - }; + const query = { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), + ...(transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ], + }, + }; - const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); + const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); - const aggs = { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - }, - aggs: { - agg: - aggregationType === 'avg' - ? { avg: { field: TRANSACTION_DURATION } } - : { - percentiles: { - field: TRANSACTION_DURATION, - percents: [aggregationType === '95th' ? 95 : 99], + const aggs = { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + }, + aggs: { + agg: + aggregationType === 'avg' + ? { avg: { field: TRANSACTION_DURATION } } + : { + percentiles: { + field: TRANSACTION_DURATION, + percents: [aggregationType === '95th' ? 95 : 99], + }, }, - }, + }, }, - }, - }; - const params = { - apm: { events: [ProcessorEvent.transaction] }, - body: { size: 0, query, aggs }, - }; - const resp = await apmEventClient.search(params); + }; + const params = { + apm: { events: [ProcessorEvent.transaction] }, + body: { size: 0, query, aggs }, + }; + const resp = await apmEventClient.search(params); - if (!resp.aggregations) { - return []; - } + if (!resp.aggregations) { + return []; + } - return resp.aggregations.timeseries.buckets.map((bucket) => { - const percentilesKey = aggregationType === '95th' ? '95.0' : '99.0'; - const x = bucket.key; - const y = - aggregationType === 'avg' - ? (bucket.agg as MetricsAggregationResponsePart).value - : (bucket.agg as { values: Record }).values[ - percentilesKey - ]; + return resp.aggregations.timeseries.buckets.map((bucket) => { + const percentilesKey = aggregationType === '95th' ? '95.0' : '99.0'; + const x = bucket.key; + const y = + aggregationType === 'avg' + ? (bucket.agg as MetricsAggregationResponsePart).value + : (bucket.agg as { values: Record }).values[ + percentilesKey + ]; - return { x, y }; + return { x, y }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts index ae1d634928ed98..aa85c44284d9d4 100644 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_count.ts @@ -7,58 +7,60 @@ import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; -import { rangeFilter } from '../../../../common/utils/range_filter'; import { AlertParams } from '../../../routes/alerts/chart_preview'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -export async function getTransactionErrorCountChartPreview({ +export function getTransactionErrorCountChartPreview({ setup, alertParams, }: { setup: Setup & SetupTimeRange; alertParams: AlertParams; }) { - const { apmEventClient, start, end } = setup; - const { serviceName, environment } = alertParams; + return withApmSpan('get_transaction_error_count_chart_preview', async () => { + const { apmEventClient, start, end } = setup; + const { serviceName, environment } = alertParams; - const query = { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), - ...getEnvironmentUiFilterES(environment), - ], - }, - }; + const query = { + bool: { + filter: [ + ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ], + }, + }; - const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); + const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); - const aggs = { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, + const aggs = { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + }, }, - }, - }; + }; - const params = { - apm: { events: [ProcessorEvent.error] }, - body: { size: 0, query, aggs }, - }; + const params = { + apm: { events: [ProcessorEvent.error] }, + body: { size: 0, query, aggs }, + }; - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - if (!resp.aggregations) { - return []; - } + if (!resp.aggregations) { + return []; + } - return resp.aggregations.timeseries.buckets.map((bucket) => { - return { - x: bucket.key, - y: bucket.doc_count, - }; + return resp.aggregations.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + y: bucket.doc_count, + }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts index fa01773c780703..88e249a71a81f0 100644 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts @@ -11,9 +11,8 @@ import { TRANSACTION_TYPE, } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; -import { rangeFilter } from '../../../../common/utils/range_filter'; import { AlertParams } from '../../../routes/alerts/chart_preview'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { @@ -34,13 +33,13 @@ export async function getTransactionErrorRateChartPreview({ const query = { bool: { filter: [ - { range: rangeFilter(start, end) }, { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, ...(serviceName ? [{ term: { [SERVICE_NAME]: serviceName } }] : []), ...(transactionType ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] : []), - ...getEnvironmentUiFilterES(environment), + ...rangeQuery(start, end), + ...environmentQuery(environment), ], }, }; diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts index e9e2e078ec344e..c7861eaa819aed 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts @@ -27,7 +27,7 @@ import { SERVICE_NAME, } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; -import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery } from '../../../common/utils/queries'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { apmActionVariables } from './action_variables'; import { alertingEsClient } from './alerting_es_client'; @@ -104,7 +104,7 @@ export function registerErrorCountAlertType({ ...(alertParams.serviceName ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }] : []), - ...getEnvironmentUiFilterES(alertParams.environment), + ...environmentQuery(alertParams.environment), ], }, }, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts index 480efd8d4c7ad0..704aee932a604f 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts @@ -20,7 +20,7 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; import { getDurationFormatter } from '../../../common/utils/formatters'; -import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery } from '../../../common/utils/queries'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { apmActionVariables } from './action_variables'; import { alertingEsClient } from './alerting_es_client'; @@ -96,7 +96,7 @@ export function registerTransactionDurationAlertType({ { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, { term: { [SERVICE_NAME]: alertParams.serviceName } }, { term: { [TRANSACTION_TYPE]: alertParams.transactionType } }, - ...getEnvironmentUiFilterES(alertParams.environment), + ...environmentQuery(alertParams.environment), ], }, }, diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts index 882bde87927615..6f58b7714d8324 100644 --- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.ts @@ -22,7 +22,7 @@ import { import { EventOutcome } from '../../../common/event_outcome'; import { ProcessorEvent } from '../../../common/processor_event'; import { asDecimalOrInteger } from '../../../common/utils/formatters'; -import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery } from '../../../common/utils/queries'; import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { apmActionVariables } from './action_variables'; import { alertingEsClient } from './alerting_es_client'; @@ -103,7 +103,7 @@ export function registerTransactionErrorRateAlertType({ }, ] : []), - ...getEnvironmentUiFilterES(alertParams.environment), + ...environmentQuery(alertParams.environment), ], }, }, diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts index 3b8e104fbf81d7..d70e19bf4a5f52 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts @@ -9,15 +9,16 @@ import { Logger } from 'kibana/server'; import uuid from 'uuid/v4'; import { snakeCase } from 'lodash'; import Boom from '@hapi/boom'; -import { ProcessorEvent } from '../../../common/processor_event'; import { ML_ERRORS } from '../../../common/anomaly_detection'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { environmentQuery } from '../../../common/utils/queries'; import { Setup } from '../helpers/setup_request'; import { TRANSACTION_DURATION, PROCESSOR_EVENT, } from '../../../common/elasticsearch_fieldnames'; import { APM_ML_JOB_GROUP, ML_MODULE_ID_APM_TRANSACTION } from './constants'; -import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { withApmSpan } from '../../utils/with_apm_span'; export async function createAnomalyDetectionJobs( setup: Setup, @@ -30,32 +31,36 @@ export async function createAnomalyDetectionJobs( throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); } - const mlCapabilities = await ml.mlSystem.mlCapabilities(); + const mlCapabilities = await withApmSpan('get_ml_capabilities', () => + ml.mlSystem.mlCapabilities() + ); if (!mlCapabilities.mlFeatureEnabledInSpace) { throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); } - logger.info( - `Creating ML anomaly detection jobs for environments: [${environments}].` - ); - - const indexPatternName = indices['apm_oss.transactionIndices']; - const responses = await Promise.all( - environments.map((environment) => - createAnomalyDetectionJob({ ml, environment, indexPatternName }) - ) - ); - const jobResponses = responses.flatMap((response) => response.jobs); - const failedJobs = jobResponses.filter(({ success }) => !success); + return withApmSpan('create_anomaly_detection_jobs', async () => { + logger.info( + `Creating ML anomaly detection jobs for environments: [${environments}].` + ); - if (failedJobs.length > 0) { - const errors = failedJobs.map(({ id, error }) => ({ id, error })); - throw new Error( - `An error occurred while creating ML jobs: ${JSON.stringify(errors)}` + const indexPatternName = indices['apm_oss.transactionIndices']; + const responses = await Promise.all( + environments.map((environment) => + createAnomalyDetectionJob({ ml, environment, indexPatternName }) + ) ); - } + const jobResponses = responses.flatMap((response) => response.jobs); + const failedJobs = jobResponses.filter(({ success }) => !success); + + if (failedJobs.length > 0) { + const errors = failedJobs.map(({ id, error }) => ({ id, error })); + throw new Error( + `An error occurred while creating ML jobs: ${JSON.stringify(errors)}` + ); + } - return jobResponses; + return jobResponses; + }); } async function createAnomalyDetectionJob({ @@ -67,34 +72,36 @@ async function createAnomalyDetectionJob({ environment: string; indexPatternName: string; }) { - const randomToken = uuid().substr(-4); + return withApmSpan('create_anomaly_detection_job', async () => { + const randomToken = uuid().substr(-4); - return ml.modules.setup({ - moduleId: ML_MODULE_ID_APM_TRANSACTION, - prefix: `${APM_ML_JOB_GROUP}-${snakeCase(environment)}-${randomToken}-`, - groups: [APM_ML_JOB_GROUP], - indexPatternName, - applyToAllSpaces: true, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - { exists: { field: TRANSACTION_DURATION } }, - ...getEnvironmentUiFilterES(environment), - ], + return ml.modules.setup({ + moduleId: ML_MODULE_ID_APM_TRANSACTION, + prefix: `${APM_ML_JOB_GROUP}-${snakeCase(environment)}-${randomToken}-`, + groups: [APM_ML_JOB_GROUP], + indexPatternName, + applyToAllSpaces: true, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { exists: { field: TRANSACTION_DURATION } }, + ...environmentQuery(environment), + ], + }, }, - }, - startDatafeed: true, - jobOverrides: [ - { - custom_settings: { - job_tags: { - environment, - // identifies this as an APM ML job & facilitates future migrations - apm_ml_version: 2, + startDatafeed: true, + jobOverrides: [ + { + custom_settings: { + job_tags: { + environment, + // identifies this as an APM ML job & facilitates future migrations + apm_ml_version: 2, + }, }, }, - }, - ], + ], + }); }); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts index 632a9398ff6acb..75b2e8289c7a85 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts @@ -10,27 +10,35 @@ import Boom from '@hapi/boom'; import { ML_ERRORS } from '../../../common/anomaly_detection'; import { Setup } from '../helpers/setup_request'; import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { +export function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { const { ml } = setup; if (!ml) { throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); } - const mlCapabilities = await ml.mlSystem.mlCapabilities(); - if (!mlCapabilities.mlFeatureEnabledInSpace) { - throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); - } + return withApmSpan('get_anomaly_detection_jobs', async () => { + const mlCapabilities = await withApmSpan('get_ml_capabilities', () => + ml.mlSystem.mlCapabilities() + ); + + if (!mlCapabilities.mlFeatureEnabledInSpace) { + throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); + } - const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); - return response.jobs - .filter((job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2) - .map((job) => { - const environment = job.custom_settings?.job_tags?.environment ?? ''; - return { - job_id: job.job_id, - environment, - }; - }); + const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); + return response.jobs + .filter( + (job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2 + ) + .map((job) => { + const environment = job.custom_settings?.job_tags?.environment ?? ''; + return { + job_id: job.job_id, + environment, + }; + }); + }); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts index 6ef8ee291a815b..bcea8f1ed6b26e 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts @@ -6,20 +6,23 @@ */ import { MlPluginSetup } from '../../../../ml/server'; +import { withApmSpan } from '../../utils/with_apm_span'; import { APM_ML_JOB_GROUP } from './constants'; // returns ml jobs containing "apm" group // workaround: the ML api returns 404 when no jobs are found. This is handled so instead of throwing an empty response is returned -export async function getMlJobsWithAPMGroup( +export function getMlJobsWithAPMGroup( anomalyDetectors: ReturnType ) { - try { - return await anomalyDetectors.jobs(APM_ML_JOB_GROUP); - } catch (e) { - if (e.statusCode === 404) { - return { count: 0, jobs: [] }; - } + return withApmSpan('get_ml_jobs_with_apm_group', async () => { + try { + return await anomalyDetectors.jobs(APM_ML_JOB_GROUP); + } catch (e) { + if (e.statusCode === 404) { + return { count: 0, jobs: [] }; + } - throw e; - } + throw e; + } + }); } diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts index 48f40e387ffae9..c189d24efc23a3 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts @@ -7,27 +7,32 @@ import Boom from '@hapi/boom'; import { ML_ERRORS } from '../../../common/anomaly_detection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup } from '../helpers/setup_request'; import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; // Determine whether there are any legacy ml jobs. // A legacy ML job has a job id that ends with "high_mean_response_time" and created_by=ml-module-apm-transaction -export async function hasLegacyJobs(setup: Setup) { +export function hasLegacyJobs(setup: Setup) { const { ml } = setup; if (!ml) { throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); } - const mlCapabilities = await ml.mlSystem.mlCapabilities(); - if (!mlCapabilities.mlFeatureEnabledInSpace) { - throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); - } + return withApmSpan('has_legacy_jobs', async () => { + const mlCapabilities = await withApmSpan('get_ml_capabilities', () => + ml.mlSystem.mlCapabilities() + ); + if (!mlCapabilities.mlFeatureEnabledInSpace) { + throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE); + } - const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); - return response.jobs.some( - (job) => - job.job_id.endsWith('high_mean_response_time') && - job.custom_settings?.created_by === 'ml-module-apm-transaction' - ); + const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors); + return response.jobs.some( + (job) => + job.job_id.endsWith('high_mean_response_time') && + job.custom_settings?.created_by === 'ml-module-apm-transaction' + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts index 686629b0d7d180..ecefdfc2b3d9b2 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts @@ -13,12 +13,13 @@ import { } from '../process_significant_term_aggs'; import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch/aggregations'; import { ESFilter } from '../../../../../../typings/elasticsearch'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { EVENT_OUTCOME, SERVICE_NAME, TRANSACTION_NAME, TRANSACTION_TYPE, + PROCESSOR_EVENT, } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; @@ -27,88 +28,95 @@ import { getOutcomeAggregation, getTransactionErrorRateTimeSeries, } from '../../helpers/transaction_error_rate'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getCorrelationsForFailedTransactions({ + environment, serviceName, transactionType, transactionName, fieldNames, setup, }: { + environment?: string; serviceName: string | undefined; transactionType: string | undefined; transactionName: string | undefined; fieldNames: string[]; setup: Setup & SetupTimeRange; }) { - const { start, end, esFilter, apmEventClient } = setup; - - const backgroundFilters: ESFilter[] = [ - ...esFilter, - { range: rangeFilter(start, end) }, - ]; - - if (serviceName) { - backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } }); - } - - if (transactionType) { - backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } }); - } - - if (transactionName) { - backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } }); - } - - const params = { - apm: { events: [ProcessorEvent.transaction] }, - track_total_hits: true, - body: { - size: 0, - query: { - bool: { filter: backgroundFilters }, - }, - aggs: { - failed_transactions: { - filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - - // significant term aggs - aggs: fieldNames.reduce((acc, fieldName) => { - return { - ...acc, - [fieldName]: { - significant_terms: { - size: 10, - field: fieldName, - background_filter: { bool: { filter: backgroundFilters } }, + return withApmSpan('get_correlations_for_failed_transactions', async () => { + const { start, end, esFilter, apmEventClient } = setup; + + const backgroundFilters: ESFilter[] = [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ]; + + if (serviceName) { + backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } }); + } + + if (transactionType) { + backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } }); + } + + if (transactionName) { + backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } }); + } + + const params = { + apm: { events: [ProcessorEvent.transaction] }, + track_total_hits: true, + body: { + size: 0, + query: { + bool: { filter: backgroundFilters }, + }, + aggs: { + failed_transactions: { + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + + // significant term aggs + aggs: fieldNames.reduce((acc, fieldName) => { + return { + ...acc, + [fieldName]: { + significant_terms: { + size: 10, + field: fieldName, + background_filter: { + bool: { + filter: backgroundFilters, + must_not: { + term: { [EVENT_OUTCOME]: EventOutcome.failure }, + }, + }, + }, + }, }, - }, - }; - }, {} as Record), + }; + }, {} as Record), + }, }, }, - }, - }; - - const response = await apmEventClient.search(params); - if (!response.aggregations) { - return {}; - } - - const failedTransactionCount = - response.aggregations?.failed_transactions.doc_count; - const totalTransactionCount = response.hits.total.value; - const avgErrorRate = (failedTransactionCount / totalTransactionCount) * 100; - const sigTermAggs = omit( - response.aggregations?.failed_transactions, - 'doc_count' - ); - - const topSigTerms = processSignificantTermAggs({ - sigTermAggs, - thresholdPercentage: avgErrorRate, + }; + + const response = await apmEventClient.search(params); + if (!response.aggregations) { + return {}; + } + + const sigTermAggs = omit( + response.aggregations?.failed_transactions, + 'doc_count' + ); + + const topSigTerms = processSignificantTermAggs({ sigTermAggs }); + return getErrorRateTimeSeries({ setup, backgroundFilters, topSigTerms }); }); - return getErrorRateTimeSeries({ setup, backgroundFilters, topSigTerms }); } export async function getErrorRateTimeSeries({ @@ -120,71 +128,73 @@ export async function getErrorRateTimeSeries({ backgroundFilters: ESFilter[]; topSigTerms: TopSigTerm[]; }) { - const { start, end, apmEventClient } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets: 30 }); - - if (isEmpty(topSigTerms)) { - return {}; - } - - const timeseriesAgg = { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { min: start, max: end }, - }, - aggs: { - outcomes: getOutcomeAggregation(), - }, - }; - - const perTermAggs = topSigTerms.reduce( - (acc, term, index) => { - acc[`term_${index}`] = { - filter: { term: { [term.fieldName]: term.fieldValue } }, - aggs: { timeseries: timeseriesAgg }, - }; - return acc; - }, - {} as { - [key: string]: { - filter: AggregationOptionsByType['filter']; - aggs: { timeseries: typeof timeseriesAgg }; - }; + return withApmSpan('get_error_rate_timeseries', async () => { + const { start, end, apmEventClient } = setup; + const { intervalString } = getBucketSize({ start, end, numBuckets: 15 }); + + if (isEmpty(topSigTerms)) { + return {}; } - ); - - const params = { - // TODO: add support for metrics - apm: { events: [ProcessorEvent.transaction] }, - body: { - size: 0, - query: { bool: { filter: backgroundFilters } }, - aggs: merge({ timeseries: timeseriesAgg }, perTermAggs), - }, - }; - - const response = await apmEventClient.search(params); - const { aggregations } = response; - - if (!aggregations) { - return {}; - } - - return { - overall: { - timeseries: getTransactionErrorRateTimeSeries( - aggregations.timeseries.buckets - ), - }, - significantTerms: topSigTerms.map((topSig, index) => { - const agg = aggregations[`term_${index}`]!; - - return { - ...topSig, - timeseries: getTransactionErrorRateTimeSeries(agg.timeseries.buckets), - }; - }), - }; + + const timeseriesAgg = { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: { + outcomes: getOutcomeAggregation(), + }, + }; + + const perTermAggs = topSigTerms.reduce( + (acc, term, index) => { + acc[`term_${index}`] = { + filter: { term: { [term.fieldName]: term.fieldValue } }, + aggs: { timeseries: timeseriesAgg }, + }; + return acc; + }, + {} as { + [key: string]: { + filter: AggregationOptionsByType['filter']; + aggs: { timeseries: typeof timeseriesAgg }; + }; + } + ); + + const params = { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: { + size: 0, + query: { bool: { filter: backgroundFilters } }, + aggs: merge({ timeseries: timeseriesAgg }, perTermAggs), + }, + }; + + const response = await apmEventClient.search(params); + const { aggregations } = response; + + if (!aggregations) { + return {}; + } + + return { + overall: { + timeseries: getTransactionErrorRateTimeSeries( + aggregations.timeseries.buckets + ), + }, + significantTerms: topSigTerms.map((topSig, index) => { + const agg = aggregations[`term_${index}`]!; + + return { + ...topSig, + timeseries: getTransactionErrorRateTimeSeries(agg.timeseries.buckets), + }; + }), + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_duration_for_percentile.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_duration_for_percentile.ts index 096d601b2f9091..27f69c3ca7d56d 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_duration_for_percentile.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_duration_for_percentile.ts @@ -8,6 +8,7 @@ import { ESFilter } from '../../../../../../typings/elasticsearch'; import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; export async function getDurationForPercentile({ @@ -19,26 +20,28 @@ export async function getDurationForPercentile({ backgroundFilters: ESFilter[]; setup: Setup & SetupTimeRange; }) { - const { apmEventClient } = setup; - const res = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - size: 0, - query: { - bool: { filter: backgroundFilters }, + return withApmSpan('get_duration_for_percentiles', async () => { + const { apmEventClient } = setup; + const res = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], }, - aggs: { - percentile: { - percentiles: { - field: TRANSACTION_DURATION, - percents: [durationPercentile], + body: { + size: 0, + query: { + bool: { filter: backgroundFilters }, + }, + aggs: { + percentile: { + percentiles: { + field: TRANSACTION_DURATION, + percents: [durationPercentile], + }, }, }, }, - }, - }); + }); - return Object.values(res.aggregations?.percentile.values || {})[0]; + return Object.values(res.aggregations?.percentile.values || {})[0]; + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts index 9601700df4a5a7..eab09e814c18db 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_latency_distribution.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isEmpty } from 'lodash'; +import { isEmpty, dropRightWhile } from 'lodash'; import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch/aggregations'; import { ESFilter } from '../../../../../../typings/elasticsearch'; import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames'; @@ -13,6 +13,7 @@ import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { TopSigTerm } from '../process_significant_term_aggs'; import { getMaxLatency } from './get_max_latency'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getLatencyDistribution({ setup, @@ -23,113 +24,120 @@ export async function getLatencyDistribution({ backgroundFilters: ESFilter[]; topSigTerms: TopSigTerm[]; }) { - const { apmEventClient } = setup; + return withApmSpan('get_latency_distribution', async () => { + const { apmEventClient } = setup; - if (isEmpty(topSigTerms)) { - return {}; - } + if (isEmpty(topSigTerms)) { + return {}; + } - const maxLatency = await getMaxLatency({ - setup, - backgroundFilters, - topSigTerms, - }); + const maxLatency = await getMaxLatency({ + setup, + backgroundFilters, + topSigTerms, + }); + + if (!maxLatency) { + return {}; + } + + const intervalBuckets = 15; + const distributionInterval = Math.floor(maxLatency / intervalBuckets); - if (!maxLatency) { - return {}; - } - - const intervalBuckets = 20; - const distributionInterval = roundtoTenth(maxLatency / intervalBuckets); - - const distributionAgg = { - // filter out outliers not included in the significant term docs - filter: { range: { [TRANSACTION_DURATION]: { lte: maxLatency } } }, - aggs: { - dist_filtered_by_latency: { - histogram: { - // TODO: add support for metrics - field: TRANSACTION_DURATION, - interval: distributionInterval, - min_doc_count: 0, - extended_bounds: { - min: 0, - max: maxLatency, + const distributionAgg = { + // filter out outliers not included in the significant term docs + filter: { range: { [TRANSACTION_DURATION]: { lte: maxLatency } } }, + aggs: { + dist_filtered_by_latency: { + histogram: { + // TODO: add support for metrics + field: TRANSACTION_DURATION, + interval: distributionInterval, + min_doc_count: 0, + extended_bounds: { + min: 0, + max: maxLatency, + }, }, }, }, - }, - }; - - const perTermAggs = topSigTerms.reduce( - (acc, term, index) => { - acc[`term_${index}`] = { - filter: { term: { [term.fieldName]: term.fieldValue } }, + }; + + const perTermAggs = topSigTerms.reduce( + (acc, term, index) => { + acc[`term_${index}`] = { + filter: { term: { [term.fieldName]: term.fieldValue } }, + aggs: { + distribution: distributionAgg, + }, + }; + return acc; + }, + {} as Record< + string, + { + filter: AggregationOptionsByType['filter']; + aggs: { + distribution: typeof distributionAgg; + }; + } + > + ); + + const params = { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: { + size: 0, + query: { bool: { filter: backgroundFilters } }, aggs: { + // overall aggs distribution: distributionAgg, - }, - }; - return acc; - }, - {} as Record< - string, - { - filter: AggregationOptionsByType['filter']; - aggs: { - distribution: typeof distributionAgg; - }; - } - > - ); - - const params = { - // TODO: add support for metrics - apm: { events: [ProcessorEvent.transaction] }, - body: { - size: 0, - query: { bool: { filter: backgroundFilters } }, - aggs: { - // overall aggs - distribution: distributionAgg, - // per term aggs - ...perTermAggs, + // per term aggs + ...perTermAggs, + }, }, - }, - }; - - const response = await apmEventClient.search(params); - type Agg = NonNullable; - - if (!response.aggregations) { - return; - } - - function formatDistribution(distribution: Agg['distribution']) { - const total = distribution.doc_count; - return distribution.dist_filtered_by_latency.buckets.map((bucket) => ({ - x: bucket.key, - y: (bucket.doc_count / total) * 100, - })); - } - - return { - distributionInterval, - overall: { - distribution: formatDistribution(response.aggregations.distribution), - }, - significantTerms: topSigTerms.map((topSig, index) => { - // @ts-expect-error - const agg = response.aggregations[`term_${index}`] as Agg; - - return { - ...topSig, - distribution: formatDistribution(agg.distribution), - }; - }), - }; -} + }; + + const response = await withApmSpan('get_terms_distribution', () => + apmEventClient.search(params) + ); + type Agg = NonNullable; + + if (!response.aggregations) { + return; + } + + function formatDistribution(distribution: Agg['distribution']) { + const total = distribution.doc_count; + + // remove trailing buckets that are empty and out of bounds of the desired number of buckets + const buckets = dropRightWhile( + distribution.dist_filtered_by_latency.buckets, + (bucket, index) => bucket.doc_count === 0 && index > intervalBuckets - 1 + ); + + return buckets.map((bucket) => ({ + x: bucket.key, + y: (bucket.doc_count / total) * 100, + })); + } + + return { + distributionInterval, + overall: { + distribution: formatDistribution(response.aggregations.distribution), + }, + significantTerms: topSigTerms.map((topSig, index) => { + // @ts-expect-error + const agg = response.aggregations[`term_${index}`] as Agg; -function roundtoTenth(v: number) { - return Math.pow(10, Math.round(Math.log10(v))); + return { + ...topSig, + distribution: formatDistribution(agg.distribution), + }; + }), + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts index 2db764982c4325..2777c0944afd13 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/get_max_latency.ts @@ -8,6 +8,7 @@ import { ESFilter } from '../../../../../../typings/elasticsearch'; import { TRANSACTION_DURATION } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { TopSigTerm } from '../process_significant_term_aggs'; @@ -20,35 +21,37 @@ export async function getMaxLatency({ backgroundFilters: ESFilter[]; topSigTerms: TopSigTerm[]; }) { - const { apmEventClient } = setup; + return withApmSpan('get_max_latency', async () => { + const { apmEventClient } = setup; - const params = { - // TODO: add support for metrics - apm: { events: [ProcessorEvent.transaction] }, - body: { - size: 0, - query: { - bool: { - filter: backgroundFilters, + const params = { + // TODO: add support for metrics + apm: { events: [ProcessorEvent.transaction] }, + body: { + size: 0, + query: { + bool: { + filter: backgroundFilters, - // only include docs containing the significant terms - should: topSigTerms.map((term) => ({ - term: { [term.fieldName]: term.fieldValue }, - })), - minimum_should_match: 1, + // only include docs containing the significant terms + should: topSigTerms.map((term) => ({ + term: { [term.fieldName]: term.fieldValue }, + })), + minimum_should_match: 1, + }, }, - }, - aggs: { - // TODO: add support for metrics - // max_latency: { max: { field: TRANSACTION_DURATION } }, - max_latency: { - percentiles: { field: TRANSACTION_DURATION, percents: [99] }, + aggs: { + // TODO: add support for metrics + // max_latency: { max: { field: TRANSACTION_DURATION } }, + max_latency: { + percentiles: { field: TRANSACTION_DURATION, percents: [99] }, + }, }, }, - }, - }; + }; - const response = await apmEventClient.search(params); - // return response.aggregations?.max_latency.value; - return Object.values(response.aggregations?.max_latency.values ?? {})[0]; + const response = await apmEventClient.search(params); + // return response.aggregations?.max_latency.value; + return Object.values(response.aggregations?.max_latency.values ?? {})[0]; + }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts index cbca2d1f41effe..832b89a18d1028 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_slow_transactions/index.ts @@ -7,20 +7,23 @@ import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch/aggregations'; import { ESFilter } from '../../../../../../typings/elasticsearch'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { SERVICE_NAME, TRANSACTION_DURATION, TRANSACTION_NAME, TRANSACTION_TYPE, + PROCESSOR_EVENT, } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getDurationForPercentile } from './get_duration_for_percentile'; import { processSignificantTermAggs } from '../process_significant_term_aggs'; import { getLatencyDistribution } from './get_latency_distribution'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getCorrelationsForSlowTransactions({ + environment, serviceName, transactionType, transactionName, @@ -28,6 +31,7 @@ export async function getCorrelationsForSlowTransactions({ fieldNames, setup, }: { + environment?: string; serviceName: string | undefined; transactionType: string | undefined; transactionName: string | undefined; @@ -35,75 +39,100 @@ export async function getCorrelationsForSlowTransactions({ fieldNames: string[]; setup: Setup & SetupTimeRange; }) { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan('get_correlations_for_slow_transactions', async () => { + const { start, end, esFilter, apmEventClient } = setup; - const backgroundFilters: ESFilter[] = [ - ...esFilter, - { range: rangeFilter(start, end) }, - ]; + const backgroundFilters: ESFilter[] = [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ]; - if (serviceName) { - backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } }); - } + if (serviceName) { + backgroundFilters.push({ term: { [SERVICE_NAME]: serviceName } }); + } - if (transactionType) { - backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } }); - } + if (transactionType) { + backgroundFilters.push({ term: { [TRANSACTION_TYPE]: transactionType } }); + } - if (transactionName) { - backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } }); - } + if (transactionName) { + backgroundFilters.push({ term: { [TRANSACTION_NAME]: transactionName } }); + } - const durationForPercentile = await getDurationForPercentile({ - durationPercentile, - backgroundFilters, - setup, - }); + const durationForPercentile = await getDurationForPercentile({ + durationPercentile, + backgroundFilters, + setup, + }); - const params = { - apm: { events: [ProcessorEvent.transaction] }, - body: { - size: 0, - query: { - bool: { - // foreground filters - filter: [ - ...backgroundFilters, - { - range: { [TRANSACTION_DURATION]: { gte: durationForPercentile } }, - }, - ], - }, - }, - aggs: fieldNames.reduce((acc, fieldName) => { - return { - ...acc, - [fieldName]: { - significant_terms: { - size: 10, - field: fieldName, - background_filter: { bool: { filter: backgroundFilters } }, + const response = await withApmSpan('get_significant_terms', () => { + const params = { + apm: { events: [ProcessorEvent.transaction] }, + body: { + size: 0, + query: { + bool: { + // foreground filters + filter: backgroundFilters, + must: { + function_score: { + query: { + range: { + [TRANSACTION_DURATION]: { gte: durationForPercentile }, + }, + }, + script_score: { + script: { + source: `Math.log(2 + doc['${TRANSACTION_DURATION}'].value)`, + }, + }, + }, + }, }, }, - }; - }, {} as Record), - }, - }; - - const response = await apmEventClient.search(params); - - if (!response.aggregations) { - return {}; - } + aggs: fieldNames.reduce((acc, fieldName) => { + return { + ...acc, + [fieldName]: { + significant_terms: { + size: 10, + field: fieldName, + background_filter: { + bool: { + filter: [ + ...backgroundFilters, + { + range: { + [TRANSACTION_DURATION]: { + lt: durationForPercentile, + }, + }, + }, + ], + }, + }, + }, + }, + }; + }, {} as Record), + }, + }; + return apmEventClient.search(params); + }); + if (!response.aggregations) { + return {}; + } - const topSigTerms = processSignificantTermAggs({ - sigTermAggs: response.aggregations, - thresholdPercentage: 100 - durationPercentile, - }); + const topSigTerms = processSignificantTermAggs({ + sigTermAggs: response.aggregations, + }); - return getLatencyDistribution({ - setup, - backgroundFilters, - topSigTerms, + return getLatencyDistribution({ + setup, + backgroundFilters, + topSigTerms, + }); }); } diff --git a/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts b/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts index 6bfc2ab2890b8a..1fe50c869f5bf3 100644 --- a/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts +++ b/x-pack/plugins/apm/server/lib/correlations/process_significant_term_aggs.ts @@ -12,11 +12,12 @@ import { } from '../../../../../typings/elasticsearch/aggregations'; export interface TopSigTerm { - bgCount: number; - fgCount: number; fieldName: string; fieldValue: string | number; score: number; + impact: number; + fieldCount: number; + valueCount: number; } type SigTermAgg = AggregationResultOf< @@ -24,31 +25,52 @@ type SigTermAgg = AggregationResultOf< {} >; +function getMaxImpactScore(scores: number[]) { + if (scores.length === 0) { + return 0; + } + + const sortedScores = scores.sort((a, b) => b - a); + const maxScore = sortedScores[0]; + + // calculate median + const halfSize = scores.length / 2; + const medianIndex = Math.floor(halfSize); + const medianScore = + medianIndex < halfSize + ? sortedScores[medianIndex] + : (sortedScores[medianIndex - 1] + sortedScores[medianIndex]) / 2; + + return Math.max(maxScore, medianScore * 2); +} + export function processSignificantTermAggs({ sigTermAggs, - thresholdPercentage, }: { sigTermAggs: Record; - thresholdPercentage: number; }) { const significantTerms = Object.entries(sigTermAggs).flatMap( ([fieldName, agg]) => { return agg.buckets.map((bucket) => ({ fieldName, fieldValue: bucket.key, - bgCount: bucket.bg_count, - fgCount: bucket.doc_count, + fieldCount: agg.doc_count, + valueCount: bucket.doc_count, score: bucket.score, })); } ); + const maxImpactScore = getMaxImpactScore( + significantTerms.map(({ score }) => score) + ); + // get top 10 terms ordered by score const topSigTerms = orderBy(significantTerms, 'score', 'desc') - .filter(({ bgCount, fgCount }) => { - // only include results that are above the threshold - return Math.floor((fgCount / bgCount) * 100) > thresholdPercentage; - }) + .map((significantTerm) => ({ + ...significantTerm, + impact: significantTerm.score / maxImpactScore, + })) .slice(0, 10); return topSigTerms; } diff --git a/x-pack/plugins/apm/server/lib/ui_filters/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/environments/__snapshots__/get_environments.test.ts.snap similarity index 92% rename from x-pack/plugins/apm/server/lib/ui_filters/__snapshots__/queries.test.ts.snap rename to x-pack/plugins/apm/server/lib/environments/__snapshots__/get_environments.test.ts.snap index 3baaefe203ce75..a244eee3d0544c 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/environments/__snapshots__/get_environments.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ui filter queries fetches environments 1`] = ` +exports[`getEnvironments fetches environments 1`] = ` Object { "apm": Object { "events": Array [ @@ -44,7 +44,7 @@ Object { } `; -exports[`ui filter queries fetches environments without a service name 1`] = ` +exports[`getEnvironments fetches environments without a service name 1`] = ` Object { "apm": Object { "events": Array [ diff --git a/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts b/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts index 44158433ce5a2d..1bf01c24776fb0 100644 --- a/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts +++ b/x-pack/plugins/apm/server/lib/environments/get_all_environments.ts @@ -13,7 +13,12 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; +/** + * This is used for getting *all* environments, and does not filter by range. + * It's used in places where we get the list of all possible environments. + */ export async function getAllEnvironments({ serviceName, setup, @@ -25,52 +30,59 @@ export async function getAllEnvironments({ searchAggregatedTransactions: boolean; includeMissing?: boolean; }) { - const { apmEventClient, config } = setup; - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; + const spanName = serviceName + ? 'get_all_environments_for_service' + : 'get_all_environments_for_all_services'; + return withApmSpan(spanName, async () => { + const { apmEventClient, config } = setup; + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; - // omit filter for service.name if "All" option is selected - const serviceNameFilter = serviceName - ? [{ term: { [SERVICE_NAME]: serviceName } }] - : []; + // omit filter for service.name if "All" option is selected + const serviceNameFilter = serviceName + ? [{ term: { [SERVICE_NAME]: serviceName } }] + : []; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - // use timeout + min_doc_count to return as early as possible - // if filter is not defined to prevent timeouts - ...(!serviceName ? { timeout: '1ms' } : {}), - size: 0, - query: { - bool: { - filter: [...serviceNameFilter], - }, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], }, - aggs: { - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - size: maxServiceEnvironments, - ...(!serviceName ? { min_doc_count: 0 } : {}), - missing: includeMissing ? ENVIRONMENT_NOT_DEFINED.value : undefined, + body: { + // use timeout + min_doc_count to return as early as possible + // if filter is not defined to prevent timeouts + ...(!serviceName ? { timeout: '1ms' } : {}), + size: 0, + query: { + bool: { + filter: [...serviceNameFilter], + }, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + size: maxServiceEnvironments, + ...(!serviceName ? { min_doc_count: 0 } : {}), + missing: includeMissing + ? ENVIRONMENT_NOT_DEFINED.value + : undefined, + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - const environments = - resp.aggregations?.environments.buckets.map( - (bucket) => bucket.key as string - ) || []; - return environments; + const environments = + resp.aggregations?.environments.buckets.map( + (bucket) => bucket.key as string + ) || []; + return environments; + }); } diff --git a/x-pack/plugins/apm/server/lib/ui_filters/queries.test.ts b/x-pack/plugins/apm/server/lib/environments/get_environments.test.ts similarity index 96% rename from x-pack/plugins/apm/server/lib/ui_filters/queries.test.ts rename to x-pack/plugins/apm/server/lib/environments/get_environments.test.ts index 4a7d2029463e51..53292cabfc3389 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/environments/get_environments.test.ts @@ -11,7 +11,7 @@ import { inspectSearchParams, } from '../../utils/test_helpers'; -describe('ui filter queries', () => { +describe('getEnvironments', () => { let mock: SearchParamsMock; afterEach(() => { diff --git a/x-pack/plugins/apm/server/lib/environments/get_environments.ts b/x-pack/plugins/apm/server/lib/environments/get_environments.ts new file mode 100644 index 00000000000000..af88493c148ce1 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/environments/get_environments.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + SERVICE_ENVIRONMENT, + SERVICE_NAME, +} from '../../../common/elasticsearch_fieldnames'; +import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { rangeQuery } from '../../../common/utils/queries'; +import { withApmSpan } from '../../utils/with_apm_span'; +import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; + +/** + * This is used for getting the list of environments for the environments selector, + * filtered by range. + */ +export async function getEnvironments({ + setup, + serviceName, + searchAggregatedTransactions, +}: { + setup: Setup & SetupTimeRange; + serviceName?: string; + searchAggregatedTransactions: boolean; +}) { + const spanName = serviceName + ? 'get_environments_for_service' + : 'get_environments'; + + return withApmSpan(spanName, async () => { + const { start, end, apmEventClient, config } = setup; + + const filter = rangeQuery(start, end); + + if (serviceName) { + filter.push({ + term: { [SERVICE_NAME]: serviceName }, + }); + } + + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; + + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.metric, + ProcessorEvent.error, + ], + }, + body: { + size: 0, + query: { + bool: { + filter, + }, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + missing: ENVIRONMENT_NOT_DEFINED.value, + size: maxServiceEnvironments, + }, + }, + }, + }, + }; + + const resp = await apmEventClient.search(params); + const aggs = resp.aggregations; + const environmentsBuckets = aggs?.environments.buckets || []; + + const environments = environmentsBuckets.map( + (environmentBucket) => environmentBucket.key as string + ); + + return environments; + }); +} diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.test.ts b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.test.ts index db8414864f5771..1712699162b73c 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.test.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.test.ts @@ -25,6 +25,7 @@ describe('get buckets', () => { }); await getBuckets({ + environment: 'prod', serviceName: 'myServiceName', bucketSize: 10, setup: { @@ -42,14 +43,8 @@ describe('get buckets', () => { get: () => 'myIndex', } ) as APMConfig, - uiFilters: { - environment: 'prod', - }, - esFilter: [ - { - term: { 'service.environment': 'prod' }, - }, - ], + uiFilters: {}, + esFilter: [], indices: { /* eslint-disable @typescript-eslint/naming-convention */ 'apm_oss.sourcemapIndices': 'apm-*', diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts index 2f409cc32ceb00..fbe406d8d1a9d2 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts @@ -11,69 +11,75 @@ import { SERVICE_NAME, } from '../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../common/processor_event'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; export async function getBuckets({ + environment, serviceName, groupId, bucketSize, setup, }: { + environment?: string; serviceName: string; groupId?: string; bucketSize: number; setup: Setup & SetupTimeRange; }) { - const { start, end, esFilter, apmEventClient } = setup; - const filter: ESFilter[] = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ]; + return withApmSpan('get_error_distribution_buckets', async () => { + const { start, end, esFilter, apmEventClient } = setup; + const filter: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ]; - if (groupId) { - filter.push({ term: { [ERROR_GROUP_ID]: groupId } }); - } + if (groupId) { + filter.push({ term: { [ERROR_GROUP_ID]: groupId } }); + } - const params = { - apm: { - events: [ProcessorEvent.error], - }, - body: { - size: 0, - query: { - bool: { - filter, - }, + const params = { + apm: { + events: [ProcessorEvent.error], }, - aggs: { - distribution: { - histogram: { - field: '@timestamp', - min_doc_count: 0, - interval: bucketSize, - extended_bounds: { - min: start, - max: end, + body: { + size: 0, + query: { + bool: { + filter, + }, + }, + aggs: { + distribution: { + histogram: { + field: '@timestamp', + min_doc_count: 0, + interval: bucketSize, + extended_bounds: { + min: start, + max: end, + }, }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - const buckets = (resp.aggregations?.distribution.buckets || []).map( - (bucket) => ({ - key: bucket.key, - count: bucket.doc_count, - }) - ); + const buckets = (resp.aggregations?.distribution.buckets || []).map( + (bucket) => ({ + key: bucket.key, + count: bucket.doc_count, + }) + ); - return { - noHits: resp.hits.total.value === 0, - buckets: resp.hits.total.value > 0 ? buckets : [], - }; + return { + noHits: resp.hits.total.value === 0, + buckets: resp.hits.total.value > 0 ? buckets : [], + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/get_distribution.ts b/x-pack/plugins/apm/server/lib/errors/distribution/get_distribution.ts index be3a29780a5b65..1fb0cbad4a5f09 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/get_distribution.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/get_distribution.ts @@ -14,16 +14,19 @@ function getBucketSize({ start, end }: SetupTimeRange) { } export async function getErrorDistribution({ + environment, serviceName, groupId, setup, }: { + environment?: string; serviceName: string; groupId?: string; setup: Setup & SetupTimeRange; }) { const bucketSize = getBucketSize({ start: setup.start, end: setup.end }); const { buckets, noHits } = await getBuckets({ + environment, serviceName, groupId, bucketSize, diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts index efaf02b10c6868..0ab26f3c6e969d 100644 --- a/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts +++ b/x-pack/plugins/apm/server/lib/errors/get_error_group_sample.ts @@ -11,58 +11,64 @@ import { TRANSACTION_SAMPLED, } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../common/utils/queries'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getTransaction } from '../transactions/get_transaction'; -export async function getErrorGroupSample({ +export function getErrorGroupSample({ + environment, serviceName, groupId, setup, }: { + environment?: string; serviceName: string; groupId: string; setup: Setup & SetupTimeRange; }) { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan('get_error_group_sample', async () => { + const { start, end, esFilter, apmEventClient } = setup; - const params = { - apm: { - events: [ProcessorEvent.error as const], - }, - body: { - size: 1, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [ERROR_GROUP_ID]: groupId } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ], - should: [{ term: { [TRANSACTION_SAMPLED]: true } }], + const params = { + apm: { + events: [ProcessorEvent.error as const], + }, + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [ERROR_GROUP_ID]: groupId } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + should: [{ term: { [TRANSACTION_SAMPLED]: true } }], + }, }, + sort: [ + { _score: 'desc' }, // sort by _score first to ensure that errors with transaction.sampled:true ends up on top + { '@timestamp': { order: 'desc' } }, // sort by timestamp to get the most recent error + ], }, - sort: [ - { _score: 'desc' }, // sort by _score first to ensure that errors with transaction.sampled:true ends up on top - { '@timestamp': { order: 'desc' } }, // sort by timestamp to get the most recent error - ], - }, - }; + }; - const resp = await apmEventClient.search(params); - const error = resp.hits.hits[0]?._source; - const transactionId = error?.transaction?.id; - const traceId = error?.trace?.id; + const resp = await apmEventClient.search(params); + const error = resp.hits.hits[0]?._source; + const transactionId = error?.transaction?.id; + const traceId = error?.trace?.id; - let transaction; - if (transactionId && traceId) { - transaction = await getTransaction({ transactionId, traceId, setup }); - } + let transaction; + if (transactionId && traceId) { + transaction = await getTransaction({ transactionId, traceId, setup }); + } - return { - transaction, - error, - occurrencesCount: resp.hits.total.value, - }; + return { + transaction, + error, + occurrencesCount: resp.hits.total.value, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts b/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts index 44b681d7ba2016..28d89eb0574709 100644 --- a/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts +++ b/x-pack/plugins/apm/server/lib/errors/get_error_groups.ts @@ -16,92 +16,103 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { getErrorGroupsProjection } from '../../projections/errors'; import { mergeProjection } from '../../projections/util/merge_projection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getErrorName } from '../helpers/get_error_name'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -export async function getErrorGroups({ +export function getErrorGroups({ + environment, serviceName, sortField, sortDirection = 'desc', setup, }: { + environment?: string; serviceName: string; sortField?: string; sortDirection?: 'asc' | 'desc'; setup: Setup & SetupTimeRange; }) { - const { apmEventClient } = setup; + return withApmSpan('get_error_groups', async () => { + const { apmEventClient } = setup; - // sort buckets by last occurrence of error - const sortByLatestOccurrence = sortField === 'latestOccurrenceAt'; + // sort buckets by last occurrence of error + const sortByLatestOccurrence = sortField === 'latestOccurrenceAt'; - const projection = getErrorGroupsProjection({ setup, serviceName }); + const projection = getErrorGroupsProjection({ + environment, + setup, + serviceName, + }); - const order: SortOptions = sortByLatestOccurrence - ? { - max_timestamp: sortDirection, - } - : { _count: sortDirection }; + const order: SortOptions = sortByLatestOccurrence + ? { + max_timestamp: sortDirection, + } + : { _count: sortDirection }; - const params = mergeProjection(projection, { - body: { - size: 0, - aggs: { - error_groups: { - terms: { - ...projection.body.aggs.error_groups.terms, - size: 500, - order, - }, - aggs: { - sample: { - top_hits: { - _source: [ - ERROR_LOG_MESSAGE, - ERROR_EXC_MESSAGE, - ERROR_EXC_HANDLED, - ERROR_EXC_TYPE, - ERROR_CULPRIT, - ERROR_GROUP_ID, - '@timestamp', - ], - sort: [{ '@timestamp': 'desc' as const }], - size: 1, - }, + const params = mergeProjection(projection, { + body: { + size: 0, + aggs: { + error_groups: { + terms: { + ...projection.body.aggs.error_groups.terms, + size: 500, + order, }, - ...(sortByLatestOccurrence - ? { - max_timestamp: { - max: { - field: '@timestamp', + aggs: { + sample: { + top_hits: { + _source: [ + ERROR_LOG_MESSAGE, + ERROR_EXC_MESSAGE, + ERROR_EXC_HANDLED, + ERROR_EXC_TYPE, + ERROR_CULPRIT, + ERROR_GROUP_ID, + '@timestamp', + ], + sort: [{ '@timestamp': 'desc' as const }], + size: 1, + }, + }, + ...(sortByLatestOccurrence + ? { + max_timestamp: { + max: { + field: '@timestamp', + }, }, - }, - } - : {}), + } + : {}), + }, }, }, }, - }, - }); + }); - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - // aggregations can be undefined when no matching indices are found. - // this is an exception rather than the rule so the ES type does not account for this. - const hits = (resp.aggregations?.error_groups.buckets || []).map((bucket) => { - const source = bucket.sample.hits.hits[0]._source; - const message = getErrorName(source); + // aggregations can be undefined when no matching indices are found. + // this is an exception rather than the rule so the ES type does not account for this. + const hits = (resp.aggregations?.error_groups.buckets || []).map( + (bucket) => { + const source = bucket.sample.hits.hits[0]._source; + const message = getErrorName(source); - return { - message, - occurrenceCount: bucket.doc_count, - culprit: source.error.culprit, - groupId: source.error.grouping_key, - latestOccurrenceAt: source['@timestamp'], - handled: source.error.exception?.[0].handled, - type: source.error.exception?.[0].type, - }; - }); + return { + message, + occurrenceCount: bucket.doc_count, + culprit: source.error.culprit, + groupId: source.error.grouping_key, + latestOccurrenceAt: source['@timestamp'], + handled: source.error.exception?.[0].handled, + type: source.error.exception?.[0].type, + }; + } + ); - return hits; + return hits; + }); } diff --git a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts index 04f7edc3e6dd56..71744c3e590929 100644 --- a/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts @@ -6,7 +6,7 @@ */ import { SearchAggregatedTransactionSetting } from '../../../../common/aggregated_transactions'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { rangeQuery } from '../../../../common/utils/queries'; import { ProcessorEvent } from '../../../../common/processor_event'; import { TRANSACTION_DURATION, @@ -14,6 +14,7 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import { APMConfig } from '../../..'; import { APMEventClient } from '../create_es_client/create_apm_event_client'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getHasAggregatedTransactions({ start, @@ -24,28 +25,30 @@ export async function getHasAggregatedTransactions({ end?: number; apmEventClient: APMEventClient; }) { - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - query: { - bool: { - filter: [ - { exists: { field: TRANSACTION_DURATION_HISTOGRAM } }, - ...(start && end ? [{ range: rangeFilter(start, end) }] : []), - ], + return withApmSpan('get_has_aggregated_transactions', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + query: { + bool: { + filter: [ + { exists: { field: TRANSACTION_DURATION_HISTOGRAM } }, + ...(start && end ? rangeQuery(start, end) : []), + ], + }, }, }, - }, - terminateAfter: 1, - }); + terminateAfter: 1, + }); - if (response.hits.total.value > 0) { - return true; - } + if (response.hits.total.value > 0) { + return true; + } - return false; + return false; + }); } export async function getSearchAggregatedTransactions({ diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.test.ts b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.test.ts deleted file mode 100644 index 57bf511f459428..00000000000000 --- a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getEnvironmentUiFilterES } from './get_environment_ui_filter_es'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; -import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; - -describe('getEnvironmentUiFilterES', () => { - it('should return empty array, when environment is undefined', () => { - const uiFilterES = getEnvironmentUiFilterES(); - expect(uiFilterES).toHaveLength(0); - }); - - it('should create a filter for a service environment', () => { - const uiFilterES = getEnvironmentUiFilterES('test'); - expect(uiFilterES).toHaveLength(1); - expect(uiFilterES[0]).toHaveProperty(['term', SERVICE_ENVIRONMENT], 'test'); - }); - - it('should create a filter for missing service environments', () => { - const uiFilterES = getEnvironmentUiFilterES(ENVIRONMENT_NOT_DEFINED.value); - expect(uiFilterES).toHaveLength(1); - expect(uiFilterES[0]).toHaveProperty( - ['bool', 'must_not', 'exists', 'field'], - SERVICE_ENVIRONMENT - ); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_es_filter.ts b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_es_filter.ts index 7d8bc59e61124a..e91c9b52deecf1 100644 --- a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_es_filter.ts +++ b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_es_filter.ts @@ -7,11 +7,10 @@ import { ESFilter } from '../../../../../../typings/elasticsearch'; import { UIFilters } from '../../../../typings/ui_filters'; -import { getEnvironmentUiFilterES } from './get_environment_ui_filter_es'; import { localUIFilters, localUIFilterNames, -} from '../../ui_filters/local_ui_filters/config'; +} from '../../rum_client/ui_filters/local_ui_filters/config'; import { esKuery } from '../../../../../../../src/plugins/data/server'; export function getEsFilter(uiFilters: UIFilters) { @@ -28,10 +27,7 @@ export function getEsFilter(uiFilters: UIFilters) { }; }) as ESFilter[]; - const esFilters = [ - ...getKueryUiFilterES(uiFilters.kuery), - ...getEnvironmentUiFilterES(uiFilters.environment), - ].concat(mappedFilters) as ESFilter[]; + const esFilters = [...getKueryUiFilterES(uiFilters.kuery), ...mappedFilters]; return esFilters; } diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index b93513646fb9f7..c47d511ca565c7 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -6,29 +6,29 @@ */ import { ValuesType } from 'utility-types'; -import { unwrapEsResponse } from '../../../../../../observability/server'; -import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; import { ElasticsearchClient, KibanaRequest, } from '../../../../../../../../src/core/server'; -import { ProcessorEvent } from '../../../../../common/processor_event'; import { ESSearchRequest, ESSearchResponse, } from '../../../../../../../typings/elasticsearch'; -import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; -import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; -import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; -import { Span } from '../../../../../typings/es_schemas/ui/span'; +import { unwrapEsResponse } from '../../../../../../observability/server'; +import { ProcessorEvent } from '../../../../../common/processor_event'; +import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; import { Metric } from '../../../../../typings/es_schemas/ui/metric'; -import { unpackProcessorEvents } from './unpack_processor_events'; +import { Span } from '../../../../../typings/es_schemas/ui/span'; +import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; +import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; import { callAsyncWithDebug, - getDebugTitle, getDebugBody, + getDebugTitle, } from '../call_async_with_debug'; import { cancelEsRequestOnAbort } from '../cancel_es_request_on_abort'; +import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; +import { unpackProcessorEvents } from './unpack_processor_events'; export type APMEventESSearchRequest = Omit & { apm: { @@ -36,11 +36,13 @@ export type APMEventESSearchRequest = Omit & { }; }; +// These keys shoul all be `ProcessorEvent.x`, but until TypeScript 4.2 we're inlining them here. +// See https://github.com/microsoft/TypeScript/issues/37888 type TypeOfProcessorEvent = { - [ProcessorEvent.error]: APMError; - [ProcessorEvent.transaction]: Transaction; - [ProcessorEvent.span]: Span; - [ProcessorEvent.metric]: Metric; + error: APMError; + transaction: Transaction; + span: Span; + metric: Metric; }[T]; type ESSearchRequestOf = Omit< diff --git a/x-pack/plugins/apm/server/lib/helpers/get_internal_saved_objects_client.ts b/x-pack/plugins/apm/server/lib/helpers/get_internal_saved_objects_client.ts index 9ebb353e049806..6c4b3bad89ecf2 100644 --- a/x-pack/plugins/apm/server/lib/helpers/get_internal_saved_objects_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/get_internal_saved_objects_client.ts @@ -7,12 +7,15 @@ import { CoreSetup } from 'src/core/server'; import { PromiseReturnType } from '../../../../observability/typings/common'; +import { withApmSpan } from '../../utils/with_apm_span'; export type InternalSavedObjectsClient = PromiseReturnType< typeof getInternalSavedObjectsClient >; export async function getInternalSavedObjectsClient(core: CoreSetup) { - return core.getStartServices().then(async ([coreStart]) => { - return coreStart.savedObjects.createInternalRepository(); - }); + return withApmSpan('get_internal_saved_objects_client', () => + core.getStartServices().then(async ([coreStart]) => { + return coreStart.savedObjects.createInternalRepository(); + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 3783714b2d6a01..b12a396befe8c4 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -6,7 +6,6 @@ */ import { Logger } from 'kibana/server'; -import moment from 'moment'; import { isActivePlatinumLicense } from '../../../common/license_check'; import { APMConfig } from '../..'; import { KibanaRequest } from '../../../../../../src/core/server'; @@ -27,6 +26,7 @@ import { APMInternalClient, createInternalESClient, } from './create_es_client/create_internal_es_client'; +import { withApmSpan } from '../../utils/with_apm_span'; // Explicitly type Setup to prevent TS initialization errors // https://github.com/microsoft/TypeScript/issues/34933 @@ -53,68 +53,72 @@ interface SetupRequestParams { /** * Timestamp in ms since epoch */ - start?: string; + start?: number; /** * Timestamp in ms since epoch */ - end?: string; + end?: number; uiFilters?: string; }; } type InferSetup = Setup & - (TParams extends { query: { start: string } } ? { start: number } : {}) & - (TParams extends { query: { end: string } } ? { end: number } : {}); + (TParams extends { query: { start: number } } ? { start: number } : {}) & + (TParams extends { query: { end: number } } ? { end: number } : {}); export async function setupRequest( context: APMRequestHandlerContext, request: KibanaRequest ): Promise> { - const { config, logger } = context; - const { query } = context.params; + return withApmSpan('setup_request', async () => { + const { config, logger } = context; + const { query } = context.params; - const [indices, includeFrozen] = await Promise.all([ - getApmIndices({ - savedObjectsClient: context.core.savedObjects.client, - config, - }), - context.core.uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN), - ]); + const [indices, includeFrozen] = await Promise.all([ + getApmIndices({ + savedObjectsClient: context.core.savedObjects.client, + config, + }), + withApmSpan('get_ui_settings', () => + context.core.uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN) + ), + ]); - const uiFilters = decodeUiFilters(logger, query.uiFilters); + const uiFilters = decodeUiFilters(logger, query.uiFilters); - const coreSetupRequest = { - indices, - apmEventClient: createApmEventClient({ - esClient: context.core.elasticsearch.client.asCurrentUser, - debug: context.params.query._debug, - request, + const coreSetupRequest = { indices, - options: { includeFrozen }, - }), - internalClient: createInternalESClient({ - context, - request, - }), - ml: - context.plugins.ml && isActivePlatinumLicense(context.licensing.license) - ? getMlSetup( - context.plugins.ml, - context.core.savedObjects.client, - request - ) - : undefined, - config, - uiFilters, - esFilter: getEsFilter(uiFilters), - }; + apmEventClient: createApmEventClient({ + esClient: context.core.elasticsearch.client.asCurrentUser, + debug: context.params.query._debug, + request, + indices, + options: { includeFrozen }, + }), + internalClient: createInternalESClient({ + context, + request, + }), + ml: + context.plugins.ml && isActivePlatinumLicense(context.licensing.license) + ? getMlSetup( + context.plugins.ml, + context.core.savedObjects.client, + request + ) + : undefined, + config, + uiFilters, + esFilter: getEsFilter(uiFilters), + }; - return { - ...('start' in query ? { start: moment.utc(query.start).valueOf() } : {}), - ...('end' in query ? { end: moment.utc(query.end).valueOf() } : {}), - ...coreSetupRequest, - } as InferSetup; + return { + ...('start' in query ? { start: query.start } : {}), + ...('end' in query ? { end: query.end } : {}), + ...coreSetupRequest, + } as InferSetup; + }); } function getMlSetup( diff --git a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts index d62386ed02835c..8d0acb7f85f5d7 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/create_static_index_pattern.ts @@ -14,6 +14,7 @@ import { hasHistoricalAgentData } from '../services/get_services/has_historical_ import { Setup } from '../helpers/setup_request'; import { APMRequestHandlerContext } from '../../routes/typings'; import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client.js'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getApmIndexPatternTitle } from './get_apm_index_pattern_title'; export async function createStaticIndexPattern( @@ -21,37 +22,41 @@ export async function createStaticIndexPattern( context: APMRequestHandlerContext, savedObjectsClient: InternalSavedObjectsClient ): Promise { - const { config } = context; + return withApmSpan('create_static_index_pattern', async () => { + const { config } = context; - // don't autocreate APM index pattern if it's been disabled via the config - if (!config['xpack.apm.autocreateApmIndexPattern']) { - return; - } + // don't autocreate APM index pattern if it's been disabled via the config + if (!config['xpack.apm.autocreateApmIndexPattern']) { + return; + } - // Discover and other apps will throw errors if an index pattern exists without having matching indices. - // The following ensures the index pattern is only created if APM data is found - const hasData = await hasHistoricalAgentData(setup); - if (!hasData) { - return; - } + // Discover and other apps will throw errors if an index pattern exists without having matching indices. + // The following ensures the index pattern is only created if APM data is found + const hasData = await hasHistoricalAgentData(setup); + if (!hasData) { + return; + } - try { - const apmIndexPatternTitle = getApmIndexPatternTitle(context); - await savedObjectsClient.create( - 'index-pattern', - { - ...apmIndexPattern.attributes, - title: apmIndexPatternTitle, - }, - { id: APM_STATIC_INDEX_PATTERN_ID, overwrite: false } - ); - return; - } catch (e) { - // if the index pattern (saved object) already exists a conflict error (code: 409) will be thrown - // that error should be silenced - if (SavedObjectsErrorHelpers.isConflictError(e)) { + try { + const apmIndexPatternTitle = getApmIndexPatternTitle(context); + await withApmSpan('create_index_pattern_saved_object', () => + savedObjectsClient.create( + 'index-pattern', + { + ...apmIndexPattern.attributes, + title: apmIndexPatternTitle, + }, + { id: APM_STATIC_INDEX_PATTERN_ID, overwrite: false } + ) + ); return; + } catch (e) { + // if the index pattern (saved object) already exists a conflict error (code: 409) will be thrown + // that error should be silenced + if (SavedObjectsErrorHelpers.isConflictError(e)) { + return; + } + throw e; } - throw e; - } + }); } diff --git a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts index c427588f8d860d..cb6183510ad168 100644 --- a/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts +++ b/x-pack/plugins/apm/server/lib/index_pattern/get_dynamic_index_pattern.ts @@ -11,8 +11,9 @@ import { FieldDescriptor, } from '../../../../../../src/plugins/data/server'; import { APMRequestHandlerContext } from '../../routes/typings'; +import { withApmSpan } from '../../utils/with_apm_span'; -interface IndexPatternTitleAndFields { +export interface IndexPatternTitleAndFields { title: string; fields: FieldDescriptor[]; } @@ -23,50 +24,52 @@ const cache = new LRU({ }); // TODO: this is currently cached globally. In the future we might want to cache this per user -export const getDynamicIndexPattern = async ({ +export const getDynamicIndexPattern = ({ context, }: { context: APMRequestHandlerContext; }) => { - const indexPatternTitle = context.config['apm_oss.indexPattern']; + return withApmSpan('get_dynamic_index_pattern', async () => { + const indexPatternTitle = context.config['apm_oss.indexPattern']; - const CACHE_KEY = `apm_dynamic_index_pattern_${indexPatternTitle}`; - if (cache.has(CACHE_KEY)) { - return cache.get(CACHE_KEY); - } + const CACHE_KEY = `apm_dynamic_index_pattern_${indexPatternTitle}`; + if (cache.has(CACHE_KEY)) { + return cache.get(CACHE_KEY); + } - const indexPatternsFetcher = new IndexPatternsFetcher( - context.core.elasticsearch.client.asCurrentUser - ); + const indexPatternsFetcher = new IndexPatternsFetcher( + context.core.elasticsearch.client.asCurrentUser + ); - // Since `getDynamicIndexPattern` is called in setup_request (and thus by every endpoint) - // and since `getFieldsForWildcard` will throw if the specified indices don't exist, - // we have to catch errors here to avoid all endpoints returning 500 for users without APM data - // (would be a bad first time experience) - try { - const fields = await indexPatternsFetcher.getFieldsForWildcard({ - pattern: indexPatternTitle, - }); + // Since `getDynamicIndexPattern` is called in setup_request (and thus by every endpoint) + // and since `getFieldsForWildcard` will throw if the specified indices don't exist, + // we have to catch errors here to avoid all endpoints returning 500 for users without APM data + // (would be a bad first time experience) + try { + const fields = await indexPatternsFetcher.getFieldsForWildcard({ + pattern: indexPatternTitle, + }); - const indexPattern: IndexPatternTitleAndFields = { - fields, - title: indexPatternTitle, - }; + const indexPattern: IndexPatternTitleAndFields = { + fields, + title: indexPatternTitle, + }; - cache.set(CACHE_KEY, indexPattern); - return indexPattern; - } catch (e) { - // since `getDynamicIndexPattern` can be called multiple times per request it can be expensive not to cache failed lookups - cache.set(CACHE_KEY, undefined); - const notExists = e.output?.statusCode === 404; - if (notExists) { - context.logger.error( - `Could not get dynamic index pattern because indices "${indexPatternTitle}" don't exist` - ); - return; - } + cache.set(CACHE_KEY, indexPattern); + return indexPattern; + } catch (e) { + // since `getDynamicIndexPattern` can be called multiple times per request it can be expensive not to cache failed lookups + cache.set(CACHE_KEY, undefined); + const notExists = e.output?.statusCode === 404; + if (notExists) { + context.logger.error( + `Could not get dynamic index pattern because indices "${indexPatternTitle}" don't exist` + ); + return; + } - // re-throw - throw e; - } + // re-throw + throw e; + } + }); }; diff --git a/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap index 961a1eee61d1d5..4eed09f3e5c28b 100644 --- a/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/metrics/__snapshots__/queries.test.ts.snap @@ -71,6 +71,11 @@ Object { "service.name": "foo", }, }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, Object { "range": Object { "@timestamp": Object { @@ -80,11 +85,6 @@ Object { }, }, }, - Object { - "term": Object { - "service.node.name": "bar", - }, - }, Object { "term": Object { "service.environment": "test", @@ -159,6 +159,11 @@ Object { "service.name": "foo", }, }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, Object { "range": Object { "@timestamp": Object { @@ -168,11 +173,6 @@ Object { }, }, }, - Object { - "term": Object { - "service.node.name": "bar", - }, - }, Object { "term": Object { "service.environment": "test", @@ -322,6 +322,11 @@ Object { "service.name": "foo", }, }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, Object { "range": Object { "@timestamp": Object { @@ -331,11 +336,6 @@ Object { }, }, }, - Object { - "term": Object { - "service.node.name": "bar", - }, - }, Object { "term": Object { "service.environment": "test", @@ -415,6 +415,11 @@ Object { "service.name": "foo", }, }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, Object { "range": Object { "@timestamp": Object { @@ -424,11 +429,6 @@ Object { }, }, }, - Object { - "term": Object { - "service.node.name": "bar", - }, - }, Object { "term": Object { "service.environment": "test", @@ -498,6 +498,11 @@ Object { "service.name": "foo", }, }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, Object { "range": Object { "@timestamp": Object { @@ -507,11 +512,6 @@ Object { }, }, }, - Object { - "term": Object { - "service.node.name": "bar", - }, - }, Object { "term": Object { "service.environment": "test", @@ -601,15 +601,6 @@ Object { "service.name": "foo", }, }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, - }, - }, Object { "bool": Object { "must_not": Array [ @@ -621,6 +612,15 @@ Object { ], }, }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, Object { "term": Object { "service.environment": "test", @@ -695,15 +695,6 @@ Object { "service.name": "foo", }, }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, - }, - }, Object { "bool": Object { "must_not": Array [ @@ -715,6 +706,15 @@ Object { ], }, }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, Object { "term": Object { "service.environment": "test", @@ -864,15 +864,6 @@ Object { "service.name": "foo", }, }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, - }, - }, Object { "bool": Object { "must_not": Array [ @@ -884,6 +875,15 @@ Object { ], }, }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, Object { "term": Object { "service.environment": "test", @@ -963,15 +963,6 @@ Object { "service.name": "foo", }, }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, - }, - }, Object { "bool": Object { "must_not": Array [ @@ -983,6 +974,15 @@ Object { ], }, }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, Object { "term": Object { "service.environment": "test", @@ -1052,15 +1052,6 @@ Object { "service.name": "foo", }, }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, - }, - }, Object { "bool": Object { "must_not": Array [ @@ -1072,6 +1063,15 @@ Object { ], }, }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, Object { "term": Object { "service.environment": "test", diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/default.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/default.ts index e03be2391597d4..c5e80600b69d43 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/default.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/default.ts @@ -9,13 +9,18 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getCPUChartData } from './shared/cpu'; import { getMemoryChartData } from './shared/memory'; -export async function getDefaultMetricsCharts( - setup: Setup & SetupTimeRange, - serviceName: string -) { +export async function getDefaultMetricsCharts({ + environment, + serviceName, + setup, +}: { + environment?: string; + serviceName: string; + setup: Setup & SetupTimeRange; +}) { const charts = await Promise.all([ - getCPUChartData({ setup, serviceName }), - getMemoryChartData({ setup, serviceName }), + getCPUChartData({ environment, setup, serviceName }), + getMemoryChartData({ environment, setup, serviceName }), ]); return { charts }; diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts index e76ad10b535b09..d7c9294c8ec7a1 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/fetch_and_transform_gc_metrics.ts @@ -22,12 +22,14 @@ import { getBucketSize } from '../../../../helpers/get_bucket_size'; import { getVizColorForIndex } from '../../../../../../common/viz_colors'; export async function fetchAndTransformGcMetrics({ + environment, setup, serviceName, serviceNodeName, chartBase, fieldName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; @@ -39,6 +41,7 @@ export async function fetchAndTransformGcMetrics({ const { bucketSize } = getBucketSize({ start, end }); const projection = getMetricsProjection({ + environment, setup, serviceName, serviceNodeName, diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_rate_chart.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_rate_chart.ts index 730423d3269012..8c5b9fb3db9221 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_rate_chart.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_rate_chart.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_GC_COUNT } from '../../../../../../common/elasticsearch_fieldnames'; import { Setup, SetupTimeRange } from '../../../../helpers/setup_request'; import { fetchAndTransformGcMetrics } from './fetch_and_transform_gc_metrics'; @@ -32,21 +33,26 @@ const chartBase: ChartBase = { }; function getGcRateChart({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformGcMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - fieldName: METRIC_JAVA_GC_COUNT, - }); + return withApmSpan('get_gc_rate_charts', () => + fetchAndTransformGcMetrics({ + environment, + setup, + serviceName, + serviceNodeName, + chartBase, + fieldName: METRIC_JAVA_GC_COUNT, + }) + ); } export { getGcRateChart }; diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_time_chart.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_time_chart.ts index ed9f135ea8b351..98f31f06c1b644 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_time_chart.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/gc/get_gc_time_chart.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_GC_TIME } from '../../../../../../common/elasticsearch_fieldnames'; import { Setup, SetupTimeRange } from '../../../../helpers/setup_request'; import { fetchAndTransformGcMetrics } from './fetch_and_transform_gc_metrics'; @@ -32,21 +33,26 @@ const chartBase: ChartBase = { }; function getGcTimeChart({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformGcMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - fieldName: METRIC_JAVA_GC_TIME, - }); + return withApmSpan('get_gc_time_charts', () => + fetchAndTransformGcMetrics({ + environment, + setup, + serviceName, + serviceNodeName, + chartBase, + fieldName: METRIC_JAVA_GC_TIME, + }) + ); } export { getGcTimeChart }; diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts index 3e34fd407dd429..d6cbc4a07e8f9b 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/heap_memory/index.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_HEAP_MEMORY_MAX, METRIC_JAVA_HEAP_MEMORY_COMMITTED, @@ -51,27 +52,32 @@ const chartBase: ChartBase = { series, }; -export async function getHeapMemoryChart({ +export function getHeapMemoryChart({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - heapMemoryMax: { avg: { field: METRIC_JAVA_HEAP_MEMORY_MAX } }, - heapMemoryCommitted: { - avg: { field: METRIC_JAVA_HEAP_MEMORY_COMMITTED }, + return withApmSpan('get_heap_memory_charts', () => + fetchAndTransformMetrics({ + environment, + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + heapMemoryMax: { avg: { field: METRIC_JAVA_HEAP_MEMORY_MAX } }, + heapMemoryCommitted: { + avg: { field: METRIC_JAVA_HEAP_MEMORY_COMMITTED }, + }, + heapMemoryUsed: { avg: { field: METRIC_JAVA_HEAP_MEMORY_USED } }, }, - heapMemoryUsed: { avg: { field: METRIC_JAVA_HEAP_MEMORY_USED } }, - }, - additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], - }); + additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts index d5751085e26c0b..970b4d3499b798 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../../utils/with_apm_span'; import { getHeapMemoryChart } from './heap_memory'; import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; import { getNonHeapMemoryChart } from './non_heap_memory'; @@ -14,24 +15,33 @@ import { getMemoryChartData } from '../shared/memory'; import { getGcRateChart } from './gc/get_gc_rate_chart'; import { getGcTimeChart } from './gc/get_gc_time_chart'; -export async function getJavaMetricsCharts({ +export function getJavaMetricsCharts({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; }) { - const charts = await Promise.all([ - getCPUChartData({ setup, serviceName, serviceNodeName }), - getMemoryChartData({ setup, serviceName, serviceNodeName }), - getHeapMemoryChart({ setup, serviceName, serviceNodeName }), - getNonHeapMemoryChart({ setup, serviceName, serviceNodeName }), - getThreadCountChart({ setup, serviceName, serviceNodeName }), - getGcRateChart({ setup, serviceName, serviceNodeName }), - getGcTimeChart({ setup, serviceName, serviceNodeName }), - ]); + return withApmSpan('get_java_system_metric_charts', async () => { + const charts = await Promise.all([ + getCPUChartData({ environment, setup, serviceName, serviceNodeName }), + getMemoryChartData({ environment, setup, serviceName, serviceNodeName }), + getHeapMemoryChart({ environment, setup, serviceName, serviceNodeName }), + getNonHeapMemoryChart({ + environment, + setup, + serviceName, + serviceNodeName, + }), + getThreadCountChart({ environment, setup, serviceName, serviceNodeName }), + getGcRateChart({ environment, setup, serviceName, serviceNodeName }), + getGcTimeChart({ environment, setup, serviceName, serviceNodeName }), + ]); - return { charts }; + return { charts }; + }); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts index 0f38c39b00d214..25abd2c34c83a9 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/non_heap_memory/index.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_NON_HEAP_MEMORY_MAX, METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED, @@ -49,28 +50,33 @@ const chartBase: ChartBase = { }; export async function getNonHeapMemoryChart({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - nonHeapMemoryMax: { avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_MAX } }, - nonHeapMemoryCommitted: { - avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED }, + return withApmSpan('get_non_heap_memory_charts', () => + fetchAndTransformMetrics({ + environment, + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + nonHeapMemoryMax: { avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_MAX } }, + nonHeapMemoryCommitted: { + avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_COMMITTED }, + }, + nonHeapMemoryUsed: { + avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_USED }, + }, }, - nonHeapMemoryUsed: { - avg: { field: METRIC_JAVA_NON_HEAP_MEMORY_USED }, - }, - }, - additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], - }); + additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts index 670a94f53515aa..c8a209fee701a1 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/java/thread_count/index.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_JAVA_THREAD_COUNT, AGENT_NAME, @@ -41,23 +42,28 @@ const chartBase: ChartBase = { }; export async function getThreadCountChart({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; }) { - return fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - threadCount: { avg: { field: METRIC_JAVA_THREAD_COUNT } }, - threadCountMax: { max: { field: METRIC_JAVA_THREAD_COUNT } }, - }, - additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], - }); + return withApmSpan('get_thread_count_charts', () => + fetchAndTransformMetrics({ + environment, + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + threadCount: { avg: { field: METRIC_JAVA_THREAD_COUNT } }, + threadCountMax: { max: { field: METRIC_JAVA_THREAD_COUNT } }, + }, + additionalFilters: [{ term: { [AGENT_NAME]: 'java' } }], + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts index 6ea8d942aad43d..ebfe504e5269b7 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/cpu/index.ts @@ -7,6 +7,7 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_SYSTEM_CPU_PERCENT, METRIC_PROCESS_CPU_PERCENT, @@ -52,27 +53,30 @@ const chartBase: ChartBase = { series, }; -export async function getCPUChartData({ +export function getCPUChartData({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; }) { - const metricsChart = await fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - systemCPUAverage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } }, - systemCPUMax: { max: { field: METRIC_SYSTEM_CPU_PERCENT } }, - processCPUAverage: { avg: { field: METRIC_PROCESS_CPU_PERCENT } }, - processCPUMax: { max: { field: METRIC_PROCESS_CPU_PERCENT } }, - }, - }); - - return metricsChart; + return withApmSpan('get_cpu_metric_charts', () => + fetchAndTransformMetrics({ + environment, + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + systemCPUAverage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } }, + systemCPUMax: { max: { field: METRIC_SYSTEM_CPU_PERCENT } }, + processCPUAverage: { avg: { field: METRIC_PROCESS_CPU_PERCENT } }, + processCPUMax: { max: { field: METRIC_PROCESS_CPU_PERCENT } }, + }, + }) + ); } diff --git a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts index 5c31ffca509bc5..55b3328bcd2a9b 100644 --- a/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts +++ b/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { withApmSpan } from '../../../../../utils/with_apm_span'; import { METRIC_CGROUP_MEMORY_LIMIT_BYTES, METRIC_CGROUP_MEMORY_USAGE_BYTES, @@ -70,44 +71,56 @@ export const percentCgroupMemoryUsedScript = { }; export async function getMemoryChartData({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; }) { - const cgroupResponse = await fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - memoryUsedAvg: { avg: { script: percentCgroupMemoryUsedScript } }, - memoryUsedMax: { max: { script: percentCgroupMemoryUsedScript } }, - }, - additionalFilters: [ - { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, - ], - }); + return withApmSpan('get_memory_metrics_charts', async () => { + const cgroupResponse = await withApmSpan( + 'get_cgroup_memory_metrics_charts', + () => + fetchAndTransformMetrics({ + environment, + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + memoryUsedAvg: { avg: { script: percentCgroupMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentCgroupMemoryUsedScript } }, + }, + additionalFilters: [ + { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, + ], + }) + ); - if (cgroupResponse.noHits) { - return await fetchAndTransformMetrics({ - setup, - serviceName, - serviceNodeName, - chartBase, - aggs: { - memoryUsedAvg: { avg: { script: percentSystemMemoryUsedScript } }, - memoryUsedMax: { max: { script: percentSystemMemoryUsedScript } }, - }, - additionalFilters: [ - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, - ], - }); - } + if (cgroupResponse.noHits) { + return await withApmSpan('get_system_memory_metrics_charts', () => + fetchAndTransformMetrics({ + environment, + setup, + serviceName, + serviceNodeName, + chartBase, + aggs: { + memoryUsedAvg: { avg: { script: percentSystemMemoryUsedScript } }, + memoryUsedMax: { max: { script: percentSystemMemoryUsedScript } }, + }, + additionalFilters: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + }) + ); + } - return cgroupResponse; + return cgroupResponse; + }); } diff --git a/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts b/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts index 7a52806601e0e8..17e9aef29ba82d 100644 --- a/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts +++ b/x-pack/plugins/apm/server/lib/metrics/fetch_and_transform_metrics.ts @@ -48,6 +48,7 @@ interface Filter { } export async function fetchAndTransformMetrics({ + environment, setup, serviceName, serviceNodeName, @@ -55,6 +56,7 @@ export async function fetchAndTransformMetrics({ aggs, additionalFilters = [], }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; @@ -65,6 +67,7 @@ export async function fetchAndTransformMetrics({ const { start, end, apmEventClient, config } = setup; const projection = getMetricsProjection({ + environment, setup, serviceName, serviceNodeName, diff --git a/x-pack/plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent.ts b/x-pack/plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent.ts index 5083982f1cb9cb..eda71ef380ee9c 100644 --- a/x-pack/plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent.ts +++ b/x-pack/plugins/apm/server/lib/metrics/get_metrics_chart_data_by_agent.ts @@ -15,11 +15,13 @@ export interface MetricsChartsByAgentAPIResponse { } export async function getMetricsChartDataByAgent({ + environment, setup, serviceName, serviceNodeName, agentName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; @@ -27,11 +29,16 @@ export async function getMetricsChartDataByAgent({ }): Promise { switch (agentName) { case 'java': { - return getJavaMetricsCharts({ setup, serviceName, serviceNodeName }); + return getJavaMetricsCharts({ + environment, + setup, + serviceName, + serviceNodeName, + }); } default: { - return getDefaultMetricsCharts(setup, serviceName); + return getDefaultMetricsCharts({ environment, setup, serviceName }); } } } diff --git a/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts index 15c3ccfaec232f..c7ac678899b58c 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts @@ -6,41 +6,44 @@ */ import { ProcessorEvent } from '../../../common/processor_event'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getServiceCount({ +export function getServiceCount({ setup, searchAggregatedTransactions, }: { setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { apmEventClient, start, end } = setup; + return withApmSpan('observability_overview_get_service_count', async () => { + const { apmEventClient, start, end } = setup; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [{ range: rangeFilter(start, end) }], + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + size: 0, + query: { + bool: { + filter: rangeQuery(start, end), + }, }, + aggs: { serviceCount: { cardinality: { field: SERVICE_NAME } } }, }, - aggs: { serviceCount: { cardinality: { field: SERVICE_NAME } } }, - }, - }; + }; - const { aggregations } = await apmEventClient.search(params); - return aggregations?.serviceCount.value || 0; + const { aggregations } = await apmEventClient.search(params); + return aggregations?.serviceCount.value || 0; + }); } diff --git a/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts index 6e6bd7ca6ef3d7..2da4b0f8de3632 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts @@ -5,13 +5,14 @@ * 2.0. */ -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { Coordinates } from '../../../../observability/typings/common'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { calculateThroughput } from '../helpers/calculate_throughput'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getTransactionCoordinates({ +export function getTransactionCoordinates({ setup, bucketSize, searchAggregatedTransactions, @@ -20,39 +21,44 @@ export async function getTransactionCoordinates({ bucketSize: string; searchAggregatedTransactions: boolean; }): Promise { - const { apmEventClient, start, end } = setup; + return withApmSpan( + 'observability_overview_get_transaction_distribution', + async () => { + const { apmEventClient, start, end } = setup; - const { aggregations } = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [{ range: rangeFilter(start, end) }], + const { aggregations } = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - }, - aggs: { - distribution: { - date_histogram: { - field: '@timestamp', - fixed_interval: bucketSize, - min_doc_count: 0, + body: { + size: 0, + query: { + bool: { + filter: rangeQuery(start, end), + }, + }, + aggs: { + distribution: { + date_histogram: { + field: '@timestamp', + fixed_interval: bucketSize, + min_doc_count: 0, + }, + }, }, }, - }, - }, - }); + }); - return ( - aggregations?.distribution.buckets.map((bucket) => ({ - x: bucket.key, - y: calculateThroughput({ start, end, value: bucket.doc_count }), - })) || [] + return ( + aggregations?.distribution.buckets.map((bucket) => ({ + x: bucket.key, + y: calculateThroughput({ start, end, value: bucket.doc_count }), + })) || [] + ); + } ); } diff --git a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts index dc9186fa2b354b..abdc8da78502c7 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts @@ -6,28 +6,31 @@ */ import { ProcessorEvent } from '../../../common/processor_event'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup } from '../helpers/setup_request'; -export async function hasData({ setup }: { setup: Setup }) { - const { apmEventClient } = setup; - try { - const params = { - apm: { - events: [ - ProcessorEvent.transaction, - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - terminateAfter: 1, - body: { - size: 0, - }, - }; +export function hasData({ setup }: { setup: Setup }) { + return withApmSpan('observability_overview_has_apm_data', async () => { + const { apmEventClient } = setup; + try { + const params = { + apm: { + events: [ + ProcessorEvent.transaction, + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + terminateAfter: 1, + body: { + size: 0, + }, + }; - const response = await apmEventClient.search(params); - return response.hits.total.value > 0; - } catch (e) { - return false; - } + const response = await apmEventClient.search(params); + return response.hits.total.value > 0; + } catch (e) { + return false; + } + }); } diff --git a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts index 368c5ec5463592..9626019347e5bb 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts @@ -11,7 +11,7 @@ import { TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { TRANSACTION_PAGE_LOAD } from '../../../common/transaction_types'; export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) { @@ -31,9 +31,7 @@ export async function hasRumData({ setup }: { setup: Setup & SetupTimeRange }) { }, aggs: { services: { - filter: { - range: rangeFilter(start, end), - }, + filter: rangeQuery(start, end)[0], aggs: { mostTraffic: { terms: { diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/__snapshots__/index.test.ts.snap similarity index 93% rename from x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/__snapshots__/queries.test.ts.snap rename to x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/__snapshots__/index.test.ts.snap index e7ca65eb740b6a..40504cec36a633 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/__snapshots__/index.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`local ui filter queries fetches local ui filter aggregations 1`] = ` +exports[`getLocalUIFilters fetches local ui filter aggregations 1`] = ` Object { "apm": Object { "events": Array [ diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/config.ts similarity index 89% rename from x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts rename to x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/config.ts index 27287ce80ca3ed..dfe3efe2aadfe3 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/config.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/config.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { filtersByName, LocalUIFilterName } from '../../../../common/ui_filter'; +import { + filtersByName, + LocalUIFilterName, +} from '../../../../../common/ui_filter'; export interface LocalUIFilter { name: LocalUIFilterName; diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/get_local_filter_query.ts b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/get_local_filter_query.ts similarity index 79% rename from x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/get_local_filter_query.ts rename to x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/get_local_filter_query.ts index 14b6eeb78c943f..8ea635467d0a15 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/get_local_filter_query.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/get_local_filter_query.ts @@ -6,12 +6,12 @@ */ import { omit } from 'lodash'; -import { mergeProjection } from '../../../projections/util/merge_projection'; -import { Projection } from '../../../projections/typings'; -import { UIFilters } from '../../../../typings/ui_filters'; -import { getEsFilter } from '../../helpers/convert_ui_filters/get_es_filter'; +import { mergeProjection } from '../../../../projections/util/merge_projection'; +import { Projection } from '../../../../projections/typings'; +import { UIFilters } from '../../../../../typings/ui_filters'; +import { getEsFilter } from '../../../helpers/convert_ui_filters/get_es_filter'; import { localUIFilters } from './config'; -import { LocalUIFilterName } from '../../../../common/ui_filter'; +import { LocalUIFilterName } from '../../../../../common/ui_filter'; export const getLocalFilterQuery = ({ uiFilters, diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/queries.test.ts b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/index.test.ts similarity index 77% rename from x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/queries.test.ts rename to x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/index.test.ts index 4452a9a80d0389..7254bb25cc5fe1 100644 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/index.test.ts @@ -5,18 +5,18 @@ * 2.0. */ -import { getLocalUIFilters } from './'; +import { getLocalUIFilters } from '.'; import { SearchParamsMock, inspectSearchParams, -} from '../../../utils/test_helpers'; -import { getServicesProjection } from '../../../projections/services'; +} from '../../../../utils/test_helpers'; +import { getServicesProjection } from '../../../../projections/services'; -describe('local ui filter queries', () => { +describe('getLocalUIFilters', () => { let mock: SearchParamsMock; beforeEach(() => { - jest.mock('../../helpers/convert_ui_filters/get_es_filter', () => { + jest.mock('../../../helpers/convert_ui_filters/get_es_filter', () => { return []; }); }); diff --git a/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/index.ts b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/index.ts new file mode 100644 index 00000000000000..8fdeb77171862b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/rum_client/ui_filters/local_ui_filters/index.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloneDeep, orderBy } from 'lodash'; +import { UIFilters } from '../../../../../typings/ui_filters'; +import { Projection } from '../../../../projections/typings'; +import { PromiseReturnType } from '../../../../../../observability/typings/common'; +import { getLocalFilterQuery } from './get_local_filter_query'; +import { Setup } from '../../../helpers/setup_request'; +import { localUIFilters } from './config'; +import { LocalUIFilterName } from '../../../../../common/ui_filter'; +import { withApmSpan } from '../../../../utils/with_apm_span'; + +export type LocalUIFiltersAPIResponse = PromiseReturnType< + typeof getLocalUIFilters +>; + +export function getLocalUIFilters({ + setup, + projection, + uiFilters, + localFilterNames, +}: { + setup: Setup; + projection: Projection; + uiFilters: UIFilters; + localFilterNames: LocalUIFilterName[]; +}) { + return withApmSpan('get_ui_filter_options', () => { + const { apmEventClient } = setup; + + const projectionWithoutAggs = cloneDeep(projection); + + delete projectionWithoutAggs.body.aggs; + + return Promise.all( + localFilterNames.map(async (name) => + withApmSpan('get_ui_filter_options_for_field', async () => { + const query = getLocalFilterQuery({ + uiFilters, + projection, + localUIFilterName: name, + }); + + const response = await apmEventClient.search(query); + + const filter = localUIFilters[name]; + + const buckets = response?.aggregations?.by_terms?.buckets ?? []; + + return { + ...filter, + options: orderBy( + buckets.map((bucket) => { + return { + name: bucket.key as string, + count: bucket.bucket_count + ? bucket.bucket_count.value + : bucket.doc_count, + }; + }), + 'count', + 'desc' + ), + }; + }) + ) + ); + }); +} diff --git a/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts b/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts index 7aa019464754de..259a0e6daea6fc 100644 --- a/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/fetch_service_paths_from_trace_ids.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { ProcessorEvent } from '../../../common/processor_event'; import { TRACE_ID } from '../../../common/elasticsearch_fieldnames'; import { @@ -14,42 +14,44 @@ import { ServiceConnectionNode, } from '../../../common/service_map'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { withApmSpan } from '../../utils/with_apm_span'; export async function fetchServicePathsFromTraceIds( setup: Setup & SetupTimeRange, traceIds: string[] ) { - const { apmEventClient } = setup; - - // make sure there's a range so ES can skip shards - const dayInMs = 24 * 60 * 60 * 1000; - const start = setup.start - dayInMs; - const end = setup.end + dayInMs; - - const serviceMapParams = { - apm: { - events: [ProcessorEvent.span, ProcessorEvent.transaction], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { - terms: { - [TRACE_ID]: traceIds, + return withApmSpan('get_service_paths_from_trace_ids', async () => { + const { apmEventClient } = setup; + + // make sure there's a range so ES can skip shards + const dayInMs = 24 * 60 * 60 * 1000; + const start = setup.start - dayInMs; + const end = setup.end + dayInMs; + + const serviceMapParams = { + apm: { + events: [ProcessorEvent.span, ProcessorEvent.transaction], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { + terms: { + [TRACE_ID]: traceIds, + }, }, - }, - { range: rangeFilter(start, end) }, - ], + ...rangeQuery(start, end), + ], + }, }, - }, - aggs: { - service_map: { - scripted_metric: { - init_script: { - lang: 'painless', - source: `state.eventsById = new HashMap(); + aggs: { + service_map: { + scripted_metric: { + init_script: { + lang: 'painless', + source: `state.eventsById = new HashMap(); String[] fieldsToCopy = new String[] { 'parent.id', @@ -63,10 +65,10 @@ export async function fetchServicePathsFromTraceIds( 'agent.name' }; state.fieldsToCopy = fieldsToCopy;`, - }, - map_script: { - lang: 'painless', - source: `def id; + }, + map_script: { + lang: 'painless', + source: `def id; if (!doc['span.id'].empty) { id = doc['span.id'].value; } else { @@ -83,14 +85,14 @@ export async function fetchServicePathsFromTraceIds( } state.eventsById[id] = copy`, - }, - combine_script: { - lang: 'painless', - source: `return state.eventsById;`, - }, - reduce_script: { - lang: 'painless', - source: ` + }, + combine_script: { + lang: 'painless', + source: `return state.eventsById;`, + }, + reduce_script: { + lang: 'painless', + source: ` def getDestination ( def event ) { def destination = new HashMap(); destination['span.destination.service.resource'] = event['span.destination.service.resource']; @@ -206,28 +208,29 @@ export async function fetchServicePathsFromTraceIds( response.discoveredServices = discoveredServices; return response;`, + }, }, }, }, }, - }, - }; - - const serviceMapFromTraceIdsScriptResponse = await apmEventClient.search( - serviceMapParams - ); - - return serviceMapFromTraceIdsScriptResponse as { - aggregations?: { - service_map: { - value: { - paths: ConnectionNode[][]; - discoveredServices: Array<{ - from: ExternalConnectionNode; - to: ServiceConnectionNode; - }>; + }; + + const serviceMapFromTraceIdsScriptResponse = await apmEventClient.search( + serviceMapParams + ); + + return serviceMapFromTraceIdsScriptResponse as { + aggregations?: { + service_map: { + value: { + paths: ConnectionNode[][]; + discoveredServices: Array<{ + from: ExternalConnectionNode; + to: ServiceConnectionNode; + }>; + }; }; }; }; - }; + }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index e9971114049869..ab221e30ea4897 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -17,6 +17,8 @@ import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../common/transaction_types'; +import { rangeQuery } from '../../../common/utils/queries'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getMlJobsWithAPMGroup } from '../anomaly_detection/get_ml_jobs_with_apm_group'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; @@ -36,115 +38,114 @@ export async function getServiceAnomalies({ setup: Setup & SetupTimeRange; environment?: string; }) { - const { ml, start, end } = setup; + return withApmSpan('get_service_anomalies', async () => { + const { ml, start, end } = setup; - if (!ml) { - throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); - } + if (!ml) { + throw Boom.notImplemented(ML_ERRORS.ML_NOT_AVAILABLE); + } - const params = { - body: { - size: 0, - query: { - bool: { - filter: [ - { terms: { result_type: ['model_plot', 'record'] } }, - { - range: { - timestamp: { - // fetch data for at least 30 minutes - gte: Math.min(end - 30 * 60 * 1000, start), - lte: end, - format: 'epoch_millis', + const params = { + body: { + size: 0, + query: { + bool: { + filter: [ + { terms: { result_type: ['model_plot', 'record'] } }, + ...rangeQuery( + Math.min(end - 30 * 60 * 1000, start), + end, + 'timestamp' + ), + { + terms: { + // Only retrieving anomalies for transaction types "request" and "page-load" + by_field_value: [TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD], }, }, - }, - { - terms: { - // Only retrieving anomalies for transaction types "request" and "page-load" - by_field_value: [TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD], - }, - }, - ], - }, - }, - aggs: { - services: { - composite: { - size: 5000, - sources: [ - { serviceName: { terms: { field: 'partition_field_value' } } }, - { jobId: { terms: { field: 'job_id' } } }, ], }, - aggs: { - metrics: { - top_metrics: { - metrics: [ - { field: 'actual' }, - { field: 'by_field_value' }, - { field: 'result_type' }, - { field: 'record_score' }, - ] as const, - sort: { - record_score: 'desc' as const, + }, + aggs: { + services: { + composite: { + size: 5000, + sources: [ + { serviceName: { terms: { field: 'partition_field_value' } } }, + { jobId: { terms: { field: 'job_id' } } }, + ], + }, + aggs: { + metrics: { + top_metrics: { + metrics: [ + { field: 'actual' }, + { field: 'by_field_value' }, + { field: 'result_type' }, + { field: 'record_score' }, + ] as const, + sort: { + record_score: 'desc' as const, + }, }, }, }, }, }, }, - }, - }; + }; - const [anomalyResponse, jobIds] = await Promise.all([ - // pass an empty array of job ids to anomaly search - // so any validation is skipped - ml.mlSystem.mlAnomalySearch(params, []), - getMLJobIds(ml.anomalyDetectors, environment), - ]); + const [anomalyResponse, jobIds] = await Promise.all([ + // pass an empty array of job ids to anomaly search + // so any validation is skipped + withApmSpan('ml_anomaly_search', () => + ml.mlSystem.mlAnomalySearch(params, []) + ), + getMLJobIds(ml.anomalyDetectors, environment), + ]); - const typedAnomalyResponse: ESSearchResponse< - unknown, - typeof params - > = anomalyResponse as any; - const relevantBuckets = uniqBy( - sortBy( - // make sure we only return data for jobs that are available in this space - typedAnomalyResponse.aggregations?.services.buckets.filter((bucket) => - jobIds.includes(bucket.key.jobId as string) - ) ?? [], - // sort by job ID in case there are multiple jobs for one service to - // ensure consistent results - (bucket) => bucket.key.jobId - ), - // return one bucket per service - (bucket) => bucket.key.serviceName - ); + const typedAnomalyResponse: ESSearchResponse< + unknown, + typeof params + > = anomalyResponse as any; + const relevantBuckets = uniqBy( + sortBy( + // make sure we only return data for jobs that are available in this space + typedAnomalyResponse.aggregations?.services.buckets.filter((bucket) => + jobIds.includes(bucket.key.jobId as string) + ) ?? [], + // sort by job ID in case there are multiple jobs for one service to + // ensure consistent results + (bucket) => bucket.key.jobId + ), + // return one bucket per service + (bucket) => bucket.key.serviceName + ); - return { - mlJobIds: jobIds, - serviceAnomalies: relevantBuckets.map((bucket) => { - const metrics = bucket.metrics.top[0].metrics; + return { + mlJobIds: jobIds, + serviceAnomalies: relevantBuckets.map((bucket) => { + const metrics = bucket.metrics.top[0].metrics; - const anomalyScore = - metrics.result_type === 'record' && metrics.record_score - ? (metrics.record_score as number) - : 0; + const anomalyScore = + metrics.result_type === 'record' && metrics.record_score + ? (metrics.record_score as number) + : 0; - const severity = getSeverity(anomalyScore); - const healthStatus = getServiceHealthStatus({ severity }); + const severity = getSeverity(anomalyScore); + const healthStatus = getServiceHealthStatus({ severity }); - return { - serviceName: bucket.key.serviceName as string, - jobId: bucket.key.jobId as string, - transactionType: metrics.by_field_value as string, - actualValue: metrics.actual as number | null, - anomalyScore, - healthStatus, - }; - }), - }; + return { + serviceName: bucket.key.serviceName as string, + jobId: bucket.key.jobId as string, + transactionType: metrics.by_field_value as string, + actualValue: metrics.actual as number | null, + anomalyScore, + healthStatus, + }; + }), + }; + }); } export async function getMLJobs( diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 5b7589925d1103..1aee1bb5b242af 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -15,7 +15,8 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { getServicesProjection } from '../../projections/services'; import { mergeProjection } from '../../projections/util/merge_projection'; -import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery } from '../../../common/utils/queries'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { DEFAULT_ANOMALIES, @@ -38,137 +39,146 @@ async function getConnectionData({ serviceName, environment, }: IEnvOptions) { - const { traceIds } = await getTraceSampleIds({ - setup, - serviceName, - environment, - }); + return withApmSpan('get_service_map_connections', async () => { + const { traceIds } = await getTraceSampleIds({ + setup, + serviceName, + environment, + }); + + const chunks = chunk( + traceIds, + setup.config['xpack.apm.serviceMapMaxTracesPerRequest'] + ); - const chunks = chunk( - traceIds, - setup.config['xpack.apm.serviceMapMaxTracesPerRequest'] - ); - - const init = { - connections: [], - discoveredServices: [], - }; - - if (!traceIds.length) { - return init; - } - - const chunkedResponses = await Promise.all( - chunks.map((traceIdsChunk) => - getServiceMapFromTraceIds({ - setup, - serviceName, - environment, - traceIds: traceIdsChunk, - }) - ) - ); - - return chunkedResponses.reduce((prev, current) => { - return { - connections: prev.connections.concat(current.connections), - discoveredServices: prev.discoveredServices.concat( - current.discoveredServices - ), + const init = { + connections: [], + discoveredServices: [], }; + + if (!traceIds.length) { + return init; + } + + const chunkedResponses = await withApmSpan( + 'get_service_paths_from_all_trace_ids', + () => + Promise.all( + chunks.map((traceIdsChunk) => + getServiceMapFromTraceIds({ + setup, + serviceName, + environment, + traceIds: traceIdsChunk, + }) + ) + ) + ); + + return chunkedResponses.reduce((prev, current) => { + return { + connections: prev.connections.concat(current.connections), + discoveredServices: prev.discoveredServices.concat( + current.discoveredServices + ), + }; + }); }); } async function getServicesData(options: IEnvOptions) { - const { setup, searchAggregatedTransactions } = options; + return withApmSpan('get_service_stats_for_service_map', async () => { + const { environment, setup, searchAggregatedTransactions } = options; - const projection = getServicesProjection({ - setup: { ...setup, esFilter: [] }, - searchAggregatedTransactions, - }); + const projection = getServicesProjection({ + setup: { ...setup, esFilter: [] }, + searchAggregatedTransactions, + }); - let { filter } = projection.body.query.bool; + let filter = [ + ...projection.body.query.bool.filter, + ...environmentQuery(environment), + ]; - if (options.serviceName) { - filter = filter.concat({ - term: { - [SERVICE_NAME]: options.serviceName, - }, - }); - } - - if (options.environment) { - filter = filter.concat(getEnvironmentUiFilterES(options.environment)); - } - - const params = mergeProjection(projection, { - body: { - size: 0, - query: { - bool: { - ...projection.body.query.bool, - filter, + if (options.serviceName) { + filter = filter.concat({ + term: { + [SERVICE_NAME]: options.serviceName, }, - }, - aggs: { - services: { - terms: { - field: projection.body.aggs.services.terms.field, - size: 500, + }); + } + + const params = mergeProjection(projection, { + body: { + size: 0, + query: { + bool: { + ...projection.body.query.bool, + filter, }, - aggs: { - agent_name: { - terms: { - field: AGENT_NAME, + }, + aggs: { + services: { + terms: { + field: projection.body.aggs.services.terms.field, + size: 500, + }, + aggs: { + agent_name: { + terms: { + field: AGENT_NAME, + }, }, }, }, }, }, - }, - }); + }); - const { apmEventClient } = setup; + const { apmEventClient } = setup; - const response = await apmEventClient.search(params); + const response = await apmEventClient.search(params); - return ( - response.aggregations?.services.buckets.map((bucket) => { - return { - [SERVICE_NAME]: bucket.key as string, - [AGENT_NAME]: - (bucket.agent_name.buckets[0]?.key as string | undefined) || '', - [SERVICE_ENVIRONMENT]: options.environment || null, - }; - }) || [] - ); + return ( + response.aggregations?.services.buckets.map((bucket) => { + return { + [SERVICE_NAME]: bucket.key as string, + [AGENT_NAME]: + (bucket.agent_name.buckets[0]?.key as string | undefined) || '', + [SERVICE_ENVIRONMENT]: options.environment || null, + }; + }) || [] + ); + }); } export type ConnectionsResponse = PromiseReturnType; export type ServicesResponse = PromiseReturnType; export type ServiceMapAPIResponse = PromiseReturnType; -export async function getServiceMap(options: IEnvOptions) { - const { logger } = options; - const anomaliesPromise = getServiceAnomalies( - options - - // always catch error to avoid breaking service maps if there is a problem with ML - ).catch((error) => { - logger.warn(`Unable to retrieve anomalies for service maps.`); - logger.error(error); - return DEFAULT_ANOMALIES; - }); +export function getServiceMap(options: IEnvOptions) { + return withApmSpan('get_service_map', async () => { + const { logger } = options; + const anomaliesPromise = getServiceAnomalies( + options + + // always catch error to avoid breaking service maps if there is a problem with ML + ).catch((error) => { + logger.warn(`Unable to retrieve anomalies for service maps.`); + logger.error(error); + return DEFAULT_ANOMALIES; + }); - const [connectionData, servicesData, anomalies] = await Promise.all([ - getConnectionData(options), - getServicesData(options), - anomaliesPromise, - ]); + const [connectionData, servicesData, anomalies] = await Promise.all([ + getConnectionData(options), + getServicesData(options), + anomaliesPromise, + ]); - return transformServiceMapResponses({ - ...connectionData, - services: servicesData, - anomalies, + return transformServiceMapResponses({ + ...connectionData, + services: servicesData, + anomalies, + }); }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts index ef4faf94063461..6e9225041b199c 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -6,7 +6,10 @@ */ import { find, uniqBy } from 'lodash'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; +import { + ENVIRONMENT_ALL, + ENVIRONMENT_NOT_DEFINED, +} from '../../../common/environment_filter_values'; import { SERVICE_ENVIRONMENT, SERVICE_NAME, @@ -27,8 +30,10 @@ export function getConnections({ if (!paths) { return []; } + const isEnvironmentSelected = + environment && environment !== ENVIRONMENT_ALL.value; - if (serviceName || environment) { + if (serviceName || isEnvironmentSelected) { paths = paths.filter((path) => { return ( path diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts index 63f28abab8f3aa..b161345e729d35 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts @@ -19,11 +19,13 @@ describe('getServiceMapServiceNodeInfo', () => { hits: { total: { value: 0 } }, }), }, + esFilter: [], indices: {}, - uiFilters: { environment: 'test environment' }, + uiFilters: {}, } as unknown) as Setup & SetupTimeRange; const serviceName = 'test service name'; const result = await getServiceMapServiceNodeInfo({ + environment: 'test environment', setup, serviceName, searchAggregatedTransactions: false, diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index 71be5f5392b6fb..e384b15685dad4 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -19,13 +19,13 @@ import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../common/transaction_types'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../common/utils/queries'; +import { withApmSpan } from '../../utils/with_apm_span'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, } from '../helpers/aggregated_transactions'; -import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { percentCgroupMemoryUsedScript, @@ -49,46 +49,49 @@ interface TaskParameters { setup: Setup; } -export async function getServiceMapServiceNodeInfo({ +export function getServiceMapServiceNodeInfo({ + environment, serviceName, setup, searchAggregatedTransactions, }: Options & { serviceName: string }) { - const { start, end, uiFilters } = setup; - - const filter: ESFilter[] = [ - { range: rangeFilter(start, end) }, - { term: { [SERVICE_NAME]: serviceName } }, - ...getEnvironmentUiFilterES(uiFilters.environment), - ]; - - const minutes = Math.abs((end - start) / (1000 * 60)); - const taskParams = { - environment: uiFilters.environment, - filter, - searchAggregatedTransactions, - minutes, - serviceName, - setup, - }; - - const [ - errorStats, - transactionStats, - cpuStats, - memoryStats, - ] = await Promise.all([ - getErrorStats(taskParams), - getTransactionStats(taskParams), - getCpuStats(taskParams), - getMemoryStats(taskParams), - ]); - return { - ...errorStats, - transactionStats, - ...cpuStats, - ...memoryStats, - }; + return withApmSpan('get_service_map_node_stats', async () => { + const { start, end, uiFilters } = setup; + + const filter: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ]; + + const minutes = Math.abs((end - start) / (1000 * 60)); + const taskParams = { + environment: uiFilters.environment, + filter, + searchAggregatedTransactions, + minutes, + serviceName, + setup, + }; + + const [ + errorStats, + transactionStats, + cpuStats, + memoryStats, + ] = await Promise.all([ + getErrorStats(taskParams), + getTransactionStats(taskParams), + getCpuStats(taskParams), + getMemoryStats(taskParams), + ]); + return { + ...errorStats, + transactionStats, + ...cpuStats, + ...memoryStats, + }; + }); } async function getErrorStats({ @@ -102,20 +105,19 @@ async function getErrorStats({ environment?: string; searchAggregatedTransactions: boolean; }) { - const setupWithBlankUiFilters = { - ...setup, - uiFilters: { environment }, - esFilter: getEnvironmentUiFilterES(environment), - }; - const { noHits, average } = await getErrorRate({ - setup: setupWithBlankUiFilters, - serviceName, - searchAggregatedTransactions, + return withApmSpan('get_error_rate_for_service_map_node', async () => { + const { noHits, average } = await getErrorRate({ + environment, + setup, + serviceName, + searchAggregatedTransactions, + }); + + return { avgErrorRate: noHits ? null : average }; }); - return { avgErrorRate: noHits ? null : average }; } -async function getTransactionStats({ +function getTransactionStats({ setup, filter, minutes, @@ -124,95 +126,67 @@ async function getTransactionStats({ avgTransactionDuration: number | null; avgRequestsPerMinute: number | null; }> { - const { apmEventClient } = setup; - - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - { - terms: { - [TRANSACTION_TYPE]: [ - TRANSACTION_REQUEST, - TRANSACTION_PAGE_LOAD, - ], + return withApmSpan('get_transaction_stats_for_service_map_node', async () => { + const { apmEventClient } = setup; + + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + ...filter, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + { + terms: { + [TRANSACTION_TYPE]: [ + TRANSACTION_REQUEST, + TRANSACTION_PAGE_LOAD, + ], + }, }, - }, - ], + ], + }, }, - }, - track_total_hits: true, - aggs: { - duration: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + track_total_hits: true, + aggs: { + duration: { + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, }, }, - }, - }; - const response = await apmEventClient.search(params); + }; + const response = await apmEventClient.search(params); - const totalRequests = response.hits.total.value; + const totalRequests = response.hits.total.value; - return { - avgTransactionDuration: response.aggregations?.duration.value ?? null, - avgRequestsPerMinute: totalRequests > 0 ? totalRequests / minutes : null, - }; + return { + avgTransactionDuration: response.aggregations?.duration.value ?? null, + avgRequestsPerMinute: totalRequests > 0 ? totalRequests / minutes : null, + }; + }); } -async function getCpuStats({ +function getCpuStats({ setup, filter, }: TaskParameters): Promise<{ avgCpuUsage: number | null }> { - const { apmEventClient } = setup; - - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: [...filter, { exists: { field: METRIC_SYSTEM_CPU_PERCENT } }], - }, - }, - aggs: { avgCpuUsage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } } }, - }, - }); - - return { avgCpuUsage: response.aggregations?.avgCpuUsage.value ?? null }; -} + return withApmSpan('get_avg_cpu_usage_for_service_map_node', async () => { + const { apmEventClient } = setup; -async function getMemoryStats({ - setup, - filter, -}: TaskParameters): Promise<{ avgMemoryUsage: number | null }> { - const { apmEventClient } = setup; - - const getAvgMemoryUsage = async ({ - additionalFilters, - script, - }: { - additionalFilters: ESFilter[]; - script: typeof percentCgroupMemoryUsedScript; - }) => { const response = await apmEventClient.search({ apm: { events: [ProcessorEvent.metric], @@ -221,34 +195,72 @@ async function getMemoryStats({ size: 0, query: { bool: { - filter: [...filter, ...additionalFilters], + filter: [ + ...filter, + { exists: { field: METRIC_SYSTEM_CPU_PERCENT } }, + ], }, }, - aggs: { - avgMemoryUsage: { avg: { script } }, - }, + aggs: { avgCpuUsage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } } }, }, }); - return response.aggregations?.avgMemoryUsage.value ?? null; - }; - - let avgMemoryUsage = await getAvgMemoryUsage({ - additionalFilters: [ - { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, - ], - script: percentCgroupMemoryUsedScript, + return { avgCpuUsage: response.aggregations?.avgCpuUsage.value ?? null }; }); +} + +function getMemoryStats({ + setup, + filter, +}: TaskParameters): Promise<{ avgMemoryUsage: number | null }> { + return withApmSpan('get_memory_stats_for_service_map_node', async () => { + const { apmEventClient } = setup; - if (!avgMemoryUsage) { - avgMemoryUsage = await getAvgMemoryUsage({ + const getAvgMemoryUsage = ({ + additionalFilters, + script, + }: { + additionalFilters: ESFilter[]; + script: typeof percentCgroupMemoryUsedScript; + }) => { + return withApmSpan('get_avg_memory_for_service_map_node', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], + }, + body: { + size: 0, + query: { + bool: { + filter: [...filter, ...additionalFilters], + }, + }, + aggs: { + avgMemoryUsage: { avg: { script } }, + }, + }, + }); + return response.aggregations?.avgMemoryUsage.value ?? null; + }); + }; + + let avgMemoryUsage = await getAvgMemoryUsage({ additionalFilters: [ - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + { exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES } }, ], - script: percentSystemMemoryUsedScript, + script: percentCgroupMemoryUsedScript, }); - } - return { avgMemoryUsage }; + if (!avgMemoryUsage) { + avgMemoryUsage = await getAvgMemoryUsage({ + additionalFilters: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + script: percentSystemMemoryUsedScript, + }); + } + + return { avgMemoryUsage }; + }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts index aa9105d2edb30a..e8dcb28baa9a38 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -16,13 +16,13 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; import { SERVICE_MAP_TIMEOUT_ERROR } from '../../../common/service_map'; -import { rangeFilter } from '../../../common/utils/range_filter'; -import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery, rangeQuery } from '../../../common/utils/queries'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; const MAX_TRACES_TO_INSPECT = 1000; -export async function getTraceSampleIds({ +export function getTraceSampleIds({ serviceName, environment, setup, @@ -31,90 +31,90 @@ export async function getTraceSampleIds({ environment?: string; setup: Setup & SetupTimeRange; }) { - const { start, end, apmEventClient, config } = setup; + return withApmSpan('get_trace_sample_ids', async () => { + const { start, end, apmEventClient, config } = setup; - const rangeQuery = { range: rangeFilter(start, end) }; - - const query = { - bool: { - filter: [ - { - exists: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, + const query = { + bool: { + filter: [ + { + exists: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + }, }, - }, - rangeQuery, - ] as ESFilter[], - }, - } as { bool: { filter: ESFilter[]; must_not?: ESFilter[] | ESFilter } }; + ...rangeQuery(start, end), + ] as ESFilter[], + }, + } as { bool: { filter: ESFilter[]; must_not?: ESFilter[] | ESFilter } }; - if (serviceName) { - query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } }); - } + if (serviceName) { + query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } }); + } - query.bool.filter.push(...getEnvironmentUiFilterES(environment)); + query.bool.filter.push(...environmentQuery(environment)); - const fingerprintBucketSize = serviceName - ? config['xpack.apm.serviceMapFingerprintBucketSize'] - : config['xpack.apm.serviceMapFingerprintGlobalBucketSize']; + const fingerprintBucketSize = serviceName + ? config['xpack.apm.serviceMapFingerprintBucketSize'] + : config['xpack.apm.serviceMapFingerprintGlobalBucketSize']; - const traceIdBucketSize = serviceName - ? config['xpack.apm.serviceMapTraceIdBucketSize'] - : config['xpack.apm.serviceMapTraceIdGlobalBucketSize']; + const traceIdBucketSize = serviceName + ? config['xpack.apm.serviceMapTraceIdBucketSize'] + : config['xpack.apm.serviceMapTraceIdGlobalBucketSize']; - const samplerShardSize = traceIdBucketSize * 10; + const samplerShardSize = traceIdBucketSize * 10; - const params = { - apm: { - events: [ProcessorEvent.span], - }, - body: { - size: 0, - query, - aggs: { - connections: { - composite: { - sources: [ - { - [SPAN_DESTINATION_SERVICE_RESOURCE]: { - terms: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, + const params = { + apm: { + events: [ProcessorEvent.span], + }, + body: { + size: 0, + query, + aggs: { + connections: { + composite: { + sources: [ + { + [SPAN_DESTINATION_SERVICE_RESOURCE]: { + terms: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + }, }, }, - }, - { - [SERVICE_NAME]: { - terms: { - field: SERVICE_NAME, + { + [SERVICE_NAME]: { + terms: { + field: SERVICE_NAME, + }, }, }, - }, - { - [SERVICE_ENVIRONMENT]: { - terms: { - field: SERVICE_ENVIRONMENT, - missing_bucket: true, + { + [SERVICE_ENVIRONMENT]: { + terms: { + field: SERVICE_ENVIRONMENT, + missing_bucket: true, + }, }, }, - }, - ], - size: fingerprintBucketSize, - }, - aggs: { - sample: { - sampler: { - shard_size: samplerShardSize, - }, - aggs: { - trace_ids: { - terms: { - field: TRACE_ID, - size: traceIdBucketSize, - execution_hint: 'map' as const, - // remove bias towards large traces by sorting on trace.id - // which will be random-esque - order: { - _key: 'desc' as const, + ], + size: fingerprintBucketSize, + }, + aggs: { + sample: { + sampler: { + shard_size: samplerShardSize, + }, + aggs: { + trace_ids: { + terms: { + field: TRACE_ID, + size: traceIdBucketSize, + execution_hint: 'map' as const, + // remove bias towards large traces by sorting on trace.id + // which will be random-esque + order: { + _key: 'desc' as const, + }, }, }, }, @@ -123,33 +123,34 @@ export async function getTraceSampleIds({ }, }, }, - }, - }; + }; - try { - const tracesSampleResponse = await apmEventClient.search(params); - // make sure at least one trace per composite/connection bucket - // is queried - const traceIdsWithPriority = - tracesSampleResponse.aggregations?.connections.buckets.flatMap((bucket) => - bucket.sample.trace_ids.buckets.map((sampleDocBucket, index) => ({ - traceId: sampleDocBucket.key as string, - priority: index, - })) - ) || []; + try { + const tracesSampleResponse = await apmEventClient.search(params); + // make sure at least one trace per composite/connection bucket + // is queried + const traceIdsWithPriority = + tracesSampleResponse.aggregations?.connections.buckets.flatMap( + (bucket) => + bucket.sample.trace_ids.buckets.map((sampleDocBucket, index) => ({ + traceId: sampleDocBucket.key as string, + priority: index, + })) + ) || []; - const traceIds = take( - uniq( - sortBy(traceIdsWithPriority, 'priority').map(({ traceId }) => traceId) - ), - MAX_TRACES_TO_INSPECT - ); + const traceIds = take( + uniq( + sortBy(traceIdsWithPriority, 'priority').map(({ traceId }) => traceId) + ), + MAX_TRACES_TO_INSPECT + ); - return { traceIds }; - } catch (error) { - if ('displayName' in error && error.displayName === 'RequestTimeout') { - throw Boom.internal(SERVICE_MAP_TIMEOUT_ERROR); + return { traceIds }; + } catch (error) { + if ('displayName' in error && error.displayName === 'RequestTimeout') { + throw Boom.internal(SERVICE_MAP_TIMEOUT_ERROR); + } + throw error; } - throw error; - } + }); } diff --git a/x-pack/plugins/apm/server/lib/service_nodes/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/service_nodes/__snapshots__/queries.test.ts.snap index d83e558775be40..e6d702cc03c0b1 100644 --- a/x-pack/plugins/apm/server/lib/service_nodes/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/service_nodes/__snapshots__/queries.test.ts.snap @@ -35,6 +35,11 @@ Object { "service.name": "foo", }, }, + Object { + "term": Object { + "service.node.name": "bar", + }, + }, Object { "range": Object { "@timestamp": Object { @@ -44,11 +49,6 @@ Object { }, }, }, - Object { - "term": Object { - "service.node.name": "bar", - }, - }, Object { "term": Object { "service.environment": "test", @@ -97,15 +97,6 @@ Object { "service.name": "foo", }, }, - Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, - }, - }, Object { "bool": Object { "must_not": Array [ @@ -117,6 +108,15 @@ Object { ], }, }, + Object { + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, + }, + }, Object { "term": Object { "service.environment": "test", diff --git a/x-pack/plugins/apm/server/lib/service_nodes/index.ts b/x-pack/plugins/apm/server/lib/service_nodes/index.ts index 01a9f3fdac3ec8..a22c732a5e8ce3 100644 --- a/x-pack/plugins/apm/server/lib/service_nodes/index.ts +++ b/x-pack/plugins/apm/server/lib/service_nodes/index.ts @@ -14,76 +14,79 @@ import { import { SERVICE_NODE_NAME_MISSING } from '../../../common/service_nodes'; import { getServiceNodesProjection } from '../../projections/service_nodes'; import { mergeProjection } from '../../projections/util/merge_projection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -const getServiceNodes = async ({ +const getServiceNodes = ({ setup, serviceName, }: { setup: Setup & SetupTimeRange; serviceName: string; }) => { - const { apmEventClient } = setup; + return withApmSpan('get_service_nodes', async () => { + const { apmEventClient } = setup; - const projection = getServiceNodesProjection({ setup, serviceName }); + const projection = getServiceNodesProjection({ setup, serviceName }); - const params = mergeProjection(projection, { - body: { - aggs: { - nodes: { - terms: { - ...projection.body.aggs.nodes.terms, - size: 10000, - missing: SERVICE_NODE_NAME_MISSING, - }, - aggs: { - cpu: { - avg: { - field: METRIC_PROCESS_CPU_PERCENT, - }, + const params = mergeProjection(projection, { + body: { + aggs: { + nodes: { + terms: { + ...projection.body.aggs.nodes.terms, + size: 10000, + missing: SERVICE_NODE_NAME_MISSING, }, - heapMemory: { - avg: { - field: METRIC_JAVA_HEAP_MEMORY_USED, + aggs: { + cpu: { + avg: { + field: METRIC_PROCESS_CPU_PERCENT, + }, }, - }, - nonHeapMemory: { - avg: { - field: METRIC_JAVA_NON_HEAP_MEMORY_USED, + heapMemory: { + avg: { + field: METRIC_JAVA_HEAP_MEMORY_USED, + }, }, - }, - threadCount: { - max: { - field: METRIC_JAVA_THREAD_COUNT, + nonHeapMemory: { + avg: { + field: METRIC_JAVA_NON_HEAP_MEMORY_USED, + }, + }, + threadCount: { + max: { + field: METRIC_JAVA_THREAD_COUNT, + }, }, }, }, }, }, - }, - }); + }); - const response = await apmEventClient.search(params); + const response = await apmEventClient.search(params); - if (!response.aggregations) { - return []; - } + if (!response.aggregations) { + return []; + } - return response.aggregations.nodes.buckets - .map((bucket) => ({ - name: bucket.key as string, - cpu: bucket.cpu.value, - heapMemory: bucket.heapMemory.value, - nonHeapMemory: bucket.nonHeapMemory.value, - threadCount: bucket.threadCount.value, - })) - .filter( - (item) => - item.cpu !== null || - item.heapMemory !== null || - item.nonHeapMemory !== null || - item.threadCount != null - ); + return response.aggregations.nodes.buckets + .map((bucket) => ({ + name: bucket.key as string, + cpu: bucket.cpu.value, + heapMemory: bucket.heapMemory.value, + nonHeapMemory: bucket.nonHeapMemory.value, + threadCount: bucket.threadCount.value, + })) + .filter( + (item) => + item.cpu !== null || + item.heapMemory !== null || + item.nonHeapMemory !== null || + item.threadCount != null + ); + }); }; export { getServiceNodes }; diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index 606ce870351564..3e68831ee7cba7 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -101,14 +101,6 @@ Array [ "aggs": Object { "transactionType": Object { "aggs": Object { - "agentName": Object { - "top_hits": Object { - "docvalue_fields": Array [ - "agent.name", - ], - "size": 1, - }, - }, "avg_duration": Object { "avg": Object { "field": "transaction.duration.us", @@ -129,6 +121,16 @@ Array [ ], }, }, + "sample": Object { + "top_metrics": Object { + "metrics": Object { + "field": "agent.name", + }, + "sort": Object { + "@timestamp": "desc", + }, + }, + }, "timeseries": Object { "aggs": Object { "avg_duration": Object { diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts index d701c5380b2e45..67aa9d7fcd8707 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts @@ -12,12 +12,12 @@ import { SERVICE_NAME, SERVICE_VERSION, } from '../../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; export async function getDerivedServiceAnnotations({ @@ -31,93 +31,97 @@ export async function getDerivedServiceAnnotations({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_derived_service_annotations', async () => { + const { start, end, apmEventClient } = setup; - const filter: ESFilter[] = [ - { term: { [SERVICE_NAME]: serviceName } }, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ...getEnvironmentUiFilterES(environment), - ]; + const filter: ESFilter[] = [ + { term: { [SERVICE_NAME]: serviceName } }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...environmentQuery(environment), + ]; - const versions = - ( - await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [...filter, { range: rangeFilter(start, end) }], - }, + const versions = + ( + await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - versions: { - terms: { - field: SERVICE_VERSION, + body: { + size: 0, + query: { + bool: { + filter: [...filter, ...rangeQuery(start, end)], + }, + }, + aggs: { + versions: { + terms: { + field: SERVICE_VERSION, + }, }, }, }, - }, - }) - ).aggregations?.versions.buckets.map((bucket) => bucket.key) ?? []; + }) + ).aggregations?.versions.buckets.map((bucket) => bucket.key) ?? []; - if (versions.length <= 1) { - return []; - } - const annotations = await Promise.all( - versions.map(async (version) => { - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [...filter, { term: { [SERVICE_VERSION]: version } }], + if (versions.length <= 1) { + return []; + } + const annotations = await Promise.all( + versions.map(async (version) => { + return withApmSpan('get_first_seen_of_version', async () => { + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - }, - aggs: { - first_seen: { - min: { - field: '@timestamp', + body: { + size: 0, + query: { + bool: { + filter: [...filter, { term: { [SERVICE_VERSION]: version } }], + }, + }, + aggs: { + first_seen: { + min: { + field: '@timestamp', + }, + }, }, }, - }, - }, - }); + }); - const firstSeen = response.aggregations?.first_seen.value; + const firstSeen = response.aggregations?.first_seen.value; - if (!isNumber(firstSeen)) { - throw new Error( - 'First seen for version was unexpectedly undefined or null.' - ); - } + if (!isNumber(firstSeen)) { + throw new Error( + 'First seen for version was unexpectedly undefined or null.' + ); + } - if (firstSeen < start || firstSeen > end) { - return null; - } + if (firstSeen < start || firstSeen > end) { + return null; + } - return { - type: AnnotationType.VERSION, - id: version, - '@timestamp': firstSeen, - text: version, - }; - }) - ); - return annotations.filter(Boolean) as Annotation[]; + return { + type: AnnotationType.VERSION, + id: version, + '@timestamp': firstSeen, + text: version, + }; + }); + }) + ); + return annotations.filter(Boolean) as Annotation[]; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts index fafd0ce3d3cdcb..6c7cbc26ea6536 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -5,18 +5,22 @@ * 2.0. */ +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { ElasticsearchClient, Logger } from 'kibana/server'; -import { unwrapEsResponse } from '../../../../../observability/server'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; +import { + unwrapEsResponse, + WrappedElasticsearchClientError, +} from '../../../../../observability/server'; import { ESSearchResponse } from '../../../../../../typings/elasticsearch'; import { Annotation as ESAnnotation } from '../../../../../observability/common/annotations'; import { ScopedAnnotationsClient } from '../../../../../observability/server'; import { Annotation, AnnotationType } from '../../../../common/annotations'; import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getStoredAnnotations({ +export function getStoredAnnotations({ setup, serviceName, environment, @@ -31,56 +35,65 @@ export async function getStoredAnnotations({ annotationsClient: ScopedAnnotationsClient; logger: Logger; }): Promise { - const body = { - size: 50, - query: { - bool: { - filter: [ - { - range: rangeFilter(setup.start, setup.end), - }, - { term: { 'annotation.type': 'deployment' } }, - { term: { tags: 'apm' } }, - { term: { [SERVICE_NAME]: serviceName } }, - ...getEnvironmentUiFilterES(environment), - ], + return withApmSpan('get_stored_annotations', async () => { + const { start, end } = setup; + + const body = { + size: 50, + query: { + bool: { + filter: [ + { term: { 'annotation.type': 'deployment' } }, + { term: { tags: 'apm' } }, + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ], + }, }, - }, - }; + }; - try { - const response: ESSearchResponse< - ESAnnotation, - { body: typeof body } - > = await unwrapEsResponse( - client.search({ - index: annotationsClient.index, - body, - }) - ); + try { + const response: ESSearchResponse< + ESAnnotation, + { body: typeof body } + > = await unwrapEsResponse( + client.search({ + index: annotationsClient.index, + body, + }) + ); - return response.hits.hits.map((hit) => { - return { - type: AnnotationType.VERSION, - id: hit._id, - '@timestamp': new Date(hit._source['@timestamp']).getTime(), - text: hit._source.message, - }; - }); - } catch (error) { - // index is only created when an annotation has been indexed, - // so we should handle this error gracefully - if (error.body?.error?.type === 'index_not_found_exception') { - return []; - } + return response.hits.hits.map((hit) => { + return { + type: AnnotationType.VERSION, + id: hit._id, + '@timestamp': new Date(hit._source['@timestamp']).getTime(), + text: hit._source.message, + }; + }); + } catch (error) { + // index is only created when an annotation has been indexed, + // so we should handle this error gracefully + if ( + error instanceof WrappedElasticsearchClientError && + error.originalError instanceof ResponseError + ) { + const type = error.originalError.body.error.type; - if (error.body?.error?.type === 'security_exception') { - logger.warn( - `Unable to get stored annotations due to a security exception. Please make sure that the user has 'indices:data/read/search' permissions for ${annotationsClient.index}` - ); - return []; - } + if (type === 'index_not_found_exception') { + return []; + } + + if (type === 'security_exception') { + logger.warn( + `Unable to get stored annotations due to a security exception. Please make sure that the user has 'indices:data/read/search' permissions for ${annotationsClient.index}` + ); + return []; + } + } - throw error; - } + throw error; + } + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts b/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts index c2399cd4542e77..3683a069342a9b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_agent_name.ts @@ -10,11 +10,12 @@ import { AGENT_NAME, SERVICE_NAME, } from '../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getServiceAgentName({ +export function getServiceAgentName({ serviceName, setup, searchAggregatedTransactions, @@ -23,38 +24,42 @@ export async function getServiceAgentName({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_agent_name', async () => { + const { start, end, apmEventClient } = setup; - const params = { - terminateAfter: 1, - apm: { - events: [ - ProcessorEvent.error, - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.metric, - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ], - }, + const params = { + terminateAfter: 1, + apm: { + events: [ + ProcessorEvent.error, + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.metric, + ], }, - aggs: { - agents: { - terms: { field: AGENT_NAME, size: 1 }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ], + }, + }, + aggs: { + agents: { + terms: { field: AGENT_NAME, size: 1 }, + }, }, }, - }, - }; + }; - const { aggregations } = await apmEventClient.search(params); - const agentName = aggregations?.agents.buckets[0]?.key as string | undefined; - return { agentName }; + const { aggregations } = await apmEventClient.search(params); + const agentName = aggregations?.agents.buckets[0]?.key as + | string + | undefined; + return { agentName }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts index f7a781fb7c075d..558d6ae22f00f5 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_destination_map.ts @@ -19,186 +19,197 @@ import { SPAN_SUBTYPE, SPAN_TYPE, } from '../../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../../common/utils/range_filter'; import { ProcessorEvent } from '../../../../common/processor_event'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { joinByKey } from '../../../../common/utils/join_by_key'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export const getDestinationMap = async ({ +export const getDestinationMap = ({ setup, serviceName, environment, }: { setup: Setup & SetupTimeRange; serviceName: string; - environment: string; + environment?: string; }) => { - const { start, end, apmEventClient } = setup; - - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.span], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } }, - { range: rangeFilter(start, end) }, - ...getEnvironmentUiFilterES(environment), - ], + return withApmSpan('get_service_destination_map', async () => { + const { start, end, apmEventClient } = setup; + + const response = await withApmSpan('get_exit_span_samples', async () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.span], }, - }, - aggs: { - connections: { - composite: { - size: 1000, - sources: [ - { - [SPAN_DESTINATION_SERVICE_RESOURCE]: { - terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE }, - }, - }, - // make sure we get samples for both successful - // and failed calls - { [EVENT_OUTCOME]: { terms: { field: EVENT_OUTCOME } } }, - ], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { exists: { field: SPAN_DESTINATION_SERVICE_RESOURCE } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ], + }, }, aggs: { - docs: { - top_hits: { - docvalue_fields: [SPAN_TYPE, SPAN_SUBTYPE, SPAN_ID] as const, - _source: false, - sort: { - '@timestamp': 'desc', + connections: { + composite: { + size: 1000, + sources: [ + { + [SPAN_DESTINATION_SERVICE_RESOURCE]: { + terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE }, + }, + }, + // make sure we get samples for both successful + // and failed calls + { [EVENT_OUTCOME]: { terms: { field: EVENT_OUTCOME } } }, + ], + }, + aggs: { + sample: { + top_metrics: { + metrics: [ + { field: SPAN_TYPE }, + { field: SPAN_SUBTYPE }, + { field: SPAN_ID }, + ] as const, + sort: { + '@timestamp': 'desc', + }, + }, }, }, }, }, }, + }) + ); + + const outgoingConnections = + response.aggregations?.connections.buckets.map((bucket) => { + const fieldValues = bucket.sample.top[0].metrics; + + return { + [SPAN_DESTINATION_SERVICE_RESOURCE]: String( + bucket.key[SPAN_DESTINATION_SERVICE_RESOURCE] + ), + [SPAN_ID]: (fieldValues[SPAN_ID] ?? '') as string, + [SPAN_TYPE]: (fieldValues[SPAN_TYPE] ?? '') as string, + [SPAN_SUBTYPE]: (fieldValues[SPAN_SUBTYPE] ?? '') as string, + }; + }) ?? []; + + const transactionResponse = await withApmSpan( + 'get_transactions_for_exit_spans', + () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + query: { + bool: { + filter: [ + { + terms: { + [PARENT_ID]: outgoingConnections.map( + (connection) => connection[SPAN_ID] + ), + }, + }, + ...rangeQuery(start, end), + ], + }, + }, + size: outgoingConnections.length, + docvalue_fields: [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + AGENT_NAME, + PARENT_ID, + ] as const, + _source: false, + }, + }) + ); + + const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ + [SPAN_ID]: String(hit.fields[PARENT_ID]![0]), + service: { + name: String(hit.fields[SERVICE_NAME]![0]), + environment: String(hit.fields[SERVICE_ENVIRONMENT]?.[0] ?? ''), + agentName: hit.fields[AGENT_NAME]![0] as AgentName, }, - }, - }); - - const outgoingConnections = - response.aggregations?.connections.buckets.map((bucket) => { - const doc = bucket.docs.hits.hits[0]; + })); + + // merge outgoing spans with transactions by span.id/parent.id + const joinedBySpanId = joinByKey( + [...outgoingConnections, ...incomingConnections], + SPAN_ID + ); + + // we could have multiple connections per address because + // of multiple event outcomes + const dedupedConnectionsByAddress = joinByKey( + joinedBySpanId, + SPAN_DESTINATION_SERVICE_RESOURCE + ); + + // identify a connection by either service.name, service.environment, agent.name + // OR span.destination.service.resource + + const connectionsWithId = dedupedConnectionsByAddress.map((connection) => { + const id = + 'service' in connection + ? { service: connection.service } + : pickKeys(connection, SPAN_DESTINATION_SERVICE_RESOURCE); return { - [SPAN_DESTINATION_SERVICE_RESOURCE]: String( - bucket.key[SPAN_DESTINATION_SERVICE_RESOURCE] - ), - [SPAN_ID]: String(doc.fields[SPAN_ID]?.[0]), - [SPAN_TYPE]: String(doc.fields[SPAN_TYPE]?.[0] ?? ''), - [SPAN_SUBTYPE]: String(doc.fields[SPAN_SUBTYPE]?.[0] ?? ''), + ...connection, + id, }; - }) ?? []; - - const transactionResponse = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - query: { - bool: { - filter: [ - { - terms: { - [PARENT_ID]: outgoingConnections.map( - (connection) => connection[SPAN_ID] - ), - }, - }, - { range: rangeFilter(start, end) }, - ], - }, - }, - size: outgoingConnections.length, - docvalue_fields: [ - SERVICE_NAME, - SERVICE_ENVIRONMENT, - AGENT_NAME, - PARENT_ID, - ] as const, - _source: false, - }, - }); + }); - const incomingConnections = transactionResponse.hits.hits.map((hit) => ({ - [SPAN_ID]: String(hit.fields[PARENT_ID]![0]), - service: { - name: String(hit.fields[SERVICE_NAME]![0]), - environment: String(hit.fields[SERVICE_ENVIRONMENT]?.[0] ?? ''), - agentName: hit.fields[AGENT_NAME]![0] as AgentName, - }, - })); - - // merge outgoing spans with transactions by span.id/parent.id - const joinedBySpanId = joinByKey( - [...outgoingConnections, ...incomingConnections], - SPAN_ID - ); - - // we could have multiple connections per address because - // of multiple event outcomes - const dedupedConnectionsByAddress = joinByKey( - joinedBySpanId, - SPAN_DESTINATION_SERVICE_RESOURCE - ); - - // identify a connection by either service.name, service.environment, agent.name - // OR span.destination.service.resource - - const connectionsWithId = dedupedConnectionsByAddress.map((connection) => { - const id = - 'service' in connection - ? { service: connection.service } - : pickKeys(connection, SPAN_DESTINATION_SERVICE_RESOURCE); - - return { - ...connection, - id, - }; - }); + const dedupedConnectionsById = joinByKey(connectionsWithId, 'id'); - const dedupedConnectionsById = joinByKey(connectionsWithId, 'id'); - - const connectionsByAddress = keyBy( - connectionsWithId, - SPAN_DESTINATION_SERVICE_RESOURCE - ); - - // per span.destination.service.resource, return merged/deduped item - return mapValues(connectionsByAddress, ({ id }) => { - const connection = dedupedConnectionsById.find((dedupedConnection) => - isEqual(id, dedupedConnection.id) - )!; - - return { - id, - span: { - type: connection[SPAN_TYPE], - subtype: connection[SPAN_SUBTYPE], - destination: { - service: { - resource: connection[SPAN_DESTINATION_SERVICE_RESOURCE], - }, - }, - }, - ...('service' in connection && connection.service - ? { + const connectionsByAddress = keyBy( + connectionsWithId, + SPAN_DESTINATION_SERVICE_RESOURCE + ); + + // per span.destination.service.resource, return merged/deduped item + return mapValues(connectionsByAddress, ({ id }) => { + const connection = dedupedConnectionsById.find((dedupedConnection) => + isEqual(id, dedupedConnection.id) + )!; + + return { + id, + span: { + type: connection[SPAN_TYPE], + subtype: connection[SPAN_SUBTYPE], + destination: { service: { - name: connection.service.name, - environment: connection.service.environment, - }, - agent: { - name: connection.service.agentName, + resource: connection[SPAN_DESTINATION_SERVICE_RESOURCE], }, - } - : {}), - }; + }, + }, + ...('service' in connection && connection.service + ? { + service: { + name: connection.service.name, + environment: connection.service.environment, + }, + agent: { + name: connection.service.agentName, + }, + } + : {}), + }; + }); }); }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts index 5500b9c30b20e7..dfbdfb3f504e8c 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/get_metrics.ts @@ -13,14 +13,14 @@ import { SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, } from '../../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../../common/utils/range_filter'; import { ProcessorEvent } from '../../../../common/processor_event'; -import { getEnvironmentUiFilterES } from '../../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { EventOutcome } from '../../../../common/event_outcome'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export const getMetrics = async ({ +export const getMetrics = ({ setup, serviceName, environment, @@ -28,64 +28,68 @@ export const getMetrics = async ({ }: { setup: Setup & SetupTimeRange; serviceName: string; - environment: string; + environment?: string; numBuckets: number; }) => { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_destination_metrics', async () => { + const { start, end, apmEventClient } = setup; - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - track_total_hits: true, - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { exists: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT } }, - { range: rangeFilter(start, end) }, - ...getEnvironmentUiFilterES(environment), - ], - }, + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], }, - aggs: { - connections: { - terms: { - field: SPAN_DESTINATION_SERVICE_RESOURCE, - size: 100, - }, - aggs: { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: getBucketSize({ start, end, numBuckets }) - .intervalString, - extended_bounds: { - min: start, - max: end, - }, + body: { + track_total_hits: true, + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { + exists: { field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT }, }, - aggs: { - latency_sum: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ], + }, + }, + aggs: { + connections: { + terms: { + field: SPAN_DESTINATION_SERVICE_RESOURCE, + size: 100, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ start, end, numBuckets }) + .intervalString, + extended_bounds: { + min: start, + max: end, }, }, - count: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + aggs: { + latency_sum: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_SUM, + }, }, - }, - [EVENT_OUTCOME]: { - terms: { - field: EVENT_OUTCOME, + count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, }, - aggs: { - count: { - sum: { - field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + }, + aggs: { + count: { + sum: { + field: SPAN_DESTINATION_SERVICE_RESPONSE_TIME_COUNT, + }, }, }, }, @@ -95,47 +99,47 @@ export const getMetrics = async ({ }, }, }, - }, - }); + }); - return ( - response.aggregations?.connections.buckets.map((bucket) => ({ - span: { - destination: { - service: { - resource: String(bucket.key), + return ( + response.aggregations?.connections.buckets.map((bucket) => ({ + span: { + destination: { + service: { + resource: String(bucket.key), + }, }, }, - }, - value: { - count: sum( - bucket.timeseries.buckets.map( - (dateBucket) => dateBucket.count.value ?? 0 - ) - ), - latency_sum: sum( - bucket.timeseries.buckets.map( - (dateBucket) => dateBucket.latency_sum.value ?? 0 - ) - ), - error_count: sum( - bucket.timeseries.buckets.flatMap( - (dateBucket) => - dateBucket[EVENT_OUTCOME].buckets.find( - (outcomeBucket) => outcomeBucket.key === EventOutcome.failure - )?.count.value ?? 0 - ) - ), - }, - timeseries: bucket.timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - count: dateBucket.count.value ?? 0, - latency_sum: dateBucket.latency_sum.value ?? 0, - error_count: - dateBucket[EVENT_OUTCOME].buckets.find( - (outcomeBucket) => outcomeBucket.key === EventOutcome.failure - )?.count.value ?? 0, - })), - })) ?? [] - ); + value: { + count: sum( + bucket.timeseries.buckets.map( + (dateBucket) => dateBucket.count.value ?? 0 + ) + ), + latency_sum: sum( + bucket.timeseries.buckets.map( + (dateBucket) => dateBucket.latency_sum.value ?? 0 + ) + ), + error_count: sum( + bucket.timeseries.buckets.flatMap( + (dateBucket) => + dateBucket[EVENT_OUTCOME].buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0 + ) + ), + }, + timeseries: bucket.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + count: dateBucket.count.value ?? 0, + latency_sum: dateBucket.latency_sum.value ?? 0, + error_count: + dateBucket[EVENT_OUTCOME].buckets.find( + (outcomeBucket) => outcomeBucket.key === EventOutcome.failure + )?.count.value ?? 0, + })), + })) ?? [] + ); + }); }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts index 5ccb6fb19cbdad..724b5278d7edfc 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_dependencies/index.ts @@ -16,6 +16,7 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getMetrics } from './get_metrics'; import { getDestinationMap } from './get_destination_map'; import { calculateThroughput } from '../../helpers/calculate_throughput'; +import { withApmSpan } from '../../../utils/with_apm_span'; export type ServiceDependencyItem = { name: string; @@ -42,7 +43,7 @@ export type ServiceDependencyItem = { | { type: 'external'; spanType?: string; spanSubtype?: string } ); -export async function getServiceDependencies({ +export function getServiceDependencies({ setup, serviceName, environment, @@ -50,174 +51,180 @@ export async function getServiceDependencies({ }: { serviceName: string; setup: Setup & SetupTimeRange; - environment: string; + environment?: string; numBuckets: number; }): Promise { - const { start, end } = setup; - const [allMetrics, destinationMap] = await Promise.all([ - getMetrics({ - setup, - serviceName, - environment, - numBuckets, - }), - getDestinationMap({ - setup, - serviceName, - environment, - }), - ]); - - const metricsWithDestinationIds = allMetrics.map((metricItem) => { - const spanDestination = metricItem.span.destination.service.resource; - - const destination = maybe(destinationMap[spanDestination]); - const id = destination?.id || { - [SPAN_DESTINATION_SERVICE_RESOURCE]: spanDestination, - }; - - return merge( - { - id, - metrics: [metricItem], - span: { - destination: { - service: { - resource: spanDestination, + return withApmSpan('get_service_dependencies', async () => { + const { start, end } = setup; + const [allMetrics, destinationMap] = await Promise.all([ + getMetrics({ + setup, + serviceName, + environment, + numBuckets, + }), + getDestinationMap({ + setup, + serviceName, + environment, + }), + ]); + + const metricsWithDestinationIds = allMetrics.map((metricItem) => { + const spanDestination = metricItem.span.destination.service.resource; + + const destination = maybe(destinationMap[spanDestination]); + const id = destination?.id || { + [SPAN_DESTINATION_SERVICE_RESOURCE]: spanDestination, + }; + + return merge( + { + id, + metrics: [metricItem], + span: { + destination: { + service: { + resource: spanDestination, + }, }, }, }, - }, - destination + destination + ); + }, []); + + const metricsJoinedByDestinationId = joinByKey( + metricsWithDestinationIds, + 'id', + (a, b) => { + const { metrics: metricsA, ...itemA } = a; + const { metrics: metricsB, ...itemB } = b; + + return merge({}, itemA, itemB, { metrics: metricsA.concat(metricsB) }); + } ); - }, []); - const metricsJoinedByDestinationId = joinByKey( - metricsWithDestinationIds, - 'id', - (a, b) => { - const { metrics: metricsA, ...itemA } = a; - const { metrics: metricsB, ...itemB } = b; + const metricsByResolvedAddress = metricsJoinedByDestinationId.map( + (item) => { + const mergedMetrics = item.metrics.reduce< + Omit, 'span'> + >( + (prev, current) => { + return { + value: { + count: prev.value.count + current.value.count, + latency_sum: prev.value.latency_sum + current.value.latency_sum, + error_count: prev.value.error_count + current.value.error_count, + }, + timeseries: joinByKey( + [...prev.timeseries, ...current.timeseries], + 'x', + (a, b) => ({ + x: a.x, + count: a.count + b.count, + latency_sum: a.latency_sum + b.latency_sum, + error_count: a.error_count + b.error_count, + }) + ), + }; + }, + { + value: { + count: 0, + latency_sum: 0, + error_count: 0, + }, + timeseries: [], + } + ); + + const destMetrics = { + latency: { + value: + mergedMetrics.value.count > 0 + ? mergedMetrics.value.latency_sum / mergedMetrics.value.count + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: point.count > 0 ? point.latency_sum / point.count : null, + })), + }, + throughput: { + value: + mergedMetrics.value.count > 0 + ? calculateThroughput({ + start, + end, + value: mergedMetrics.value.count, + }) + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: + point.count > 0 + ? calculateThroughput({ start, end, value: point.count }) + : null, + })), + }, + errorRate: { + value: + mergedMetrics.value.count > 0 + ? (mergedMetrics.value.error_count ?? 0) / + mergedMetrics.value.count + : null, + timeseries: mergedMetrics.timeseries.map((point) => ({ + x: point.x, + y: + point.count > 0 ? (point.error_count ?? 0) / point.count : null, + })), + }, + }; - return merge({}, itemA, itemB, { metrics: metricsA.concat(metricsB) }); - } - ); + if (item.service) { + return { + name: item.service.name, + type: 'service' as const, + serviceName: item.service.name, + environment: item.service.environment, + // agent.name should always be there, type returned from joinByKey is too pessimistic + agentName: item.agent!.name, + ...destMetrics, + }; + } - const metricsByResolvedAddress = metricsJoinedByDestinationId.map((item) => { - const mergedMetrics = item.metrics.reduce< - Omit, 'span'> - >( - (prev, current) => { return { - value: { - count: prev.value.count + current.value.count, - latency_sum: prev.value.latency_sum + current.value.latency_sum, - error_count: prev.value.error_count + current.value.error_count, - }, - timeseries: joinByKey( - [...prev.timeseries, ...current.timeseries], - 'x', - (a, b) => ({ - x: a.x, - count: a.count + b.count, - latency_sum: a.latency_sum + b.latency_sum, - error_count: a.error_count + b.error_count, - }) - ), + name: item.span.destination.service.resource, + type: 'external' as const, + spanType: item.span.type, + spanSubtype: item.span.subtype, + ...destMetrics, }; - }, - { - value: { - count: 0, - latency_sum: 0, - error_count: 0, - }, - timeseries: [], } ); - const destMetrics = { - latency: { - value: - mergedMetrics.value.count > 0 - ? mergedMetrics.value.latency_sum / mergedMetrics.value.count - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: point.count > 0 ? point.latency_sum / point.count : null, - })), - }, - throughput: { - value: - mergedMetrics.value.count > 0 - ? calculateThroughput({ - start, - end, - value: mergedMetrics.value.count, - }) - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: - point.count > 0 - ? calculateThroughput({ start, end, value: point.count }) - : null, - })), - }, - errorRate: { - value: - mergedMetrics.value.count > 0 - ? (mergedMetrics.value.error_count ?? 0) / mergedMetrics.value.count - : null, - timeseries: mergedMetrics.timeseries.map((point) => ({ - x: point.x, - y: point.count > 0 ? (point.error_count ?? 0) / point.count : null, - })), - }, - }; - - if (item.service) { - return { - name: item.service.name, - type: 'service' as const, - serviceName: item.service.name, - environment: item.service.environment, - // agent.name should always be there, type returned from joinByKey is too pessimistic - agentName: item.agent!.name, - ...destMetrics, - }; - } + const latencySums = metricsByResolvedAddress + .map( + (metric) => (metric.latency.value ?? 0) * (metric.throughput.value ?? 0) + ) + .filter(isFiniteNumber); - return { - name: item.span.destination.service.resource, - type: 'external' as const, - spanType: item.span.type, - spanSubtype: item.span.subtype, - ...destMetrics, - }; - }); + const minLatencySum = Math.min(...latencySums); + const maxLatencySum = Math.max(...latencySums); + + return metricsByResolvedAddress.map((metric) => { + const impact = + isFiniteNumber(metric.latency.value) && + isFiniteNumber(metric.throughput.value) + ? ((metric.latency.value * metric.throughput.value - minLatencySum) / + (maxLatencySum - minLatencySum)) * + 100 + : 0; - const latencySums = metricsByResolvedAddress - .map( - (metric) => (metric.latency.value ?? 0) * (metric.throughput.value ?? 0) - ) - .filter(isFiniteNumber); - - const minLatencySum = Math.min(...latencySums); - const maxLatencySum = Math.max(...latencySums); - - return metricsByResolvedAddress.map((metric) => { - const impact = - isFiniteNumber(metric.latency.value) && - isFiniteNumber(metric.throughput.value) - ? ((metric.latency.value * metric.throughput.value - minLatencySum) / - (maxLatencySum - minLatencySum)) * - 100 - : 0; - - return { - ...metric, - impact, - }; + return { + ...metric, + impact, + }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts index f27125874f2b48..a17fb6da2007fa 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_error_groups/index.ts @@ -9,7 +9,7 @@ import { ValuesType } from 'utility-types'; import { orderBy } from 'lodash'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; import { PromiseReturnType } from '../../../../../observability/typings/common'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { ProcessorEvent } from '../../../../common/processor_event'; import { ERROR_EXC_MESSAGE, @@ -21,12 +21,14 @@ import { import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getBucketSize } from '../../helpers/get_bucket_size'; import { getErrorName } from '../../helpers/get_error_name'; +import { withApmSpan } from '../../../utils/with_apm_span'; export type ServiceErrorGroupItem = ValuesType< PromiseReturnType >; export async function getServiceErrorGroups({ + environment, serviceName, setup, size, @@ -36,6 +38,7 @@ export async function getServiceErrorGroups({ sortField, transactionType, }: { + environment?: string; serviceName: string; setup: Setup & SetupTimeRange; size: number; @@ -45,139 +48,154 @@ export async function getServiceErrorGroups({ sortField: 'name' | 'last_seen' | 'occurrences'; transactionType: string; }) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_error_groups', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets }); + const { intervalString } = getBucketSize({ start, end, numBuckets }); - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.error], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ], + const response = await withApmSpan('get_top_service_error_groups', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.error], }, - }, - aggs: { - error_groups: { - terms: { - field: ERROR_GROUP_ID, - size: 500, - order: { - _count: 'desc', + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], }, }, aggs: { - sample: { - top_hits: { - size: 1, - _source: [ERROR_LOG_MESSAGE, ERROR_EXC_MESSAGE, '@timestamp'], - sort: { - '@timestamp': 'desc', + error_groups: { + terms: { + field: ERROR_GROUP_ID, + size: 500, + order: { + _count: 'desc', + }, + }, + aggs: { + sample: { + top_hits: { + size: 1, + _source: [ + ERROR_LOG_MESSAGE, + ERROR_EXC_MESSAGE, + '@timestamp', + ], + sort: { + '@timestamp': 'desc', + }, + }, }, }, }, }, }, - }, - }, - }); + }) + ); - const errorGroups = - response.aggregations?.error_groups.buckets.map((bucket) => ({ - group_id: bucket.key as string, - name: - getErrorName(bucket.sample.hits.hits[0]._source) ?? NOT_AVAILABLE_LABEL, - last_seen: new Date( - bucket.sample.hits.hits[0]?._source['@timestamp'] - ).getTime(), - occurrences: { - value: bucket.doc_count, - }, - })) ?? []; + const errorGroups = + response.aggregations?.error_groups.buckets.map((bucket) => ({ + group_id: bucket.key as string, + name: + getErrorName(bucket.sample.hits.hits[0]._source) ?? + NOT_AVAILABLE_LABEL, + last_seen: new Date( + bucket.sample.hits.hits[0]?._source['@timestamp'] + ).getTime(), + occurrences: { + value: bucket.doc_count, + }, + })) ?? []; - // Sort error groups first, and only get timeseries for data in view. - // This is to limit the possibility of creating too many buckets. + // Sort error groups first, and only get timeseries for data in view. + // This is to limit the possibility of creating too many buckets. - const sortedAndSlicedErrorGroups = orderBy( - errorGroups, - (group) => { - if (sortField === 'occurrences') { - return group.occurrences.value; - } - return group[sortField]; - }, - [sortDirection] - ).slice(pageIndex * size, pageIndex * size + size); + const sortedAndSlicedErrorGroups = orderBy( + errorGroups, + (group) => { + if (sortField === 'occurrences') { + return group.occurrences.value; + } + return group[sortField]; + }, + [sortDirection] + ).slice(pageIndex * size, pageIndex * size + size); - const sortedErrorGroupIds = sortedAndSlicedErrorGroups.map( - (group) => group.group_id - ); + const sortedErrorGroupIds = sortedAndSlicedErrorGroups.map( + (group) => group.group_id + ); - const timeseriesResponse = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.error], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { terms: { [ERROR_GROUP_ID]: sortedErrorGroupIds } }, - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ], - }, - }, - aggs: { - error_groups: { - terms: { - field: ERROR_GROUP_ID, - size, + const timeseriesResponse = await withApmSpan( + 'get_service_error_groups_timeseries', + async () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.error], }, - aggs: { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, + body: { + size: 0, + query: { + bool: { + filter: [ + { terms: { [ERROR_GROUP_ID]: sortedErrorGroupIds } }, + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + }, + }, + aggs: { + error_groups: { + terms: { + field: ERROR_GROUP_ID, + size, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + }, }, }, }, }, + }) + ); + + return { + total_error_groups: errorGroups.length, + is_aggregation_accurate: + (response.aggregations?.error_groups.sum_other_doc_count ?? 0) === 0, + error_groups: sortedAndSlicedErrorGroups.map((errorGroup) => ({ + ...errorGroup, + occurrences: { + ...errorGroup.occurrences, + timeseries: + timeseriesResponse.aggregations?.error_groups.buckets + .find((bucket) => bucket.key === errorGroup.group_id) + ?.timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.doc_count, + })) ?? null, }, - }, - }, + })), + }; }); - - return { - total_error_groups: errorGroups.length, - is_aggregation_accurate: - (response.aggregations?.error_groups.sum_other_doc_count ?? 0) === 0, - error_groups: sortedAndSlicedErrorGroups.map((errorGroup) => ({ - ...errorGroup, - occurrences: { - ...errorGroup.occurrences, - timeseries: - timeseriesResponse.aggregations?.error_groups.buckets - .find((bucket) => bucket.key === errorGroup.group_id) - ?.timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.doc_count, - })) ?? null, - }, - })), - }; } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts index 999ed62ed4ba6b..ef90e5197229b9 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_system_metric_stats.ts @@ -6,7 +6,7 @@ */ import { AggregationOptionsByType } from '../../../../../../typings/elasticsearch'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; import { METRIC_CGROUP_MEMORY_USAGE_BYTES, @@ -23,129 +23,134 @@ import { percentCgroupMemoryUsedScript, percentSystemMemoryUsedScript, } from '../../metrics/by_agent/shared/memory'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getServiceInstanceSystemMetricStats({ + environment, setup, serviceName, size, numBuckets, }: ServiceInstanceParams) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_instance_system_metric_stats', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets }); + const { intervalString } = getBucketSize({ start, end, numBuckets }); - const systemMemoryFilter = { - bool: { - filter: [ - { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, - { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, - ], - }, - }; + const systemMemoryFilter = { + bool: { + filter: [ + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, + ], + }, + }; - const cgroupMemoryFilter = { - exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES }, - }; + const cgroupMemoryFilter = { + exists: { field: METRIC_CGROUP_MEMORY_USAGE_BYTES }, + }; - const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } }; + const cpuUsageFilter = { exists: { field: METRIC_PROCESS_CPU_PERCENT } }; - function withTimeseries(agg: T) { - return { - avg: { avg: agg }, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, + function withTimeseries(agg: T) { + return { + avg: { avg: agg }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + avg: { avg: agg }, }, }, - aggs: { - avg: { avg: agg }, - }, + }; + } + + const subAggs = { + memory_usage_cgroup: { + filter: cgroupMemoryFilter, + aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }), + }, + memory_usage_system: { + filter: systemMemoryFilter, + aggs: withTimeseries({ script: percentSystemMemoryUsedScript }), + }, + cpu_usage: { + filter: cpuUsageFilter, + aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }), }, }; - } - const subAggs = { - memory_usage_cgroup: { - filter: cgroupMemoryFilter, - aggs: withTimeseries({ script: percentCgroupMemoryUsedScript }), - }, - memory_usage_system: { - filter: systemMemoryFilter, - aggs: withTimeseries({ script: percentSystemMemoryUsedScript }), - }, - cpu_usage: { - filter: cpuUsageFilter, - aggs: withTimeseries({ field: METRIC_PROCESS_CPU_PERCENT }), - }, - }; - - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - { term: { [SERVICE_NAME]: serviceName } }, - ...esFilter, - ], - should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter], - minimum_should_match: 1, - }, + const response = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.metric], }, - aggs: { - [SERVICE_NODE_NAME]: { - terms: { - field: SERVICE_NODE_NAME, - missing: SERVICE_NODE_NAME_MISSING, - size, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + should: [cgroupMemoryFilter, systemMemoryFilter, cpuUsageFilter], + minimum_should_match: 1, + }, + }, + aggs: { + [SERVICE_NODE_NAME]: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + size, + }, + aggs: subAggs, }, - aggs: subAggs, }, }, - }, - }); + }); - return ( - response.aggregations?.[SERVICE_NODE_NAME].buckets.map( - (serviceNodeBucket) => { - const hasCGroupData = - serviceNodeBucket.memory_usage_cgroup.avg.value !== null; + return ( + response.aggregations?.[SERVICE_NODE_NAME].buckets.map( + (serviceNodeBucket) => { + const hasCGroupData = + serviceNodeBucket.memory_usage_cgroup.avg.value !== null; - const memoryMetricsKey = hasCGroupData - ? 'memory_usage_cgroup' - : 'memory_usage_system'; + const memoryMetricsKey = hasCGroupData + ? 'memory_usage_cgroup' + : 'memory_usage_system'; - return { - serviceNodeName: String(serviceNodeBucket.key), - cpuUsage: { - value: serviceNodeBucket.cpu_usage.avg.value, - timeseries: serviceNodeBucket.cpu_usage.timeseries.buckets.map( - (dateBucket) => ({ + return { + serviceNodeName: String(serviceNodeBucket.key), + cpuUsage: { + value: serviceNodeBucket.cpu_usage.avg.value, + timeseries: serviceNodeBucket.cpu_usage.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg.value, + }) + ), + }, + memoryUsage: { + value: serviceNodeBucket[memoryMetricsKey].avg.value, + timeseries: serviceNodeBucket[ + memoryMetricsKey + ].timeseries.buckets.map((dateBucket) => ({ x: dateBucket.key, y: dateBucket.avg.value, - }) - ), - }, - memoryUsage: { - value: serviceNodeBucket[memoryMetricsKey].avg.value, - timeseries: serviceNodeBucket[ - memoryMetricsKey - ].timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg.value, - })), - }, - }; - } - ) ?? [] - ); + })), + }, + }; + } + ) ?? [] + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts index 42c822fb4e133a..b56625bcebc998 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts @@ -6,7 +6,7 @@ */ import { EventOutcome } from '../../../../common/event_outcome'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { SERVICE_NODE_NAME_MISSING } from '../../../../common/service_nodes'; import { EVENT_OUTCOME, @@ -21,8 +21,10 @@ import { getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; import { calculateThroughput } from '../../helpers/calculate_throughput'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getServiceInstanceTransactionStats({ + environment, setup, transactionType, serviceName, @@ -30,120 +32,123 @@ export async function getServiceInstanceTransactionStats({ searchAggregatedTransactions, numBuckets, }: ServiceInstanceParams) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_instance_transaction_stats', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const { intervalString, bucketSize } = getBucketSize({ - start, - end, - numBuckets, - }); + const { intervalString, bucketSize } = getBucketSize({ + start, + end, + numBuckets, + }); - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); - const subAggs = { - avg_transaction_duration: { - avg: { - field, + const subAggs = { + avg_transaction_duration: { + avg: { + field, + }, }, - }, - failures: { - filter: { - term: { - [EVENT_OUTCOME]: EventOutcome.failure, + failures: { + filter: { + term: { + [EVENT_OUTCOME]: EventOutcome.failure, + }, }, }, - }, - }; + }; - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - ...esFilter, - ], - }, + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - [SERVICE_NODE_NAME]: { - terms: { - field: SERVICE_NODE_NAME, - missing: SERVICE_NODE_NAME_MISSING, - size, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], }, - aggs: { - ...subAggs, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, + }, + aggs: { + [SERVICE_NODE_NAME]: { + terms: { + field: SERVICE_NODE_NAME, + missing: SERVICE_NODE_NAME_MISSING, + size, + }, + aggs: { + ...subAggs, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + ...subAggs, }, - }, - aggs: { - ...subAggs, }, }, }, }, }, - }, - }); + }); - const bucketSizeInMinutes = bucketSize / 60; + const bucketSizeInMinutes = bucketSize / 60; - return ( - response.aggregations?.[SERVICE_NODE_NAME].buckets.map( - (serviceNodeBucket) => { - const { - doc_count: count, - avg_transaction_duration: avgTransactionDuration, - key, - failures, - timeseries, - } = serviceNodeBucket; + return ( + response.aggregations?.[SERVICE_NODE_NAME].buckets.map( + (serviceNodeBucket) => { + const { + doc_count: count, + avg_transaction_duration: avgTransactionDuration, + key, + failures, + timeseries, + } = serviceNodeBucket; - return { - serviceNodeName: String(key), - errorRate: { - value: failures.doc_count / count, - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.failures.doc_count / dateBucket.doc_count, - })), - }, - throughput: { - value: calculateThroughput({ start, end, value: count }), - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.doc_count / bucketSizeInMinutes, - })), - }, - latency: { - value: avgTransactionDuration.value, - timeseries: timeseries.buckets.map((dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg_transaction_duration.value, - })), - }, - }; - } - ) ?? [] - ); + return { + serviceNodeName: String(key), + errorRate: { + value: failures.doc_count / count, + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.failures.doc_count / dateBucket.doc_count, + })), + }, + throughput: { + value: calculateThroughput({ start, end, value: count }), + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.doc_count / bucketSizeInMinutes, + })), + }, + latency: { + value: avgTransactionDuration.value, + timeseries: timeseries.buckets.map((dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg_transaction_duration.value, + })), + }, + }; + } + ) ?? [] + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts index 4dae5e0a33a908..4c16940e6d2538 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/index.ts @@ -6,11 +6,13 @@ */ import { joinByKey } from '../../../../common/utils/join_by_key'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getServiceInstanceSystemMetricStats } from './get_service_instance_system_metric_stats'; import { getServiceInstanceTransactionStats } from './get_service_instance_transaction_stats'; export interface ServiceInstanceParams { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; transactionType: string; @@ -22,20 +24,22 @@ export interface ServiceInstanceParams { export async function getServiceInstances( params: Omit ) { - const paramsForSubQueries = { - ...params, - size: 50, - }; + return withApmSpan('get_service_instances', async () => { + const paramsForSubQueries = { + ...params, + size: 50, + }; - const [transactionStats, systemMetricStats] = await Promise.all([ - getServiceInstanceTransactionStats(paramsForSubQueries), - getServiceInstanceSystemMetricStats(paramsForSubQueries), - ]); + const [transactionStats, systemMetricStats] = await Promise.all([ + getServiceInstanceTransactionStats(paramsForSubQueries), + getServiceInstanceSystemMetricStats(paramsForSubQueries), + ]); - const stats = joinByKey( - [...transactionStats, ...systemMetricStats], - 'serviceNodeName' - ); + const stats = joinByKey( + [...transactionStats, ...systemMetricStats], + 'serviceNodeName' + ); - return stats; + return stats; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts index 246184f617b077..5c43191cf588c2 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_details.ts @@ -21,18 +21,19 @@ import { SERVICE_VERSION, } from '../../../common/elasticsearch_fieldnames'; import { ContainerType } from '../../../common/service_metadata'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { should } from './get_service_metadata_icons'; +import { withApmSpan } from '../../utils/with_apm_span'; type ServiceMetadataDetailsRaw = Pick< TransactionRaw, 'service' | 'agent' | 'host' | 'container' | 'kubernetes' | 'cloud' >; -interface ServiceMetadataDetails { +export interface ServiceMetadataDetails { service?: { versions?: string[]; runtime?: { @@ -59,7 +60,7 @@ interface ServiceMetadataDetails { }; } -export async function getServiceMetadataDetails({ +export function getServiceMetadataDetails({ serviceName, setup, searchAggregatedTransactions, @@ -68,103 +69,105 @@ export async function getServiceMetadataDetails({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }): Promise { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_metadata_details', async () => { + const { start, end, apmEventClient } = setup; - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ]; + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ]; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - size: 1, - _source: [SERVICE, AGENT, HOST, CONTAINER_ID, KUBERNETES, CLOUD], - query: { bool: { filter, should } }, - aggs: { - serviceVersions: { - terms: { - field: SERVICE_VERSION, - size: 10, - order: { _key: 'desc' } as SortOptions, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + size: 1, + _source: [SERVICE, AGENT, HOST, CONTAINER_ID, KUBERNETES, CLOUD], + query: { bool: { filter, should } }, + aggs: { + serviceVersions: { + terms: { + field: SERVICE_VERSION, + size: 10, + order: { _key: 'desc' } as SortOptions, + }, }, - }, - availabilityZones: { - terms: { - field: CLOUD_AVAILABILITY_ZONE, - size: 10, + availabilityZones: { + terms: { + field: CLOUD_AVAILABILITY_ZONE, + size: 10, + }, }, - }, - machineTypes: { - terms: { - field: CLOUD_MACHINE_TYPE, - size: 10, + machineTypes: { + terms: { + field: CLOUD_MACHINE_TYPE, + size: 10, + }, }, + totalNumberInstances: { cardinality: { field: SERVICE_NODE_NAME } }, }, - totalNumberInstances: { cardinality: { field: SERVICE_NODE_NAME } }, }, - }, - }; + }; - const response = await apmEventClient.search(params); + const response = await apmEventClient.search(params); - if (response.hits.total.value === 0) { - return { - service: undefined, - container: undefined, - cloud: undefined, - }; - } + if (response.hits.total.value === 0) { + return { + service: undefined, + container: undefined, + cloud: undefined, + }; + } - const { service, agent, host, kubernetes, container, cloud } = response.hits - .hits[0]._source as ServiceMetadataDetailsRaw; + const { service, agent, host, kubernetes, container, cloud } = response.hits + .hits[0]._source as ServiceMetadataDetailsRaw; - const serviceMetadataDetails = { - versions: response.aggregations?.serviceVersions.buckets.map( - (bucket) => bucket.key as string - ), - runtime: service.runtime, - framework: service.framework?.name, - agent, - }; + const serviceMetadataDetails = { + versions: response.aggregations?.serviceVersions.buckets.map( + (bucket) => bucket.key as string + ), + runtime: service.runtime, + framework: service.framework?.name, + agent, + }; - const totalNumberInstances = - response.aggregations?.totalNumberInstances.value; + const totalNumberInstances = + response.aggregations?.totalNumberInstances.value; - const containerDetails = - host || container || totalNumberInstances || kubernetes + const containerDetails = + host || container || totalNumberInstances || kubernetes + ? { + os: host?.os?.platform, + type: (!!kubernetes ? 'Kubernetes' : 'Docker') as ContainerType, + isContainerized: !!container?.id, + totalNumberInstances, + } + : undefined; + + const cloudDetails = cloud ? { - os: host?.os?.platform, - type: (!!kubernetes ? 'Kubernetes' : 'Docker') as ContainerType, - isContainerized: !!container?.id, - totalNumberInstances, + provider: cloud.provider, + projectName: cloud.project?.name, + availabilityZones: response.aggregations?.availabilityZones.buckets.map( + (bucket) => bucket.key as string + ), + machineTypes: response.aggregations?.machineTypes.buckets.map( + (bucket) => bucket.key as string + ), } : undefined; - const cloudDetails = cloud - ? { - provider: cloud.provider, - projectName: cloud.project?.name, - availabilityZones: response.aggregations?.availabilityZones.buckets.map( - (bucket) => bucket.key as string - ), - machineTypes: response.aggregations?.machineTypes.buckets.map( - (bucket) => bucket.key as string - ), - } - : undefined; - - return { - service: serviceMetadataDetails, - container: containerDetails, - cloud: cloudDetails, - }; + return { + service: serviceMetadataDetails, + container: containerDetails, + cloud: cloudDetails, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts index b09b2305629ab9..b342ffea02464e 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_metadata_icons.ts @@ -16,17 +16,18 @@ import { HOST_OS_PLATFORM, } from '../../../common/elasticsearch_fieldnames'; import { ContainerType } from '../../../common/service_metadata'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { TransactionRaw } from '../../../typings/es_schemas/raw/transaction_raw'; import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { withApmSpan } from '../../utils/with_apm_span'; type ServiceMetadataIconsRaw = Pick< TransactionRaw, 'kubernetes' | 'cloud' | 'container' | 'agent' >; -interface ServiceMetadataIcons { +export interface ServiceMetadataIcons { agentName?: string; containerType?: ContainerType; cloudProvider?: string; @@ -40,7 +41,7 @@ export const should = [ { exists: { field: AGENT_NAME } }, ]; -export async function getServiceMetadataIcons({ +export function getServiceMetadataIcons({ serviceName, setup, searchAggregatedTransactions, @@ -49,53 +50,55 @@ export async function getServiceMetadataIcons({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }): Promise { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_metadata_icons', async () => { + const { start, end, apmEventClient } = setup; - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ]; + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ]; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - size: 1, - _source: [KUBERNETES, CLOUD_PROVIDER, CONTAINER_ID, AGENT_NAME], - query: { bool: { filter, should } }, - }, - }; + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + size: 1, + _source: [KUBERNETES, CLOUD_PROVIDER, CONTAINER_ID, AGENT_NAME], + query: { bool: { filter, should } }, + }, + }; - const response = await apmEventClient.search(params); + const response = await apmEventClient.search(params); - if (response.hits.total.value === 0) { - return { - agentName: undefined, - containerType: undefined, - cloudProvider: undefined, - }; - } + if (response.hits.total.value === 0) { + return { + agentName: undefined, + containerType: undefined, + cloudProvider: undefined, + }; + } - const { kubernetes, cloud, container, agent } = response.hits.hits[0] - ._source as ServiceMetadataIconsRaw; + const { kubernetes, cloud, container, agent } = response.hits.hits[0] + ._source as ServiceMetadataIconsRaw; - let containerType: ContainerType; - if (!!kubernetes) { - containerType = 'Kubernetes'; - } else if (!!container) { - containerType = 'Docker'; - } + let containerType: ContainerType; + if (!!kubernetes) { + containerType = 'Kubernetes'; + } else if (!!container) { + containerType = 'Docker'; + } - return { - agentName: agent?.name, - containerType, - cloudProvider: cloud?.provider, - }; + return { + agentName: agent?.name, + containerType, + cloudProvider: cloud?.provider, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_node_metadata.ts b/x-pack/plugins/apm/server/lib/services/get_service_node_metadata.ts index 07665c901e6cae..16753db416eddb 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_node_metadata.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_node_metadata.ts @@ -13,8 +13,9 @@ import { import { NOT_AVAILABLE_LABEL } from '../../../common/i18n'; import { mergeProjection } from '../../projections/util/merge_projection'; import { getServiceNodesProjection } from '../../projections/service_nodes'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getServiceNodeMetadata({ +export function getServiceNodeMetadata({ serviceName, serviceNodeName, setup, @@ -23,40 +24,43 @@ export async function getServiceNodeMetadata({ serviceNodeName: string; setup: Setup & SetupTimeRange; }) { - const { apmEventClient } = setup; + return withApmSpan('get_service_node_metadata', async () => { + const { apmEventClient } = setup; - const query = mergeProjection( - getServiceNodesProjection({ - setup, - serviceName, - serviceNodeName, - }), - { - body: { - size: 0, - aggs: { - host: { - terms: { - field: HOST_NAME, - size: 1, + const query = mergeProjection( + getServiceNodesProjection({ + setup, + serviceName, + serviceNodeName, + }), + { + body: { + size: 0, + aggs: { + host: { + terms: { + field: HOST_NAME, + size: 1, + }, }, - }, - containerId: { - terms: { - field: CONTAINER_ID, - size: 1, + containerId: { + terms: { + field: CONTAINER_ID, + size: 1, + }, }, }, }, - }, - } - ); + } + ); - const response = await apmEventClient.search(query); + const response = await apmEventClient.search(query); - return { - host: response.aggregations?.host.buckets[0]?.key || NOT_AVAILABLE_LABEL, - containerId: - response.aggregations?.containerId.buckets[0]?.key || NOT_AVAILABLE_LABEL, - }; + return { + host: response.aggregations?.host.buckets[0]?.key || NOT_AVAILABLE_LABEL, + containerId: + response.aggregations?.containerId.buckets[0]?.key || + NOT_AVAILABLE_LABEL, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts new file mode 100644 index 00000000000000..ce36db3e82babe --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_group_comparison_statistics.ts @@ -0,0 +1,182 @@ +/* + * 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 { keyBy } from 'lodash'; +import { + EVENT_OUTCOME, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { EventOutcome } from '../../../common/event_outcome'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { environmentQuery, rangeQuery } from '../../../common/utils/queries'; +import { Coordinate } from '../../../typings/timeseries'; +import { withApmSpan } from '../../utils/with_apm_span'; +import { + getDocumentTypeFilterForAggregatedTransactions, + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../helpers/aggregated_transactions'; +import { getBucketSize } from '../helpers/get_bucket_size'; +import { + getLatencyAggregation, + getLatencyValue, +} from '../helpers/latency_aggregation_type'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { calculateTransactionErrorPercentage } from '../helpers/transaction_error_rate'; + +export async function getServiceTransactionGroupComparisonStatistics({ + environment, + serviceName, + transactionNames, + setup, + numBuckets, + searchAggregatedTransactions, + transactionType, + latencyAggregationType, +}: { + environment?: string; + serviceName: string; + transactionNames: string[]; + setup: Setup & SetupTimeRange; + numBuckets: number; + searchAggregatedTransactions: boolean; + transactionType: string; + latencyAggregationType: LatencyAggregationType; +}): Promise< + Record< + string, + { + latency: Coordinate[]; + throughput: Coordinate[]; + errorRate: Coordinate[]; + impact: number; + } + > +> { + return withApmSpan( + 'get_service_transaction_group_comparison_statistics', + async () => { + const { apmEventClient, start, end, esFilter } = setup; + const { intervalString } = getBucketSize({ start, end, numBuckets }); + + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + }, + }, + aggs: { + total_duration: { sum: { field } }, + transaction_groups: { + terms: { + field: TRANSACTION_NAME, + include: transactionNames, + size: transactionNames.length, + }, + aggs: { + transaction_group_total_duration: { + sum: { field }, + }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: start, + max: end, + }, + }, + aggs: { + throughput_rate: { + rate: { + unit: 'minute', + }, + }, + ...getLatencyAggregation(latencyAggregationType, field), + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + include: [EventOutcome.failure, EventOutcome.success], + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const buckets = response.aggregations?.transaction_groups.buckets ?? []; + + const totalDuration = response.aggregations?.total_duration.value; + return keyBy( + buckets.map((bucket) => { + const transactionName = bucket.key; + const latency = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: getLatencyValue({ + latencyAggregationType, + aggregation: timeseriesBucket.latency, + }), + })); + const throughput = bucket.timeseries.buckets.map( + (timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: timeseriesBucket.throughput_rate.value, + }) + ); + const errorRate = bucket.timeseries.buckets.map( + (timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: calculateTransactionErrorPercentage( + timeseriesBucket[EVENT_OUTCOME] + ), + }) + ); + const transactionGroupTotalDuration = + bucket.transaction_group_total_duration.value || 0; + return { + transactionName, + latency, + throughput, + errorRate, + impact: totalDuration + ? (transactionGroupTotalDuration * 100) / totalDuration + : 0, + }; + }), + 'transactionName' + ); + } + ); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts new file mode 100644 index 00000000000000..ddbfd617faf65b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups.ts @@ -0,0 +1,148 @@ +/* + * 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 { + EVENT_OUTCOME, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { EventOutcome } from '../../../common/event_outcome'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { environmentQuery, rangeQuery } from '../../../common/utils/queries'; +import { withApmSpan } from '../../utils/with_apm_span'; +import { + getDocumentTypeFilterForAggregatedTransactions, + getProcessorEventForAggregatedTransactions, + getTransactionDurationFieldForAggregatedTransactions, +} from '../helpers/aggregated_transactions'; +import { calculateThroughput } from '../helpers/calculate_throughput'; +import { + getLatencyAggregation, + getLatencyValue, +} from '../helpers/latency_aggregation_type'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { calculateTransactionErrorPercentage } from '../helpers/transaction_error_rate'; + +export type ServiceOverviewTransactionGroupSortField = + | 'name' + | 'latency' + | 'throughput' + | 'errorRate' + | 'impact'; + +export async function getServiceTransactionGroups({ + environment, + serviceName, + setup, + searchAggregatedTransactions, + transactionType, + latencyAggregationType, +}: { + environment?: string; + serviceName: string; + setup: Setup & SetupTimeRange; + searchAggregatedTransactions: boolean; + transactionType: string; + latencyAggregationType: LatencyAggregationType; +}) { + return withApmSpan('get_service_transaction_groups', async () => { + const { apmEventClient, start, end, esFilter } = setup; + + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + }, + }, + aggs: { + total_duration: { sum: { field } }, + transaction_groups: { + terms: { + field: TRANSACTION_NAME, + size: 500, + order: { _count: 'desc' }, + }, + aggs: { + transaction_group_total_duration: { + sum: { field }, + }, + ...getLatencyAggregation(latencyAggregationType, field), + [EVENT_OUTCOME]: { + terms: { + field: EVENT_OUTCOME, + include: [EventOutcome.failure, EventOutcome.success], + }, + }, + }, + }, + }, + }, + }); + + const totalDuration = response.aggregations?.total_duration.value; + + const transactionGroups = + response.aggregations?.transaction_groups.buckets.map((bucket) => { + const errorRate = calculateTransactionErrorPercentage( + bucket[EVENT_OUTCOME] + ); + + const transactionGroupTotalDuration = + bucket.transaction_group_total_duration.value || 0; + + return { + name: bucket.key as string, + latency: getLatencyValue({ + latencyAggregationType, + aggregation: bucket.latency, + }), + throughput: calculateThroughput({ + start, + end, + value: bucket.doc_count, + }), + errorRate, + impact: totalDuration + ? (transactionGroupTotalDuration * 100) / totalDuration + : 0, + }; + }) ?? []; + + return { + transactionGroups: transactionGroups.map((transactionGroup) => ({ + ...transactionGroup, + transactionType, + })), + isAggregationAccurate: + (response.aggregations?.transaction_groups.sum_other_doc_count ?? 0) === + 0, + }; + }); +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts deleted file mode 100644 index 21db304c4dfe83..00000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts +++ /dev/null @@ -1,119 +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 { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { PromiseReturnType } from '../../../../../observability/typings/common'; -import { EventOutcome } from '../../../../common/event_outcome'; -import { rangeFilter } from '../../../../common/utils/range_filter'; -import { - EVENT_OUTCOME, - SERVICE_NAME, - TRANSACTION_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; - -import { ESFilter } from '../../../../../../typings/elasticsearch'; -import { - getDocumentTypeFilterForAggregatedTransactions, - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../../helpers/aggregated_transactions'; -import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; -import { getBucketSize } from '../../helpers/get_bucket_size'; -import { getLatencyAggregation } from '../../helpers/latency_aggregation_type'; - -export type TransactionGroupTimeseriesData = PromiseReturnType< - typeof getTimeseriesDataForTransactionGroups ->; - -export async function getTimeseriesDataForTransactionGroups({ - apmEventClient, - start, - end, - serviceName, - transactionNames, - esFilter, - searchAggregatedTransactions, - size, - numBuckets, - transactionType, - latencyAggregationType, -}: { - apmEventClient: APMEventClient; - start: number; - end: number; - serviceName: string; - transactionNames: string[]; - esFilter: ESFilter[]; - searchAggregatedTransactions: boolean; - size: number; - numBuckets: number; - transactionType: string; - latencyAggregationType: LatencyAggregationType; -}) { - const { intervalString } = getBucketSize({ start, end, numBuckets }); - - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); - - const timeseriesResponse = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { terms: { [TRANSACTION_NAME]: transactionNames } }, - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ...esFilter, - ], - }, - }, - aggs: { - transaction_groups: { - terms: { - field: TRANSACTION_NAME, - size, - }, - aggs: { - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: intervalString, - min_doc_count: 0, - extended_bounds: { - min: start, - max: end, - }, - }, - aggs: { - ...getLatencyAggregation(latencyAggregationType, field), - [EVENT_OUTCOME]: { - filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - }, - }, - }, - }, - }, - }, - }, - }); - - return timeseriesResponse.aggregations?.transaction_groups.buckets ?? []; -} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts deleted file mode 100644 index 9038ddff04e3c3..00000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts +++ /dev/null @@ -1,165 +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 { orderBy } from 'lodash'; -import { ValuesType } from 'utility-types'; -import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { PromiseReturnType } from '../../../../../observability/typings/common'; -import { EventOutcome } from '../../../../common/event_outcome'; -import { ESFilter } from '../../../../../../typings/elasticsearch'; -import { rangeFilter } from '../../../../common/utils/range_filter'; -import { - EVENT_OUTCOME, - SERVICE_NAME, - TRANSACTION_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; -import { - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../../helpers/aggregated_transactions'; -import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; -import { - getLatencyAggregation, - getLatencyValue, -} from '../../helpers/latency_aggregation_type'; -import { calculateThroughput } from '../../helpers/calculate_throughput'; - -export type ServiceOverviewTransactionGroupSortField = - | 'name' - | 'latency' - | 'throughput' - | 'errorRate' - | 'impact'; - -export type TransactionGroupWithoutTimeseriesData = ValuesType< - PromiseReturnType['transactionGroups'] ->; - -export async function getTransactionGroupsForPage({ - apmEventClient, - searchAggregatedTransactions, - serviceName, - start, - end, - esFilter, - sortField, - sortDirection, - pageIndex, - size, - transactionType, - latencyAggregationType, -}: { - apmEventClient: APMEventClient; - searchAggregatedTransactions: boolean; - serviceName: string; - start: number; - end: number; - esFilter: ESFilter[]; - sortField: ServiceOverviewTransactionGroupSortField; - sortDirection: 'asc' | 'desc'; - pageIndex: number; - size: number; - transactionType: string; - latencyAggregationType: LatencyAggregationType; -}) { - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); - - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ], - }, - }, - aggs: { - transaction_groups: { - terms: { - field: TRANSACTION_NAME, - size: 500, - order: { _count: 'desc' }, - }, - aggs: { - ...getLatencyAggregation(latencyAggregationType, field), - [EVENT_OUTCOME]: { - filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - }, - }, - }, - }, - }, - }); - - const transactionGroups = - response.aggregations?.transaction_groups.buckets.map((bucket) => { - const errorRate = - bucket.doc_count > 0 - ? bucket[EVENT_OUTCOME].doc_count / bucket.doc_count - : null; - - return { - name: bucket.key as string, - latency: getLatencyValue({ - latencyAggregationType, - aggregation: bucket.latency, - }), - throughput: calculateThroughput({ - start, - end, - value: bucket.doc_count, - }), - errorRate, - }; - }) ?? []; - - const totalDurationValues = transactionGroups.map( - (group) => (group.latency ?? 0) * group.throughput - ); - - const minTotalDuration = Math.min(...totalDurationValues); - const maxTotalDuration = Math.max(...totalDurationValues); - - const transactionGroupsWithImpact = transactionGroups.map((group) => ({ - ...group, - impact: - (((group.latency ?? 0) * group.throughput - minTotalDuration) / - (maxTotalDuration - minTotalDuration)) * - 100, - })); - - // Sort transaction groups first, and only get timeseries for data in view. - // This is to limit the possibility of creating too many buckets. - - const sortedAndSlicedTransactionGroups = orderBy( - transactionGroupsWithImpact, - sortField, - [sortDirection] - ).slice(pageIndex * size, pageIndex * size + size); - - return { - transactionGroups: sortedAndSlicedTransactionGroups, - totalTransactionGroups: transactionGroups.length, - isAggregationAccurate: - (response.aggregations?.transaction_groups.sum_other_doc_count ?? 0) === - 0, - }; -} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts deleted file mode 100644 index 3b5426a3c1764a..00000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts +++ /dev/null @@ -1,89 +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 { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { getTimeseriesDataForTransactionGroups } from './get_timeseries_data_for_transaction_groups'; -import { - getTransactionGroupsForPage, - ServiceOverviewTransactionGroupSortField, -} from './get_transaction_groups_for_page'; -import { mergeTransactionGroupData } from './merge_transaction_group_data'; - -export async function getServiceTransactionGroups({ - serviceName, - setup, - size, - numBuckets, - pageIndex, - sortDirection, - sortField, - searchAggregatedTransactions, - transactionType, - latencyAggregationType, -}: { - serviceName: string; - setup: Setup & SetupTimeRange; - size: number; - pageIndex: number; - numBuckets: number; - sortDirection: 'asc' | 'desc'; - sortField: ServiceOverviewTransactionGroupSortField; - searchAggregatedTransactions: boolean; - transactionType: string; - latencyAggregationType: LatencyAggregationType; -}) { - const { apmEventClient, start, end, esFilter } = setup; - - const { - transactionGroups, - totalTransactionGroups, - isAggregationAccurate, - } = await getTransactionGroupsForPage({ - apmEventClient, - start, - end, - serviceName, - esFilter, - pageIndex, - sortField, - sortDirection, - size, - searchAggregatedTransactions, - transactionType, - latencyAggregationType, - }); - - const transactionNames = transactionGroups.map((group) => group.name); - - const timeseriesData = await getTimeseriesDataForTransactionGroups({ - apmEventClient, - start, - end, - esFilter, - numBuckets, - searchAggregatedTransactions, - serviceName, - size, - transactionNames, - transactionType, - latencyAggregationType, - }); - - return { - transactionGroups: mergeTransactionGroupData({ - transactionGroups, - timeseriesData, - start, - end, - latencyAggregationType, - transactionType, - }), - totalTransactionGroups, - isAggregationAccurate, - }; -} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts deleted file mode 100644 index 6d6ad3bf830840..00000000000000 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EVENT_OUTCOME } from '../../../../common/elasticsearch_fieldnames'; -import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { calculateThroughput } from '../../helpers/calculate_throughput'; -import { getLatencyValue } from '../../helpers/latency_aggregation_type'; -import { TransactionGroupTimeseriesData } from './get_timeseries_data_for_transaction_groups'; -import { TransactionGroupWithoutTimeseriesData } from './get_transaction_groups_for_page'; - -export function mergeTransactionGroupData({ - start, - end, - transactionGroups, - timeseriesData, - latencyAggregationType, - transactionType, -}: { - start: number; - end: number; - transactionGroups: TransactionGroupWithoutTimeseriesData[]; - timeseriesData: TransactionGroupTimeseriesData; - latencyAggregationType: LatencyAggregationType; - transactionType: string; -}) { - return transactionGroups.map((transactionGroup) => { - const groupBucket = timeseriesData.find( - ({ key }) => key === transactionGroup.name - ); - - const timeseriesBuckets = groupBucket?.timeseries.buckets ?? []; - - return timeseriesBuckets.reduce( - (acc, point) => { - return { - ...acc, - latency: { - ...acc.latency, - timeseries: acc.latency.timeseries.concat({ - x: point.key, - y: getLatencyValue({ - latencyAggregationType, - aggregation: point.latency, - }), - }), - }, - throughput: { - ...acc.throughput, - timeseries: acc.throughput.timeseries.concat({ - x: point.key, - y: calculateThroughput({ - start, - end, - value: point.doc_count, - }), - }), - }, - errorRate: { - ...acc.errorRate, - timeseries: acc.errorRate.timeseries.concat({ - x: point.key, - y: point[EVENT_OUTCOME].doc_count / point.doc_count, - }), - }, - }; - }, - { - name: transactionGroup.name, - transactionType, - latency: { - value: transactionGroup.latency, - timeseries: [] as Array<{ x: number; y: number | null }>, - }, - throughput: { - value: transactionGroup.throughput, - timeseries: [] as Array<{ x: number; y: number }>, - }, - errorRate: { - value: transactionGroup.errorRate, - timeseries: [] as Array<{ x: number; y: number | null }>, - }, - impact: transactionGroup.impact, - } - ); - }); -} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_types.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_types.ts index de0e991e219ccb..3d77bf5bd6baf3 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_types.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_types.ts @@ -9,14 +9,15 @@ import { SERVICE_NAME, TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; -export async function getServiceTransactionTypes({ +export function getServiceTransactionTypes({ setup, serviceName, searchAggregatedTransactions, @@ -25,39 +26,41 @@ export async function getServiceTransactionTypes({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_service_transaction_types', async () => { + const { start, end, apmEventClient } = setup; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ], - }, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - types: { - terms: { field: TRANSACTION_TYPE, size: 100 }, + body: { + size: 0, + query: { + bool: { + filter: [ + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + { term: { [SERVICE_NAME]: serviceName } }, + ...rangeQuery(start, end), + ], + }, + }, + aggs: { + types: { + terms: { field: TRANSACTION_TYPE, size: 100 }, + }, }, }, - }, - }; + }; - const { aggregations } = await apmEventClient.search(params); - const transactionTypes = - aggregations?.types.buckets.map((bucket) => bucket.key as string) || []; - return { transactionTypes }; + const { aggregations } = await apmEventClient.search(params); + const transactionTypes = + aggregations?.types.buckets.map((bucket) => bucket.key as string) || []; + return { transactionTypes }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_health_statuses.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_health_statuses.ts index e88fafb061912d..6fc868b0f0a4ec 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_health_statuses.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_health_statuses.ts @@ -8,28 +8,25 @@ import { getSeverity } from '../../../../common/anomaly_detection'; import { getServiceHealthStatus } from '../../../../common/service_health_status'; import { getServiceAnomalies } from '../../service_map/get_service_anomalies'; -import { - ServicesItemsProjection, - ServicesItemsSetup, -} from './get_services_items'; +import { ServicesItemsSetup } from './get_services_items'; interface AggregationParams { + environment?: string; setup: ServicesItemsSetup; - projection: ServicesItemsProjection; searchAggregatedTransactions: boolean; } -export const getHealthStatuses = async ( - { setup }: AggregationParams, - mlAnomaliesEnvironment?: string -) => { +export const getHealthStatuses = async ({ + environment, + setup, +}: AggregationParams) => { if (!setup.ml) { return []; } const anomalies = await getServiceAnomalies({ setup, - environment: mlAnomaliesEnvironment, + environment, }); return anomalies.serviceAnomalies.map((anomalyStats) => { diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts index d69c6682bc8074..87f3c0a5d1b389 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_legacy_data_status.ts @@ -8,27 +8,32 @@ import { ProcessorEvent } from '../../../../common/processor_event'; import { OBSERVER_VERSION_MAJOR } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; // returns true if 6.x data is found export async function getLegacyDataStatus(setup: Setup) { - const { apmEventClient } = setup; + return withApmSpan('get_legacy_data_status', async () => { + const { apmEventClient } = setup; - const params = { - terminateAfter: 1, - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - size: 0, - query: { - bool: { - filter: [{ range: { [OBSERVER_VERSION_MAJOR]: { lt: 7 } } }], + const params = { + terminateAfter: 1, + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + size: 0, + query: { + bool: { + filter: [{ range: { [OBSERVER_VERSION_MAJOR]: { lt: 7 } } }], + }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params, { includeLegacyData: true }); - const hasLegacyData = resp.hits.total.value > 0; - return hasLegacyData; + const resp = await apmEventClient.search(params, { + includeLegacyData: true, + }); + const hasLegacyData = resp.hits.total.value > 0; + return hasLegacyData; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts index 71cccfa61607fd..e1f8bca83829cc 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts @@ -15,7 +15,7 @@ import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../../common/transaction_types'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { getDocumentTypeFilterForAggregatedTransactions, @@ -29,8 +29,10 @@ import { getOutcomeAggregation, } from '../../helpers/transaction_error_rate'; import { ServicesItemsSetup } from './get_services_items'; +import { withApmSpan } from '../../../utils/with_apm_span'; interface AggregationParams { + environment?: string; setup: ServicesItemsSetup; searchAggregatedTransactions: boolean; } @@ -38,146 +40,152 @@ interface AggregationParams { const MAX_NUMBER_OF_SERVICES = 500; export async function getServiceTransactionStats({ + environment, setup, searchAggregatedTransactions, }: AggregationParams) { - const { apmEventClient, start, end, esFilter } = setup; + return withApmSpan('get_service_transaction_stats', async () => { + const { apmEventClient, start, end, esFilter } = setup; - const outcomes = getOutcomeAggregation(); + const outcomes = getOutcomeAggregation(); - const metrics = { - avg_duration: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + const metrics = { + avg_duration: { + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, - }, - outcomes, - }; + outcomes, + }; - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { range: rangeFilter(start, end) }, - ...esFilter, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, + const response = await apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - services: { - terms: { - field: SERVICE_NAME, - size: MAX_NUMBER_OF_SERVICES, + body: { + size: 0, + query: { + bool: { + filter: [ + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], }, - aggs: { - transactionType: { - terms: { - field: TRANSACTION_TYPE, - }, - aggs: { - ...metrics, - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - missing: '', - }, + }, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size: MAX_NUMBER_OF_SERVICES, + }, + aggs: { + transactionType: { + terms: { + field: TRANSACTION_TYPE, }, - agentName: { - top_hits: { - docvalue_fields: [AGENT_NAME] as const, - size: 1, + aggs: { + ...metrics, + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + missing: '', + }, }, - }, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: getBucketSize({ - start, - end, - numBuckets: 20, - }).intervalString, - min_doc_count: 0, - extended_bounds: { min: start, max: end }, + sample: { + top_metrics: { + metrics: { field: AGENT_NAME } as const, + sort: { + '@timestamp': 'desc', + }, + }, + }, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ + start, + end, + numBuckets: 20, + }).intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: metrics, }, - aggs: metrics, }, }, }, }, }, }, - }, - }); + }); - return ( - response.aggregations?.services.buckets.map((bucket) => { - const topTransactionTypeBucket = - bucket.transactionType.buckets.find( - ({ key }) => - key === TRANSACTION_REQUEST || key === TRANSACTION_PAGE_LOAD - ) ?? bucket.transactionType.buckets[0]; + return ( + response.aggregations?.services.buckets.map((bucket) => { + const topTransactionTypeBucket = + bucket.transactionType.buckets.find( + ({ key }) => + key === TRANSACTION_REQUEST || key === TRANSACTION_PAGE_LOAD + ) ?? bucket.transactionType.buckets[0]; - return { - serviceName: bucket.key as string, - transactionType: topTransactionTypeBucket.key as string, - environments: topTransactionTypeBucket.environments.buckets - .map((environmentBucket) => environmentBucket.key as string) - .filter(Boolean), - agentName: topTransactionTypeBucket.agentName.hits.hits[0].fields[ - 'agent.name' - ]?.[0] as AgentName, - avgResponseTime: { - value: topTransactionTypeBucket.avg_duration.value, - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: dateBucket.avg_duration.value, - }) - ), - }, - transactionErrorRate: { - value: calculateTransactionErrorPercentage( - topTransactionTypeBucket.outcomes - ), - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: calculateTransactionErrorPercentage(dateBucket.outcomes), - }) - ), - }, - transactionsPerMinute: { - value: calculateThroughput({ - start, - end, - value: topTransactionTypeBucket.doc_count, - }), - timeseries: topTransactionTypeBucket.timeseries.buckets.map( - (dateBucket) => ({ - x: dateBucket.key, - y: calculateThroughput({ - start, - end, - value: dateBucket.doc_count, - }), - }) - ), - }, - }; - }) ?? [] - ); + return { + serviceName: bucket.key as string, + transactionType: topTransactionTypeBucket.key as string, + environments: topTransactionTypeBucket.environments.buckets + .map((environmentBucket) => environmentBucket.key as string) + .filter(Boolean), + agentName: topTransactionTypeBucket.sample.top[0].metrics[ + AGENT_NAME + ] as AgentName, + avgResponseTime: { + value: topTransactionTypeBucket.avg_duration.value, + timeseries: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: dateBucket.avg_duration.value, + }) + ), + }, + transactionErrorRate: { + value: calculateTransactionErrorPercentage( + topTransactionTypeBucket.outcomes + ), + timeseries: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: calculateTransactionErrorPercentage(dateBucket.outcomes), + }) + ), + }, + transactionsPerMinute: { + value: calculateThroughput({ + start, + end, + value: topTransactionTypeBucket.doc_count, + }), + timeseries: topTransactionTypeBucket.timeseries.buckets.map( + (dateBucket) => ({ + x: dateBucket.key, + y: calculateThroughput({ + start, + end, + value: dateBucket.doc_count, + }), + }) + ), + }, + }; + }) ?? [] + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts index a2310eebb639d0..c2677af038486b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_services_items.ts @@ -8,48 +8,53 @@ import { Logger } from '@kbn/logging'; import { joinByKey } from '../../../../common/utils/join_by_key'; import { getServicesProjection } from '../../../projections/services'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getHealthStatuses } from './get_health_statuses'; import { getServiceTransactionStats } from './get_service_transaction_stats'; export type ServicesItemsSetup = Setup & SetupTimeRange; -export type ServicesItemsProjection = ReturnType; export async function getServicesItems({ + environment, setup, searchAggregatedTransactions, logger, }: { + environment?: string; setup: ServicesItemsSetup; searchAggregatedTransactions: boolean; logger: Logger; }) { - const params = { - projection: getServicesProjection({ + return withApmSpan('get_services_items', async () => { + const params = { + environment, + projection: getServicesProjection({ + setup, + searchAggregatedTransactions, + }), setup, searchAggregatedTransactions, - }), - setup, - searchAggregatedTransactions, - }; - - const [transactionStats, healthStatuses] = await Promise.all([ - getServiceTransactionStats(params), - getHealthStatuses(params, setup.uiFilters.environment).catch((err) => { - logger.error(err); - return []; - }), - ]); - - const apmServices = transactionStats.map(({ serviceName }) => serviceName); - - // make sure to exclude health statuses from services - // that are not found in APM data - const matchedHealthStatuses = healthStatuses.filter(({ serviceName }) => - apmServices.includes(serviceName) - ); - - const allMetrics = [...transactionStats, ...matchedHealthStatuses]; - - return joinByKey(allMetrics, 'serviceName'); + }; + + const [transactionStats, healthStatuses] = await Promise.all([ + getServiceTransactionStats(params), + getHealthStatuses(params).catch((err) => { + logger.error(err); + return []; + }), + ]); + + const apmServices = transactionStats.map(({ serviceName }) => serviceName); + + // make sure to exclude health statuses from services + // that are not found in APM data + const matchedHealthStatuses = healthStatuses.filter(({ serviceName }) => + apmServices.includes(serviceName) + ); + + const allMetrics = [...transactionStats, ...matchedHealthStatuses]; + + return joinByKey(allMetrics, 'serviceName'); + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts b/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts index 8363e59f2522ed..28f6944fd24daf 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/has_historical_agent_data.ts @@ -6,26 +6,29 @@ */ import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; // Note: this logic is duplicated in tutorials/apm/envs/on_prem export async function hasHistoricalAgentData(setup: Setup) { - const { apmEventClient } = setup; + return withApmSpan('has_historical_agent_data', async () => { + const { apmEventClient } = setup; - const params = { - terminateAfter: 1, - apm: { - events: [ - ProcessorEvent.error, - ProcessorEvent.metric, - ProcessorEvent.transaction, - ], - }, - body: { - size: 0, - }, - }; + const params = { + terminateAfter: 1, + apm: { + events: [ + ProcessorEvent.error, + ProcessorEvent.metric, + ProcessorEvent.transaction, + ], + }, + body: { + size: 0, + }, + }; - const resp = await apmEventClient.search(params); - return resp.hits.total.value > 0; + const resp = await apmEventClient.search(params); + return resp.hits.total.value > 0; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_services/index.ts b/x-pack/plugins/apm/server/lib/services/get_services/index.ts index 530f04c72b6c16..1a0ddeda11651b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/index.ts @@ -7,37 +7,43 @@ import { Logger } from '@kbn/logging'; import { isEmpty } from 'lodash'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getLegacyDataStatus } from './get_legacy_data_status'; import { getServicesItems } from './get_services_items'; import { hasHistoricalAgentData } from './has_historical_agent_data'; export async function getServices({ + environment, setup, searchAggregatedTransactions, logger, }: { + environment?: string; setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; logger: Logger; }) { - const [items, hasLegacyData] = await Promise.all([ - getServicesItems({ - setup, - searchAggregatedTransactions, - logger, - }), - getLegacyDataStatus(setup), - ]); + return withApmSpan('get_services', async () => { + const [items, hasLegacyData] = await Promise.all([ + getServicesItems({ + environment, + setup, + searchAggregatedTransactions, + logger, + }), + getLegacyDataStatus(setup), + ]); - const noDataInCurrentTimeRange = isEmpty(items); - const hasHistoricalData = noDataInCurrentTimeRange - ? await hasHistoricalAgentData(setup) - : true; + const noDataInCurrentTimeRange = isEmpty(items); + const hasHistoricalData = noDataInCurrentTimeRange + ? await hasHistoricalAgentData(setup) + : true; - return { - items, - hasHistoricalData, - hasLegacyData, - }; + return { + items, + hasHistoricalData, + hasLegacyData, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index 492f41e153a89c..f7cd23b0e37a7b 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -6,57 +6,49 @@ */ import { ESFilter } from '../../../../../typings/elasticsearch'; -import { PromiseReturnType } from '../../../../observability/typings/common'; import { SERVICE_NAME, TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../common/utils/queries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, } from '../helpers/aggregated_transactions'; import { getBucketSize } from '../helpers/get_bucket_size'; -import { calculateThroughput } from '../helpers/calculate_throughput'; -import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { Setup } from '../helpers/setup_request'; +import { withApmSpan } from '../../utils/with_apm_span'; interface Options { + environment?: string; searchAggregatedTransactions: boolean; serviceName: string; - setup: Setup & SetupTimeRange; + setup: Setup; transactionType: string; + start: number; + end: number; } -type ESResponse = PromiseReturnType; - -function transform(options: Options, response: ESResponse) { - if (response.hits.total.value === 0) { - return []; - } - const { start, end } = options.setup; - const buckets = response.aggregations?.throughput.buckets ?? []; - return buckets.map(({ key: x, doc_count: value }) => ({ - x, - y: calculateThroughput({ start, end, value }), - })); -} - -async function fetcher({ +function fetcher({ + environment, searchAggregatedTransactions, serviceName, setup, transactionType, + start, + end, }: Options) { - const { start, end, apmEventClient } = setup; + const { esFilter, apmEventClient } = setup; const { intervalString } = getBucketSize({ start, end }); const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, ...getDocumentTypeFilterForAggregatedTransactions( searchAggregatedTransactions ), - ...setup.esFilter, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, ]; const params = { @@ -71,13 +63,20 @@ async function fetcher({ size: 0, query: { bool: { filter } }, aggs: { - throughput: { + timeseries: { date_histogram: { field: '@timestamp', fixed_interval: intervalString, min_doc_count: 0, extended_bounds: { min: start, max: end }, }, + aggs: { + throughput: { + rate: { + unit: 'minute' as const, + }, + }, + }, }, }, }, @@ -86,8 +85,17 @@ async function fetcher({ return apmEventClient.search(params); } -export async function getThroughput(options: Options) { - return { - throughput: transform(options, await fetcher(options)), - }; +export function getThroughput(options: Options) { + return withApmSpan('get_throughput_for_service', async () => { + const response = await fetcher(options); + + return ( + response.aggregations?.timeseries.buckets.map((bucket) => { + return { + x: bucket.key, + y: bucket.throughput.value, + }; + }) ?? [] + ); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts index ff1315ed1e3f02..18853824355622 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts @@ -12,8 +12,9 @@ import { AgentConfigurationIntake, } from '../../../../common/agent_configuration/configuration_types'; import { APMIndexDocumentParams } from '../../helpers/create_es_client/create_internal_es_client'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function createOrUpdateConfiguration({ +export function createOrUpdateConfiguration({ configurationId, configurationIntake, setup, @@ -22,28 +23,30 @@ export async function createOrUpdateConfiguration({ configurationIntake: AgentConfigurationIntake; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('create_or_update_configuration', async () => { + const { internalClient, indices } = setup; - const params: APMIndexDocumentParams = { - refresh: true, - index: indices.apmAgentConfigurationIndex, - body: { - agent_name: configurationIntake.agent_name, - service: { - name: configurationIntake.service.name, - environment: configurationIntake.service.environment, + const params: APMIndexDocumentParams = { + refresh: true, + index: indices.apmAgentConfigurationIndex, + body: { + agent_name: configurationIntake.agent_name, + service: { + name: configurationIntake.service.name, + environment: configurationIntake.service.environment, + }, + settings: configurationIntake.settings, + '@timestamp': Date.now(), + applied_by_agent: false, + etag: hash(configurationIntake), }, - settings: configurationIntake.settings, - '@timestamp': Date.now(), - applied_by_agent: false, - etag: hash(configurationIntake), - }, - }; + }; - // by specifying an id elasticsearch will delete the previous doc and insert the updated doc - if (configurationId) { - params.id = configurationId; - } + // by specifying an id elasticsearch will delete the previous doc and insert the updated doc + if (configurationId) { + params.id = configurationId; + } - return internalClient.index(params); + return internalClient.index(params); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts index 51844115067692..6ed6f79979889b 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/delete_configuration.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; export async function deleteConfiguration({ @@ -14,13 +15,15 @@ export async function deleteConfiguration({ configurationId: string; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('delete_agent_configuration', async () => { + const { internalClient, indices } = setup; - const params = { - refresh: 'wait_for' as const, - index: indices.apmAgentConfigurationIndex, - id: configurationId, - }; + const params = { + refresh: 'wait_for' as const, + index: indices.apmAgentConfigurationIndex, + id: configurationId, + }; - return internalClient.delete(params); + return internalClient.delete(params); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts index 1657177f7672e7..55d00b70b8c29c 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts @@ -11,44 +11,49 @@ import { SERVICE_ENVIRONMENT, SERVICE_NAME, } from '../../../../common/elasticsearch_fieldnames'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; import { convertConfigSettingsToString } from './convert_settings_to_string'; -export async function findExactConfiguration({ +export function findExactConfiguration({ service, setup, }: { service: AgentConfiguration['service']; setup: Setup; }) { - const { internalClient, indices } = setup; - - const serviceNameFilter = service.name - ? { term: { [SERVICE_NAME]: service.name } } - : { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } }; - - const environmentFilter = service.environment - ? { term: { [SERVICE_ENVIRONMENT]: service.environment } } - : { bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] } }; - - const params = { - index: indices.apmAgentConfigurationIndex, - body: { - query: { - bool: { filter: [serviceNameFilter, environmentFilter] }, + return withApmSpan('find_exact_agent_configuration', async () => { + const { internalClient, indices } = setup; + + const serviceNameFilter = service.name + ? { term: { [SERVICE_NAME]: service.name } } + : { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } }; + + const environmentFilter = service.environment + ? { term: { [SERVICE_ENVIRONMENT]: service.environment } } + : { bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] } }; + + const params = { + index: indices.apmAgentConfigurationIndex, + body: { + query: { + bool: { filter: [serviceNameFilter, environmentFilter] }, + }, }, - }, - }; + }; - const resp = await internalClient.search( - params - ); + const resp = await internalClient.search( + params + ); - const hit = resp.hits.hits[0] as ESSearchHit | undefined; + const hit = resp.hits.hits[0] as + | ESSearchHit + | undefined; - if (!hit) { - return; - } + if (!hit) { + return; + } - return convertConfigSettingsToString(hit); + return convertConfigSettingsToString(hit); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_agent_name_by_service.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_agent_name_by_service.ts index 322bb5fc3b1fe7..379ed12e373895 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_agent_name_by_service.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_agent_name_by_service.ts @@ -9,6 +9,7 @@ import { ProcessorEvent } from '../../../../common/processor_event'; import { Setup } from '../../helpers/setup_request'; import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; import { AGENT_NAME } from '../../../../common/elasticsearch_fieldnames'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getAgentNameByService({ serviceName, @@ -17,33 +18,35 @@ export async function getAgentNameByService({ serviceName: string; setup: Setup; }) { - const { apmEventClient } = setup; + return withApmSpan('get_agent_name_by_service', async () => { + const { apmEventClient } = setup; - const params = { - terminateAfter: 1, - apm: { - events: [ - ProcessorEvent.transaction, - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [{ term: { [SERVICE_NAME]: serviceName } }], - }, + const params = { + terminateAfter: 1, + apm: { + events: [ + ProcessorEvent.transaction, + ProcessorEvent.error, + ProcessorEvent.metric, + ], }, - aggs: { - agent_names: { - terms: { field: AGENT_NAME, size: 1 }, + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [SERVICE_NAME]: serviceName } }], + }, + }, + aggs: { + agent_names: { + terms: { field: AGENT_NAME, size: 1 }, + }, }, }, - }, - }; + }; - const { aggregations } = await apmEventClient.search(params); - const agentName = aggregations?.agent_names.buckets[0]?.key; - return agentName as string | undefined; + const { aggregations } = await apmEventClient.search(params); + const agentName = aggregations?.agent_names.buckets[0]?.key; + return agentName as string | undefined; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts index 01350db8ae4a40..4a32b3c3a370bd 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../../utils/with_apm_span'; import { Setup } from '../../../helpers/setup_request'; import { SERVICE_NAME, @@ -19,34 +20,36 @@ export async function getExistingEnvironmentsForService({ serviceName: string | undefined; setup: Setup; }) { - const { internalClient, indices, config } = setup; - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; + return withApmSpan('get_existing_environments_for_service', async () => { + const { internalClient, indices, config } = setup; + const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; - const bool = serviceName - ? { filter: [{ term: { [SERVICE_NAME]: serviceName } }] } - : { must_not: [{ exists: { field: SERVICE_NAME } }] }; + const bool = serviceName + ? { filter: [{ term: { [SERVICE_NAME]: serviceName } }] } + : { must_not: [{ exists: { field: SERVICE_NAME } }] }; - const params = { - index: indices.apmAgentConfigurationIndex, - body: { - size: 0, - query: { bool }, - aggs: { - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - missing: ALL_OPTION_VALUE, - size: maxServiceEnvironments, + const params = { + index: indices.apmAgentConfigurationIndex, + body: { + size: 0, + query: { bool }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + missing: ALL_OPTION_VALUE, + size: maxServiceEnvironments, + }, }, }, }, - }, - }; + }; - const resp = await internalClient.search(params); - const existingEnvironments = - resp.aggregations?.environments.buckets.map( - (bucket) => bucket.key as string - ) || []; - return existingEnvironments; + const resp = await internalClient.search(params); + const existingEnvironments = + resp.aggregations?.environments.buckets.map( + (bucket) => bucket.key as string + ) || []; + return existingEnvironments; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts index 46fe4bbf9363fd..0ab56ac372706e 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../../utils/with_apm_span'; import { getAllEnvironments } from '../../../environments/get_all_environments'; import { Setup } from '../../../helpers/setup_request'; import { PromiseReturnType } from '../../../../../../observability/typings/common'; @@ -24,15 +25,17 @@ export async function getEnvironments({ setup: Setup; searchAggregatedTransactions: boolean; }) { - const [allEnvironments, existingEnvironments] = await Promise.all([ - getAllEnvironments({ serviceName, setup, searchAggregatedTransactions }), - getExistingEnvironmentsForService({ serviceName, setup }), - ]); + return withApmSpan('get_environments_for_agent_configuration', async () => { + const [allEnvironments, existingEnvironments] = await Promise.all([ + getAllEnvironments({ serviceName, setup, searchAggregatedTransactions }), + getExistingEnvironmentsForService({ serviceName, setup }), + ]); - return [ALL_OPTION_VALUE, ...allEnvironments].map((environment) => { - return { - name: environment, - alreadyConfigured: existingEnvironments.includes(environment), - }; + return [ALL_OPTION_VALUE, ...allEnvironments].map((environment) => { + return { + name: environment, + alreadyConfigured: existingEnvironments.includes(environment), + }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts index 3f39268fc35318..9c56455f45902f 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts @@ -11,49 +11,52 @@ import { PromiseReturnType } from '../../../../../observability/typings/common'; import { SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; import { ALL_OPTION_VALUE } from '../../../../common/agent_configuration/all_option'; import { getProcessorEventForAggregatedTransactions } from '../../helpers/aggregated_transactions'; +import { withApmSpan } from '../../../utils/with_apm_span'; export type AgentConfigurationServicesAPIResponse = PromiseReturnType< typeof getServiceNames >; -export async function getServiceNames({ +export function getServiceNames({ setup, searchAggregatedTransactions, }: { setup: Setup; searchAggregatedTransactions: boolean; }) { - const { apmEventClient, config } = setup; - const maxServiceSelection = config['xpack.apm.maxServiceSelection']; + return withApmSpan('get_service_names_for_agent_config', async () => { + const { apmEventClient, config } = setup; + const maxServiceSelection = config['xpack.apm.maxServiceSelection']; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.error, - ProcessorEvent.metric, - ], - }, - body: { - timeout: '1ms', - size: 0, - aggs: { - services: { - terms: { - field: SERVICE_NAME, - size: maxServiceSelection, - min_doc_count: 0, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ProcessorEvent.error, + ProcessorEvent.metric, + ], + }, + body: { + timeout: '1ms', + size: 0, + aggs: { + services: { + terms: { + field: SERVICE_NAME, + size: maxServiceSelection, + min_doc_count: 0, + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); - const serviceNames = - resp.aggregations?.services.buckets - .map((bucket) => bucket.key as string) - .sort() || []; - return [ALL_OPTION_VALUE, ...serviceNames]; + const resp = await apmEventClient.search(params); + const serviceNames = + resp.aggregations?.services.buckets + .map((bucket) => bucket.key as string) + .sort() || []; + return [ALL_OPTION_VALUE, ...serviceNames]; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts index 871a96f6f85f1e..adcfe88392dc8b 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts @@ -8,6 +8,7 @@ import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { convertConfigSettingsToString } from './convert_settings_to_string'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function listConfigurations({ setup }: { setup: Setup }) { const { internalClient, indices } = setup; @@ -17,7 +18,10 @@ export async function listConfigurations({ setup }: { setup: Setup }) { size: 200, }; - const resp = await internalClient.search(params); + const resp = await withApmSpan('list_agent_configurations', () => + internalClient.search(params) + ); + return resp.hits.hits .map(convertConfigSettingsToString) .map((hit) => hit._source); diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts index b5e35dde04a90f..2026742a936a49 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts @@ -8,6 +8,7 @@ import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; +// We're not wrapping this function with a span as it is not blocking the request export async function markAppliedByAgent({ id, body, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts index 546d1e562de1ca..0e7205c309e9fc 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts @@ -13,6 +13,7 @@ import { import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { convertConfigSettingsToString } from './convert_settings_to_string'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function searchConfigurations({ service, @@ -21,61 +22,67 @@ export async function searchConfigurations({ service: AgentConfiguration['service']; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('search_agent_configurations', async () => { + const { internalClient, indices } = setup; - // In the following `constant_score` is being used to disable IDF calculation (where frequency of a term influences scoring). - // Additionally a boost has been added to service.name to ensure it scores higher. - // If there is tie between a config with a matching service.name and a config with a matching environment, the config that matches service.name wins - const serviceNameFilter = service.name - ? [ - { - constant_score: { - filter: { term: { [SERVICE_NAME]: service.name } }, - boost: 2, + // In the following `constant_score` is being used to disable IDF calculation (where frequency of a term influences scoring). + // Additionally a boost has been added to service.name to ensure it scores higher. + // If there is tie between a config with a matching service.name and a config with a matching environment, the config that matches service.name wins + const serviceNameFilter = service.name + ? [ + { + constant_score: { + filter: { term: { [SERVICE_NAME]: service.name } }, + boost: 2, + }, }, - }, - ] - : []; + ] + : []; - const environmentFilter = service.environment - ? [ - { - constant_score: { - filter: { term: { [SERVICE_ENVIRONMENT]: service.environment } }, - boost: 1, + const environmentFilter = service.environment + ? [ + { + constant_score: { + filter: { term: { [SERVICE_ENVIRONMENT]: service.environment } }, + boost: 1, + }, }, - }, - ] - : []; + ] + : []; - const params = { - index: indices.apmAgentConfigurationIndex, - body: { - query: { - bool: { - minimum_should_match: 2, - should: [ - ...serviceNameFilter, - ...environmentFilter, - { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } }, - { - bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] }, - }, - ], + const params = { + index: indices.apmAgentConfigurationIndex, + body: { + query: { + bool: { + minimum_should_match: 2, + should: [ + ...serviceNameFilter, + ...environmentFilter, + { bool: { must_not: [{ exists: { field: SERVICE_NAME } }] } }, + { + bool: { + must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }], + }, + }, + ], + }, }, }, - }, - }; + }; - const resp = await internalClient.search( - params - ); + const resp = await internalClient.search( + params + ); - const hit = resp.hits.hits[0] as ESSearchHit | undefined; + const hit = resp.hits.hits[0] as + | ESSearchHit + | undefined; - if (!hit) { - return; - } + if (!hit) { + return; + } - return convertConfigSettingsToString(hit); + return convertConfigSettingsToString(hit); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts index 38075fb818f9e6..a1587611b0a2a9 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/get_apm_indices.ts @@ -15,6 +15,7 @@ import { } from '../../../../common/apm_saved_object_constants'; import { APMConfig } from '../../..'; import { APMRequestHandlerContext } from '../../../routes/typings'; +import { withApmSpan } from '../../../utils/with_apm_span'; type ISavedObjectsClient = Pick; @@ -36,9 +37,11 @@ export type ApmIndicesName = keyof ApmIndicesConfig; async function getApmIndicesSavedObject( savedObjectsClient: ISavedObjectsClient ) { - const apmIndices = await savedObjectsClient.get>( - APM_INDICES_SAVED_OBJECT_TYPE, - APM_INDICES_SAVED_OBJECT_ID + const apmIndices = await withApmSpan('get_apm_indices_saved_object', () => + savedObjectsClient.get>( + APM_INDICES_SAVED_OBJECT_TYPE, + APM_INDICES_SAVED_OBJECT_ID + ) ); return apmIndices.attributes; } diff --git a/x-pack/plugins/apm/server/lib/settings/apm_indices/save_apm_indices.ts b/x-pack/plugins/apm/server/lib/settings/apm_indices/save_apm_indices.ts index 3f346891138c4b..14a5830d8246cc 100644 --- a/x-pack/plugins/apm/server/lib/settings/apm_indices/save_apm_indices.ts +++ b/x-pack/plugins/apm/server/lib/settings/apm_indices/save_apm_indices.ts @@ -10,19 +10,22 @@ import { APM_INDICES_SAVED_OBJECT_TYPE, APM_INDICES_SAVED_OBJECT_ID, } from '../../../../common/apm_saved_object_constants'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { ApmIndicesConfig } from './get_apm_indices'; -export async function saveApmIndices( +export function saveApmIndices( savedObjectsClient: SavedObjectsClientContract, apmIndices: Partial ) { - return await savedObjectsClient.create( - APM_INDICES_SAVED_OBJECT_TYPE, - removeEmpty(apmIndices), - { - id: APM_INDICES_SAVED_OBJECT_ID, - overwrite: true, - } + return withApmSpan('save_apm_indices', () => + savedObjectsClient.create( + APM_INDICES_SAVED_OBJECT_TYPE, + removeEmpty(apmIndices), + { + id: APM_INDICES_SAVED_OBJECT_ID, + overwrite: true, + } + ) ); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts index b24124565b1811..7e546fb5550360 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_or_update_custom_link.ts @@ -12,8 +12,9 @@ import { import { Setup } from '../../helpers/setup_request'; import { toESFormat } from './helper'; import { APMIndexDocumentParams } from '../../helpers/create_es_client/create_internal_es_client'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function createOrUpdateCustomLink({ +export function createOrUpdateCustomLink({ customLinkId, customLink, setup, @@ -22,21 +23,23 @@ export async function createOrUpdateCustomLink({ customLink: Omit; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('create_or_update_custom_link', () => { + const { internalClient, indices } = setup; - const params: APMIndexDocumentParams = { - refresh: true, - index: indices.apmCustomLinkIndex, - body: { - '@timestamp': Date.now(), - ...toESFormat(customLink), - }, - }; + const params: APMIndexDocumentParams = { + refresh: true, + index: indices.apmCustomLinkIndex, + body: { + '@timestamp': Date.now(), + ...toESFormat(customLink), + }, + }; - // by specifying an id elasticsearch will delete the previous doc and insert the updated doc - if (customLinkId) { - params.id = customLinkId; - } + // by specifying an id elasticsearch will delete the previous doc and insert the updated doc + if (customLinkId) { + params.id = customLinkId; + } - return internalClient.index(params); + return internalClient.index(params); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts index 3a7f53adc6aaeb..48f547e3deb0f7 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/custom_link_types.ts @@ -6,29 +6,34 @@ */ import * as t from 'io-ts'; -import { - SERVICE_NAME, - SERVICE_ENVIRONMENT, - TRANSACTION_NAME, - TRANSACTION_TYPE, -} from '../../../../common/elasticsearch_fieldnames'; + +// These should be imported, but until TypeScript 4.2 we're inlining them here. +// All instances of "service.name", "service.environment", "transaction.name", +// and "transaction.type" need to be changed back to using the constants. +// See https://github.com/microsoft/TypeScript/issues/37888 +// import { +// SERVICE_NAME, +// SERVICE_ENVIRONMENT, +// TRANSACTION_NAME, +// TRANSACTION_TYPE, +// } from '../../../../common/elasticsearch_fieldnames'; export interface CustomLinkES { id?: string; '@timestamp'?: number; label: string; url: string; - [SERVICE_NAME]?: string[]; - [SERVICE_ENVIRONMENT]?: string[]; - [TRANSACTION_NAME]?: string[]; - [TRANSACTION_TYPE]?: string[]; + 'service.name'?: string[]; + 'service.environment'?: string[]; + 'transaction.name'?: string[]; + 'transaction.type'?: string[]; } export const filterOptionsRt = t.partial({ - [SERVICE_NAME]: t.string, - [SERVICE_ENVIRONMENT]: t.string, - [TRANSACTION_NAME]: t.string, - [TRANSACTION_TYPE]: t.string, + 'service.name': t.string, + 'service.environment': t.string, + 'transaction.name': t.string, + 'transaction.type': t.string, }); export const payloadRt = t.intersection([ diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts index 64eba026770e59..7c88bcc43cc7f7 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/delete_custom_link.ts @@ -5,22 +5,25 @@ * 2.0. */ +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; -export async function deleteCustomLink({ +export function deleteCustomLink({ customLinkId, setup, }: { customLinkId: string; setup: Setup; }) { - const { internalClient, indices } = setup; + return withApmSpan('delete_custom_link', () => { + const { internalClient, indices } = setup; - const params = { - refresh: 'wait_for' as const, - index: indices.apmCustomLinkIndex, - id: customLinkId, - }; + const params = { + refresh: 'wait_for' as const, + index: indices.apmCustomLinkIndex, + id: customLinkId, + }; - return internalClient.delete(params); + return internalClient.delete(params); + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts index 679b6b62776e03..8e343ecfe6a642 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/get_transaction.ts @@ -10,40 +10,43 @@ import { Setup } from '../../helpers/setup_request'; import { ProcessorEvent } from '../../../../common/processor_event'; import { filterOptionsRt } from './custom_link_types'; import { splitFilterValueByComma } from './helper'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getTransaction({ +export function getTransaction({ setup, filters = {}, }: { setup: Setup; filters?: t.TypeOf; }) { - const { apmEventClient } = setup; + return withApmSpan('get_transaction_for_custom_link', async () => { + const { apmEventClient } = setup; - const esFilters = Object.entries(filters) - // loops through the filters splitting the value by comma and removing white spaces - .map(([key, value]) => { - if (value) { - return { terms: { [key]: splitFilterValueByComma(value) } }; - } - }) - // removes filters without value - .filter((value) => value); + const esFilters = Object.entries(filters) + // loops through the filters splitting the value by comma and removing white spaces + .map(([key, value]) => { + if (value) { + return { terms: { [key]: splitFilterValueByComma(value) } }; + } + }) + // removes filters without value + .filter((value) => value); - const params = { - terminateAfter: 1, - apm: { - events: [ProcessorEvent.transaction as const], - }, - size: 1, - body: { - query: { - bool: { - filter: esFilters, + const params = { + terminateAfter: 1, + apm: { + events: [ProcessorEvent.transaction as const], + }, + size: 1, + body: { + query: { + bool: { + filter: esFilters, + }, }, }, - }, - }; - const resp = await apmEventClient.search(params); - return resp.hits.hits[0]?._source; + }; + const resp = await apmEventClient.search(params); + return resp.hits.hits[0]?._source; + }); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts index 3d8a003155455d..7437b8328b876f 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/list_custom_links.ts @@ -13,51 +13,54 @@ import { import { Setup } from '../../helpers/setup_request'; import { fromESFormat } from './helper'; import { filterOptionsRt } from './custom_link_types'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function listCustomLinks({ +export function listCustomLinks({ setup, filters = {}, }: { setup: Setup; filters?: t.TypeOf; }): Promise { - const { internalClient, indices } = setup; - const esFilters = Object.entries(filters).map(([key, value]) => { - return { - bool: { - minimum_should_match: 1, - should: [ - { term: { [key]: value } }, - { bool: { must_not: [{ exists: { field: key } }] } }, - ], - }, - }; - }); - - const params = { - index: indices.apmCustomLinkIndex, - size: 500, - body: { - query: { + return withApmSpan('list_custom_links', async () => { + const { internalClient, indices } = setup; + const esFilters = Object.entries(filters).map(([key, value]) => { + return { bool: { - filter: esFilters, + minimum_should_match: 1, + should: [ + { term: { [key]: value } }, + { bool: { must_not: [{ exists: { field: key } }] } }, + ], }, - }, - sort: [ - { - 'label.keyword': { - order: 'asc', + }; + }); + + const params = { + index: indices.apmCustomLinkIndex, + size: 500, + body: { + query: { + bool: { + filter: esFilters, }, }, - ], - }, - }; - const resp = await internalClient.search(params); - const customLinks = resp.hits.hits.map((item) => - fromESFormat({ - id: item._id, - ...item._source, - }) - ); - return customLinks; + sort: [ + { + 'label.keyword': { + order: 'asc', + }, + }, + ], + }, + }; + const resp = await internalClient.search(params); + const customLinks = resp.hits.hits.map((item) => + fromESFormat({ + id: item._id, + ...item._source, + }) + ); + return customLinks; + }); } diff --git a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts index de2136b0c8b5af..f631657f872761 100644 --- a/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts +++ b/x-pack/plugins/apm/server/lib/traces/get_trace_items.ts @@ -15,11 +15,12 @@ import { ERROR_LOG_LEVEL, } from '../../../common/elasticsearch_fieldnames'; import { APMError } from '../../../typings/es_schemas/ui/apm_error'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { rangeQuery } from '../../../common/utils/queries'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { PromiseValueType } from '../../../typings/common'; +import { withApmSpan } from '../../utils/with_apm_span'; -interface ErrorsPerTransaction { +export interface ErrorsPerTransaction { [transactionId: string]: number; } @@ -27,94 +28,103 @@ export async function getTraceItems( traceId: string, setup: Setup & SetupTimeRange ) { - const { start, end, apmEventClient, config } = setup; - const maxTraceItems = config['xpack.apm.ui.maxTraceItems']; - const excludedLogLevels = ['debug', 'info', 'warning']; + return withApmSpan('get_trace_items', async () => { + const { start, end, apmEventClient, config } = setup; + const maxTraceItems = config['xpack.apm.ui.maxTraceItems']; + const excludedLogLevels = ['debug', 'info', 'warning']; - const errorResponsePromise = apmEventClient.search({ - apm: { - events: [ProcessorEvent.error], - }, - body: { - size: maxTraceItems, - query: { - bool: { - filter: [ - { term: { [TRACE_ID]: traceId } }, - { range: rangeFilter(start, end) }, - ], - must_not: { terms: { [ERROR_LOG_LEVEL]: excludedLogLevels } }, + const errorResponsePromise = withApmSpan('get_trace_error_items', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.error], }, - }, - aggs: { - by_transaction_id: { - terms: { - field: TRANSACTION_ID, - size: maxTraceItems, - // high cardinality - execution_hint: 'map' as const, + body: { + size: maxTraceItems, + query: { + bool: { + filter: [ + { term: { [TRACE_ID]: traceId } }, + ...rangeQuery(start, end), + ], + must_not: { terms: { [ERROR_LOG_LEVEL]: excludedLogLevels } }, + }, + }, + aggs: { + by_transaction_id: { + terms: { + field: TRANSACTION_ID, + size: maxTraceItems, + // high cardinality + execution_hint: 'map' as const, + }, + }, }, }, - }, - }, - }); + }) + ); - const traceResponsePromise = apmEventClient.search({ - apm: { - events: [ProcessorEvent.span, ProcessorEvent.transaction], - }, - body: { - size: maxTraceItems, - query: { - bool: { - filter: [ - { term: { [TRACE_ID]: traceId } }, - { range: rangeFilter(start, end) }, - ], - should: { - exists: { field: PARENT_ID }, + const traceResponsePromise = withApmSpan('get_trace_span_items', () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.span, ProcessorEvent.transaction], + }, + body: { + size: maxTraceItems, + query: { + bool: { + filter: [ + { term: { [TRACE_ID]: traceId } }, + ...rangeQuery(start, end), + ], + should: { + exists: { field: PARENT_ID }, + }, + }, }, + sort: [ + { _score: { order: 'asc' as const } }, + { [TRANSACTION_DURATION]: { order: 'desc' as const } }, + { [SPAN_DURATION]: { order: 'desc' as const } }, + ], + track_total_hits: true, }, - }, - sort: [ - { _score: { order: 'asc' as const } }, - { [TRANSACTION_DURATION]: { order: 'desc' as const } }, - { [SPAN_DURATION]: { order: 'desc' as const } }, - ], - track_total_hits: true, - }, - }); + }) + ); - const [errorResponse, traceResponse]: [ - // explicit intermediary types to avoid TS "excessively deep" error - PromiseValueType, - PromiseValueType - ] = (await Promise.all([errorResponsePromise, traceResponsePromise])) as any; + const [errorResponse, traceResponse]: [ + // explicit intermediary types to avoid TS "excessively deep" error + PromiseValueType, + PromiseValueType + ] = (await Promise.all([ + errorResponsePromise, + traceResponsePromise, + ])) as any; - const exceedsMax = traceResponse.hits.total.value > maxTraceItems; + const exceedsMax = traceResponse.hits.total.value > maxTraceItems; - const items = traceResponse.hits.hits.map((hit) => hit._source); + const items = traceResponse.hits.hits.map((hit) => hit._source); - const errorFrequencies: { - errorsPerTransaction: ErrorsPerTransaction; - errorDocs: APMError[]; - } = { - errorDocs: errorResponse.hits.hits.map(({ _source }) => _source), - errorsPerTransaction: - errorResponse.aggregations?.by_transaction_id.buckets.reduce( - (acc, current) => { - return { - ...acc, - [current.key]: current.doc_count, - }; - }, - {} as ErrorsPerTransaction - ) ?? {}, - }; + const errorFrequencies: { + errorsPerTransaction: ErrorsPerTransaction; + errorDocs: APMError[]; + } = { + errorDocs: errorResponse.hits.hits.map(({ _source }) => _source), + errorsPerTransaction: + errorResponse.aggregations?.by_transaction_id.buckets.reduce( + (acc, current) => { + return { + ...acc, + [current.key]: current.doc_count, + }; + }, + {} as ErrorsPerTransaction + ) ?? {}, + }; - return { - items, - exceedsMax, - ...errorFrequencies, - }; + return { + items, + exceedsMax, + ...errorFrequencies, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap index 89069d74bacf8b..7fb2bb2fcbeeb1 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap @@ -13,11 +13,13 @@ Array [ "transaction_groups": Object { "aggs": Object { "transaction_type": Object { - "top_hits": Object { - "_source": Array [ - "transaction.type", - ], - "size": 1, + "top_metrics": Object { + "metrics": Object { + "field": "transaction.type", + }, + "sort": Object { + "@timestamp": "desc", + }, }, }, }, @@ -222,11 +224,13 @@ Array [ "transaction_groups": Object { "aggs": Object { "transaction_type": Object { - "top_hits": Object { - "_source": Array [ - "transaction.type", - ], - "size": 1, + "top_metrics": Object { + "metrics": Object { + "field": "transaction.type", + }, + "sort": Object { + "@timestamp": "desc", + }, }, }, }, @@ -240,12 +244,8 @@ Array [ "bool": Object { "filter": Array [ Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, + "term": Object { + "service.name": "foo", }, }, Object { @@ -254,8 +254,12 @@ Array [ }, }, Object { - "term": Object { - "service.name": "foo", + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, }, }, Object { @@ -295,12 +299,8 @@ Array [ "bool": Object { "filter": Array [ Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, + "term": Object { + "service.name": "foo", }, }, Object { @@ -309,8 +309,12 @@ Array [ }, }, Object { - "term": Object { - "service.name": "foo", + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, }, }, Object { @@ -350,12 +354,8 @@ Array [ "bool": Object { "filter": Array [ Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, + "term": Object { + "service.name": "foo", }, }, Object { @@ -364,8 +364,12 @@ Array [ }, }, Object { - "term": Object { - "service.name": "foo", + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, }, }, Object { @@ -411,12 +415,8 @@ Array [ "bool": Object { "filter": Array [ Object { - "range": Object { - "@timestamp": Object { - "format": "epoch_millis", - "gte": 1528113600000, - "lte": 1528977600000, - }, + "term": Object { + "service.name": "foo", }, }, Object { @@ -425,8 +425,12 @@ Array [ }, }, Object { - "term": Object { - "service.name": "foo", + "range": Object { + "@timestamp": Object { + "format": "epoch_millis", + "gte": 1528113600000, + "lte": 1528977600000, + }, }, }, Object { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index 0d0f376f493533..09e5e358a1b7c1 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -17,6 +17,7 @@ import { import { joinByKey } from '../../../common/utils/join_by_key'; import { getTransactionGroupsProjection } from '../../projections/transaction_groups'; import { mergeProjection } from '../../projections/util/merge_projection'; +import { withApmSpan } from '../../utils/with_apm_span'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { getAverages, @@ -26,6 +27,7 @@ import { } from './get_transaction_group_stats'; interface TopTransactionOptions { + environment?: string; type: 'top_transactions'; serviceName: string; transactionType: string; @@ -34,6 +36,7 @@ interface TopTransactionOptions { } interface TopTraceOptions { + environment?: string; type: 'top_traces'; transactionName?: string; searchAggregatedTransactions: boolean; @@ -95,118 +98,124 @@ function getItemsWithRelativeImpact( return itemsWithRelativeImpact; } -export async function transactionGroupsFetcher( +export function transactionGroupsFetcher( options: Options, setup: TransactionGroupSetup, bucketSize: number ) { - const projection = getTransactionGroupsProjection({ - setup, - options, - }); - - const isTopTraces = options.type === 'top_traces'; - - // @ts-expect-error - delete projection.body.aggs; - - // traces overview is hardcoded to 10000 - // transactions overview: 1 extra bucket is added to check whether the total number of buckets exceed the specified bucket size. - const expectedBucketSize = isTopTraces ? 10000 : bucketSize; - const size = isTopTraces ? 10000 : expectedBucketSize + 1; - - const request = mergeProjection(projection, { - body: { - size: 0, - aggs: { - transaction_groups: { - ...(isTopTraces - ? { - composite: { - sources: [ - { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, - { - [TRANSACTION_NAME]: { - terms: { field: TRANSACTION_NAME }, + const spanName = + options.type === 'top_traces' ? 'get_top_traces' : 'get_top_transactions'; + + return withApmSpan(spanName, async () => { + const projection = getTransactionGroupsProjection({ + setup, + options, + }); + + const isTopTraces = options.type === 'top_traces'; + + // @ts-expect-error + delete projection.body.aggs; + + // traces overview is hardcoded to 10000 + // transactions overview: 1 extra bucket is added to check whether the total number of buckets exceed the specified bucket size. + const expectedBucketSize = isTopTraces ? 10000 : bucketSize; + const size = isTopTraces ? 10000 : expectedBucketSize + 1; + + const request = mergeProjection(projection, { + body: { + size: 0, + aggs: { + transaction_groups: { + ...(isTopTraces + ? { + composite: { + sources: [ + { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, + { + [TRANSACTION_NAME]: { + terms: { field: TRANSACTION_NAME }, + }, }, - }, - ], - size, - }, - } - : { - terms: { - field: TRANSACTION_NAME, - size, - }, - }), + ], + size, + }, + } + : { + terms: { + field: TRANSACTION_NAME, + size, + }, + }), + }, }, }, - }, - }); + }); - const params = { - request, - setup, - searchAggregatedTransactions: options.searchAggregatedTransactions, - }; + const params = { + request, + setup, + searchAggregatedTransactions: options.searchAggregatedTransactions, + }; - const [counts, averages, sums, percentiles] = await Promise.all([ - getCounts(params), - getAverages(params), - getSums(params), - !isTopTraces ? getPercentiles(params) : Promise.resolve(undefined), - ]); - - const stats = [ - ...averages, - ...counts, - ...sums, - ...(percentiles ? percentiles : []), - ]; - - const items = joinByKey(stats, 'key'); - - const itemsWithRelativeImpact = getItemsWithRelativeImpact(setup, items); - - const defaultServiceName = - options.type === 'top_transactions' ? options.serviceName : undefined; - - const itemsWithKeys: TransactionGroup[] = itemsWithRelativeImpact.map( - (item) => { - let transactionName: string; - let serviceName: string; - - if (typeof item.key === 'string') { - transactionName = item.key; - serviceName = defaultServiceName!; - } else { - transactionName = item.key[TRANSACTION_NAME]; - serviceName = item.key[SERVICE_NAME]; + const [counts, averages, sums, percentiles] = await Promise.all([ + getCounts(params), + getAverages(params), + getSums(params), + !isTopTraces ? getPercentiles(params) : Promise.resolve(undefined), + ]); + + const stats = [ + ...averages, + ...counts, + ...sums, + ...(percentiles ? percentiles : []), + ]; + + const items = joinByKey(stats, 'key'); + + const itemsWithRelativeImpact = getItemsWithRelativeImpact(setup, items); + + const defaultServiceName = + options.type === 'top_transactions' ? options.serviceName : undefined; + + const itemsWithKeys: TransactionGroup[] = itemsWithRelativeImpact.map( + (item) => { + let transactionName: string; + let serviceName: string; + + if (typeof item.key === 'string') { + transactionName = item.key; + serviceName = defaultServiceName!; + } else { + transactionName = item.key[TRANSACTION_NAME]; + serviceName = item.key[SERVICE_NAME]; + } + + return { + ...item, + transactionName, + serviceName, + }; } + ); - return { - ...item, - transactionName, - serviceName, - }; - } - ); - - return { - items: take( - // sort by impact by default so most impactful services are not cut off - sortBy(itemsWithKeys, 'impact').reverse(), - bucketSize - ), - // The aggregation is considered accurate if the configured bucket size is larger or equal to the number of buckets returned - // the actual number of buckets retrieved are `bucketsize + 1` to detect whether it's above the limit - isAggregationAccurate: expectedBucketSize >= itemsWithRelativeImpact.length, - bucketSize, - }; + return { + items: take( + // sort by impact by default so most impactful services are not cut off + sortBy(itemsWithKeys, 'impact').reverse(), + bucketSize + ), + // The aggregation is considered accurate if the configured bucket size is larger or equal to the number of buckets returned + // the actual number of buckets retrieved are `bucketsize + 1` to detect whether it's above the limit + isAggregationAccurate: + expectedBucketSize >= itemsWithRelativeImpact.length, + bucketSize, + }; + }); } -interface TransactionGroup { +export interface TransactionGroup { key: string | Record<'service.name' | 'transaction.name', string>; serviceName: string; transactionName: string; diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index bd0edfcf9e9e57..d1a056002db078 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -14,7 +14,7 @@ import { TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { EventOutcome } from '../../../common/event_outcome'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../common/utils/queries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, @@ -26,14 +26,17 @@ import { getOutcomeAggregation, getTransactionErrorRateTimeSeries, } from '../helpers/transaction_error_rate'; +import { withApmSpan } from '../../utils/with_apm_span'; export async function getErrorRate({ + environment, serviceName, transactionType, transactionName, setup, searchAggregatedTransactions, }: { + environment?: string; serviceName: string; transactionType?: string; transactionName?: string; @@ -44,74 +47,79 @@ export async function getErrorRate({ transactionErrorRate: Coordinate[]; average: number | null; }> { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan('get_transaction_group_error_rate', async () => { + const { start, end, esFilter, apmEventClient } = setup; - const transactionNamefilter = transactionName - ? [{ term: { [TRANSACTION_NAME]: transactionName } }] - : []; - const transactionTypefilter = transactionType - ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] - : []; + const transactionNamefilter = transactionName + ? [{ term: { [TRANSACTION_NAME]: transactionName } }] + : []; + const transactionTypefilter = transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []; - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - { - terms: { [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success] }, - }, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ...transactionNamefilter, - ...transactionTypefilter, - ...esFilter, - ]; + const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, + { + terms: { + [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success], + }, + }, + ...transactionNamefilter, + ...transactionTypefilter, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ]; - const outcomes = getOutcomeAggregation(); + const outcomes = getOutcomeAggregation(); - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { bool: { filter } }, - aggs: { - outcomes, - timeseries: { - date_histogram: { - field: '@timestamp', - fixed_interval: getBucketSize({ start, end }).intervalString, - min_doc_count: 0, - extended_bounds: { min: start, max: end }, - }, - aggs: { - outcomes, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + size: 0, + query: { bool: { filter } }, + aggs: { + outcomes, + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: getBucketSize({ start, end }).intervalString, + min_doc_count: 0, + extended_bounds: { min: start, max: end }, + }, + aggs: { + outcomes, + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); + const resp = await apmEventClient.search(params); - const noHits = resp.hits.total.value === 0; + const noHits = resp.hits.total.value === 0; - if (!resp.aggregations) { - return { noHits, transactionErrorRate: [], average: null }; - } + if (!resp.aggregations) { + return { noHits, transactionErrorRate: [], average: null }; + } - const transactionErrorRate = getTransactionErrorRateTimeSeries( - resp.aggregations.timeseries.buckets - ); + const transactionErrorRate = getTransactionErrorRateTimeSeries( + resp.aggregations.timeseries.buckets + ); - const average = calculateTransactionErrorPercentage( - resp.aggregations.outcomes - ); + const average = calculateTransactionErrorPercentage( + resp.aggregations.outcomes + ); - return { noHits, transactionErrorRate, average }; + return { noHits, transactionErrorRate, average }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts index 5c740fc0db686c..5ee46bf1a5918a 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts @@ -11,6 +11,7 @@ import { arrayUnionToCallable } from '../../../common/utils/array_union_to_calla import { AggregationInputMap } from '../../../../../typings/elasticsearch'; import { TransactionGroupRequestBase, TransactionGroupSetup } from './fetcher'; import { getTransactionDurationFieldForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { withApmSpan } from '../../utils/with_apm_span'; interface MetricParams { request: TransactionGroupRequestBase; @@ -35,116 +36,122 @@ function mergeRequestWithAggs< }); } -export async function getAverages({ +export function getAverages({ request, setup, searchAggregatedTransactions, }: MetricParams) { - const params = mergeRequestWithAggs(request, { - avg: { + return withApmSpan('get_avg_transaction_group_duration', async () => { + const params = mergeRequestWithAggs(request, { avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + avg: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, - }, - }); - - const response = await setup.apmEventClient.search(params); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - avg: bucket.avg.value, - }; + }); + + const response = await setup.apmEventClient.search(params); + + return arrayUnionToCallable( + response.aggregations?.transaction_groups.buckets ?? [] + ).map((bucket) => { + return { + key: bucket.key as BucketKey, + avg: bucket.avg.value, + }; + }); }); } -export async function getCounts({ - request, - setup, - searchAggregatedTransactions, -}: MetricParams) { - const params = mergeRequestWithAggs(request, { - transaction_type: { - top_hits: { - size: 1, - _source: [TRANSACTION_TYPE], +export function getCounts({ request, setup }: MetricParams) { + return withApmSpan('get_transaction_group_transaction_count', async () => { + const params = mergeRequestWithAggs(request, { + transaction_type: { + top_metrics: { + sort: { + '@timestamp': 'desc', + }, + metrics: { + field: TRANSACTION_TYPE, + } as const, + }, }, - }, - }); - - const response = await setup.apmEventClient.search(params); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - // type is Transaction | APMBaseDoc because it could be a metric document - const source = (bucket.transaction_type.hits.hits[0] - ._source as unknown) as { transaction: { type: string } }; - - return { - key: bucket.key as BucketKey, - count: bucket.doc_count, - transactionType: source.transaction.type, - }; + }); + + const response = await setup.apmEventClient.search(params); + + return arrayUnionToCallable( + response.aggregations?.transaction_groups.buckets ?? [] + ).map((bucket) => { + return { + key: bucket.key as BucketKey, + count: bucket.doc_count, + transactionType: bucket.transaction_type.top[0].metrics[ + TRANSACTION_TYPE + ] as string, + }; + }); }); } -export async function getSums({ +export function getSums({ request, setup, searchAggregatedTransactions, }: MetricParams) { - const params = mergeRequestWithAggs(request, { - sum: { + return withApmSpan('get_transaction_group_latency_sums', async () => { + const params = mergeRequestWithAggs(request, { sum: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + sum: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, - }, - }); - - const response = await setup.apmEventClient.search(params); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - sum: bucket.sum.value, - }; + }); + + const response = await setup.apmEventClient.search(params); + + return arrayUnionToCallable( + response.aggregations?.transaction_groups.buckets ?? [] + ).map((bucket) => { + return { + key: bucket.key as BucketKey, + sum: bucket.sum.value, + }; + }); }); } -export async function getPercentiles({ +export function getPercentiles({ request, setup, searchAggregatedTransactions, }: MetricParams) { - const params = mergeRequestWithAggs(request, { - p95: { - percentiles: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - hdr: { number_of_significant_value_digits: 2 }, - percents: [95], + return withApmSpan('get_transaction_group_latency_percentiles', async () => { + const params = mergeRequestWithAggs(request, { + p95: { + percentiles: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + hdr: { number_of_significant_value_digits: 2 }, + percents: [95], + }, }, - }, - }); - - const response = await setup.apmEventClient.search(params); - - return arrayUnionToCallable( - response.aggregations?.transaction_groups.buckets ?? [] - ).map((bucket) => { - return { - key: bucket.key as BucketKey, - p95: Object.values(bucket.p95.values)[0], - }; + }); + + const response = await setup.apmEventClient.search(params); + + return arrayUnionToCallable( + response.aggregations?.transaction_groups.buckets ?? [] + ).map((bucket) => { + return { + key: bucket.key as BucketKey, + p95: Object.values(bucket.p95.values)[0], + }; + }); }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts index 53669d745c5344..c3741184c807db 100644 --- a/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/breakdown/index.ts @@ -18,209 +18,215 @@ import { TRANSACTION_BREAKDOWN_COUNT, } from '../../../../common/elasticsearch_fieldnames'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { getMetricsDateHistogramParams } from '../../helpers/metrics'; import { MAX_KPIS } from './constants'; import { getVizColorForIndex } from '../../../../common/viz_colors'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getTransactionBreakdown({ +export function getTransactionBreakdown({ + environment, setup, serviceName, transactionName, transactionType, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; transactionName?: string; transactionType: string; }) { - const { esFilter, apmEventClient, start, end, config } = setup; + return withApmSpan('get_transaction_breakdown', async () => { + const { esFilter, apmEventClient, start, end, config } = setup; - const subAggs = { - sum_all_self_times: { - sum: { - field: SPAN_SELF_TIME_SUM, - }, - }, - total_transaction_breakdown_count: { - sum: { - field: TRANSACTION_BREAKDOWN_COUNT, + const subAggs = { + sum_all_self_times: { + sum: { + field: SPAN_SELF_TIME_SUM, + }, }, - }, - types: { - terms: { - field: SPAN_TYPE, - size: 20, - order: { - _count: 'desc' as const, + total_transaction_breakdown_count: { + sum: { + field: TRANSACTION_BREAKDOWN_COUNT, }, }, - aggs: { - subtypes: { - terms: { - field: SPAN_SUBTYPE, - missing: '', - size: 20, - order: { - _count: 'desc' as const, - }, + types: { + terms: { + field: SPAN_TYPE, + size: 20, + order: { + _count: 'desc' as const, }, - aggs: { - total_self_time_per_subtype: { - sum: { - field: SPAN_SELF_TIME_SUM, + }, + aggs: { + subtypes: { + terms: { + field: SPAN_SUBTYPE, + missing: '', + size: 20, + order: { + _count: 'desc' as const, + }, + }, + aggs: { + total_self_time_per_subtype: { + sum: { + field: SPAN_SELF_TIME_SUM, + }, }, }, }, }, }, - }, - }; - - const filters = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ]; - - if (transactionName) { - filters.push({ term: { [TRANSACTION_NAME]: transactionName } }); - } - - const params = { - apm: { - events: [ProcessorEvent.metric], - }, - body: { - size: 0, - query: { - bool: { - filter: filters, - }, + }; + + const filters = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ]; + + if (transactionName) { + filters.push({ term: { [TRANSACTION_NAME]: transactionName } }); + } + + const params = { + apm: { + events: [ProcessorEvent.metric], }, - aggs: { - ...subAggs, - by_date: { - date_histogram: getMetricsDateHistogramParams( - start, - end, - config['xpack.apm.metricsInterval'] - ), - aggs: subAggs, + body: { + size: 0, + query: { + bool: { + filter: filters, + }, + }, + aggs: { + ...subAggs, + by_date: { + date_histogram: getMetricsDateHistogramParams( + start, + end, + config['xpack.apm.metricsInterval'] + ), + aggs: subAggs, + }, }, }, - }, - }; - - const resp = await apmEventClient.search(params); - - const formatBucket = ( - aggs: - | Required['aggregations'] - | Required['aggregations']['by_date']['buckets'][0] - ) => { - const sumAllSelfTimes = aggs.sum_all_self_times.value || 0; - - const breakdowns = flatten( - aggs.types.buckets.map((bucket) => { - const type = bucket.key as string; - - return bucket.subtypes.buckets.map((subBucket) => { - return { - name: (subBucket.key as string) || type, - percentage: - (subBucket.total_self_time_per_subtype.value || 0) / - sumAllSelfTimes, - }; - }); - }) - ); - - return breakdowns; - }; - - const visibleKpis = resp.aggregations - ? orderBy(formatBucket(resp.aggregations), 'percentage', 'desc').slice( - 0, - MAX_KPIS - ) - : []; - - const kpis = orderBy( - visibleKpis.map((kpi) => ({ - ...kpi, - lowerCaseName: kpi.name.toLowerCase(), - })), - 'lowerCaseName' - ).map((kpi, index) => { - const { lowerCaseName, ...rest } = kpi; - return { - ...rest, - color: getVizColorForIndex(index), }; - }); - const kpiNames = kpis.map((kpi) => kpi.name); + const resp = await apmEventClient.search(params); + + const formatBucket = ( + aggs: + | Required['aggregations'] + | Required['aggregations']['by_date']['buckets'][0] + ) => { + const sumAllSelfTimes = aggs.sum_all_self_times.value || 0; + + const breakdowns = flatten( + aggs.types.buckets.map((bucket) => { + const type = bucket.key as string; + + return bucket.subtypes.buckets.map((subBucket) => { + return { + name: (subBucket.key as string) || type, + percentage: + (subBucket.total_self_time_per_subtype.value || 0) / + sumAllSelfTimes, + }; + }); + }) + ); + + return breakdowns; + }; - const bucketsByDate = resp.aggregations?.by_date.buckets || []; + const visibleKpis = resp.aggregations + ? orderBy(formatBucket(resp.aggregations), 'percentage', 'desc').slice( + 0, + MAX_KPIS + ) + : []; + + const kpis = orderBy( + visibleKpis.map((kpi) => ({ + ...kpi, + lowerCaseName: kpi.name.toLowerCase(), + })), + 'lowerCaseName' + ).map((kpi, index) => { + const { lowerCaseName, ...rest } = kpi; + return { + ...rest, + color: getVizColorForIndex(index), + }; + }); - const timeseriesPerSubtype = bucketsByDate.reduce((prev, bucket) => { - const formattedValues = formatBucket(bucket); - const time = bucket.key; + const kpiNames = kpis.map((kpi) => kpi.name); - const updatedSeries = kpiNames.reduce((p, kpiName) => { - const { name, percentage } = formattedValues.find( - (val) => val.name === kpiName - ) || { - name: kpiName, - percentage: null, - }; + const bucketsByDate = resp.aggregations?.by_date.buckets || []; - if (!p[name]) { - p[name] = []; - } - return { - ...p, - [name]: p[name].concat({ - x: time, - y: percentage, - }), - }; - }, prev); - - const lastValues = Object.values(updatedSeries).map(last); - - // If for a given timestamp, some series have data, but others do not, - // we have to set any null values to 0 to make sure the stacked area chart - // is drawn correctly. - // If we set all values to 0, the chart always displays null values as 0, - // and the chart looks weird. - const hasAnyValues = lastValues.some((value) => value?.y !== null); - const hasNullValues = lastValues.some((value) => value?.y === null); - - if (hasAnyValues && hasNullValues) { - Object.values(updatedSeries).forEach((series) => { - const value = series[series.length - 1]; - const isEmpty = value.y === null; - if (isEmpty) { - // local mutation to prevent complicated map/reduce calls - value.y = 0; + const timeseriesPerSubtype = bucketsByDate.reduce((prev, bucket) => { + const formattedValues = formatBucket(bucket); + const time = bucket.key; + + const updatedSeries = kpiNames.reduce((p, kpiName) => { + const { name, percentage } = formattedValues.find( + (val) => val.name === kpiName + ) || { + name: kpiName, + percentage: null, + }; + + if (!p[name]) { + p[name] = []; } - }); - } + return { + ...p, + [name]: p[name].concat({ + x: time, + y: percentage, + }), + }; + }, prev); + + const lastValues = Object.values(updatedSeries).map(last); + + // If for a given timestamp, some series have data, but others do not, + // we have to set any null values to 0 to make sure the stacked area chart + // is drawn correctly. + // If we set all values to 0, the chart always displays null values as 0, + // and the chart looks weird. + const hasAnyValues = lastValues.some((value) => value?.y !== null); + const hasNullValues = lastValues.some((value) => value?.y === null); + + if (hasAnyValues && hasNullValues) { + Object.values(updatedSeries).forEach((series) => { + const value = series[series.length - 1]; + const isEmpty = value.y === null; + if (isEmpty) { + // local mutation to prevent complicated map/reduce calls + value.y = 0; + } + }); + } - return updatedSeries; - }, {} as Record>); + return updatedSeries; + }, {} as Record>); - const timeseries = kpis.map((kpi) => ({ - title: kpi.name, - color: kpi.color, - type: 'areaStacked', - data: timeseriesPerSubtype[kpi.name], - hideLegend: false, - legendValue: asPercent(kpi.percentage, 1), - })); + const timeseries = kpis.map((kpi) => ({ + title: kpi.name, + color: kpi.color, + type: 'areaStacked', + data: timeseriesPerSubtype[kpi.name], + hideLegend: false, + legendValue: asPercent(kpi.percentage, 1), + })); - return { timeseries }; + return { timeseries }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts index 86621c7aaa7e2f..7ed016cd4b4c61 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/get_buckets/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { withApmSpan } from '../../../../utils/with_apm_span'; import { SERVICE_NAME, TRACE_ID, @@ -16,7 +17,10 @@ import { } from '../../../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../../../common/processor_event'; import { joinByKey } from '../../../../../common/utils/join_by_key'; -import { rangeFilter } from '../../../../../common/utils/range_filter'; +import { + environmentQuery, + rangeQuery, +} from '../../../../../common/utils/queries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, @@ -45,6 +49,7 @@ function getHistogramAggOptions({ } export async function getBuckets({ + environment, serviceName, transactionName, transactionType, @@ -55,6 +60,7 @@ export async function getBuckets({ setup, searchAggregatedTransactions, }: { + environment?: string; serviceName: string; transactionName: string; transactionType: string; @@ -65,134 +71,151 @@ export async function getBuckets({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan( + 'get_latency_distribution_buckets_with_samples', + async () => { + const { start, end, esFilter, apmEventClient } = setup; - const commonFilters = [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { term: { [TRANSACTION_NAME]: transactionName } }, - { range: rangeFilter(start, end) }, - ...esFilter, - ]; + const commonFilters = [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { term: { [TRANSACTION_NAME]: transactionName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ]; - async function getSamplesForDistributionBuckets() { - const response = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - query: { - bool: { - filter: [ - ...commonFilters, - { term: { [TRANSACTION_SAMPLED]: true } }, - ], - should: [ - { term: { [TRACE_ID]: traceId } }, - { term: { [TRANSACTION_ID]: transactionId } }, - ], - }, - }, - aggs: { - distribution: { - histogram: getHistogramAggOptions({ - bucketSize, - field: TRANSACTION_DURATION, - distributionMax, - }), - aggs: { - samples: { - top_hits: { - _source: [TRANSACTION_ID, TRACE_ID], - size: 10, - sort: { - _score: 'desc', + async function getSamplesForDistributionBuckets() { + const response = await withApmSpan( + 'get_samples_for_latency_distribution_buckets', + () => + apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + query: { + bool: { + filter: [ + ...commonFilters, + { term: { [TRANSACTION_SAMPLED]: true } }, + ], + should: [ + { term: { [TRACE_ID]: traceId } }, + { term: { [TRANSACTION_ID]: transactionId } }, + ], + }, + }, + aggs: { + distribution: { + histogram: getHistogramAggOptions({ + bucketSize, + field: TRANSACTION_DURATION, + distributionMax, + }), + aggs: { + samples: { + top_metrics: { + metrics: [ + { field: TRANSACTION_ID }, + { field: TRACE_ID }, + ] as const, + size: 10, + sort: { + _score: 'desc', + }, + }, + }, + }, }, }, }, - }, - }, - }, - }, - }); + }) + ); - return ( - response.aggregations?.distribution.buckets.map((bucket) => { - return { - key: bucket.key, - samples: bucket.samples.hits.hits.map((hit) => ({ - traceId: hit._source.trace.id, - transactionId: hit._source.transaction.id, - })), - }; - }) ?? [] - ); - } + return ( + response.aggregations?.distribution.buckets.map((bucket) => { + return { + key: bucket.key, + samples: bucket.samples.top.map((sample) => ({ + traceId: sample.metrics[TRACE_ID] as string, + transactionId: sample.metrics[TRANSACTION_ID] as string, + })), + }; + }) ?? [] + ); + } - async function getDistributionBuckets() { - const response = await apmEventClient.search({ - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - query: { - bool: { - filter: [ - ...commonFilters, - ...getDocumentTypeFilterForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - }, - aggs: { - distribution: { - histogram: getHistogramAggOptions({ - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - bucketSize, - distributionMax, - }), - }, - }, - }, - }); + async function getDistributionBuckets() { + const response = await withApmSpan( + 'get_latency_distribution_buckets', + () => + apmEventClient.search({ + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + body: { + query: { + bool: { + filter: [ + ...commonFilters, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), + ], + }, + }, + aggs: { + distribution: { + histogram: getHistogramAggOptions({ + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + bucketSize, + distributionMax, + }), + }, + }, + }, + }) + ); - return ( - response.aggregations?.distribution.buckets.map((bucket) => { - return { - key: bucket.key, - count: bucket.doc_count, - }; - }) ?? [] - ); - } + return ( + response.aggregations?.distribution.buckets.map((bucket) => { + return { + key: bucket.key, + count: bucket.doc_count, + }; + }) ?? [] + ); + } - const [ - samplesForDistributionBuckets, - distributionBuckets, - ] = await Promise.all([ - getSamplesForDistributionBuckets(), - getDistributionBuckets(), - ]); + const [ + samplesForDistributionBuckets, + distributionBuckets, + ] = await Promise.all([ + getSamplesForDistributionBuckets(), + getDistributionBuckets(), + ]); - const buckets = joinByKey( - [...samplesForDistributionBuckets, ...distributionBuckets], - 'key' - ).map((bucket) => ({ - ...bucket, - samples: bucket.samples ?? [], - count: bucket.count ?? 0, - })); + const buckets = joinByKey( + [...samplesForDistributionBuckets, ...distributionBuckets], + 'key' + ).map((bucket) => ({ + ...bucket, + samples: bucket.samples ?? [], + count: bucket.count ?? 0, + })); - return { - noHits: buckets.length === 0, - bucketSize, - buckets, - }; + return { + noHits: buckets.length === 0, + bucketSize, + buckets, + }; + } + ); } diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts index 5a7502aaf5932b..f8061ea9894698 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/get_distribution_max.ts @@ -15,63 +15,62 @@ import { getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getDistributionMax({ + environment, serviceName, transactionName, transactionType, setup, searchAggregatedTransactions, }: { + environment?: string; serviceName: string; transactionName: string; transactionType: string; setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { start, end, esFilter, apmEventClient } = setup; + return withApmSpan('get_latency_distribution_max', async () => { + const { start, end, esFilter, apmEventClient } = setup; - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ], - }, - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [SERVICE_NAME]: serviceName } }, - { term: { [TRANSACTION_TYPE]: transactionType } }, - { term: { [TRANSACTION_NAME]: transactionName } }, - { - range: { - '@timestamp': { - gte: start, - lte: end, - format: 'epoch_millis', - }, - }, - }, - ...esFilter, - ], - }, + const params = { + apm: { + events: [ + getProcessorEventForAggregatedTransactions( + searchAggregatedTransactions + ), + ], }, - aggs: { - stats: { - max: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [SERVICE_NAME]: serviceName } }, + { term: { [TRANSACTION_TYPE]: transactionType } }, + { term: { [TRANSACTION_NAME]: transactionName } }, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, + ], + }, + }, + aggs: { + stats: { + max: { + field: getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ), + }, }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); - return resp.aggregations?.stats.value ?? null; + const resp = await apmEventClient.search(params); + return resp.aggregations?.stats.value ?? null; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts index d1d2881b6cb5b5..92d1d96b4a8e3a 100644 --- a/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/distribution/index.ts @@ -10,6 +10,7 @@ import { getBuckets } from './get_buckets'; import { getDistributionMax } from './get_distribution_max'; import { roundToNearestFiveOrTen } from '../../helpers/round_to_nearest_five_or_ten'; import { MINIMUM_BUCKET_SIZE, BUCKET_TARGET_COUNT } from '../constants'; +import { withApmSpan } from '../../../utils/with_apm_span'; function getBucketSize(max: number) { const bucketSize = max / BUCKET_TARGET_COUNT; @@ -19,6 +20,7 @@ function getBucketSize(max: number) { } export async function getTransactionDistribution({ + environment, serviceName, transactionName, transactionType, @@ -27,6 +29,7 @@ export async function getTransactionDistribution({ setup, searchAggregatedTransactions, }: { + environment?: string; serviceName: string; transactionName: string; transactionType: string; @@ -35,35 +38,39 @@ export async function getTransactionDistribution({ setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const distributionMax = await getDistributionMax({ - serviceName, - transactionName, - transactionType, - setup, - searchAggregatedTransactions, - }); + return withApmSpan('get_transaction_latency_distribution', async () => { + const distributionMax = await getDistributionMax({ + environment, + serviceName, + transactionName, + transactionType, + setup, + searchAggregatedTransactions, + }); - if (distributionMax == null) { - return { noHits: true, buckets: [], bucketSize: 0 }; - } + if (distributionMax == null) { + return { noHits: true, buckets: [], bucketSize: 0 }; + } - const bucketSize = getBucketSize(distributionMax); + const bucketSize = getBucketSize(distributionMax); - const { buckets, noHits } = await getBuckets({ - serviceName, - transactionName, - transactionType, - transactionId, - traceId, - distributionMax, - bucketSize, - setup, - searchAggregatedTransactions, - }); + const { buckets, noHits } = await getBuckets({ + environment, + serviceName, + transactionName, + transactionType, + transactionId, + traceId, + distributionMax, + bucketSize, + setup, + searchAggregatedTransactions, + }); - return { - noHits, - buckets, - bucketSize, - }; + return { + noHits, + buckets, + bucketSize, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts index 7dd9ba63bd6f51..d566f3a169e78a 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/fetcher.ts @@ -7,6 +7,8 @@ import { ESSearchResponse } from '../../../../../../typings/elasticsearch'; import { PromiseReturnType } from '../../../../../observability/typings/common'; +import { rangeQuery } from '../../../../common/utils/queries'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { Setup } from '../../helpers/setup_request'; export type ESResponse = Exclude< @@ -14,7 +16,7 @@ export type ESResponse = Exclude< undefined >; -export async function anomalySeriesFetcher({ +export function anomalySeriesFetcher({ serviceName, transactionType, intervalString, @@ -29,63 +31,57 @@ export async function anomalySeriesFetcher({ start: number; end: number; }) { - const params = { - body: { - size: 0, - query: { - bool: { - filter: [ - { terms: { result_type: ['model_plot', 'record'] } }, - { term: { partition_field_value: serviceName } }, - { term: { by_field_value: transactionType } }, - { - range: { - timestamp: { - gte: start, - lte: end, - format: 'epoch_millis', - }, - }, - }, - ], - }, - }, - aggs: { - job_id: { - terms: { - field: 'job_id', + return withApmSpan('get_latency_anomaly_data', async () => { + const params = { + body: { + size: 0, + query: { + bool: { + filter: [ + { terms: { result_type: ['model_plot', 'record'] } }, + { term: { partition_field_value: serviceName } }, + { term: { by_field_value: transactionType } }, + ...rangeQuery(start, end, 'timestamp'), + ], }, - aggs: { - ml_avg_response_times: { - date_histogram: { - field: 'timestamp', - fixed_interval: intervalString, - extended_bounds: { min: start, max: end }, - }, - aggs: { - anomaly_score: { - top_metrics: { - metrics: [ - { field: 'record_score' }, - { field: 'timestamp' }, - { field: 'bucket_span' }, - ] as const, - sort: { - record_score: 'desc' as const, + }, + aggs: { + job_id: { + terms: { + field: 'job_id', + }, + aggs: { + ml_avg_response_times: { + date_histogram: { + field: 'timestamp', + fixed_interval: intervalString, + extended_bounds: { min: start, max: end }, + }, + aggs: { + anomaly_score: { + top_metrics: { + metrics: [ + { field: 'record_score' }, + { field: 'timestamp' }, + { field: 'bucket_span' }, + ] as const, + sort: { + record_score: 'desc' as const, + }, }, }, + lower: { min: { field: 'model_lower' } }, + upper: { max: { field: 'model_upper' } }, }, - lower: { min: { field: 'model_lower' } }, - upper: { max: { field: 'model_upper' } }, }, }, }, }, }, - }, - }; + }; - return (ml.mlSystem.mlAnomalySearch(params, []) as unknown) as Promise< - ESSearchResponse - >; + return (ml.mlSystem.mlAnomalySearch(params, []) as unknown) as Promise< + ESSearchResponse + >; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts index f3dae239d957d9..a03b1ac82e90a6 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts @@ -15,22 +15,24 @@ import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { anomalySeriesFetcher } from './fetcher'; import { getMLJobIds } from '../../service_map/get_service_anomalies'; import { ANOMALY_THRESHOLD } from '../../../../../ml/common'; +import { withApmSpan } from '../../../utils/with_apm_span'; export async function getAnomalySeries({ + environment, serviceName, transactionType, transactionName, setup, logger, }: { + environment?: string; serviceName: string; transactionType: string; transactionName?: string; setup: Setup & SetupTimeRange; logger: Logger; }) { - const { uiFilters, start, end, ml } = setup; - const { environment } = uiFilters; + const { start, end, ml } = setup; // don't fetch anomalies if the ML plugin is not setup if (!ml) { @@ -44,89 +46,91 @@ export async function getAnomalySeries({ } // don't fetch anomalies when no specific environment is selected - if (environment === ENVIRONMENT_ALL.value) { + if (!environment || environment === ENVIRONMENT_ALL.value) { return undefined; } - // don't fetch anomalies if unknown uiFilters are applied - const knownFilters = ['environment', 'serviceName']; - const hasUnknownFiltersApplied = Object.entries(setup.uiFilters) - .filter(([key, value]) => !!value) - .map(([key]) => key) - .some((uiFilterName) => !knownFilters.includes(uiFilterName)); + // Don't fetch anomalies if uiFilters are applied. This filters out anything + // with empty values so `kuery: ''` returns false but `kuery: 'x:y'` returns true. + const hasUiFiltersApplied = + Object.entries(setup.uiFilters).filter(([_key, value]) => !!value).length > + 0; - if (hasUnknownFiltersApplied) { + if (hasUiFiltersApplied) { return undefined; } - const { intervalString } = getBucketSize({ start, end }); - - // move the start back with one bucket size, to ensure to get anomaly data in the beginning - // this is required because ML has a minimum bucket size (default is 900s) so if our buckets - // are smaller, we might have several null buckets in the beginning - const mlStart = start - 900 * 1000; - - const [anomaliesResponse, jobIds] = await Promise.all([ - anomalySeriesFetcher({ - serviceName, - transactionType, - intervalString, - ml, - start: mlStart, - end, - }), - getMLJobIds(ml.anomalyDetectors, environment), - ]); - - const scoreSeriesCollection = anomaliesResponse?.aggregations?.job_id.buckets - .filter((bucket) => jobIds.includes(bucket.key as string)) - .map((bucket) => { - const dateBuckets = bucket.ml_avg_response_times.buckets; - - return { - jobId: bucket.key as string, - anomalyScore: compact( - dateBuckets.map((dateBucket) => { - const metrics = maybe(dateBucket.anomaly_score.top[0])?.metrics; - const score = metrics?.record_score; - - if ( - !metrics || - !isFiniteNumber(score) || - score < ANOMALY_THRESHOLD.CRITICAL - ) { - return null; - } - - const anomalyStart = Date.parse(metrics.timestamp as string); - const anomalyEnd = - anomalyStart + (metrics.bucket_span as number) * 1000; - - return { - x0: anomalyStart, - x: anomalyEnd, - y: score, - }; - }) - ), - anomalyBoundaries: dateBuckets - .filter( - (dateBucket) => - dateBucket.lower.value !== null && dateBucket.upper.value !== null - ) - .map((dateBucket) => ({ - x: dateBucket.key, - y0: dateBucket.lower.value as number, - y: dateBucket.upper.value as number, - })), - }; - }); - - if ((scoreSeriesCollection?.length ?? 0) > 1) { - logger.warn( - `More than one ML job was found for ${serviceName} for environment ${environment}. Only showing results from ${scoreSeriesCollection?.[0].jobId}` - ); - } - - return scoreSeriesCollection?.[0]; + return withApmSpan('get_latency_anomaly_series', async () => { + const { intervalString } = getBucketSize({ start, end }); + + // move the start back with one bucket size, to ensure to get anomaly data in the beginning + // this is required because ML has a minimum bucket size (default is 900s) so if our buckets + // are smaller, we might have several null buckets in the beginning + const mlStart = start - 900 * 1000; + + const [anomaliesResponse, jobIds] = await Promise.all([ + anomalySeriesFetcher({ + serviceName, + transactionType, + intervalString, + ml, + start: mlStart, + end, + }), + getMLJobIds(ml.anomalyDetectors, environment), + ]); + + const scoreSeriesCollection = anomaliesResponse?.aggregations?.job_id.buckets + .filter((bucket) => jobIds.includes(bucket.key as string)) + .map((bucket) => { + const dateBuckets = bucket.ml_avg_response_times.buckets; + + return { + jobId: bucket.key as string, + anomalyScore: compact( + dateBuckets.map((dateBucket) => { + const metrics = maybe(dateBucket.anomaly_score.top[0])?.metrics; + const score = metrics?.record_score; + + if ( + !metrics || + !isFiniteNumber(score) || + score < ANOMALY_THRESHOLD.CRITICAL + ) { + return null; + } + + const anomalyStart = Date.parse(metrics.timestamp as string); + const anomalyEnd = + anomalyStart + (metrics.bucket_span as number) * 1000; + + return { + x0: anomalyStart, + x: anomalyEnd, + y: score, + }; + }) + ), + anomalyBoundaries: dateBuckets + .filter( + (dateBucket) => + dateBucket.lower.value !== null && + dateBucket.upper.value !== null + ) + .map((dateBucket) => ({ + x: dateBucket.key, + y0: dateBucket.lower.value as number, + y: dateBucket.upper.value as number, + })), + }; + }); + + if ((scoreSeriesCollection?.length ?? 0) > 1) { + logger.warn( + `More than one ML job was found for ${serviceName} for environment ${environment}. Only showing results from ${scoreSeriesCollection?.[0].jobId}` + ); + } + + return scoreSeriesCollection?.[0]; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts index f5dd645eacdbe1..e1d3921d298c77 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts @@ -13,7 +13,7 @@ import { TRANSACTION_TYPE, } from '../../../../common/elasticsearch_fieldnames'; import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, @@ -21,6 +21,7 @@ import { } from '../../../lib/helpers/aggregated_transactions'; import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getLatencyAggregation, getLatencyValue, @@ -29,7 +30,8 @@ export type LatencyChartsSearchResponse = PromiseReturnType< typeof searchLatency >; -async function searchLatency({ +function searchLatency({ + environment, serviceName, transactionType, transactionName, @@ -37,6 +39,7 @@ async function searchLatency({ searchAggregatedTransactions, latencyAggregationType, }: { + environment?: string; serviceName: string; transactionType: string | undefined; transactionName: string | undefined; @@ -44,16 +47,17 @@ async function searchLatency({ searchAggregatedTransactions: boolean; latencyAggregationType: LatencyAggregationType; }) { - const { start, end, apmEventClient } = setup; + const { esFilter, start, end, apmEventClient } = setup; const { intervalString } = getBucketSize({ start, end }); const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, ...getDocumentTypeFilterForAggregatedTransactions( searchAggregatedTransactions ), - ...setup.esFilter, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, ]; if (transactionName) { @@ -100,7 +104,8 @@ async function searchLatency({ return apmEventClient.search(params); } -export async function getLatencyTimeseries({ +export function getLatencyTimeseries({ + environment, serviceName, transactionType, transactionName, @@ -108,6 +113,7 @@ export async function getLatencyTimeseries({ searchAggregatedTransactions, latencyAggregationType, }: { + environment?: string; serviceName: string; transactionType: string | undefined; transactionName: string | undefined; @@ -115,32 +121,35 @@ export async function getLatencyTimeseries({ searchAggregatedTransactions: boolean; latencyAggregationType: LatencyAggregationType; }) { - const response = await searchLatency({ - serviceName, - transactionType, - transactionName, - setup, - searchAggregatedTransactions, - latencyAggregationType, - }); + return withApmSpan('get_latency_charts', async () => { + const response = await searchLatency({ + environment, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + latencyAggregationType, + }); - if (!response.aggregations) { - return { latencyTimeseries: [], overallAvgDuration: null }; - } + if (!response.aggregations) { + return { latencyTimeseries: [], overallAvgDuration: null }; + } - return { - overallAvgDuration: - response.aggregations.overall_avg_duration.value || null, - latencyTimeseries: response.aggregations.latencyTimeseries.buckets.map( - (bucket) => { - return { - x: bucket.key, - y: getLatencyValue({ - latencyAggregationType, - aggregation: bucket.latency, - }), - }; - } - ), - }; + return { + overallAvgDuration: + response.aggregations.overall_avg_duration.value || null, + latencyTimeseries: response.aggregations.latencyTimeseries.buckets.map( + (bucket) => { + return { + x: bucket.key, + y: getLatencyValue({ + latencyAggregationType, + aggregation: bucket.latency, + }), + }; + } + ), + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts index 71d9c21c677820..ec5dbf0eab3e9e 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts @@ -13,20 +13,22 @@ import { TRANSACTION_RESULT, TRANSACTION_TYPE, } from '../../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../../../common/utils/queries'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, } from '../../../lib/helpers/aggregated_transactions'; import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; +import { withApmSpan } from '../../../utils/with_apm_span'; import { getThroughputBuckets } from './transform'; export type ThroughputChartsResponse = PromiseReturnType< typeof searchThroughput >; -async function searchThroughput({ +function searchThroughput({ + environment, serviceName, transactionType, transactionName, @@ -34,6 +36,7 @@ async function searchThroughput({ searchAggregatedTransactions, intervalString, }: { + environment?: string; serviceName: string; transactionType: string; transactionName: string | undefined; @@ -41,16 +44,17 @@ async function searchThroughput({ searchAggregatedTransactions: boolean; intervalString: string; }) { - const { start, end, apmEventClient } = setup; + const { esFilter, start, end, apmEventClient } = setup; const filter: ESFilter[] = [ { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, + { term: { [TRANSACTION_TYPE]: transactionType } }, ...getDocumentTypeFilterForAggregatedTransactions( searchAggregatedTransactions ), - { term: { [TRANSACTION_TYPE]: transactionType } }, - ...setup.esFilter, + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, ]; if (transactionName) { @@ -90,34 +94,39 @@ async function searchThroughput({ } export async function getThroughputCharts({ + environment, serviceName, transactionType, transactionName, setup, searchAggregatedTransactions, }: { + environment?: string; serviceName: string; transactionType: string; transactionName: string | undefined; setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; }) { - const { bucketSize, intervalString } = getBucketSize(setup); + return withApmSpan('get_transaction_throughput_series', async () => { + const { bucketSize, intervalString } = getBucketSize(setup); - const response = await searchThroughput({ - serviceName, - transactionType, - transactionName, - setup, - searchAggregatedTransactions, - intervalString, - }); + const response = await searchThroughput({ + environment, + serviceName, + transactionType, + transactionName, + setup, + searchAggregatedTransactions, + intervalString, + }); - return { - throughputTimeseries: getThroughputBuckets({ - throughputResultBuckets: response.aggregations?.throughput.buckets, - bucketSize, - setupTimeRange: setup, - }), - }; + return { + throughputTimeseries: getThroughputBuckets({ + throughputResultBuckets: response.aggregations?.throughput.buckets, + bucketSize, + setupTimeRange: setup, + }), + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts index 119abd06bc7835..38d6b593dc72dc 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_transaction/index.ts @@ -9,11 +9,12 @@ import { TRACE_ID, TRANSACTION_ID, } from '../../../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../../../common/utils/range_filter'; +import { rangeQuery } from '../../../../common/utils/queries'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getTransaction({ +export function getTransaction({ transactionId, traceId, setup, @@ -22,25 +23,27 @@ export async function getTransaction({ traceId: string; setup: Setup & SetupTimeRange; }) { - const { start, end, apmEventClient } = setup; + return withApmSpan('get_transaction', async () => { + const { start, end, apmEventClient } = setup; - const resp = await apmEventClient.search({ - apm: { - events: [ProcessorEvent.transaction], - }, - body: { - size: 1, - query: { - bool: { - filter: [ - { term: { [TRANSACTION_ID]: transactionId } }, - { term: { [TRACE_ID]: traceId } }, - { range: rangeFilter(start, end) }, - ], + const resp = await apmEventClient.search({ + apm: { + events: [ProcessorEvent.transaction], + }, + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { [TRANSACTION_ID]: transactionId } }, + { term: { [TRACE_ID]: traceId } }, + ...rangeQuery(start, end), + ], + }, }, }, - }, - }); + }); - return resp.hits.hits[0]?._source; + return resp.hits.hits[0]?._source; + }); } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_transaction_by_trace/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_transaction_by_trace/index.ts index 4024c339b5a23b..dfdad2f59a848f 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_transaction_by_trace/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_transaction_by_trace/index.ts @@ -11,40 +11,40 @@ import { } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../helpers/setup_request'; import { ProcessorEvent } from '../../../../common/processor_event'; +import { withApmSpan } from '../../../utils/with_apm_span'; -export async function getRootTransactionByTraceId( - traceId: string, - setup: Setup -) { - const { apmEventClient } = setup; +export function getRootTransactionByTraceId(traceId: string, setup: Setup) { + return withApmSpan('get_root_transaction_by_trace_id', async () => { + const { apmEventClient } = setup; - const params = { - apm: { - events: [ProcessorEvent.transaction as const], - }, - body: { - size: 1, - query: { - bool: { - should: [ - { - constant_score: { - filter: { - bool: { - must_not: { exists: { field: PARENT_ID } }, + const params = { + apm: { + events: [ProcessorEvent.transaction as const], + }, + body: { + size: 1, + query: { + bool: { + should: [ + { + constant_score: { + filter: { + bool: { + must_not: { exists: { field: PARENT_ID } }, + }, }, }, }, - }, - ], - filter: [{ term: { [TRACE_ID]: traceId } }], + ], + filter: [{ term: { [TRACE_ID]: traceId } }], + }, }, }, - }, - }; + }; - const resp = await apmEventClient.search(params); - return { - transaction: resp.hits.hits[0]?._source, - }; + const resp = await apmEventClient.search(params); + return { + transaction: resp.hits.hits[0]?._source, + }; + }); } diff --git a/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts b/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts deleted file mode 100644 index 1be16698a1a1f9..00000000000000 --- a/x-pack/plugins/apm/server/lib/ui_filters/get_environments.ts +++ /dev/null @@ -1,78 +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 { ESFilter } from '../../../../../typings/elasticsearch'; -import { - SERVICE_ENVIRONMENT, - SERVICE_NAME, -} from '../../../common/elasticsearch_fieldnames'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; -import { ProcessorEvent } from '../../../common/processor_event'; -import { rangeFilter } from '../../../common/utils/range_filter'; -import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; -import { Setup, SetupTimeRange } from '../helpers/setup_request'; - -export async function getEnvironments({ - setup, - serviceName, - searchAggregatedTransactions, -}: { - setup: Setup & SetupTimeRange; - serviceName?: string; - searchAggregatedTransactions: boolean; -}) { - const { start, end, apmEventClient, config } = setup; - - const filter: ESFilter[] = [{ range: rangeFilter(start, end) }]; - - if (serviceName) { - filter.push({ - term: { [SERVICE_NAME]: serviceName }, - }); - } - - const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments']; - - const params = { - apm: { - events: [ - getProcessorEventForAggregatedTransactions( - searchAggregatedTransactions - ), - ProcessorEvent.metric, - ProcessorEvent.error, - ], - }, - body: { - size: 0, - query: { - bool: { - filter, - }, - }, - aggs: { - environments: { - terms: { - field: SERVICE_ENVIRONMENT, - missing: ENVIRONMENT_NOT_DEFINED.value, - size: maxServiceEnvironments, - }, - }, - }, - }, - }; - - const resp = await apmEventClient.search(params); - const aggs = resp.aggregations; - const environmentsBuckets = aggs?.environments.buckets || []; - - const environments = environmentsBuckets.map( - (environmentBucket) => environmentBucket.key as string - ); - - return environments; -} diff --git a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts b/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts deleted file mode 100644 index 12bfc7e23bf4cc..00000000000000 --- a/x-pack/plugins/apm/server/lib/ui_filters/local_ui_filters/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { cloneDeep, orderBy } from 'lodash'; -import { UIFilters } from '../../../../typings/ui_filters'; -import { Projection } from '../../../projections/typings'; -import { PromiseReturnType } from '../../../../../observability/typings/common'; -import { getLocalFilterQuery } from './get_local_filter_query'; -import { Setup } from '../../helpers/setup_request'; -import { localUIFilters } from './config'; -import { LocalUIFilterName } from '../../../../common/ui_filter'; - -export type LocalUIFiltersAPIResponse = PromiseReturnType< - typeof getLocalUIFilters ->; - -export async function getLocalUIFilters({ - setup, - projection, - uiFilters, - localFilterNames, -}: { - setup: Setup; - projection: Projection; - uiFilters: UIFilters; - localFilterNames: LocalUIFilterName[]; -}) { - const { apmEventClient } = setup; - - const projectionWithoutAggs = cloneDeep(projection); - - delete projectionWithoutAggs.body.aggs; - - return Promise.all( - localFilterNames.map(async (name) => { - const query = getLocalFilterQuery({ - uiFilters, - projection, - localUIFilterName: name, - }); - - const response = await apmEventClient.search(query); - - const filter = localUIFilters[name]; - - const buckets = response?.aggregations?.by_terms?.buckets ?? []; - - return { - ...filter, - options: orderBy( - buckets.map((bucket) => { - return { - name: bucket.key as string, - count: bucket.bucket_count - ? bucket.bucket_count.value - : bucket.doc_count, - }; - }), - 'count', - 'desc' - ), - }; - }) - ); -} diff --git a/x-pack/plugins/apm/server/projections/errors.ts b/x-pack/plugins/apm/server/projections/errors.ts index 082fd53a0ca93d..342d78608efbf6 100644 --- a/x-pack/plugins/apm/server/projections/errors.ts +++ b/x-pack/plugins/apm/server/projections/errors.ts @@ -10,13 +10,15 @@ import { SERVICE_NAME, ERROR_GROUP_ID, } from '../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../common/utils/queries'; import { ProcessorEvent } from '../../common/processor_event'; export function getErrorGroupsProjection({ + environment, setup, serviceName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; }) { @@ -31,7 +33,8 @@ export function getErrorGroupsProjection({ bool: { filter: [ { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, + ...rangeQuery(start, end), + ...environmentQuery(environment), ...esFilter, ], }, diff --git a/x-pack/plugins/apm/server/projections/metrics.ts b/x-pack/plugins/apm/server/projections/metrics.ts index f6c3f85ed48075..a32c2ae46c870b 100644 --- a/x-pack/plugins/apm/server/projections/metrics.ts +++ b/x-pack/plugins/apm/server/projections/metrics.ts @@ -10,7 +10,7 @@ import { SERVICE_NAME, SERVICE_NODE_NAME, } from '../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../common/utils/queries'; import { SERVICE_NODE_NAME_MISSING } from '../../common/service_nodes'; import { ProcessorEvent } from '../../common/processor_event'; @@ -27,10 +27,12 @@ function getServiceNodeNameFilters(serviceNodeName?: string) { } export function getMetricsProjection({ + environment, setup, serviceName, serviceNodeName, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName: string; serviceNodeName?: string; @@ -39,8 +41,9 @@ export function getMetricsProjection({ const filter = [ { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, ...getServiceNodeNameFilters(serviceNodeName), + ...rangeQuery(start, end), + ...environmentQuery(environment), ...esFilter, ]; diff --git a/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts b/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts index ff8d868bc4abe5..1d5f7316b69ad6 100644 --- a/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts +++ b/x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts @@ -11,7 +11,7 @@ import { TRANSACTION_TYPE, SERVICE_LANGUAGE_NAME, } from '../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../common/utils/range_filter'; +import { rangeQuery } from '../../common/utils/queries'; import { ProcessorEvent } from '../../common/processor_event'; import { TRANSACTION_PAGE_LOAD } from '../../common/transaction_types'; @@ -28,7 +28,7 @@ export function getRumPageLoadTransactionsProjection({ const bool = { filter: [ - { range: rangeFilter(start, end) }, + ...rangeQuery(start, end), { term: { [TRANSACTION_TYPE]: TRANSACTION_PAGE_LOAD } }, ...(checkFetchStartFieldExists ? [ @@ -79,7 +79,7 @@ export function getRumErrorsProjection({ const bool = { filter: [ - { range: rangeFilter(start, end) }, + ...rangeQuery(start, end), { term: { [AGENT_NAME]: 'rum-js' } }, { term: { diff --git a/x-pack/plugins/apm/server/projections/services.ts b/x-pack/plugins/apm/server/projections/services.ts index 33ffb45a006378..a9f5a7efd0e67e 100644 --- a/x-pack/plugins/apm/server/projections/services.ts +++ b/x-pack/plugins/apm/server/projections/services.ts @@ -7,7 +7,7 @@ import { Setup, SetupTimeRange } from '../../server/lib/helpers/setup_request'; import { SERVICE_NAME } from '../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../common/utils/range_filter'; +import { rangeQuery } from '../../common/utils/queries'; import { ProcessorEvent } from '../../common/processor_event'; import { getProcessorEventForAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; @@ -34,7 +34,7 @@ export function getServicesProjection({ size: 0, query: { bool: { - filter: [{ range: rangeFilter(start, end) }, ...esFilter], + filter: [...rangeQuery(start, end), ...esFilter], }, }, aggs: { diff --git a/x-pack/plugins/apm/server/projections/transactions.ts b/x-pack/plugins/apm/server/projections/transactions.ts index 76f2fc164e3fd2..45ed5d2865a67e 100644 --- a/x-pack/plugins/apm/server/projections/transactions.ts +++ b/x-pack/plugins/apm/server/projections/transactions.ts @@ -11,19 +11,21 @@ import { TRANSACTION_TYPE, TRANSACTION_NAME, } from '../../common/elasticsearch_fieldnames'; -import { rangeFilter } from '../../common/utils/range_filter'; +import { environmentQuery, rangeQuery } from '../../common/utils/queries'; import { getProcessorEventForAggregatedTransactions, getDocumentTypeFilterForAggregatedTransactions, } from '../lib/helpers/aggregated_transactions'; export function getTransactionsProjection({ + environment, setup, serviceName, transactionName, transactionType, searchAggregatedTransactions, }: { + environment?: string; setup: Setup & SetupTimeRange; serviceName?: string; transactionName?: string; @@ -44,14 +46,15 @@ export function getTransactionsProjection({ const bool = { filter: [ - { range: rangeFilter(start, end) }, + ...serviceNameFilter, ...transactionNameFilter, ...transactionTypeFilter, - ...serviceNameFilter, - ...esFilter, ...getDocumentTypeFilterForAggregatedTransactions( searchAggregatedTransactions ), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...esFilter, ], }; diff --git a/x-pack/plugins/apm/server/routes/correlations.ts b/x-pack/plugins/apm/server/routes/correlations.ts index 3c2ff00153ce79..d4a0db3c0d6c7b 100644 --- a/x-pack/plugins/apm/server/routes/correlations.ts +++ b/x-pack/plugins/apm/server/routes/correlations.ts @@ -13,7 +13,7 @@ import { getCorrelationsForFailedTransactions } from '../lib/correlations/get_co import { getCorrelationsForSlowTransactions } from '../lib/correlations/get_correlations_for_slow_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { createRoute } from './create_route'; -import { rangeRt } from './default_api_types'; +import { environmentRt, rangeRt } from './default_api_types'; const INVALID_LICENSE = i18n.translate( 'xpack.apm.significanTerms.license.text', @@ -37,6 +37,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ fieldNames: t.string, }), t.partial({ uiFilters: t.string }), + environmentRt, rangeRt, ]), }), @@ -47,6 +48,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ } const setup = await setupRequest(context, request); const { + environment, serviceName, transactionType, transactionName, @@ -55,6 +57,7 @@ export const correlationsForSlowTransactionsRoute = createRoute({ } = context.params.query; return getCorrelationsForSlowTransactions({ + environment, serviceName, transactionType, transactionName, @@ -78,6 +81,7 @@ export const correlationsForFailedTransactionsRoute = createRoute({ fieldNames: t.string, }), t.partial({ uiFilters: t.string }), + environmentRt, rangeRt, ]), }), @@ -88,14 +92,15 @@ export const correlationsForFailedTransactionsRoute = createRoute({ } const setup = await setupRequest(context, request); const { + environment, serviceName, transactionType, transactionName, - fieldNames, } = context.params.query; return getCorrelationsForFailedTransactions({ + environment, serviceName, transactionType, transactionName, diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index 15bfd9d2d9822f..46f2628cc73d5b 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -13,6 +13,7 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { isLeft } from 'fp-ts/lib/Either'; import { KibanaResponseFactory, RouteRegistrar } from 'src/core/server'; import { RequestAbortedError } from '@elastic/elasticsearch/lib/errors'; +import agent from 'elastic-apm-node'; import { merge } from '../../../common/runtime_types/merge'; import { strictKeysRt } from '../../../common/runtime_types/strict_keys_rt'; import { APMConfig } from '../..'; @@ -95,6 +96,12 @@ export function createApi() { }, }, async (context, request, response) => { + if (agent.isStarted()) { + agent.addLabels({ + plugin: 'apm', + }); + } + try { const paramMap = pickBy( { 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 5d580fc0e253a5..822a45fca269fd 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -11,6 +11,7 @@ import { apmIndexPatternTitleRoute, } from './index_pattern'; import { createApi } from './create_api'; +import { environmentsRoute } from './environments'; import { errorDistributionRoute, errorGroupsRoute, @@ -61,14 +62,11 @@ import { transactionChartsDistributionRoute, transactionChartsErrorRateRoute, transactionGroupsRoute, - transactionGroupsOverviewRoute, - transactionLatencyChatsRoute, - transactionThroughputChatsRoute, + transactionGroupsPrimaryStatisticsRoute, + transactionLatencyChartsRoute, + transactionThroughputChartsRoute, + transactionGroupsComparisonStatisticsRoute, } from './transactions'; -import { - rumOverviewLocalFiltersRoute, - uiFiltersEnvironmentsRoute, -} from './ui_filters'; import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map'; import { createCustomLinkRoute, @@ -91,6 +89,7 @@ import { rumClientMetricsRoute, rumJSErrors, rumLongTaskMetrics, + rumOverviewLocalFiltersRoute, rumPageLoadDistBreakdownRoute, rumPageLoadDistributionRoute, rumPageViewsTrendRoute, @@ -112,6 +111,9 @@ const createApmApi = () => { .add(dynamicIndexPatternRoute) .add(apmIndexPatternTitleRoute) + // Environments + .add(environmentsRoute) + // Errors .add(errorDistributionRoute) .add(errorGroupsRoute) @@ -164,12 +166,10 @@ const createApmApi = () => { .add(transactionChartsDistributionRoute) .add(transactionChartsErrorRateRoute) .add(transactionGroupsRoute) - .add(transactionGroupsOverviewRoute) - .add(transactionLatencyChatsRoute) - .add(transactionThroughputChatsRoute) - - // UI filters - .add(uiFiltersEnvironmentsRoute) + .add(transactionGroupsPrimaryStatisticsRoute) + .add(transactionLatencyChartsRoute) + .add(transactionThroughputChartsRoute) + .add(transactionGroupsComparisonStatisticsRoute) // Service map .add(serviceMapRoute) diff --git a/x-pack/plugins/apm/server/routes/default_api_types.ts b/x-pack/plugins/apm/server/routes/default_api_types.ts index 0ab4e0331652b3..990b462a520d23 100644 --- a/x-pack/plugins/apm/server/routes/default_api_types.ts +++ b/x-pack/plugins/apm/server/routes/default_api_types.ts @@ -6,11 +6,18 @@ */ import * as t from 'io-ts'; -import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; +import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; export const rangeRt = t.type({ - start: dateAsStringRt, - end: dateAsStringRt, + start: isoToEpochRt, + end: isoToEpochRt, }); +export const comparisonRangeRt = t.partial({ + comparisonStart: isoToEpochRt, + comparisonEnd: isoToEpochRt, +}); + +export const environmentRt = t.partial({ environment: t.string }); + export const uiFiltersRt = t.type({ uiFilters: t.string }); diff --git a/x-pack/plugins/apm/server/routes/environments.ts b/x-pack/plugins/apm/server/routes/environments.ts new file mode 100644 index 00000000000000..448591f7e143ff --- /dev/null +++ b/x-pack/plugins/apm/server/routes/environments.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as t from 'io-ts'; +import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; +import { setupRequest } from '../lib/helpers/setup_request'; +import { getEnvironments } from '../lib/environments/get_environments'; +import { createRoute } from './create_route'; +import { rangeRt } from './default_api_types'; + +export const environmentsRoute = createRoute({ + endpoint: 'GET /api/apm/environments', + params: t.type({ + query: t.intersection([ + t.partial({ + serviceName: t.string, + }), + rangeRt, + ]), + }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.query; + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + + return getEnvironments({ + setup, + serviceName, + searchAggregatedTransactions, + }); + }, +}); diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index cc9db2e6a48551..073a91bfe15487 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -11,7 +11,7 @@ import { getErrorDistribution } from '../lib/errors/distribution/get_distributio import { getErrorGroupSample } from '../lib/errors/get_error_group_sample'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; -import { uiFiltersRt, rangeRt } from './default_api_types'; +import { environmentRt, uiFiltersRt, rangeRt } from './default_api_types'; export const errorsRoute = createRoute({ endpoint: 'GET /api/apm/services/{serviceName}/errors', @@ -24,6 +24,7 @@ export const errorsRoute = createRoute({ sortField: t.string, sortDirection: t.union([t.literal('asc'), t.literal('desc')]), }), + environmentRt, uiFiltersRt, rangeRt, ]), @@ -33,9 +34,10 @@ export const errorsRoute = createRoute({ const setup = await setupRequest(context, request); const { params } = context; const { serviceName } = params.path; - const { sortField, sortDirection } = params.query; + const { environment, sortField, sortDirection } = params.query; return getErrorGroups({ + environment, serviceName, sortField, sortDirection, @@ -51,13 +53,15 @@ export const errorGroupsRoute = createRoute({ serviceName: t.string, groupId: t.string, }), - query: t.intersection([uiFiltersRt, rangeRt]), + query: t.intersection([environmentRt, uiFiltersRt, rangeRt]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName, groupId } = context.params.path; - return getErrorGroupSample({ serviceName, groupId, setup }); + const { environment } = context.params.query; + + return getErrorGroupSample({ environment, serviceName, groupId, setup }); }, }); @@ -71,6 +75,7 @@ export const errorDistributionRoute = createRoute({ t.partial({ groupId: t.string, }), + environmentRt, uiFiltersRt, rangeRt, ]), @@ -80,7 +85,7 @@ export const errorDistributionRoute = createRoute({ const setup = await setupRequest(context, request); const { params } = context; const { serviceName } = params.path; - const { groupId } = params.query; - return getErrorDistribution({ serviceName, groupId, setup }); + const { environment, groupId } = params.query; + return getErrorDistribution({ environment, serviceName, groupId, setup }); }, }); diff --git a/x-pack/plugins/apm/server/routes/index_pattern.ts b/x-pack/plugins/apm/server/routes/index_pattern.ts index 7f2b45072454fe..ed1354a2191645 100644 --- a/x-pack/plugins/apm/server/routes/index_pattern.ts +++ b/x-pack/plugins/apm/server/routes/index_pattern.ts @@ -16,8 +16,10 @@ export const staticIndexPatternRoute = createRoute((core) => ({ endpoint: 'POST /api/apm/index_pattern/static', options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const savedObjectsClient = await getInternalSavedObjectsClient(core); + const [setup, savedObjectsClient] = await Promise.all([ + setupRequest(context, request), + getInternalSavedObjectsClient(core), + ]); await createStaticIndexPattern(setup, context, savedObjectsClient); diff --git a/x-pack/plugins/apm/server/routes/metrics.ts b/x-pack/plugins/apm/server/routes/metrics.ts index d07504a1046ee5..08376ed0e37ffb 100644 --- a/x-pack/plugins/apm/server/routes/metrics.ts +++ b/x-pack/plugins/apm/server/routes/metrics.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; import { getMetricsChartDataByAgent } from '../lib/metrics/get_metrics_chart_data_by_agent'; import { createRoute } from './create_route'; -import { uiFiltersRt, rangeRt } from './default_api_types'; +import { environmentRt, uiFiltersRt, rangeRt } from './default_api_types'; export const metricsChartsRoute = createRoute({ endpoint: `GET /api/apm/services/{serviceName}/metrics/charts`, @@ -24,6 +24,7 @@ export const metricsChartsRoute = createRoute({ t.partial({ serviceNodeName: t.string, }), + environmentRt, uiFiltersRt, rangeRt, ]), @@ -33,8 +34,9 @@ export const metricsChartsRoute = createRoute({ const setup = await setupRequest(context, request); const { params } = context; const { serviceName } = params.path; - const { agentName, serviceNodeName } = params.query; + const { agentName, environment, serviceNodeName } = params.query; return await getMetricsChartDataByAgent({ + environment, setup, serviceName, agentName, diff --git a/x-pack/plugins/apm/server/routes/observability_overview.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts index 149f899fdfb5f4..1a1fa799639bc1 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -13,6 +13,7 @@ import { hasData } from '../lib/observability_overview/has_data'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; +import { withApmSpan } from '../utils/with_apm_span'; export const observabilityOverviewHasDataRoute = createRoute({ endpoint: 'GET /api/apm/observability_overview/has_data', @@ -36,20 +37,19 @@ export const observabilityOverviewRoute = createRoute({ setup ); - const serviceCountPromise = getServiceCount({ - setup, - searchAggregatedTransactions, + return withApmSpan('observability_overview', async () => { + const [serviceCount, transactionCoordinates] = await Promise.all([ + getServiceCount({ + setup, + searchAggregatedTransactions, + }), + getTransactionCoordinates({ + setup, + bucketSize, + searchAggregatedTransactions, + }), + ]); + return { serviceCount, transactionCoordinates }; }); - const transactionCoordinatesPromise = getTransactionCoordinates({ - setup, - bucketSize, - searchAggregatedTransactions, - }); - - const [serviceCount, transactionCoordinates] = await Promise.all([ - serviceCountPromise, - transactionCoordinatesPromise, - ]); - return { serviceCount, transactionCoordinates }; }, }); diff --git a/x-pack/plugins/apm/server/routes/rum_client.ts b/x-pack/plugins/apm/server/routes/rum_client.ts index 69e169e96af78a..c9fa4253bb58e2 100644 --- a/x-pack/plugins/apm/server/routes/rum_client.ts +++ b/x-pack/plugins/apm/server/routes/rum_client.ts @@ -6,20 +6,33 @@ */ import * as t from 'io-ts'; -import { createRoute } from './create_route'; -import { setupRequest } from '../lib/helpers/setup_request'; +import { omit } from 'lodash'; +import { jsonRt } from '../../common/runtime_types/json_rt'; +import { LocalUIFilterName } from '../../common/ui_filter'; +import { getEsFilter } from '../lib/helpers/convert_ui_filters/get_es_filter'; +import { + Setup, + setupRequest, + SetupTimeRange, +} from '../lib/helpers/setup_request'; import { getClientMetrics } from '../lib/rum_client/get_client_metrics'; -import { rangeRt, uiFiltersRt } from './default_api_types'; -import { getPageViewTrends } from '../lib/rum_client/get_page_view_trends'; +import { getJSErrors } from '../lib/rum_client/get_js_errors'; +import { getLongTaskMetrics } from '../lib/rum_client/get_long_task_metrics'; import { getPageLoadDistribution } from '../lib/rum_client/get_page_load_distribution'; +import { getPageViewTrends } from '../lib/rum_client/get_page_view_trends'; import { getPageLoadDistBreakdown } from '../lib/rum_client/get_pl_dist_breakdown'; import { getRumServices } from '../lib/rum_client/get_rum_services'; +import { getUrlSearch } from '../lib/rum_client/get_url_search'; import { getVisitorBreakdown } from '../lib/rum_client/get_visitor_breakdown'; import { getWebCoreVitals } from '../lib/rum_client/get_web_core_vitals'; -import { getJSErrors } from '../lib/rum_client/get_js_errors'; -import { getLongTaskMetrics } from '../lib/rum_client/get_long_task_metrics'; -import { getUrlSearch } from '../lib/rum_client/get_url_search'; import { hasRumData } from '../lib/rum_client/has_rum_data'; +import { getLocalUIFilters } from '../lib/rum_client/ui_filters/local_ui_filters'; +import { localUIFilterNames } from '../lib/rum_client/ui_filters/local_ui_filters/config'; +import { getRumPageLoadTransactionsProjection } from '../projections/rum_page_load_transactions'; +import { Projection } from '../projections/typings'; +import { createRoute } from './create_route'; +import { rangeRt, uiFiltersRt } from './default_api_types'; +import { APMRequestHandlerContext } from './typings'; export const percentileRangeRt = t.partial({ minPercentile: t.string, @@ -253,3 +266,96 @@ export const rumHasDataRoute = createRoute({ return await hasRumData({ setup }); }, }); + +// Everything below here was originally in ui_filters.ts but now is here, since +// UX is the only part of APM using UI filters now. + +const filterNamesRt = t.type({ + filterNames: jsonRt.pipe( + t.array( + t.keyof( + Object.fromEntries( + localUIFilterNames.map((filterName) => [filterName, null]) + ) as Record + ) + ) + ), +}); + +const localUiBaseQueryRt = t.intersection([ + filterNamesRt, + uiFiltersRt, + rangeRt, +]); + +function createLocalFiltersRoute< + TEndpoint extends string, + TProjection extends Projection, + TQueryRT extends t.HasProps +>({ + endpoint, + getProjection, + queryRt, +}: { + endpoint: TEndpoint; + getProjection: GetProjection< + TProjection, + t.IntersectionC<[TQueryRT, BaseQueryType]> + >; + queryRt: TQueryRT; +}) { + return createRoute({ + endpoint, + params: t.type({ + query: t.intersection([localUiBaseQueryRt, queryRt]), + }), + options: { tags: ['access:apm'] }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { uiFilters } = setup; + const { query } = context.params; + + const { filterNames } = query; + const projection = await getProjection({ + query, + context, + setup: { + ...setup, + esFilter: getEsFilter(omit(uiFilters, filterNames)), + }, + }); + + return getLocalUIFilters({ + projection, + setup, + uiFilters, + localFilterNames: filterNames, + }); + }, + }); +} + +export const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ + endpoint: 'GET /api/apm/rum/local_filters', + getProjection: async ({ setup }) => { + return getRumPageLoadTransactionsProjection({ + setup, + }); + }, + queryRt: t.type({}), +}); + +type BaseQueryType = typeof localUiBaseQueryRt; + +type GetProjection< + TProjection extends Projection, + TQueryRT extends t.HasProps +> = ({ + query, + setup, + context, +}: { + query: t.TypeOf; + setup: Setup & SetupTimeRange; + context: APMRequestHandlerContext; +}) => Promise | TProjection; diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 7cca6cd0a19430..65c7b245958f32 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -12,7 +12,7 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; -import { rangeRt, uiFiltersRt } from './default_api_types'; +import { environmentRt, rangeRt, uiFiltersRt } from './default_api_types'; import { notifyFeatureUsage } from '../feature'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { isActivePlatinumLicense } from '../../common/license_check'; @@ -22,9 +22,9 @@ export const serviceMapRoute = createRoute({ params: t.type({ query: t.intersection([ t.partial({ - environment: t.string, serviceName: t.string, }), + environmentRt, rangeRt, ]), }), diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 02cae86f6992ef..e59b438305b349 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,41 +5,49 @@ * 2.0. */ -import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import * as t from 'io-ts'; import { uniq } from 'lodash'; +import { isoToEpochRt } from '../../common/runtime_types/iso_to_epoch_rt'; +import { toNumberRt } from '../../common/runtime_types/to_number_rt'; +import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; -import { getServiceAgentName } from '../lib/services/get_service_agent_name'; -import { getServices } from '../lib/services/get_services'; -import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; -import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; -import { createRoute } from './create_route'; -import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; -import { dateAsStringRt } from '../../common/runtime_types/date_as_string_rt'; -import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; -import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; +import { getServices } from '../lib/services/get_services'; +import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServiceDependencies } from '../lib/services/get_service_dependencies'; -import { toNumberRt } from '../../common/runtime_types/to_number_rt'; -import { getThroughput } from '../lib/services/get_throughput'; +import { getServiceErrorGroups } from '../lib/services/get_service_error_groups'; import { getServiceInstances } from '../lib/services/get_service_instances'; import { getServiceMetadataDetails } from '../lib/services/get_service_metadata_details'; import { getServiceMetadataIcons } from '../lib/services/get_service_metadata_icons'; +import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; +import { getServiceTransactionTypes } from '../lib/services/get_service_transaction_types'; +import { getThroughput } from '../lib/services/get_throughput'; +import { offsetPreviousPeriodCoordinates } from '../utils/offset_previous_period_coordinate'; +import { createRoute } from './create_route'; +import { + comparisonRangeRt, + environmentRt, + rangeRt, + uiFiltersRt, +} from './default_api_types'; +import { withApmSpan } from '../utils/with_apm_span'; export const servicesRoute = createRoute({ endpoint: 'GET /api/apm/services', params: t.type({ - query: t.intersection([uiFiltersRt, rangeRt]), + query: t.intersection([environmentRt, uiFiltersRt, rangeRt]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - + const { environment } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); const services = await getServices({ + environment, setup, searchAggregatedTransactions, logger: context.logger, @@ -178,14 +186,17 @@ export const serviceAnnotationsRoute = createRoute({ const { serviceName } = context.params.path; const { environment } = context.params.query; + const { observability } = context.plugins; + const [ annotationsClient, searchAggregatedTransactions, ] = await Promise.all([ - context.plugins.observability?.getScopedAnnotationsClient( - context, - request - ), + observability + ? withApmSpan('get_scoped_annotations_client', () => + observability.getScopedAnnotationsClient(context, request) + ) + : undefined, getSearchAggregatedTransactions(setup), ]); @@ -212,7 +223,7 @@ export const serviceAnnotationsCreateRoute = createRoute({ }), body: t.intersection([ t.type({ - '@timestamp': dateAsStringRt, + '@timestamp': isoToEpochRt, service: t.intersection([ t.type({ version: t.string, @@ -229,10 +240,13 @@ export const serviceAnnotationsCreateRoute = createRoute({ ]), }), handler: async ({ request, context }) => { - const annotationsClient = await context.plugins.observability?.getScopedAnnotationsClient( - context, - request - ); + const { observability } = context.plugins; + + const annotationsClient = observability + ? await withApmSpan('get_scoped_annotations_client', () => + observability.getScopedAnnotationsClient(context, request) + ) + : undefined; if (!annotationsClient) { throw Boom.notFound(); @@ -240,18 +254,21 @@ export const serviceAnnotationsCreateRoute = createRoute({ const { body, path } = context.params; - return annotationsClient.create({ - message: body.service.version, - ...body, - annotation: { - type: 'deployment', - }, - service: { - ...body.service, - name: path.serviceName, - }, - tags: uniq(['apm'].concat(body.tags ?? [])), - }); + return withApmSpan('create_annotation', () => + annotationsClient.create({ + message: body.service.version, + ...body, + '@timestamp': new Date(body['@timestamp']).toISOString(), + annotation: { + type: 'deployment', + }, + service: { + ...body.service, + name: path.serviceName, + }, + tags: uniq(['apm'].concat(body.tags ?? [])), + }) + ); }, }); @@ -262,6 +279,7 @@ export const serviceErrorGroupsRoute = createRoute({ serviceName: t.string, }), query: t.intersection([ + environmentRt, rangeRt, uiFiltersRt, t.type({ @@ -285,6 +303,7 @@ export const serviceErrorGroupsRoute = createRoute({ const { path: { serviceName }, query: { + environment, numBuckets, pageIndex, size, @@ -293,7 +312,9 @@ export const serviceErrorGroupsRoute = createRoute({ transactionType, }, } = context.params; + return getServiceErrorGroups({ + environment, serviceName, setup, size, @@ -314,25 +335,62 @@ export const serviceThroughputRoute = createRoute({ }), query: t.intersection([ t.type({ transactionType: t.string }), + environmentRt, uiFiltersRt, rangeRt, + comparisonRangeRt, ]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; - const { transactionType } = context.params.query; + const { + environment, + transactionType, + comparisonStart, + comparisonEnd, + } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); - return getThroughput({ + const { start, end } = setup; + + const commonProps = { searchAggregatedTransactions, serviceName, setup, transactionType, - }); + }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + getThroughput({ + ...commonProps, + environment, + start, + end, + }), + comparisonStart && comparisonEnd + ? getThroughput({ + ...commonProps, + environment, + start: comparisonStart, + end: comparisonEnd, + }).then((coordinates) => + offsetPreviousPeriodCoordinates({ + currentPeriodStart: start, + previousPeriodStart: comparisonStart, + previousPeriodTimeseries: coordinates, + }) + ) + : [], + ]); + + return { + currentPeriod, + previousPeriod, + }; }, }); @@ -344,6 +402,7 @@ export const serviceInstancesRoute = createRoute({ }), query: t.intersection([ t.type({ transactionType: t.string, numBuckets: toNumberRt }), + environmentRt, uiFiltersRt, rangeRt, ]), @@ -352,13 +411,14 @@ export const serviceInstancesRoute = createRoute({ handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; - const { transactionType, numBuckets } = context.params.query; + const { environment, transactionType, numBuckets } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); return getServiceInstances({ + environment, serviceName, setup, transactionType, @@ -376,9 +436,9 @@ export const serviceDependenciesRoute = createRoute({ }), query: t.intersection([ t.type({ - environment: t.string, numBuckets: toNumberRt, }), + environmentRt, rangeRt, ]), }), diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 61875db0985e49..ae0d9aeeaade16 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import Boom from '@hapi/boom'; +import { toBooleanRt } from '../../../common/runtime_types/to_boolean_rt'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getServiceNames } from '../../lib/settings/agent_configuration/get_service_names'; import { createOrUpdateConfiguration } from '../../lib/settings/agent_configuration/create_or_update_configuration'; @@ -22,7 +23,6 @@ import { serviceRt, agentConfigurationIntakeRt, } from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; -import { jsonRt } from '../../../common/runtime_types/json_rt'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; // get list of configurations @@ -103,7 +103,7 @@ export const createOrUpdateAgentConfigurationRoute = createRoute({ tags: ['access:apm', 'access:apm_write'], }, params: t.intersection([ - t.partial({ query: t.partial({ overwrite: jsonRt.pipe(t.boolean) }) }), + t.partial({ query: t.partial({ overwrite: toBooleanRt }) }), t.type({ body: agentConfigurationIntakeRt }), ]), handler: async ({ context, request }) => { diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index 40235e8d8a74f1..25afb11f264590 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -17,6 +17,7 @@ import { getAllEnvironments } from '../../lib/environments/get_all_environments' import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; import { getSearchAggregatedTransactions } from '../../lib/helpers/aggregated_transactions'; import { notifyFeatureUsage } from '../../feature'; +import { withApmSpan } from '../../utils/with_apm_span'; // get ML anomaly detection jobs for each environment export const anomalyDetectionJobsRoute = createRoute({ @@ -31,10 +32,13 @@ export const anomalyDetectionJobsRoute = createRoute({ throw Boom.forbidden(ML_ERRORS.INVALID_LICENSE); } - const [jobs, legacyJobs] = await Promise.all([ - getAnomalyDetectionJobs(setup, context.logger), - hasLegacyJobs(setup), - ]); + const [jobs, legacyJobs] = await withApmSpan('get_available_ml_jobs', () => + Promise.all([ + getAnomalyDetectionJobs(setup, context.logger), + hasLegacyJobs(setup), + ]) + ); + return { jobs, hasLegacyJobs: legacyJobs, diff --git a/x-pack/plugins/apm/server/routes/traces.ts b/x-pack/plugins/apm/server/routes/traces.ts index 722675906487ce..5d3f99be7af344 100644 --- a/x-pack/plugins/apm/server/routes/traces.ts +++ b/x-pack/plugins/apm/server/routes/traces.ts @@ -10,23 +10,25 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getTrace } from '../lib/traces/get_trace'; import { getTransactionGroupList } from '../lib/transaction_groups'; import { createRoute } from './create_route'; -import { rangeRt, uiFiltersRt } from './default_api_types'; +import { environmentRt, rangeRt, uiFiltersRt } from './default_api_types'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { getRootTransactionByTraceId } from '../lib/transactions/get_transaction_by_trace'; export const tracesRoute = createRoute({ endpoint: 'GET /api/apm/traces', params: t.type({ - query: t.intersection([rangeRt, uiFiltersRt]), + query: t.intersection([environmentRt, rangeRt, uiFiltersRt]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); + const { environment } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); + return getTransactionGroupList( - { type: 'top_traces', searchAggregatedTransactions }, + { environment, type: 'top_traces', searchAggregatedTransactions }, setup ); }, diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index 912820975cad19..5a4be216a817c2 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -5,29 +5,30 @@ * 2.0. */ -import Boom from '@hapi/boom'; import * as t from 'io-ts'; -import { createRoute } from './create_route'; -import { rangeRt, uiFiltersRt } from './default_api_types'; +import { + LatencyAggregationType, + latencyAggregationTypeRt, +} from '../../common/latency_aggregation_types'; +import { jsonRt } from '../../common/runtime_types/json_rt'; import { toNumberRt } from '../../common/runtime_types/to_number_rt'; import { getSearchAggregatedTransactions } from '../lib/helpers/aggregated_transactions'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceTransactionGroups } from '../lib/services/get_service_transaction_groups'; +import { getServiceTransactionGroupComparisonStatistics } from '../lib/services/get_service_transaction_group_comparison_statistics'; import { getTransactionBreakdown } from '../lib/transactions/breakdown'; -import { getAnomalySeries } from '../lib/transactions/get_anomaly_data'; import { getTransactionDistribution } from '../lib/transactions/distribution'; -import { getTransactionGroupList } from '../lib/transaction_groups'; -import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; +import { getAnomalySeries } from '../lib/transactions/get_anomaly_data'; import { getLatencyTimeseries } from '../lib/transactions/get_latency_charts'; import { getThroughputCharts } from '../lib/transactions/get_throughput_charts'; -import { - LatencyAggregationType, - latencyAggregationTypeRt, -} from '../../common/latency_aggregation_types'; +import { getTransactionGroupList } from '../lib/transaction_groups'; +import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; +import { createRoute } from './create_route'; +import { environmentRt, rangeRt, uiFiltersRt } from './default_api_types'; /** * Returns a list of transactions grouped by name - * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/overview/ + * //TODO: delete this once we moved away from the old table in the transaction overview page. It should be replaced by /transactions/groups/primary_statistics/ */ export const transactionGroupsRoute = createRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups', @@ -37,6 +38,7 @@ export const transactionGroupsRoute = createRoute({ }), query: t.intersection([ t.type({ transactionType: t.string }), + environmentRt, uiFiltersRt, rangeRt, ]), @@ -45,7 +47,7 @@ export const transactionGroupsRoute = createRoute({ handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; - const { transactionType } = context.params.query; + const { environment, transactionType } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup @@ -53,6 +55,7 @@ export const transactionGroupsRoute = createRoute({ return getTransactionGroupList( { + environment, type: 'top_transactions', serviceName, transactionType, @@ -63,25 +66,59 @@ export const transactionGroupsRoute = createRoute({ }, }); -export const transactionGroupsOverviewRoute = createRoute({ - endpoint: 'GET /api/apm/services/{serviceName}/transactions/groups/overview', +export const transactionGroupsPrimaryStatisticsRoute = createRoute({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics', + params: t.type({ + path: t.type({ serviceName: t.string }), + query: t.intersection([ + environmentRt, + rangeRt, + uiFiltersRt, + t.type({ + transactionType: t.string, + latencyAggregationType: latencyAggregationTypeRt, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + + const searchAggregatedTransactions = await getSearchAggregatedTransactions( + setup + ); + + const { + path: { serviceName }, + query: { environment, latencyAggregationType, transactionType }, + } = context.params; + + return getServiceTransactionGroups({ + environment, + setup, + serviceName, + searchAggregatedTransactions, + transactionType, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, + }); + }, +}); + +export const transactionGroupsComparisonStatisticsRoute = createRoute({ + endpoint: + 'GET /api/apm/services/{serviceName}/transactions/groups/comparison_statistics', params: t.type({ path: t.type({ serviceName: t.string }), query: t.intersection([ + environmentRt, rangeRt, uiFiltersRt, t.type({ - size: toNumberRt, + transactionNames: jsonRt, numBuckets: toNumberRt, - pageIndex: toNumberRt, - sortDirection: t.union([t.literal('asc'), t.literal('desc')]), - sortField: t.union([ - t.literal('name'), - t.literal('latency'), - t.literal('throughput'), - t.literal('errorRate'), - t.literal('impact'), - ]), transactionType: t.string, latencyAggregationType: latencyAggregationTypeRt, }), @@ -100,24 +137,20 @@ export const transactionGroupsOverviewRoute = createRoute({ const { path: { serviceName }, query: { + environment, + transactionNames, latencyAggregationType, numBuckets, - pageIndex, - size, - sortDirection, - sortField, transactionType, }, } = context.params; - return getServiceTransactionGroups({ + return getServiceTransactionGroupComparisonStatistics({ + environment, setup, serviceName, - pageIndex, + transactionNames, searchAggregatedTransactions, - size, - sortDirection, - sortField, transactionType, numBuckets, latencyAggregationType: latencyAggregationType as LatencyAggregationType, @@ -125,7 +158,7 @@ export const transactionGroupsOverviewRoute = createRoute({ }, }); -export const transactionLatencyChatsRoute = createRoute({ +export const transactionLatencyChartsRoute = createRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/latency', params: t.type({ path: t.type({ @@ -139,6 +172,7 @@ export const transactionLatencyChatsRoute = createRoute({ transactionType: t.string, latencyAggregationType: latencyAggregationTypeRt, }), + environmentRt, uiFiltersRt, rangeRt, ]), @@ -149,22 +183,18 @@ export const transactionLatencyChatsRoute = createRoute({ const logger = context.logger; const { serviceName } = context.params.path; const { + environment, transactionType, transactionName, latencyAggregationType, } = context.params.query; - if (!setup.uiFilters.environment) { - throw Boom.badRequest( - `environment is a required property of the ?uiFilters JSON for transaction_groups/charts.` - ); - } - const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); const options = { + environment, serviceName, transactionType, transactionName, @@ -195,7 +225,7 @@ export const transactionLatencyChatsRoute = createRoute({ }, }); -export const transactionThroughputChatsRoute = createRoute({ +export const transactionThroughputChartsRoute = createRoute({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/throughput', params: t.type({ @@ -207,25 +237,25 @@ export const transactionThroughputChatsRoute = createRoute({ t.partial({ transactionName: t.string }), uiFiltersRt, rangeRt, + environmentRt, ]), }), options: { tags: ['access:apm'] }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; - const { transactionType, transactionName } = context.params.query; - - if (!setup.uiFilters.environment) { - throw Boom.badRequest( - `environment is a required property of the ?uiFilters JSON for transaction_groups/charts.` - ); - } + const { + environment, + transactionType, + transactionName, + } = context.params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); return await getThroughputCharts({ + environment, serviceName, transactionType, transactionName, @@ -251,6 +281,7 @@ export const transactionChartsDistributionRoute = createRoute({ transactionId: t.string, traceId: t.string, }), + environmentRt, uiFiltersRt, rangeRt, ]), @@ -260,6 +291,7 @@ export const transactionChartsDistributionRoute = createRoute({ const setup = await setupRequest(context, request); const { serviceName } = context.params.path; const { + environment, transactionType, transactionName, transactionId = '', @@ -271,6 +303,7 @@ export const transactionChartsDistributionRoute = createRoute({ ); return getTransactionDistribution({ + environment, serviceName, transactionType, transactionName, @@ -291,6 +324,7 @@ export const transactionChartsBreakdownRoute = createRoute({ query: t.intersection([ t.type({ transactionType: t.string }), t.partial({ transactionName: t.string }), + environmentRt, uiFiltersRt, rangeRt, ]), @@ -299,9 +333,14 @@ export const transactionChartsBreakdownRoute = createRoute({ handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; - const { transactionName, transactionType } = context.params.query; + const { + environment, + transactionName, + transactionType, + } = context.params.query; return getTransactionBreakdown({ + environment, serviceName, transactionName, transactionType, @@ -318,6 +357,7 @@ export const transactionChartsErrorRateRoute = createRoute({ serviceName: t.string, }), query: t.intersection([ + environmentRt, uiFiltersRt, rangeRt, t.type({ transactionType: t.string }), @@ -329,13 +369,14 @@ export const transactionChartsErrorRateRoute = createRoute({ const setup = await setupRequest(context, request); const { params } = context; const { serviceName } = params.path; - const { transactionType, transactionName } = params.query; + const { environment, transactionType, transactionName } = params.query; const searchAggregatedTransactions = await getSearchAggregatedTransactions( setup ); return getErrorRate({ + environment, serviceName, transactionType, transactionName, diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index e5901cabc4ef69..4d3e07040f76b0 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -143,7 +143,7 @@ export type Client< forceCache?: boolean; endpoint: TEndpoint; } & (TRouteState[TEndpoint] extends { params: t.Any } - ? MaybeOptional<{ params: t.TypeOf }> + ? MaybeOptional<{ params: t.OutputOf }> : {}) & (TOptions extends { abortable: true } ? { signal: AbortSignal | null } : {}) ) => Promise< diff --git a/x-pack/plugins/apm/server/routes/ui_filters.ts b/x-pack/plugins/apm/server/routes/ui_filters.ts deleted file mode 100644 index b14a47e302caa9..00000000000000 --- a/x-pack/plugins/apm/server/routes/ui_filters.ts +++ /dev/null @@ -1,142 +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 * 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 { - Setup, - setupRequest, - SetupTimeRange, -} from '../lib/helpers/setup_request'; -import { getEnvironments } from '../lib/ui_filters/get_environments'; -import { getLocalUIFilters } from '../lib/ui_filters/local_ui_filters'; -import { localUIFilterNames } from '../lib/ui_filters/local_ui_filters/config'; -import { getRumPageLoadTransactionsProjection } from '../projections/rum_page_load_transactions'; -import { Projection } from '../projections/typings'; -import { createRoute } from './create_route'; -import { rangeRt, uiFiltersRt } from './default_api_types'; -import { APMRequestHandlerContext } from './typings'; - -export const uiFiltersEnvironmentsRoute = createRoute({ - endpoint: 'GET /api/apm/ui_filters/environments', - params: t.type({ - query: t.intersection([ - t.partial({ - serviceName: t.string, - }), - rangeRt, - ]), - }), - options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; - const searchAggregatedTransactions = await getSearchAggregatedTransactions( - setup - ); - - return getEnvironments({ - setup, - serviceName, - searchAggregatedTransactions, - }); - }, -}); - -const filterNamesRt = t.type({ - filterNames: jsonRt.pipe( - t.array( - t.keyof( - Object.fromEntries( - localUIFilterNames.map((filterName) => [filterName, null]) - ) as Record - ) - ) - ), -}); - -const localUiBaseQueryRt = t.intersection([ - filterNamesRt, - uiFiltersRt, - rangeRt, -]); - -function createLocalFiltersRoute< - TEndpoint extends string, - TProjection extends Projection, - TQueryRT extends t.HasProps ->({ - endpoint, - getProjection, - queryRt, -}: { - endpoint: TEndpoint; - getProjection: GetProjection< - TProjection, - t.IntersectionC<[TQueryRT, BaseQueryType]> - >; - queryRt: TQueryRT; -}) { - return createRoute({ - endpoint, - params: t.type({ - query: t.intersection([localUiBaseQueryRt, queryRt]), - }), - options: { tags: ['access:apm'] }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { uiFilters } = setup; - const { query } = context.params; - - const { filterNames } = query; - const projection = await getProjection({ - query, - context, - setup: { - ...setup, - esFilter: getEsFilter(omit(uiFilters, filterNames)), - }, - }); - - return getLocalUIFilters({ - projection, - setup, - uiFilters, - localFilterNames: filterNames, - }); - }, - }); -} - -export const rumOverviewLocalFiltersRoute = createLocalFiltersRoute({ - endpoint: 'GET /api/apm/ui_filters/local_filters/rumOverview', - getProjection: async ({ setup }) => { - return getRumPageLoadTransactionsProjection({ - setup, - }); - }, - queryRt: t.type({}), -}); - -type BaseQueryType = typeof localUiBaseQueryRt; - -type GetProjection< - TProjection extends Projection, - TQueryRT extends t.HasProps -> = ({ - query, - setup, - context, -}: { - query: t.TypeOf; - setup: Setup & SetupTimeRange; - context: APMRequestHandlerContext; -}) => Promise | TProjection; diff --git a/x-pack/plugins/apm/server/tutorial/elastic_cloud.ts b/x-pack/plugins/apm/server/tutorial/elastic_cloud.ts index fac38027e1b82b..08e1ff75d43242 100644 --- a/x-pack/plugins/apm/server/tutorial/elastic_cloud.ts +++ b/x-pack/plugins/apm/server/tutorial/elastic_cloud.ts @@ -18,6 +18,7 @@ import { createGoAgentInstructions, createJavaAgentInstructions, createDotNetAgentInstructions, + createPhpAgentInstructions, } from '../../../../../src/plugins/apm_oss/server'; import { CloudSetup } from '../../../cloud/server'; @@ -105,6 +106,10 @@ function getApmAgentInstructionSet(cloudSetup?: CloudSetup) { id: INSTRUCTION_VARIANT.DOTNET, instructions: createDotNetAgentInstructions(apmServerUrl, secretToken), }, + { + id: INSTRUCTION_VARIANT.PHP, + instructions: createPhpAgentInstructions(apmServerUrl, secretToken), + }, ], }; } diff --git a/x-pack/plugins/apm/server/ui_settings.ts b/x-pack/plugins/apm/server/ui_settings.ts index a52cdbcc4f0795..5952cdb7022951 100644 --- a/x-pack/plugins/apm/server/ui_settings.ts +++ b/x-pack/plugins/apm/server/ui_settings.ts @@ -8,29 +8,12 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { UiSettingsParams } from '../../../../src/core/types'; -import { - enableCorrelations, - enableServiceOverview, -} from '../common/ui_settings_keys'; +import { enableServiceOverview } from '../common/ui_settings_keys'; /** * uiSettings definitions for APM. */ export const uiSettings: Record> = { - [enableCorrelations]: { - category: ['observability'], - name: i18n.translate('xpack.apm.enableCorrelationsExperimentName', { - defaultMessage: 'APM correlations (Platinum required)', - }), - value: false, - description: i18n.translate( - 'xpack.apm.enableCorrelationsExperimentDescription', - { - defaultMessage: 'Enable the experimental correlations feature in APM', - } - ), - schema: schema.boolean(), - }, [enableServiceOverview]: { category: ['observability'], name: i18n.translate('xpack.apm.enableServiceOverviewExperimentName', { diff --git a/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts new file mode 100644 index 00000000000000..6436c7c5193ecb --- /dev/null +++ b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Coordinate } from '../../typings/timeseries'; +import { offsetPreviousPeriodCoordinates } from './offset_previous_period_coordinate'; + +const previousPeriodStart = new Date('2021-01-27T14:45:00.000Z').valueOf(); +const currentPeriodStart = new Date('2021-01-28T14:45:00.000Z').valueOf(); + +describe('mergePeriodsTimeseries', () => { + describe('returns empty array', () => { + it('when previous timeseries is not defined', () => { + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries: undefined, + }) + ).toEqual([]); + }); + + it('when previous timeseries is empty', () => { + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries: [], + }) + ).toEqual([]); + }); + }); + + it('offsets previous period timeseries', () => { + const previousPeriodTimeseries: Coordinate[] = [ + { x: new Date('2021-01-27T14:45:00.000Z').valueOf(), y: 1 }, + { x: new Date('2021-01-27T15:00:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-27T15:15:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-27T15:30:00.000Z').valueOf(), y: 3 }, + ]; + + expect( + offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries, + }) + ).toEqual([ + { x: new Date('2021-01-28T14:45:00.000Z').valueOf(), y: 1 }, + { x: new Date('2021-01-28T15:00:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-28T15:15:00.000Z').valueOf(), y: 2 }, + { x: new Date('2021-01-28T15:30:00.000Z').valueOf(), y: 3 }, + ]); + }); +}); diff --git a/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts new file mode 100644 index 00000000000000..837e3d02056f0f --- /dev/null +++ b/x-pack/plugins/apm/server/utils/offset_previous_period_coordinate.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { Coordinate } from '../../typings/timeseries'; + +export function offsetPreviousPeriodCoordinates({ + currentPeriodStart, + previousPeriodStart, + previousPeriodTimeseries, +}: { + currentPeriodStart: number; + previousPeriodStart: number; + previousPeriodTimeseries?: Coordinate[]; +}) { + if (!previousPeriodTimeseries) { + return []; + } + + const dateOffset = moment(currentPeriodStart).diff( + moment(previousPeriodStart) + ); + + return previousPeriodTimeseries.map(({ x, y }) => { + const offsetX = moment(x).add(dateOffset).valueOf(); + return { + x: offsetX, + y, + }; + }); +} diff --git a/x-pack/plugins/apm/server/utils/test_helpers.tsx b/x-pack/plugins/apm/server/utils/test_helpers.tsx index 19ac562121d9dc..4df638cc2c5df2 100644 --- a/x-pack/plugins/apm/server/utils/test_helpers.tsx +++ b/x-pack/plugins/apm/server/utils/test_helpers.tsx @@ -88,7 +88,7 @@ export async function inspectSearchParams( }, } ) as APMConfig, - uiFilters: { environment: 'test' }, + uiFilters: {}, esFilter: [{ term: { 'service.environment': 'test' } }], indices: { /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/x-pack/plugins/apm/server/utils/with_apm_span.ts b/x-pack/plugins/apm/server/utils/with_apm_span.ts new file mode 100644 index 00000000000000..9762a7213d0a2b --- /dev/null +++ b/x-pack/plugins/apm/server/utils/with_apm_span.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { withSpan, SpanOptions, parseSpanOptions } from '@kbn/apm-utils'; + +export function withApmSpan( + optionsOrName: SpanOptions | string, + cb: () => Promise +): Promise { + const options = parseSpanOptions(optionsOrName); + + const optionsWithDefaults = { + type: 'plugin:apm', + ...options, + labels: { + plugin: 'apm', + ...options.labels, + }, + }; + + return withSpan(optionsWithDefaults, cb); +} diff --git a/x-pack/plugins/apm/tsconfig.json b/x-pack/plugins/apm/tsconfig.json new file mode 100644 index 00000000000000..bb2e0e06679a29 --- /dev/null +++ b/x-pack/plugins/apm/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../typings/**/*", + "common/**/*", + "public/**/*", + "scripts/**/*", + "server/**/*", + "typings/**/*", + // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 + "public/**/*.json", + "server/**/*.json" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/apm_oss/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/index_pattern_management/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../actions/tsconfig.json" }, + { "path": "../alerts/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../maps/tsconfig.json" }, + { "path": "../ml/tsconfig.json" }, + { "path": "../observability/tsconfig.json" }, + { "path": "../reporting/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../task_manager/tsconfig.json" }, + { "path": "../triggers_actions_ui/tsconfig.json" } + ] +} diff --git a/x-pack/plugins/beats_management/public/components/table/controls/action_control.tsx b/x-pack/plugins/beats_management/public/components/table/controls/action_control.tsx index c498f9e06e1d36..5badef9a71fe17 100644 --- a/x-pack/plugins/beats_management/public/components/table/controls/action_control.tsx +++ b/x-pack/plugins/beats_management/public/components/table/controls/action_control.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButton, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiButton, EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { AssignmentActionType } from '../table'; @@ -58,40 +58,38 @@ export class ActionControl extends React.PureComponent {this.state.showModal && ( - - + } + confirmButtonText={ + + } + onConfirm={() => { + actionHandler(action); + this.setState({ showModal: false }); + }} + onCancel={() => this.setState({ showModal: false })} + title={ + warningHeading ? ( + warningHeading + ) : ( - } - confirmButtonText={ - - } - onConfirm={() => { - actionHandler(action); - this.setState({ showModal: false }); - }} - onCancel={() => this.setState({ showModal: false })} - title={ - warningHeading ? ( - warningHeading - ) : ( - - ) - } - > - {warningMessage} - - + ) + } + > + {warningMessage} + )}

); diff --git a/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx b/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx index f09d34eaa6e614..0ab02430e90e61 100644 --- a/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx +++ b/x-pack/plugins/beats_management/public/pages/overview/enrolled_beats.tsx @@ -13,7 +13,6 @@ import { EuiModalBody, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; @@ -104,58 +103,56 @@ class BeatsPageComponent extends React.PureComponent { {this.props.location.pathname === '/overview/enrolled_beats/enroll' && ( - - { - this.props.setUrlState({ - enrollmentToken: '', - }); - this.props.goTo(`/overview/enrolled_beats`); - }} - style={{ width: '640px' }} - > - - - - - - - { - const enrollmentTokens = await this.props.libs.tokens.createEnrollmentTokens(); - this.props.setUrlState({ - enrollmentToken: enrollmentTokens[0], - }); - }} - onBeatEnrolled={() => { - this.props.setUrlState({ - enrollmentToken: '', - }); - }} + { + this.props.setUrlState({ + enrollmentToken: '', + }); + this.props.goTo(`/overview/enrolled_beats`); + }} + style={{ width: '640px' }} + > + + + - {!this.props.urlState.enrollmentToken && ( - - { - this.props.goTo('/overview/enrolled_beats'); - }} - > - Done - - - )} - - - + + + + { + const enrollmentTokens = await this.props.libs.tokens.createEnrollmentTokens(); + this.props.setUrlState({ + enrollmentToken: enrollmentTokens[0], + }); + }} + onBeatEnrolled={() => { + this.props.setUrlState({ + enrollmentToken: '', + }); + }} + /> + {!this.props.urlState.enrollmentToken && ( + + { + this.props.goTo('/overview/enrolled_beats'); + }} + > + Done + + + )} + + )} ); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx index f06c305e47beaa..7795aa9671b83d 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiPanel, EuiProgress, EuiSpacer, @@ -75,71 +74,69 @@ export const AssetManager: FC = (props) => { }; return ( - - onClose()} - className="canvasAssetManager canvasModal--fixedSize" - maxWidth="1000px" - > - - - {strings.getModalTitle()} - - - - {isLoading ? ( - - ) : ( - - )} - - - - - -

{strings.getDescription()}

-
- - {assets.length ? ( - - {assets.map((asset) => ( - - ))} - - ) : ( - emptyAssets - )} -
- - - - onClose()} + className="canvasAssetManager canvasModal--fixedSize" + maxWidth="1000px" + > + + + {strings.getModalTitle()} + + + + {isLoading ? ( + + ) : ( + - - - - {strings.getSpaceUsedText(percentageUsed)} - - - - onClose()}> - {strings.getModalCloseButtonLabel()} - - -
-
+ )} +
+
+ + + +

{strings.getDescription()}

+
+ + {assets.length ? ( + + {assets.map((asset) => ( + + ))} + + ) : ( + emptyAssets + )} +
+ + + + + + + + {strings.getSpaceUsedText(percentageUsed)} + + + + onClose()}> + {strings.getModalCloseButtonLabel()} + + + ); }; diff --git a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx index 38be3b8559af2c..521ced0d731f2c 100644 --- a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx +++ b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import PropTypes from 'prop-types'; import React, { FunctionComponent } from 'react'; @@ -39,21 +39,19 @@ export const ConfirmModal: FunctionComponent = (props) => { } return ( - - - {message} - - + + {message} + ); }; diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js index 4a5861b41d06cc..a55f73a0874676 100644 --- a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/datasource_preview.js @@ -8,7 +8,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import { - EuiOverlayMask, EuiModal, EuiModalBody, EuiModalHeader, @@ -27,48 +26,46 @@ const { DatasourceDatasourcePreview: strings } = ComponentStrings; const { DatasourceDatasourceComponent: datasourceStrings } = ComponentStrings; export const DatasourcePreview = ({ done, datatable }) => ( - - - - {strings.getModalTitle()} - - - -

- {datasourceStrings.getSaveButtonLabel()}, - }} + + + {strings.getModalTitle()} + + + +

+ {datasourceStrings.getSaveButtonLabel()}, + }} + /> +

+
+ + {datatable.type === 'error' ? ( + + ) : ( + + {datatable.rows.length > 0 ? ( + + ) : ( + {strings.getEmptyTitle()}} + titleSize="s" + body={ +

+ {strings.getEmptyFirstLineDescription()} +
+ {strings.getEmptySecondLineDescription()} +

+ } /> -

- - - {datatable.type === 'error' ? ( - - ) : ( - - {datatable.rows.length > 0 ? ( - - ) : ( - {strings.getEmptyTitle()}} - titleSize="s" - body={ -

- {strings.getEmptyFirstLineDescription()} -
- {strings.getEmptySecondLineDescription()} -

- } - /> - )} -
- )} -
-
-
+ )} + + )} + + ); DatasourcePreview.propTypes = { diff --git a/x-pack/plugins/canvas/public/components/keyboard_shortcuts_doc/__stories__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot b/x-pack/plugins/canvas/public/components/keyboard_shortcuts_doc/__stories__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot index aea9626d7b57ad..a28986c0418a29 100644 --- a/x-pack/plugins/canvas/public/components/keyboard_shortcuts_doc/__stories__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/keyboard_shortcuts_doc/__stories__/__snapshots__/keyboard_shortcuts_doc.stories.storyshot @@ -5,7 +5,7 @@ exports[`Storyshots components/KeyboardShortcutsDoc default 1`] = ` data-eui="EuiFocusTrap" >
diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx index e99cc60dfcaa4b..bc0039245f4322 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/saved_elements_modal.component.tsx @@ -23,7 +23,6 @@ import { EuiEmptyPrompt, EuiFieldSearch, EuiSpacer, - EuiOverlayMask, EuiButton, } from '@elastic/eui'; import { sortBy } from 'lodash'; @@ -117,16 +116,14 @@ export const SavedElementsModal: FunctionComponent = ({ } return ( - - - + ); }; @@ -176,40 +173,34 @@ export const SavedElementsModal: FunctionComponent = ({ return ( - - - - - {strings.getModalTitle()} - - - - - - - {customElementContent} - - - - {strings.getSavedElementsModalCloseButtonLabel()} - - - - + + + + {strings.getModalTitle()} + + + + + + + {customElementContent} + + + + {strings.getSavedElementsModalCloseButtonLabel()} + + + {renderDeleteModal()} {renderEditModal()} diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx index edf7d33eff79c4..6e5c936a113bf8 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx @@ -12,7 +12,6 @@ import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, - EuiOverlayMask, EuiModal, EuiModalFooter, EuiButton, @@ -93,16 +92,14 @@ export const Toolbar: FC = ({ const openWorkpadManager = () => setShowWorkpadManager(true); const workpadManager = ( - - - - - - {strings.getWorkpadManagerCloseButtonLabel()} - - - - + + + + + {strings.getWorkpadManagerCloseButtonLabel()} + + + ); const trays = { diff --git a/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot index bf5629596d6b67..6277c599032c1e 100644 --- a/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/var_config/__stories__/__snapshots__/var_config.stories.storyshot @@ -77,8 +77,11 @@ exports[`Storyshots components/Variables/VarConfig default 1`] = `
= ({ )} {isModalVisible ? ( - - - + ) : null} ); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot index 4e84a9d5a0d211..010037bee4a0fd 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/__stories__/__snapshots__/pdf_panel.stories.storyshot @@ -150,8 +150,11 @@ exports[`Storyshots components/WorkpadHeader/ShareMenu/PDFPanel default 1`] = `
diff --git a/x-pack/plugins/canvas/server/lib/normalize_type.ts b/x-pack/plugins/canvas/server/lib/normalize_type.ts index 68313d8f39ae38..76290ca0b497cf 100644 --- a/x-pack/plugins/canvas/server/lib/normalize_type.ts +++ b/x-pack/plugins/canvas/server/lib/normalize_type.ts @@ -7,7 +7,7 @@ export function normalizeType(type: string) { const normalTypes: Record = { - string: ['string', 'text', 'keyword', '_type', '_id', '_index', 'geo_point'], + string: ['string', 'text', 'keyword', '_type', '_id', '_index', 'geo_point', 'ip'], number: [ 'float', 'half_float', diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index bebd261fb7b9b3..49643ca1f4d0c7 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -10,22 +10,21 @@ import * as rt from 'io-ts'; import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; -import { CasesStatusResponseRt } from './status'; +import { CasesStatusResponseRt, CaseStatusRt } from './status'; import { CaseConnectorRt, ESCaseConnector } from '../connectors'; +import { SubCaseResponseRt } from './sub_case'; -export enum CaseStatuses { - open = 'open', - 'in-progress' = 'in-progress', - closed = 'closed', +export enum CaseType { + collection = 'collection', + individual = 'individual', } -const CaseStatusRt = rt.union([ - rt.literal(CaseStatuses.open), - rt.literal(CaseStatuses['in-progress']), - rt.literal(CaseStatuses.closed), -]); +/** + * Exposing the field used to define the case type so that it can be used for filtering in saved object find queries. + */ +export const caseTypeField = 'type'; -export const caseStatuses = Object.values(CaseStatuses); +const CaseTypeRt = rt.union([rt.literal(CaseType.collection), rt.literal(CaseType.individual)]); const SettingsRt = rt.type({ syncAlerts: rt.boolean, @@ -36,6 +35,7 @@ const CaseBasicRt = rt.type({ status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, + [caseTypeField]: CaseTypeRt, connector: CaseConnectorRt, settings: SettingsRt, }); @@ -72,7 +72,7 @@ export const CaseAttributesRt = rt.intersection([ }), ]); -export const CasePostRequestRt = rt.type({ +const CasePostRequestNoTypeRt = rt.type({ description: rt.string, tags: rt.array(rt.string), title: rt.string, @@ -80,7 +80,27 @@ export const CasePostRequestRt = rt.type({ settings: SettingsRt, }); +/** + * This type is used for validating a create case request. It requires that the type field be defined. + */ +export const CaseClientPostRequestRt = rt.type({ + ...CasePostRequestNoTypeRt.props, + [caseTypeField]: CaseTypeRt, +}); + +/** + * This type is not used for validation when decoding a request because intersection does not have props defined which + * required for the excess function. Instead we use this as the type used by the UI. This allows the type field to be + * optional and the server will handle setting it to a default value before validating that the request + * has all the necessary fields. CaseClientPostRequestRt is used for validation. + */ +export const CasePostRequestRt = rt.intersection([ + rt.partial({ type: CaseTypeRt }), + CasePostRequestNoTypeRt, +]); + export const CasesFindRequestRt = rt.partial({ + type: CaseTypeRt, tags: rt.union([rt.array(rt.string), rt.string]), status: CaseStatusRt, reporters: rt.union([rt.array(rt.string), rt.string]), @@ -99,9 +119,11 @@ export const CaseResponseRt = rt.intersection([ rt.type({ id: rt.string, totalComment: rt.number, + totalAlerts: rt.number, version: rt.string, }), rt.partial({ + subCases: rt.array(SubCaseResponseRt), comments: rt.array(CommentResponseRt), }), ]); @@ -150,13 +172,21 @@ export const ExternalServiceResponseRt = rt.intersection([ ]); export type CaseAttributes = rt.TypeOf; +/** + * This field differs from the CasePostRequest in that the post request's type field can be optional. This type requires + * that the type field be defined. The CasePostRequest should be used in most places (the UI etc). This type is really + * only necessary for validation. + */ +export type CaseClientPostRequest = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; export type CasesResponse = rt.TypeOf; +export type CasesFindRequest = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; +export type CaseSettings = rt.TypeOf; export type ExternalServiceResponse = rt.TypeOf; export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 7c9b31f496e54d..cfc6099fa4bb57 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -9,7 +9,22 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; +/** + * this is used to differentiate between an alert attached to a top-level case and a group of alerts that should only + * be attached to a sub case. The reason we need this is because an alert group comment will have references to both a case and + * sub case when it is created. For us to be able to filter out alert groups in a top-level case we need a field to + * use as a filter. + */ +export enum AssociationType { + case = 'case', + subCase = 'sub_case', +} + export const CommentAttributesBasicRt = rt.type({ + associationType: rt.union([ + rt.literal(AssociationType.case), + rt.literal(AssociationType.subCase), + ]), created_at: rt.string, created_by: UserRT, pushed_at: rt.union([rt.string, rt.null]), @@ -18,24 +33,33 @@ export const CommentAttributesBasicRt = rt.type({ updated_by: rt.union([UserRT, rt.null]), }); +export enum CommentType { + user = 'user', + alert = 'alert', + generatedAlert = 'generated_alert', +} + export const ContextTypeUserRt = rt.type({ comment: rt.string, - type: rt.literal('user'), + type: rt.literal(CommentType.user), }); -export const ContextTypeAlertRt = rt.type({ - type: rt.literal('alert'), - alertId: rt.string, +/** + * This defines the structure of how alerts (generated or user attached) are stored in saved objects documents. It also + * represents of an alert after it has been transformed. A generated alert will be transformed by the connector so that + * it matches this structure. User attached alerts do not need to be transformed. + */ +export const AlertCommentRequestRt = rt.type({ + type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]), + alertId: rt.union([rt.array(rt.string), rt.string]), index: rt.string, }); const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]); -const AttributesTypeAlertsRt = rt.intersection([ContextTypeAlertRt, CommentAttributesBasicRt]); +const AttributesTypeAlertsRt = rt.intersection([AlertCommentRequestRt, CommentAttributesBasicRt]); const CommentAttributesRt = rt.union([AttributesTypeUserRt, AttributesTypeAlertsRt]); -const ContextBasicRt = rt.union([ContextTypeUserRt, ContextTypeAlertRt]); - -export const CommentRequestRt = ContextBasicRt; +export const CommentRequestRt = rt.union([ContextTypeUserRt, AlertCommentRequestRt]); export const CommentResponseRt = rt.intersection([ CommentAttributesRt, @@ -60,7 +84,7 @@ export const CommentPatchRequestRt = rt.intersection([ * Partial updates are not allowed. * We want to prevent the user for changing the type without removing invalid fields. */ - ContextBasicRt, + CommentRequestRt, rt.type({ id: rt.string, version: rt.string }), ]); @@ -71,7 +95,7 @@ export const CommentPatchRequestRt = rt.intersection([ * We ensure that partial updates of CommentContext is not going to happen inside the patch comment route. */ export const CommentPatchAttributesRt = rt.intersection([ - rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(ContextTypeAlertRt.props)]), + rt.union([rt.partial(CommentAttributesBasicRt.props), rt.partial(AlertCommentRequestRt.props)]), rt.partial(CommentAttributesBasicRt.props), ]); @@ -82,11 +106,6 @@ export const CommentsResponseRt = rt.type({ total: rt.number, }); -export enum CommentType { - user = 'user', - alert = 'alert', -} - export const AllCommentsResponseRt = rt.array(CommentResponseRt); export type CommentAttributes = rt.TypeOf; @@ -98,4 +117,4 @@ export type CommentsResponse = rt.TypeOf; export type CommentPatchRequest = rt.TypeOf; export type CommentPatchAttributes = rt.TypeOf; export type CommentRequestUserType = rt.TypeOf; -export type CommentRequestAlertType = rt.TypeOf; +export type CommentRequestAlertType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/commentable_case.ts b/x-pack/plugins/case/common/api/cases/commentable_case.ts new file mode 100644 index 00000000000000..023229a90d352d --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/commentable_case.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { CaseAttributesRt } from './case'; +import { CommentResponseRt } from './comment'; +import { SubCaseAttributesRt, SubCaseResponseRt } from './sub_case'; + +export const CollectionSubCaseAttributesRt = rt.intersection([ + rt.partial({ subCase: SubCaseAttributesRt }), + rt.type({ + case: CaseAttributesRt, + }), +]); + +export const CollectWithSubCaseResponseRt = rt.intersection([ + CaseAttributesRt, + rt.type({ + id: rt.string, + totalComment: rt.number, + version: rt.string, + }), + rt.partial({ + subCase: SubCaseResponseRt, + totalAlerts: rt.number, + comments: rt.array(CommentResponseRt), + }), +]); + +export type CollectionWithSubCaseResponse = rt.TypeOf; +export type CollectionWithSubCaseAttributes = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index a3ad4100b7ce37..4d1fc68109ddb7 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -10,3 +10,5 @@ export * from './configure'; export * from './comment'; export * from './status'; export * from './user_actions'; +export * from './sub_case'; +export * from './commentable_case'; diff --git a/x-pack/plugins/case/common/api/cases/status.ts b/x-pack/plugins/case/common/api/cases/status.ts index 2e05930c37f5da..7286e19da91592 100644 --- a/x-pack/plugins/case/common/api/cases/status.ts +++ b/x-pack/plugins/case/common/api/cases/status.ts @@ -7,6 +7,20 @@ import * as rt from 'io-ts'; +export enum CaseStatuses { + open = 'open', + 'in-progress' = 'in-progress', + closed = 'closed', +} + +export const CaseStatusRt = rt.union([ + rt.literal(CaseStatuses.open), + rt.literal(CaseStatuses['in-progress']), + rt.literal(CaseStatuses.closed), +]); + +export const caseStatuses = Object.values(CaseStatuses); + export const CasesStatusResponseRt = rt.type({ count_open_cases: rt.number, count_in_progress_cases: rt.number, diff --git a/x-pack/plugins/case/common/api/cases/sub_case.ts b/x-pack/plugins/case/common/api/cases/sub_case.ts new file mode 100644 index 00000000000000..c46f87c547d50b --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/sub_case.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +import { NumberFromString } from '../saved_object'; +import { UserRT } from '../user'; +import { CommentResponseRt } from './comment'; +import { CasesStatusResponseRt } from './status'; +import { CaseStatusRt } from './status'; + +const SubCaseBasicRt = rt.type({ + status: CaseStatusRt, +}); + +export const SubCaseAttributesRt = rt.intersection([ + SubCaseBasicRt, + rt.type({ + closed_at: rt.union([rt.string, rt.null]), + closed_by: rt.union([UserRT, rt.null]), + created_at: rt.string, + created_by: rt.union([UserRT, rt.null]), + updated_at: rt.union([rt.string, rt.null]), + updated_by: rt.union([UserRT, rt.null]), + }), +]); + +export const SubCasesFindRequestRt = rt.partial({ + status: CaseStatusRt, + defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + fields: rt.array(rt.string), + page: NumberFromString, + perPage: NumberFromString, + search: rt.string, + searchFields: rt.array(rt.string), + sortField: rt.string, + sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]), +}); + +export const SubCaseResponseRt = rt.intersection([ + SubCaseAttributesRt, + rt.type({ + id: rt.string, + totalComment: rt.number, + totalAlerts: rt.number, + version: rt.string, + }), + rt.partial({ + comments: rt.array(CommentResponseRt), + }), +]); + +export const SubCasesFindResponseRt = rt.intersection([ + rt.type({ + subCases: rt.array(SubCaseResponseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, + }), + CasesStatusResponseRt, +]); + +export const SubCasePatchRequestRt = rt.intersection([ + rt.partial(SubCaseBasicRt.props), + rt.type({ id: rt.string, version: rt.string }), +]); + +export const SubCasesPatchRequestRt = rt.type({ subCases: rt.array(SubCasePatchRequestRt) }); +export const SubCasesResponseRt = rt.array(SubCaseResponseRt); + +export type SubCaseAttributes = rt.TypeOf; +export type SubCaseResponse = rt.TypeOf; +export type SubCasesResponse = rt.TypeOf; +export type SubCasesFindResponse = rt.TypeOf; +export type SubCasePatchRequest = rt.TypeOf; +export type SubCasesPatchRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index a83b8e46ae04eb..de9e88993df9a2 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -12,18 +12,18 @@ import { UserRT } from '../user'; /* To the next developer, if you add/removed fields here * make sure to check this file (x-pack/plugins/case/server/services/user_actions/helpers.ts) too */ -const UserActionFieldRt = rt.array( - rt.union([ - rt.literal('comment'), - rt.literal('connector'), - rt.literal('description'), - rt.literal('pushed'), - rt.literal('tags'), - rt.literal('title'), - rt.literal('status'), - rt.literal('settings'), - ]) -); +const UserActionFieldTypeRt = rt.union([ + rt.literal('comment'), + rt.literal('connector'), + rt.literal('description'), + rt.literal('pushed'), + rt.literal('tags'), + rt.literal('title'), + rt.literal('status'), + rt.literal('settings'), + rt.literal('sub_case'), +]); +const UserActionFieldRt = rt.array(UserActionFieldTypeRt); const UserActionRt = rt.union([ rt.literal('add'), rt.literal('create'), @@ -60,3 +60,4 @@ export type CaseUserActionsResponse = rt.TypeOf; export type UserActionField = rt.TypeOf; +export type UserActionFieldType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index 24c4756a1596b4..9c290c0a4d6128 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -10,6 +10,8 @@ import { CASE_COMMENTS_URL, CASE_USER_ACTIONS_URL, CASE_COMMENT_DETAILS_URL, + SUB_CASE_DETAILS_URL, + SUB_CASES_URL, CASE_PUSH_URL, } from '../constants'; @@ -17,6 +19,14 @@ export const getCaseDetailsUrl = (id: string): string => { return CASE_DETAILS_URL.replace('{case_id}', id); }; +export const getSubCasesUrl = (caseID: string): string => { + return SUB_CASES_URL.replace('{case_id}', caseID); +}; + +export const getSubCaseDetailsUrl = (caseID: string, subCaseID: string): string => { + return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID); +}; + export const getCaseCommentsUrl = (id: string): string => { return CASE_COMMENTS_URL.replace('{case_id}', id); }; diff --git a/x-pack/plugins/case/common/api/saved_object.ts b/x-pack/plugins/case/common/api/saved_object.ts index 91eace4e3655e8..e0ae4ee82c490f 100644 --- a/x-pack/plugins/case/common/api/saved_object.ts +++ b/x-pack/plugins/case/common/api/saved_object.ts @@ -20,9 +20,12 @@ export const NumberFromString = new rt.Type( String ); +const ReferenceRt = rt.type({ id: rt.string, type: rt.string }); + export const SavedObjectFindOptionsRt = rt.partial({ defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), - hasReference: rt.type({ id: rt.string, type: rt.string }), + hasReferenceOperator: rt.union([rt.literal('AND'), rt.literal('OR')]), + hasReference: rt.union([rt.array(ReferenceRt), ReferenceRt]), fields: rt.array(rt.string), filter: rt.string, page: NumberFromString, diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 92dd2312f1ecf3..5d34ed120ff6f8 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -15,6 +15,11 @@ export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; + +export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`; +export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`; +export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`; + export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`; diff --git a/x-pack/plugins/case/package.json b/x-pack/plugins/case/package.json new file mode 100644 index 00000000000000..5a254142969460 --- /dev/null +++ b/x-pack/plugins/case/package.json @@ -0,0 +1,10 @@ +{ + "author": "Elastic", + "name": "case", + "version": "8.0.0", + "private": true, + "license": "Elastic-License", + "scripts": { + "test:sub-cases": "node server/scripts/sub_cases/generator" + } +} diff --git a/x-pack/plugins/case/server/client/alerts/get.ts b/x-pack/plugins/case/server/client/alerts/get.ts index 718dd327aa08c8..a7ca5d9742c6bb 100644 --- a/x-pack/plugins/case/server/client/alerts/get.ts +++ b/x-pack/plugins/case/server/client/alerts/get.ts @@ -5,24 +5,32 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { CaseClientGetAlerts, CaseClientFactoryArguments } from '../types'; +import { ElasticsearchClient } from 'kibana/server'; +import { AlertServiceContract } from '../../services'; import { CaseClientGetAlertsResponse } from './types'; -export const get = ({ alertsService, request, context }: CaseClientFactoryArguments) => async ({ +interface GetParams { + alertsService: AlertServiceContract; + ids: string[]; + indices: Set; + scopedClusterClient: ElasticsearchClient; +} + +export const get = async ({ + alertsService, ids, -}: CaseClientGetAlerts): Promise => { - const securitySolutionClient = context?.securitySolution?.getAppClient(); - if (securitySolutionClient == null) { - throw Boom.notFound('securitySolutionClient client have not been found'); + indices, + scopedClusterClient, +}: GetParams): Promise => { + if (ids.length === 0 || indices.size <= 0) { + return []; } - if (ids.length === 0) { + const alerts = await alertsService.getAlerts({ ids, indices, scopedClusterClient }); + if (!alerts) { return []; } - const index = securitySolutionClient.getSignalsIndex(); - const alerts = await alertsService.getAlerts({ ids, index, request }); return alerts.hits.hits.map((alert) => ({ id: alert._id, index: alert._index, diff --git a/x-pack/plugins/case/server/client/alerts/update_status.test.ts b/x-pack/plugins/case/server/client/alerts/update_status.test.ts index f7b028fd98cd6a..c8df1c8ab74f36 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.test.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.test.ts @@ -10,45 +10,21 @@ import { createMockSavedObjectsRepository } from '../../routes/api/__fixtures__' import { createCaseClientWithMockSavedObjectsClient } from '../mocks'; describe('updateAlertsStatus', () => { - describe('happy path', () => { - test('it update the status of the alert correctly', async () => { - const savedObjectsClient = createMockSavedObjectsRepository(); + it('updates the status of the alert correctly', async () => { + const savedObjectsClient = createMockSavedObjectsRepository(); - const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - await caseClient.client.updateAlertsStatus({ - ids: ['alert-id-1'], - status: CaseStatuses.closed, - }); - - expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ - ids: ['alert-id-1'], - index: '.siem-signals', - request: {}, - status: CaseStatuses.closed, - }); + const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); + await caseClient.client.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + indices: new Set(['.siem-signals']), }); - describe('unhappy path', () => { - test('it throws when missing securitySolutionClient', async () => { - expect.assertions(3); - - const savedObjectsClient = createMockSavedObjectsRepository(); - - const caseClient = await createCaseClientWithMockSavedObjectsClient({ - savedObjectsClient, - omitFromContext: ['securitySolution'], - }); - caseClient.client - .updateAlertsStatus({ - ids: ['alert-id-1'], - status: CaseStatuses.closed, - }) - .catch((e) => { - expect(e).not.toBeNull(); - expect(e.isBoom).toBe(true); - expect(e.output.statusCode).toBe(404); - }); - }); + expect(caseClient.services.alertsService.updateAlertsStatus).toHaveBeenCalledWith({ + scopedClusterClient: expect.anything(), + ids: ['alert-id-1'], + indices: new Set(['.siem-signals']), + status: CaseStatuses.closed, }); }); }); diff --git a/x-pack/plugins/case/server/client/alerts/update_status.ts b/x-pack/plugins/case/server/client/alerts/update_status.ts index daaa6d7233f027..cb18bd4fc16e3e 100644 --- a/x-pack/plugins/case/server/client/alerts/update_status.ts +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -5,22 +5,24 @@ * 2.0. */ -import Boom from '@hapi/boom'; -import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../types'; +import { ElasticsearchClient } from 'src/core/server'; +import { CaseStatuses } from '../../../common/api'; +import { AlertServiceContract } from '../../services'; -export const updateAlertsStatus = ({ +interface UpdateAlertsStatusArgs { + alertsService: AlertServiceContract; + ids: string[]; + status: CaseStatuses; + indices: Set; + scopedClusterClient: ElasticsearchClient; +} + +export const updateAlertsStatus = async ({ alertsService, - request, - context, -}: CaseClientFactoryArguments) => async ({ ids, status, -}: CaseClientUpdateAlertsStatus): Promise => { - const securitySolutionClient = context?.securitySolution?.getAppClient(); - if (securitySolutionClient == null) { - throw Boom.notFound('securitySolutionClient client have not been found'); - } - - const index = securitySolutionClient.getSignalsIndex(); - await alertsService.updateAlertsStatus({ ids, status, index, request }); + indices, + scopedClusterClient, +}: UpdateAlertsStatusArgs): Promise => { + await alertsService.updateAlertsStatus({ ids, status, indices, scopedClusterClient }); }; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index 919128a2cfbc59..065825472954b8 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConnectorTypes, CasePostRequest, CaseStatuses } from '../../../common/api'; +import { ConnectorTypes, CaseStatuses, CaseType, CaseClientPostRequest } from '../../../common/api'; import { createMockSavedObjectsRepository, @@ -25,10 +25,11 @@ describe('create', () => { describe('happy path', () => { test('it creates the case correctly', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: '123', name: 'Jira', @@ -38,75 +39,100 @@ describe('create', () => { settings: { syncAlerts: true, }, - } as CasePostRequest; + }; const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "Jira", + "type": ".jira", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions - ).toEqual([ - { - attributes: { - action: 'create', - action_at: '2019-11-25T21:54:48.952Z', - action_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', + // using a snapshot here so we don't have to update the text field manually each time it changes + ).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object { + "action": "create", + "action_at": "2019-11-25T21:54:48.952Z", + "action_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "action_field": Array [ + "description", + "status", + "tags", + "title", + "connector", + "settings", + ], + "new_value": "{\\"type\\":\\"individual\\",\\"description\\":\\"This is a brand new case of a bad meanie defacing data\\",\\"title\\":\\"Super Bad Security Issue\\",\\"tags\\":[\\"defacement\\"],\\"connector\\":{\\"id\\":\\"123\\",\\"name\\":\\"Jira\\",\\"type\\":\\".jira\\",\\"fields\\":{\\"issueType\\":\\"Task\\",\\"priority\\":\\"High\\",\\"parent\\":null}},\\"settings\\":{\\"syncAlerts\\":true}}", + "old_value": null, }, - action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], - new_value: - '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}},"settings":{"syncAlerts":true}}', - old_value: null, + "references": Array [ + Object { + "id": "mock-it", + "name": "associated-cases", + "type": "cases", + }, + ], }, - references: [ - { - id: 'mock-it', - name: 'associated-cases', - type: 'cases', - }, - ], - }, - ]); + ] + `); }); test('it creates the case without connector in the configuration', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -122,36 +148,53 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('Allow user to create case without authentication', async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'This is a brand new case of a bad meanie defacing data', title: 'Super Bad Security Issue', tags: ['defacement'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -170,33 +213,45 @@ describe('create', () => { savedObjectsClient, badAuth: true, }); - const res = await caseClient.client.create({ theCase: postCase }); + const res = await caseClient.client.create(postCase); - expect(res).toEqual({ - id: 'mock-it', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); @@ -338,6 +393,7 @@ describe('create', () => { title: 'a title', description: 'This is a brand new case of a bad meanie defacing data', tags: ['defacement'], + type: CaseType.individual, status: CaseStatuses.closed, connector: { id: 'none', @@ -354,7 +410,7 @@ describe('create', () => { caseSavedObject: mockCases, }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create({ theCase: postCase }).catch((e) => { + caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); @@ -362,10 +418,11 @@ describe('create', () => { }); it(`Returns an error if postNewCase throws`, async () => { - const postCase = { + const postCase: CaseClientPostRequest = { description: 'Throw an error', title: 'Super Bad Security Issue', tags: ['error'], + type: CaseType.individual, connector: { id: 'none', name: 'none', @@ -381,7 +438,7 @@ describe('create', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.create({ theCase: postCase }).catch((e) => { + caseClient.client.create(postCase).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(400); diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index b9c2c1991537af..ee47c59072fdd9 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -10,14 +10,18 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { SavedObjectsClientContract } from 'src/core/server'; import { flattenCaseSavedObject, transformNewCase } from '../../routes/api/utils'; import { - CasePostRequestRt, throwErrors, excess, CaseResponseRt, CaseResponse, + CaseClientPostRequestRt, + CasePostRequest, + CaseType, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; import { @@ -25,22 +29,39 @@ import { transformCaseConnectorToEsConnector, } from '../../routes/api/cases/helpers'; -import { CaseClientCreate, CaseClientFactoryArguments } from '../types'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; + +interface CreateCaseArgs { + caseConfigureService: CaseConfigureServiceSetup; + caseService: CaseServiceSetup; + user: User; + savedObjectsClient: SavedObjectsClientContract; + userActionService: CaseUserActionServiceSetup; + theCase: CasePostRequest; +} -export const create = ({ +export const create = async ({ savedObjectsClient, caseService, caseConfigureService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ theCase }: CaseClientCreate): Promise => { + user, + theCase, +}: CreateCaseArgs): Promise => { + // default to an individual case if the type is not defined. + const { type = CaseType.individual, ...nonTypeCaseFields } = theCase; const query = pipe( - excess(CasePostRequestRt).decode(theCase), + // decode with the defaulted type field + excess(CaseClientPostRequestRt).decode({ type, ...nonTypeCaseFields }), fold(throwErrors(Boom.badRequest), identity) ); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); + const { username, full_name, email } = user; const createdDate = new Date().toISOString(); const myCaseConfigure = await caseConfigureService.find({ client: savedObjectsClient }); const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts index c1901ccaae511c..eab43a0c4d4536 100644 --- a/x-pack/plugins/case/server/client/cases/get.ts +++ b/x-pack/plugins/case/server/client/cases/get.ts @@ -5,17 +5,30 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { CaseResponseRt, CaseResponse } from '../../../common/api'; -import { CaseClientGet, CaseClientFactoryArguments } from '../types'; +import { CaseServiceSetup } from '../../services'; +import { countAlertsForID } from '../../common'; -export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArguments) => async ({ +interface GetParams { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + id: string; + includeComments?: boolean; + includeSubCaseComments?: boolean; +} + +export const get = async ({ + savedObjectsClient, + caseService, id, includeComments = false, -}: CaseClientGet): Promise => { + includeSubCaseComments = false, +}: GetParams): Promise => { const theCase = await caseService.getCase({ client: savedObjectsClient, - caseId: id, + id, }); if (!includeComments) { @@ -28,11 +41,12 @@ export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArgume const theComments = await caseService.getAllCaseComments({ client: savedObjectsClient, - caseId: id, + id, options: { sortField: 'created_at', sortOrder: 'asc', }, + includeSubCaseComments, }); return CaseResponseRt.encode( @@ -40,6 +54,7 @@ export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArgume savedObject: theCase, comments: theComments.saved_objects, totalComment: theComments.total, + totalAlerts: countAlertsForID({ comments: theComments, id }), }) ); }; diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts index 57e2d4373a52b8..2be9f410598312 100644 --- a/x-pack/plugins/case/server/client/cases/mock.ts +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -10,6 +10,7 @@ import { CommentType, ConnectorMappingsAttributes, CaseUserActionsResponse, + AssociationType, } from '../../../common/api'; import { BasicParams } from './types'; @@ -27,6 +28,7 @@ const entity = { }; export const comment: CommentResponse = { + associationType: AssociationType.case, id: 'mock-comment-1', comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user as const, @@ -48,6 +50,7 @@ export const comment: CommentResponse = { }; export const commentAlert: CommentResponse = { + associationType: AssociationType.case, id: 'mock-comment-1', alertId: 'alert-id-1', index: 'alert-index-1', diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts index f329fb4d00d077..1e0c246855d88b 100644 --- a/x-pack/plugins/case/server/client/cases/push.ts +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -6,9 +6,13 @@ */ import Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; - -import { SavedObjectsBulkUpdateResponse, SavedObjectsUpdateResponse } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { + SavedObjectsBulkUpdateResponse, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ActionResult, ActionsClient } from '../../../../actions/server'; +import { flattenCaseSavedObject, getAlertIndicesAndIDs } from '../../routes/api/utils'; import { ActionConnector, @@ -18,11 +22,18 @@ import { ExternalServiceResponse, ESCaseAttributes, CommentAttributes, + CaseUserActionsResponse, + User, } from '../../../common/api'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { CaseClientPush, CaseClientFactoryArguments } from '../types'; -import { createIncident, getCommentContextFromAttributes, isCommentAlertType } from './utils'; +import { createIncident, getCommentContextFromAttributes } from './utils'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; +import { CaseClientHandler } from '../client'; const createError = (e: Error | BoomType, message: string): Error | BoomType => { if (isBoom(e)) { @@ -34,30 +45,40 @@ const createError = (e: Error | BoomType, message: string): Error | BoomType => return Error(message); }; -export const push = ({ +interface PushParams { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + caseConfigureService: CaseConfigureServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; + caseId: string; + connectorId: string; + caseClient: CaseClientHandler; + actionsClient: ActionsClient; +} + +export const push = async ({ savedObjectsClient, caseService, caseConfigureService, userActionService, - request, - response, -}: CaseClientFactoryArguments) => async ({ - actionsClient, caseClient, - caseId, + actionsClient, connectorId, -}: CaseClientPush): Promise => { + caseId, + user, +}: PushParams): Promise => { /* Start of push to external service */ - let theCase; - let connector; - let userActions; + let theCase: CaseResponse; + let connector: ActionResult; + let userActions: CaseUserActionsResponse; let alerts; let connectorMappings; let externalServiceIncident; try { [theCase, connector, userActions] = await Promise.all([ - caseClient.get({ id: caseId, includeComments: true }), + caseClient.get({ id: caseId, includeComments: true, includeSubCaseComments: true }), actionsClient.get({ id: connectorId }), caseClient.getUserActions({ caseId }), ]); @@ -73,9 +94,12 @@ export const push = ({ ); } + const { ids, indices } = getAlertIndicesAndIDs(theCase?.comments); + try { alerts = await caseClient.getAlerts({ - ids: theCase?.comments?.filter(isCommentAlertType).map((comment) => comment.alertId) ?? [], + ids, + indices, }); } catch (e) { throw new Error(`Error getting alerts for case with id ${theCase.id}: ${e.message}`); @@ -84,7 +108,6 @@ export const push = ({ try { connectorMappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.actionTypeId, }); @@ -124,27 +147,26 @@ export const push = ({ /* End of push to external service */ /* Start of update case with push information */ - let user; let myCase; let myCaseConfigure; let comments; try { - [user, myCase, myCaseConfigure, comments] = await Promise.all([ - caseService.getUser({ request, response }), + [myCase, myCaseConfigure, comments] = await Promise.all([ caseService.getCase({ client: savedObjectsClient, - caseId, + id: caseId, }), caseConfigureService.find({ client: savedObjectsClient }), caseService.getAllCaseComments({ client: savedObjectsClient, - caseId, + id: caseId, options: { fields: [], page: 1, perPage: theCase?.totalComment ?? 0, }, + includeSubCaseComments: true, }), ]); } catch (e) { diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index 0fd72c86a50ba5..53e233c74deb4e 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -40,39 +40,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.closed, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); expect( caseClient.services.userActionService.postUserActions.mock.calls[0][0].actions @@ -123,39 +139,51 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.open, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it change the status of case to in-progress correctly', async () => { @@ -174,43 +202,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: { - issueType: 'Task', - parent: null, - priority: 'High', + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - id: 'mock-id-4', - external_service: null, - status: CaseStatuses['in-progress'], - tags: ['LOLBins'], - title: 'Another bad one', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, - }, - }, - ]); + ] + `); }); test('it updates a case without a connector.id', async () => { @@ -229,39 +269,54 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - id: 'mock-no-connector_id', - comments: [], - totalComment: 0, - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'elastic', email: 'testemail@elastic.co', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.closed, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it updates the connector correctly', async () => { @@ -285,47 +340,55 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - const res = await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - - expect(res).toEqual([ - { - id: 'mock-id-3', - comments: [], - totalComment: 0, - closed_at: null, - closed_by: null, - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Bug', priority: 'Low', parent: null }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - external_service: null, - title: 'Another bad one', - status: CaseStatuses.open, - tags: ['LOLBins'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'Awesome D00d', - email: 'd00d@awesome.com', - username: 'awesome', - }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + const res = await caseClient.client.update(patchCases); + + expect(res).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Bug", + "parent": null, + "priority": "Low", + }, + "id": "456", + "name": "My connector 2", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); test('it updates alert status when the status is updated and syncAlerts=true', async () => { @@ -341,20 +404,29 @@ describe('update', () => { const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, - caseCommentSavedObject: [{ ...mockCaseComments[3] }], + caseCommentSavedObject: [ + { + ...mockCaseComments[3], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-1', + }, + ], + }, + ], }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], status: 'closed', + indices: new Set(['test-index']), }); }); @@ -382,10 +454,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -414,14 +483,12 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-id'], status: 'open', + indices: new Set(['test-index']), }); }); @@ -444,10 +511,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -478,25 +542,50 @@ describe('update', () => { ...mockCases[1], }, ], - caseCommentSavedObject: [{ ...mockCaseComments[3] }, { ...mockCaseComments[4] }], + caseCommentSavedObject: [ + { + ...mockCaseComments[3], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-1', + }, + ], + }, + { + ...mockCaseComments[4], + references: [ + { + type: 'cases', + name: 'associated-cases', + id: 'mock-id-2', + }, + ], + }, + ], }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); - + await caseClient.client.update(patchCases); + /** + * the update code will put each comment into a status bucket and then make at most 1 call + * to ES for each status bucket + * Now instead of doing a call per case to get the comments, it will do a single call with all the cases + * and sub cases and get all the comments in one go + */ expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(1, { - ids: ['test-id', 'test-id-2'], + ids: ['test-id'], status: 'open', + indices: new Set(['test-index']), }); expect(caseClient.client.updateAlertsStatus).toHaveBeenNthCalledWith(2, { - ids: ['test-id', 'test-id-2'], + ids: ['test-id-2'], status: 'closed', + indices: new Set(['test-index-2']), }); }); @@ -518,10 +607,7 @@ describe('update', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client.updateAlertsStatus = jest.fn(); - await caseClient.client.update({ - caseClient: caseClient.client, - cases: patchCases, - }); + await caseClient.client.update(patchCases); expect(caseClient.client.updateAlertsStatus).not.toHaveBeenCalled(); }); @@ -607,7 +693,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(406); @@ -637,7 +723,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(404); @@ -664,7 +750,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); - caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { + caseClient.client.update(patchCases).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(409); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 99802ba47c8396..a4ca2b4cbdef98 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -10,18 +10,34 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { SavedObjectsFindResponse } from 'kibana/server'; -import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsFindResult, +} from 'kibana/server'; +import { + AlertInfo, + flattenCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, +} from '../../routes/api/utils'; import { throwErrors, excess, CasesResponseRt, - CasesPatchRequestRt, ESCasePatchRequest, CasePatchRequest, CasesResponse, CaseStatuses, + CasesPatchRequestRt, + CommentType, + ESCaseAttributes, + CaseType, + CasesPatchRequest, + AssociationType, + CommentAttributes, + User, } from '../../../common/api'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { @@ -29,17 +45,296 @@ import { transformCaseConnectorToEsConnector, } from '../../routes/api/cases/helpers'; -import { CaseClientUpdate, CaseClientFactoryArguments } from '../types'; +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { + CASE_COMMENT_SAVED_OBJECT, + CASE_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; +import { CaseClientHandler } from '..'; +import { addAlertInfoToStatusMap } from '../../common'; + +/** + * Throws an error if any of the requests attempt to update a collection style cases' status field. + */ +function throwIfUpdateStatusOfCollection( + requests: ESCasePatchRequest[], + casesMap: Map> +) { + const requestsUpdatingStatusOfCollection = requests.filter( + (req) => + req.status !== undefined && casesMap.get(req.id)?.attributes.type === CaseType.collection + ); + + if (requestsUpdatingStatusOfCollection.length > 0) { + const ids = requestsUpdatingStatusOfCollection.map((req) => req.id); + throw Boom.badRequest( + `Updating the status of a collection is not allowed ids: [${ids.join(', ')}]` + ); + } +} + +/** + * Throws an error if any of the requests attempt to update a collection style case to an individual one. + */ +function throwIfUpdateTypeCollectionToIndividual( + requests: ESCasePatchRequest[], + casesMap: Map> +) { + const requestsUpdatingTypeCollectionToInd = requests.filter( + (req) => + req.type === CaseType.individual && + casesMap.get(req.id)?.attributes.type === CaseType.collection + ); + + if (requestsUpdatingTypeCollectionToInd.length > 0) { + const ids = requestsUpdatingTypeCollectionToInd.map((req) => req.id); + throw Boom.badRequest( + `Converting a collection to an individual case is not allowed ids: [${ids.join(', ')}]` + ); + } +} + +/** + * Throws an error if any of the requests attempt to update an individual style cases' type field to a collection + * when alerts are attached to the case. + */ +async function throwIfInvalidUpdateOfTypeWithAlerts({ + requests, + caseService, + client, +}: { + requests: ESCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}) { + const getAlertsForID = async (caseToUpdate: ESCasePatchRequest) => { + const alerts = await caseService.getAllCaseComments({ + client, + id: caseToUpdate.id, + options: { + fields: [], + // there should never be generated alerts attached to an individual case but we'll check anyway + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + page: 1, + perPage: 1, + }, + }); + + return { id: caseToUpdate.id, alerts }; + }; + + const requestsUpdatingTypeField = requests.filter((req) => req.type === CaseType.collection); + const casesAlertTotals = await Promise.all( + requestsUpdatingTypeField.map((caseToUpdate) => getAlertsForID(caseToUpdate)) + ); + + // grab the cases that have at least one alert comment attached to them + const typeUpdateWithAlerts = casesAlertTotals.filter((caseInfo) => caseInfo.alerts.total > 0); + + if (typeUpdateWithAlerts.length > 0) { + const ids = typeUpdateWithAlerts.map((req) => req.id); + throw Boom.badRequest( + `Converting a case to a collection is not allowed when it has alert comments, ids: [${ids.join( + ', ' + )}]` + ); + } +} + +/** + * Get the id from a reference in a comment for a specific type. + */ +function getID( + comment: SavedObject, + type: typeof CASE_SAVED_OBJECT | typeof SUB_CASE_SAVED_OBJECT +): string | undefined { + return comment.references.find((ref) => ref.type === type)?.id; +} + +/** + * Gets all the alert comments (generated or user alerts) for the requested cases. + */ +async function getAlertComments({ + casesToSync, + caseService, + client, +}: { + casesToSync: ESCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const idsOfCasesToSync = casesToSync.map((casePatchReq) => casePatchReq.id); + + // getAllCaseComments will by default get all the comments, unless page or perPage fields are set + return caseService.getAllCaseComments({ + client, + id: idsOfCasesToSync, + includeSubCaseComments: true, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); +} + +/** + * Returns a map of sub case IDs to their status. This uses a group of alert comments to determine which sub cases should + * be retrieved. This is based on whether the comment is associated to a sub case. + */ +async function getSubCasesToStatus({ + totalAlerts, + caseService, + client, +}: { + totalAlerts: SavedObjectsFindResponse; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const subCasesToRetrieve = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if ( + isCommentRequestTypeAlertOrGenAlert(alertComment.attributes) && + alertComment.attributes.associationType === AssociationType.subCase + ) { + const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); + if (id !== undefined) { + acc.add(id); + } + } + return acc; + }, new Set()); + + const subCases = await caseService.getSubCases({ + ids: Array.from(subCasesToRetrieve.values()), + client, + }); + + return subCases.saved_objects.reduce((acc, subCase) => { + // log about the sub cases that we couldn't find + if (!subCase.error) { + acc.set(subCase.id, subCase.attributes.status); + } + return acc; + }, new Map()); +} + +/** + * Returns what status the alert comment should have based on whether it is associated to a case or sub case. + */ +function getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, +}: { + alertComment: SavedObjectsFindResult; + casesToSyncToStatus: Map; + subCasesToStatus: Map; +}): CaseStatuses { + let status: CaseStatuses = CaseStatuses.open; + if (alertComment.attributes.associationType === AssociationType.case) { + const id = getID(alertComment, CASE_SAVED_OBJECT); + // We should log if we can't find the status + // attempt to get the case status from our cases to sync map if we found the ID otherwise default to open + status = + id !== undefined ? casesToSyncToStatus.get(id) ?? CaseStatuses.open : CaseStatuses.open; + } else if (alertComment.attributes.associationType === AssociationType.subCase) { + const id = getID(alertComment, SUB_CASE_SAVED_OBJECT); + status = id !== undefined ? subCasesToStatus.get(id) ?? CaseStatuses.open : CaseStatuses.open; + } + return status; +} + +/** + * Updates the alert ID's status field based on the patch requests + */ +async function updateAlerts({ + casesWithSyncSettingChangedToOn, + casesWithStatusChangedAndSynced, + casesMap, + caseService, + client, + caseClient, +}: { + casesWithSyncSettingChangedToOn: ESCasePatchRequest[]; + casesWithStatusChangedAndSynced: ESCasePatchRequest[]; + casesMap: Map>; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClientHandler; +}) { + /** + * It's possible that a case ID can appear multiple times in each array. I'm intentionally placing the status changes + * last so when the map is built we will use the last status change as the source of truth. + */ + const casesToSync = [...casesWithSyncSettingChangedToOn, ...casesWithStatusChangedAndSynced]; + + // build a map of case id to the status it has + // this will have collections in it but the alerts should be associated to sub cases and not collections so it shouldn't + // matter. + const casesToSyncToStatus = casesToSync.reduce((acc, caseInfo) => { + acc.set( + caseInfo.id, + caseInfo.status ?? casesMap.get(caseInfo.id)?.attributes.status ?? CaseStatuses.open + ); + return acc; + }, new Map()); + + // get all the alerts for all the alert comments for all cases and collections. Collections themselves won't have any + // but their sub cases could + const totalAlerts = await getAlertComments({ + casesToSync, + caseService, + client, + }); + + // get a map of sub case id to the sub case status + const subCasesToStatus = await getSubCasesToStatus({ totalAlerts, client, caseService }); + + // create a map of the case statuses to the alert information that we need to update for that status + // This allows us to make at most 3 calls to ES, one for each status type that we need to update + // One potential improvement here is to do a tick (set timeout) to reduce the memory footprint if that becomes an issue + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const status = getSyncStatusForComment({ + alertComment, + casesToSyncToStatus, + subCasesToStatus, + }); + + addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); + } -export const update = ({ + return acc; + }, new Map()); + + // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress + for (const [status, alertInfo] of alertsToUpdate.entries()) { + if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { + caseClient.updateAlertsStatus({ + ids: alertInfo.ids, + status, + indices: alertInfo.indices, + }); + } + } +} + +interface UpdateArgs { + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; + caseClient: CaseClientHandler; + cases: CasesPatchRequest; +} + +export const update = async ({ savedObjectsClient, caseService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ + user, caseClient, cases, -}: CaseClientUpdate): Promise => { +}: UpdateArgs): Promise => { const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) @@ -95,133 +390,119 @@ export const update = ({ return Object.keys(updateCaseAttributes).length > 0; }); - if (updateFilterCases.length > 0) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const updatedDt = new Date().toISOString(); - const updatedCases = await caseService.patchCases({ - client: savedObjectsClient, - cases: updateFilterCases.map((thisCase) => { - const { id: caseId, version, ...updateCaseAttributes } = thisCase; - let closedInfo = {}; - if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { - closedInfo = { - closed_at: updatedDt, - closed_by: { email, full_name, username }, - }; - } else if ( - updateCaseAttributes.status && - (updateCaseAttributes.status === CaseStatuses.open || - updateCaseAttributes.status === CaseStatuses['in-progress']) - ) { - closedInfo = { - closed_at: null, - closed_by: null, - }; - } - return { - caseId, - updatedAttributes: { - ...updateCaseAttributes, - ...closedInfo, - updated_at: updatedDt, - updated_by: { email, full_name, username }, - }, - version, - }; - }), - }); + if (updateFilterCases.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } - // If a status update occurred and the case is synced then we need to update all alerts' status - // attached to the case to the new status. - const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.status != null && - currentCase.attributes.status !== caseToUpdate.status && - currentCase.attributes.settings.syncAlerts - ); - }); + const casesMap = myCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); - // If syncAlerts setting turned on we need to update all alerts' status - // attached to the case to the current status. - const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); - return ( - currentCase != null && - caseToUpdate.settings?.syncAlerts != null && - currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && - caseToUpdate.settings.syncAlerts - ); - }); + throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); + throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); + await throwIfInvalidUpdateOfTypeWithAlerts({ + requests: updateFilterCases, + caseService, + client: savedObjectsClient, + }); - for (const theCase of [ - ...casesWithSyncSettingChangedToOn, - ...casesWithStatusChangedAndSynced, - ]) { - const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); - const totalComments = await caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId: theCase.id, - options: { - fields: [], - filter: 'cases-comments.attributes.type: alert', - page: 1, - perPage: 1, + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const updatedDt = new Date().toISOString(); + const updatedCases = await caseService.patchCases({ + client: savedObjectsClient, + cases: updateFilterCases.map((thisCase) => { + const { id: caseId, version, ...updateCaseAttributes } = thisCase; + let closedInfo = {}; + if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { + closedInfo = { + closed_at: updatedDt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateCaseAttributes.status && + (updateCaseAttributes.status === CaseStatuses.open || + updateCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + caseId, + updatedAttributes: { + ...updateCaseAttributes, + ...closedInfo, + updated_at: updatedDt, + updated_by: { email, full_name, username }, }, - }); + version, + }; + }), + }); - const caseComments = (await caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId: theCase.id, - options: { - fields: [], - filter: 'cases-comments.attributes.type: alert', - page: 1, - perPage: totalComments.total, - }, - // The filter guarantees that the comments will be of type alert - })) as SavedObjectsFindResponse<{ alertId: string }>; - - const commentIds = caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId); - if (commentIds.length > 0) { - caseClient.updateAlertsStatus({ - ids: commentIds, - // Either there is a status update or the syncAlerts got turned on. - status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, - }); - } - } + // If a status update occurred and the case is synced then we need to update all alerts' status + // attached to the case to the new status. + const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.status != null && + currentCase.attributes.status !== caseToUpdate.status && + currentCase.attributes.settings.syncAlerts + ); + }); - const returnUpdatedCase = myCases.saved_objects - .filter((myCase) => - updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) - ) - .map((myCase) => { - const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); - return flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - version: updatedCase?.version ?? myCase.version, - }, - }); - }); + // If syncAlerts setting turned on we need to update all alerts' status + // attached to the case to the current status. + const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.settings?.syncAlerts != null && + currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && + caseToUpdate.settings.syncAlerts + ); + }); + + // Update the alert's status to match any case status or sync settings changes + await updateAlerts({ + casesWithStatusChangedAndSynced, + casesWithSyncSettingChangedToOn, + caseService, + client: savedObjectsClient, + caseClient, + casesMap, + }); - await userActionService.postUserActions({ - client: savedObjectsClient, - actions: buildCaseUserActions({ - originalCases: myCases.saved_objects, - updatedCases: updatedCases.saved_objects, - actionDate: updatedDt, - actionBy: { email, full_name, username }, - }), + const returnUpdatedCase = myCases.saved_objects + .filter((myCase) => + updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) + ) + .map((myCase) => { + const updatedCase = updatedCases.saved_objects.find((c) => c.id === myCase.id); + return flattenCaseSavedObject({ + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + version: updatedCase?.version ?? myCase.version, + }, + }); }); - return CasesResponseRt.encode(returnUpdatedCase); - } - throw Boom.notAcceptable('All update fields are identical to current version.'); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + }); + + return CasesResponseRt.encode(returnUpdatedCase); }; diff --git a/x-pack/plugins/case/server/client/cases/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts index dca2c34602678b..361d0fb561afd7 100644 --- a/x-pack/plugins/case/server/client/cases/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -537,12 +537,12 @@ describe('utils', () => { }, { comment: - 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', commentId: 'comment-alert-1', }, { comment: - 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + 'Alert with ids alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', commentId: 'comment-alert-2', }, ]); diff --git a/x-pack/plugins/case/server/client/cases/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts index 6974fd4ffa2883..78bdc6d282c698 100644 --- a/x-pack/plugins/case/server/client/cases/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -38,6 +38,7 @@ import { TransformerArgs, TransformFieldsArgs, } from './types'; +import { getAlertIds } from '../../routes/api/utils'; export const getLatestPushInfo = ( connectorId: string, @@ -66,8 +67,9 @@ const isConnectorSupported = (connectorId: string): connectorId is FormatterConn const getCommentContent = (comment: CommentResponse): string => { if (comment.type === CommentType.user) { return comment.comment; - } else if (comment.type === CommentType.alert) { - return `Alert with id ${comment.alertId} added to case`; + } else if (comment.type === CommentType.alert || comment.type === CommentType.generatedAlert) { + const ids = getAlertIds(comment); + return `Alert with ids ${ids.join(', ')} added to case`; } return ''; @@ -306,9 +308,10 @@ export const getCommentContextFromAttributes = ( type: CommentType.user, comment: attributes.comment, }; + case CommentType.generatedAlert: case CommentType.alert: return { - type: CommentType.alert, + type: attributes.type, alertId: attributes.alertId, index: attributes.index, }; diff --git a/x-pack/plugins/case/server/client/client.ts b/x-pack/plugins/case/server/client/client.ts new file mode 100644 index 00000000000000..c684548decbe6f --- /dev/null +++ b/x-pack/plugins/case/server/client/client.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; +import { + CaseClientFactoryArguments, + CaseClient, + ConfigureFields, + MappingsClient, + CaseClientUpdateAlertsStatus, + CaseClientAddComment, + CaseClientGet, + CaseClientGetUserActions, + CaseClientGetAlerts, + CaseClientPush, +} from './types'; +import { create } from './cases/create'; +import { update } from './cases/update'; +import { addComment } from './comments/add'; +import { getFields } from './configure/get_fields'; +import { getMappings } from './configure/get_mappings'; +import { updateAlertsStatus } from './alerts/update_status'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + ConnectorMappingsServiceSetup, + CaseUserActionServiceSetup, + AlertServiceContract, +} from '../services'; +import { CasesPatchRequest, CasePostRequest, User } from '../../common/api'; +import { get } from './cases/get'; +import { get as getUserActions } from './user_actions/get'; +import { get as getAlerts } from './alerts/get'; +import { push } from './cases/push'; + +/** + * This class is a pass through for common case functionality (like creating, get a case). + */ +export class CaseClientHandler implements CaseClient { + private readonly _scopedClusterClient: ElasticsearchClient; + private readonly _caseConfigureService: CaseConfigureServiceSetup; + private readonly _caseService: CaseServiceSetup; + private readonly _connectorMappingsService: ConnectorMappingsServiceSetup; + private readonly user: User; + private readonly _savedObjectsClient: SavedObjectsClientContract; + private readonly _userActionService: CaseUserActionServiceSetup; + private readonly _alertsService: AlertServiceContract; + + constructor(clientArgs: CaseClientFactoryArguments) { + this._scopedClusterClient = clientArgs.scopedClusterClient; + this._caseConfigureService = clientArgs.caseConfigureService; + this._caseService = clientArgs.caseService; + this._connectorMappingsService = clientArgs.connectorMappingsService; + this.user = clientArgs.user; + this._savedObjectsClient = clientArgs.savedObjectsClient; + this._userActionService = clientArgs.userActionService; + this._alertsService = clientArgs.alertsService; + } + + public async create(caseInfo: CasePostRequest) { + return create({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + caseConfigureService: this._caseConfigureService, + userActionService: this._userActionService, + user: this.user, + theCase: caseInfo, + }); + } + + public async update(cases: CasesPatchRequest) { + return update({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + cases, + caseClient: this, + }); + } + + public async addComment({ caseId, comment }: CaseClientAddComment) { + return addComment({ + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + caseClient: this, + caseId, + comment, + user: this.user, + }); + } + + public async getFields(fields: ConfigureFields) { + return getFields(fields); + } + + public async getMappings(args: MappingsClient) { + return getMappings({ + ...args, + savedObjectsClient: this._savedObjectsClient, + connectorMappingsService: this._connectorMappingsService, + caseClient: this, + }); + } + + public async updateAlertsStatus(args: CaseClientUpdateAlertsStatus) { + return updateAlertsStatus({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + }); + } + + public async get(args: CaseClientGet) { + return get({ + ...args, + caseService: this._caseService, + savedObjectsClient: this._savedObjectsClient, + }); + } + + public async getUserActions(args: CaseClientGetUserActions) { + return getUserActions({ + ...args, + savedObjectsClient: this._savedObjectsClient, + userActionService: this._userActionService, + }); + } + + public async getAlerts(args: CaseClientGetAlerts) { + return getAlerts({ + ...args, + alertsService: this._alertsService, + scopedClusterClient: this._scopedClusterClient, + }); + } + + public async push(args: CaseClientPush) { + return push({ + ...args, + savedObjectsClient: this._savedObjectsClient, + caseService: this._caseService, + userActionService: this._userActionService, + user: this.user, + caseClient: this, + caseConfigureService: this._caseConfigureService, + }); + } +} diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index 746176284a2928..315203a1f5e1d9 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -32,7 +32,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -42,22 +41,25 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it adds a comment of type alert correctly', async () => { @@ -68,7 +70,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -79,23 +80,26 @@ describe('addComment', () => { expect(res.id).toEqual('mock-id-1'); expect(res.totalComment).toEqual(res.comments!.length); - expect(res.comments![res.comments!.length - 1]).toEqual({ - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: 'd00d@awesome.com', - full_name: 'Awesome D00d', - username: 'awesome', - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "alertId": "test-id", + "associationType": "case", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "id": "mock-comment", + "index": "test-index", + "pushed_at": null, + "pushed_by": null, + "type": "alert", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it updates the case correctly after adding a comment', async () => { @@ -106,7 +110,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -130,7 +133,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -181,7 +183,6 @@ describe('addComment', () => { badAuth: true, }); const res = await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -190,22 +191,25 @@ describe('addComment', () => { }); expect(res.id).toEqual('mock-id-1'); - expect(res.comments![res.comments!.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2020-10-23T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(res.comments![res.comments!.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2020-10-23T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); test('it update the status of the alert if the case is synced with alerts', async () => { @@ -222,7 +226,6 @@ describe('addComment', () => { caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -234,6 +237,7 @@ describe('addComment', () => { expect(caseClient.client.updateAlertsStatus).toHaveBeenCalledWith({ ids: ['test-alert'], status: 'open', + indices: new Set(['test-index']), }); }); @@ -256,7 +260,6 @@ describe('addComment', () => { caseClient.client.updateAlertsStatus = jest.fn(); await caseClient.client.addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -336,7 +339,6 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -398,7 +400,6 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -425,7 +426,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'not-exists', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -449,7 +449,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Throw an error', @@ -474,7 +473,6 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); caseClient.client .addComment({ - caseClient: caseClient.client, caseId: 'mock-id-4', comment: { type: CommentType.alert, diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 58d7c9abcbfd3a..7dd1b4a8f6c5cd 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -10,135 +10,297 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; +import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; +import { + decodeCommentRequest, + getAlertIds, + isCommentRequestTypeGenAlert, +} from '../../routes/api/utils'; import { throwErrors, - CaseResponseRt, CommentRequestRt, - CaseResponse, CommentType, CaseStatuses, + CaseType, + SubCaseAttributes, + CommentRequest, + CollectionWithSubCaseResponse, + User, + CommentRequestAlertType, + AlertCommentRequestRt, } from '../../../common/api'; -import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; +import { + buildCaseUserActionItem, + buildCommentUserActionItem, +} from '../../services/user_actions/helpers'; + +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services'; +import { CommentableCase } from '../../common'; +import { CaseClientHandler } from '..'; + +async function getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt, + userActionService, + user, +}: { + caseService: CaseServiceSetup; + savedObjectsClient: SavedObjectsClientContract; + caseId: string; + createdAt: string; + userActionService: CaseUserActionServiceSetup; + user: User; +}): Promise> { + const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId); + if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) { + return mostRecentSubCase; + } -import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; -import { CASE_SAVED_OBJECT } from '../../saved_object_types'; + const newSubCase = await caseService.createSubCase({ + client: savedObjectsClient, + createdAt, + caseId, + createdBy: user, + }); + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdAt, + actionBy: user, + caseId, + subCaseId: newSubCase.id, + fields: ['status', 'sub_case'], + newValue: JSON.stringify({ status: newSubCase.attributes.status }), + }), + ], + }); + return newSubCase; +} + +interface AddCommentFromRuleArgs { + caseClient: CaseClientHandler; + caseId: string; + comment: CommentRequestAlertType; + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; +} -export const addComment = ({ +const addGeneratedAlerts = async ({ savedObjectsClient, caseService, userActionService, - request, -}: CaseClientFactoryArguments) => async ({ caseClient, caseId, comment, -}: CaseClientAddComment): Promise => { +}: AddCommentFromRuleArgs): Promise => { const query = pipe( - CommentRequestRt.decode(comment), + AlertCommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); - decodeComment(comment); + decodeCommentRequest(comment); + + // This function only supports adding generated alerts + if (comment.type !== CommentType.generatedAlert) { + throw Boom.internal('Attempting to add a non generated alert in the wrong context'); + } + const createdDate = new Date().toISOString(); - const myCase = await caseService.getCase({ + const caseInfo = await caseService.getCase({ client: savedObjectsClient, - caseId, + id: caseId, }); - // An alert cannot be attach to a closed case. - if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { - throw Boom.badRequest('Alert cannot be attached to a closed case'); + if ( + query.type === CommentType.generatedAlert && + caseInfo.attributes.type !== CaseType.collection + ) { + throw Boom.badRequest('Sub case style alert comment cannot be added to an individual case'); } - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request }); - const createdDate = new Date().toISOString(); + const userDetails: User = { + username: caseInfo.attributes.created_by?.username, + full_name: caseInfo.attributes.created_by?.full_name, + email: caseInfo.attributes.created_by?.email, + }; - const [newComment, updatedCase] = await Promise.all([ - caseService.postNewComment({ - client: savedObjectsClient, - attributes: transformNewComment({ - createdDate, - ...query, - username, - full_name, - email, - }), - references: [ - { - type: CASE_SAVED_OBJECT, - name: `associated-${CASE_SAVED_OBJECT}`, - id: myCase.id, - }, - ], - }), - caseService.patchCase({ - client: savedObjectsClient, - caseId, - updatedAttributes: { - updated_at: createdDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, - }), - ]); + const subCase = await getSubCase({ + caseService, + savedObjectsClient, + caseId, + createdAt: createdDate, + userActionService, + user: userDetails, + }); + + const commentableCase = new CommentableCase({ + collection: caseInfo, + subCase, + soClient: savedObjectsClient, + service: caseService, + }); - // If the case is synced with alerts the newly attached alert must match the status of the case. - if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { + const { + comment: newComment, + commentableCase: updatedCase, + } = await commentableCase.createComment({ createdDate, user: userDetails, commentReq: query }); + + if ( + (newComment.attributes.type === CommentType.alert || + newComment.attributes.type === CommentType.generatedAlert) && + caseInfo.attributes.settings.syncAlerts + ) { + const ids = getAlertIds(query); await caseClient.updateAlertsStatus({ - ids: [newComment.attributes.alertId], - status: myCase.attributes.status, + ids, + status: subCase.attributes.status, + indices: new Set([newComment.attributes.index]), }); } - const totalCommentsFindByCases = await caseService.getAllCaseComments({ + await userActionService.postUserActions({ client: savedObjectsClient, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { ...userDetails }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client: savedObjectsClient, - caseId, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, + return updatedCase.encode(); +}; + +async function getCombinedCase( + service: CaseServiceSetup, + client: SavedObjectsClientContract, + id: string +): Promise { + const [casePromise, subCasePromise] = await Promise.allSettled([ + service.getCase({ + client, + id, }), - userActionService.postUserActions({ - client: savedObjectsClient, - actions: [ - buildCommentUserActionItem({ - action: 'create', - actionAt: createdDate, - actionBy: { username, full_name, email }, - caseId: myCase.id, - commentId: newComment.id, - fields: ['comment'], - newValue: JSON.stringify(query), - }), - ], + service.getSubCase({ + client, + id, }), ]); - return CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - comments: comments.saved_objects, - }) + if (subCasePromise.status === 'fulfilled') { + if (subCasePromise.value.references.length > 0) { + const caseValue = await service.getCase({ + client, + id: subCasePromise.value.references[0].id, + }); + return new CommentableCase({ + collection: caseValue, + subCase: subCasePromise.value, + service, + soClient: client, + }); + } else { + throw Boom.badRequest('Sub case found without reference to collection'); + } + } + + if (casePromise.status === 'rejected') { + throw casePromise.reason; + } else { + return new CommentableCase({ collection: casePromise.value, service, soClient: client }); + } +} + +interface AddCommentArgs { + caseClient: CaseClientHandler; + caseId: string; + comment: CommentRequest; + savedObjectsClient: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + user: User; +} + +export const addComment = async ({ + savedObjectsClient, + caseService, + userActionService, + caseClient, + caseId, + comment, + user, +}: AddCommentArgs): Promise => { + const query = pipe( + CommentRequestRt.decode(comment), + fold(throwErrors(Boom.badRequest), identity) ); + + if (isCommentRequestTypeGenAlert(comment)) { + return addGeneratedAlerts({ + caseId, + comment, + caseClient, + savedObjectsClient, + userActionService, + caseService, + }); + } + + decodeCommentRequest(comment); + const createdDate = new Date().toISOString(); + + const combinedCase = await getCombinedCase(caseService, savedObjectsClient, caseId); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const userInfo: User = { + username, + full_name, + email, + }; + + const { comment: newComment, commentableCase: updatedCase } = await combinedCase.createComment({ + createdDate, + user: userInfo, + commentReq: query, + }); + + if (newComment.attributes.type === CommentType.alert && updatedCase.settings.syncAlerts) { + const ids = getAlertIds(query); + await caseClient.updateAlertsStatus({ + ids, + status: updatedCase.status, + indices: new Set([newComment.attributes.index]), + }); + } + + await userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: updatedCase.caseId, + subCaseId: updatedCase.subCaseId, + commentId: newComment.id, + fields: ['comment'], + newValue: JSON.stringify(query), + }), + ], + }); + + return updatedCase.encode(); }; diff --git a/x-pack/plugins/case/server/client/configure/get_fields.ts b/x-pack/plugins/case/server/client/configure/get_fields.ts index a797e120b971bd..deabae33810b2e 100644 --- a/x-pack/plugins/case/server/client/configure/get_fields.ts +++ b/x-pack/plugins/case/server/client/configure/get_fields.ts @@ -11,7 +11,7 @@ import { GetFieldsResponse } from '../../../common/api'; import { ConfigureFields } from '../types'; import { createDefaultMapping, formatFields } from './utils'; -export const getFields = () => async ({ +export const getFields = async ({ actionsClient, connectorType, connectorId, diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts index 4ec9fa7e8e8c2d..d4dad182d815e5 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.test.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.test.ts @@ -31,7 +31,6 @@ describe('get_mappings', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, - caseClient: caseClient.client, connectorType: ConnectorTypes.jira, connectorId: '123', }); @@ -45,7 +44,6 @@ describe('get_mappings', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient({ savedObjectsClient }); const res = await caseClient.client.getMappings({ actionsClient: actionsMock, - caseClient: caseClient.client, connectorType: ConnectorTypes.jira, connectorId: '123', }); diff --git a/x-pack/plugins/case/server/client/configure/get_mappings.ts b/x-pack/plugins/case/server/client/configure/get_mappings.ts index a2d2711264b137..5dd90efd8a2d74 100644 --- a/x-pack/plugins/case/server/client/configure/get_mappings.ts +++ b/x-pack/plugins/case/server/client/configure/get_mappings.ts @@ -5,20 +5,31 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'src/core/server'; +import { ActionsClient } from '../../../../actions/server'; import { ConnectorMappingsAttributes, ConnectorTypes } from '../../../common/api'; -import { CaseClientFactoryArguments, MappingsClient } from '../types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server/saved_objects'; +import { ConnectorMappingsServiceSetup } from '../../services'; +import { CaseClientHandler } from '..'; -export const getMappings = ({ +interface GetMappingsArgs { + savedObjectsClient: SavedObjectsClientContract; + connectorMappingsService: ConnectorMappingsServiceSetup; + actionsClient: ActionsClient; + caseClient: CaseClientHandler; + connectorType: string; + connectorId: string; +} + +export const getMappings = async ({ savedObjectsClient, connectorMappingsService, -}: CaseClientFactoryArguments) => async ({ actionsClient, caseClient, connectorType, connectorId, -}: MappingsClient): Promise => { +}: GetMappingsArgs): Promise => { if (connectorType === ConnectorTypes.none) { return []; } diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 4daa4d1c0bd8b4..8a085bf29f2147 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -5,9 +5,11 @@ * 2.0. */ -import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { createCaseClient } from '.'; +import { + elasticsearchServiceMock, + savedObjectsClientMock, +} from '../../../../../src/core/server/mocks'; +import { nullUser } from '../common'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -16,87 +18,30 @@ import { createAlertServiceMock, } from '../services/mocks'; -import { create } from './cases/create'; -import { get } from './cases/get'; -import { update } from './cases/update'; -import { push } from './cases/push'; -import { addComment } from './comments/add'; -import { getFields } from './configure/get_fields'; -import { getMappings } from './configure/get_mappings'; -import { updateAlertsStatus } from './alerts/update_status'; -import { get as getUserActions } from './user_actions/get'; -import { get as getAlerts } from './alerts/get'; -import type { CasesRequestHandlerContext } from '../types'; - -jest.mock('./cases/create'); -jest.mock('./cases/update'); -jest.mock('./cases/get'); -jest.mock('./cases/push'); -jest.mock('./comments/add'); -jest.mock('./alerts/update_status'); -jest.mock('./alerts/get'); -jest.mock('./user_actions/get'); -jest.mock('./configure/get_fields'); -jest.mock('./configure/get_mappings'); +jest.mock('./client'); +import { CaseClientHandler } from './client'; +import { createExternalCaseClient } from './index'; +const esClient = elasticsearchServiceMock.createElasticsearchClient(); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); const connectorMappingsService = connectorMappingsServiceMock(); -const request = {} as KibanaRequest; -const response = kibanaResponseFactory; const savedObjectsClient = savedObjectsClientMock.create(); const userActionService = createUserActionServiceMock(); -const context = {} as CasesRequestHandlerContext; -const createMock = create as jest.Mock; -const getMock = get as jest.Mock; -const updateMock = update as jest.Mock; -const pushMock = push as jest.Mock; -const addCommentMock = addComment as jest.Mock; -const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; -const getAlertsStatusMock = getAlerts as jest.Mock; -const getFieldsMock = getFields as jest.Mock; -const getMappingsMock = getMappings as jest.Mock; -const getUserActionsMock = getUserActions as jest.Mock; - -describe('createCaseClient()', () => { +describe('createExternalCaseClient()', () => { test('it creates the client correctly', async () => { - createCaseClient({ + createExternalCaseClient({ + scopedClusterClient: esClient, alertsService, caseConfigureService, caseService, connectorMappingsService, - context, - request, - response, + user: nullUser, savedObjectsClient, userActionService, }); - - [ - createMock, - getMock, - updateMock, - pushMock, - addCommentMock, - updateAlertsStatusMock, - getAlertsStatusMock, - getFieldsMock, - getMappingsMock, - getUserActionsMock, - ].forEach((method) => - expect(method).toHaveBeenCalledWith({ - caseConfigureService, - caseService, - connectorMappingsService, - request, - response, - savedObjectsClient, - userActionService, - alertsService, - context, - }) - ); + expect(CaseClientHandler).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index e15b9fc766562b..900b5a92ebf924 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -5,41 +5,16 @@ * 2.0. */ -import { - CaseClientFactoryArguments, - CaseClient, - CaseClientFactoryMethods, - CaseClientMethods, -} from './types'; -import { create } from './cases/create'; -import { get } from './cases/get'; -import { update } from './cases/update'; -import { push } from './cases/push'; -import { addComment } from './comments/add'; -import { getFields } from './configure/get_fields'; -import { getMappings } from './configure/get_mappings'; -import { updateAlertsStatus } from './alerts/update_status'; -import { get as getUserActions } from './user_actions/get'; -import { get as getAlerts } from './alerts/get'; +import { CaseClientFactoryArguments, CaseClient } from './types'; +import { CaseClientHandler } from './client'; +export { CaseClientHandler } from './client'; export { CaseClient } from './types'; -export const createCaseClient = (args: CaseClientFactoryArguments): CaseClient => { - const methods: CaseClientFactoryMethods = { - create, - get, - update, - push, - addComment, - getAlerts, - getFields, - getMappings, - getUserActions, - updateAlertsStatus, - }; - - return (Object.keys(methods) as CaseClientMethods[]).reduce((client, method) => { - client[method] = methods[method](args); - return client; - }, {} as CaseClient); +/** + * Create a CaseClientHandler to external services (other plugins). + */ +export const createExternalCaseClient = (clientArgs: CaseClientFactoryArguments): CaseClient => { + const client = new CaseClientHandler(clientArgs); + return client; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index b2a07e36b3aed7..302745913babbd 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -5,10 +5,7 @@ * 2.0. */ -import { omit } from 'lodash/fp'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server/http'; -import { loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; import { AlertServiceContract, CaseConfigureService, @@ -17,12 +14,11 @@ import { ConnectorMappingsService, } from '../services'; import { CaseClient } from './types'; -import { authenticationMock, createActionsClient } from '../routes/api/__fixtures__'; -import { createCaseClient } from '.'; -import type { CasesRequestHandlerContext } from '../types'; +import { authenticationMock } from '../routes/api/__fixtures__'; +import { createExternalCaseClient } from '.'; -export type CaseClientMock = jest.Mocked; -export const createCaseClientMock = (): CaseClientMock => ({ +export type CaseClientPluginContractMock = jest.Mocked; +export const createExternalCaseClientMock = (): CaseClientPluginContractMock => ({ addComment: jest.fn(), create: jest.fn(), get: jest.fn(), @@ -50,18 +46,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ alertsService: jest.Mocked; }; }> => { - const actionsMock = createActionsClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + // const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); - const request = {} as KibanaRequest; - const response = kibanaResponseFactory; - const caseServicePlugin = new CaseService(log); + const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + const caseService = new CaseService(log, auth); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const connectorMappingsService = await connectorMappingsServicePlugin.setup(); @@ -76,33 +69,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ getAlerts: jest.fn(), }; - const context = { - core: { - savedObjects: { - client: savedObjectsClient, - }, - }, - actions: { getActionsClient: () => actionsMock }, - case: { - getCaseClient: () => caseClient, - }, - securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - }, - }; - - const caseClient = createCaseClient({ + const caseClient = createExternalCaseClient({ savedObjectsClient, - request, - response, + user: auth.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context: (omit(omitFromContext, context) as unknown) as CasesRequestHandlerContext, + scopedClusterClient: esClient, }); return { client: caseClient, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 8778aa46a2d244..a8f64227daf83f 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KibanaRequest, KibanaResponseFactory, SavedObjectsClientContract } from 'kibana/server'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CasePostRequest, @@ -13,10 +13,12 @@ import { CasesPatchRequest, CasesResponse, CaseStatuses, + CollectionWithSubCaseResponse, CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, CaseUserActionsResponse, + User, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -25,32 +27,21 @@ import { AlertServiceContract, } from '../services'; import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; -import type { CasesRequestHandlerContext } from '../types'; import { CaseClientGetAlertsResponse } from './alerts/types'; -export interface CaseClientCreate { - theCase: CasePostRequest; -} - -export interface CaseClientUpdate { - caseClient: CaseClient; - cases: CasesPatchRequest; -} - export interface CaseClientGet { id: string; includeComments?: boolean; + includeSubCaseComments?: boolean; } export interface CaseClientPush { actionsClient: ActionsClient; - caseClient: CaseClient; caseId: string; connectorId: string; } export interface CaseClientAddComment { - caseClient: CaseClient; caseId: string; comment: CommentRequest; } @@ -58,10 +49,12 @@ export interface CaseClientAddComment { export interface CaseClientUpdateAlertsStatus { ids: string[]; status: CaseStatuses; + indices: Set; } export interface CaseClientGetAlerts { ids: string[]; + indices: Set; } export interface CaseClientGetUserActions { @@ -70,21 +63,19 @@ export interface CaseClientGetUserActions { export interface MappingsClient { actionsClient: ActionsClient; - caseClient: CaseClient; connectorId: string; connectorType: string; } export interface CaseClientFactoryArguments { + scopedClusterClient: ElasticsearchClient; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; - request: KibanaRequest; - response: KibanaResponseFactory; + user: User; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; - context?: Omit; } export interface ConfigureFields { @@ -92,25 +83,25 @@ export interface ConfigureFields { connectorId: string; connectorType: string; } + +/** + * This represents the interface that other plugins can access. + */ export interface CaseClient { - addComment: (args: CaseClientAddComment) => Promise; - create: (args: CaseClientCreate) => Promise; - get: (args: CaseClientGet) => Promise; - getAlerts: (args: CaseClientGetAlerts) => Promise; - getFields: (args: ConfigureFields) => Promise; - getMappings: (args: MappingsClient) => Promise; - getUserActions: (args: CaseClientGetUserActions) => Promise; - push: (args: CaseClientPush) => Promise; - update: (args: CaseClientUpdate) => Promise; - updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; + addComment(args: CaseClientAddComment): Promise; + create(theCase: CasePostRequest): Promise; + get(args: CaseClientGet): Promise; + getAlerts(args: CaseClientGetAlerts): Promise; + getFields(args: ConfigureFields): Promise; + getMappings(args: MappingsClient): Promise; + getUserActions(args: CaseClientGetUserActions): Promise; + push(args: CaseClientPush): Promise; + update(args: CasesPatchRequest): Promise; + updateAlertsStatus(args: CaseClientUpdateAlertsStatus): Promise; } -export type CaseClientFactoryMethod = ( - factoryArgs: CaseClientFactoryArguments -) => (methodArgs: any) => Promise; - -export type CaseClientMethods = keyof CaseClient; - -export type CaseClientFactoryMethods = { - [K in CaseClientMethods]: CaseClientFactoryMethod; -}; +export interface MappingsClient { + actionsClient: ActionsClient; + connectorId: string; + connectorType: string; +} diff --git a/x-pack/plugins/case/server/client/user_actions/get.ts b/x-pack/plugins/case/server/client/user_actions/get.ts index e83a9e34842625..8a4e45f71b9ca3 100644 --- a/x-pack/plugins/case/server/client/user_actions/get.ts +++ b/x-pack/plugins/case/server/client/user_actions/get.ts @@ -5,16 +5,22 @@ * 2.0. */ +import { SavedObjectsClientContract } from 'kibana/server'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; -import { CaseClientGetUserActions, CaseClientFactoryArguments } from '../types'; +import { CaseUserActionServiceSetup } from '../../services'; -export const get = ({ +interface GetParams { + savedObjectsClient: SavedObjectsClientContract; + userActionService: CaseUserActionServiceSetup; + caseId: string; +} + +export const get = async ({ savedObjectsClient, userActionService, -}: CaseClientFactoryArguments) => async ({ caseId, -}: CaseClientGetUserActions): Promise => { +}: GetParams): Promise => { const userActions = await userActionService.getUserActions({ client: savedObjectsClient, caseId, diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts b/x-pack/plugins/case/server/common/index.ts similarity index 81% rename from x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts rename to x-pack/plugins/case/server/common/index.ts index da960a20c15388..0960b28b3d25ac 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/index.ts +++ b/x-pack/plugins/case/server/common/index.ts @@ -5,4 +5,5 @@ * 2.0. */ -export { JobSpacesFlyout } from './jobs_spaces_flyout'; +export * from './models'; +export * from './utils'; diff --git a/x-pack/plugins/case/server/common/models/commentable_case.ts b/x-pack/plugins/case/server/common/models/commentable_case.ts new file mode 100644 index 00000000000000..9827118ee8e298 --- /dev/null +++ b/x-pack/plugins/case/server/common/models/commentable_case.ts @@ -0,0 +1,300 @@ +/* + * 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 Boom from '@hapi/boom'; + +import { + SavedObject, + SavedObjectReference, + SavedObjectsClientContract, + SavedObjectsUpdateResponse, +} from 'src/core/server'; +import { + AssociationType, + CaseSettings, + CaseStatuses, + CaseType, + CollectionWithSubCaseResponse, + CollectWithSubCaseResponseRt, + CommentAttributes, + CommentPatchRequest, + CommentRequest, + CommentType, + ESCaseAttributes, + SubCaseAttributes, + User, +} from '../../../common/api'; +import { transformESConnectorToCaseConnector } from '../../routes/api/cases/helpers'; +import { + flattenCommentSavedObjects, + flattenSubCaseSavedObject, + transformNewComment, +} from '../../routes/api/utils'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { CaseServiceSetup } from '../../services'; +import { countAlertsForID } from '../index'; + +interface UpdateCommentResp { + comment: SavedObjectsUpdateResponse; + commentableCase: CommentableCase; +} + +interface NewCommentResp { + comment: SavedObject; + commentableCase: CommentableCase; +} + +interface CommentableCaseParams { + collection: SavedObject; + subCase?: SavedObject; + soClient: SavedObjectsClientContract; + service: CaseServiceSetup; +} + +/** + * This class represents a case that can have a comment attached to it. This includes + * a Sub Case, Case, and Collection. + */ +export class CommentableCase { + private readonly collection: SavedObject; + private readonly subCase?: SavedObject; + private readonly soClient: SavedObjectsClientContract; + private readonly service: CaseServiceSetup; + constructor({ collection, subCase, soClient, service }: CommentableCaseParams) { + this.collection = collection; + this.subCase = subCase; + this.soClient = soClient; + this.service = service; + } + + public get status(): CaseStatuses { + return this.subCase?.attributes.status ?? this.collection.attributes.status; + } + + /** + * This property is used to abstract away which element is actually being acted upon in this class. + * If the sub case was initialized then it will be the focus of creating comments. So if you want the id + * of the saved object that the comment is primarily being attached to use this property. + * + * This is a little confusing because the created comment will have references to both the sub case and the + * collection but from the UI's perspective only the sub case really has the comment attached to it. + */ + public get id(): string { + return this.subCase?.id ?? this.collection.id; + } + + public get settings(): CaseSettings { + return this.collection.attributes.settings; + } + + /** + * These functions break the abstraction of this class but they are needed to build the comment user action item. + * Another potential solution would be to implement another function that handles creating the user action in this + * class so that we don't need to expose these properties. + */ + public get caseId(): string { + return this.collection.id; + } + + public get subCaseId(): string | undefined { + return this.subCase?.id; + } + + private buildRefsToCase(): SavedObjectReference[] { + const subCaseSOType = SUB_CASE_SAVED_OBJECT; + const caseSOType = CASE_SAVED_OBJECT; + return [ + { + type: caseSOType, + name: `associated-${caseSOType}`, + id: this.collection.id, + }, + ...(this.subCase + ? [{ type: subCaseSOType, name: `associated-${subCaseSOType}`, id: this.subCase.id }] + : []), + ]; + } + + private async update({ date, user }: { date: string; user: User }): Promise { + let updatedSubCaseAttributes: SavedObject | undefined; + + if (this.subCase) { + const updatedSubCase = await this.service.patchSubCase({ + client: this.soClient, + subCaseId: this.subCase.id, + updatedAttributes: { + updated_at: date, + updated_by: { + ...user, + }, + }, + version: this.subCase.version, + }); + + updatedSubCaseAttributes = { + ...this.subCase, + attributes: { + ...this.subCase.attributes, + ...updatedSubCase.attributes, + }, + version: updatedSubCase.version ?? this.subCase.version, + }; + } + + const updatedCase = await this.service.patchCase({ + client: this.soClient, + caseId: this.collection.id, + updatedAttributes: { + updated_at: date, + updated_by: { ...user }, + }, + version: this.collection.version, + }); + + // this will contain the updated sub case information if the sub case was defined initially + return new CommentableCase({ + collection: { + ...this.collection, + attributes: { + ...this.collection.attributes, + ...updatedCase.attributes, + }, + version: updatedCase.version ?? this.collection.version, + }, + subCase: updatedSubCaseAttributes, + soClient: this.soClient, + service: this.service, + }); + } + + /** + * Update a comment and update the corresponding case's update_at and updated_by fields. + */ + public async updateComment({ + updateRequest, + updatedAt, + user, + }: { + updateRequest: CommentPatchRequest; + updatedAt: string; + user: User; + }): Promise { + const { id, version, ...queryRestAttributes } = updateRequest; + + const [comment, commentableCase] = await Promise.all([ + this.service.patchComment({ + client: this.soClient, + commentId: id, + updatedAttributes: { + ...queryRestAttributes, + updated_at: updatedAt, + updated_by: user, + }, + version, + }), + this.update({ date: updatedAt, user }), + ]); + return { + comment, + commentableCase, + }; + } + + /** + * Create a new comment on the appropriate case. This updates the case's updated_at and updated_by fields. + */ + public async createComment({ + createdDate, + user, + commentReq, + }: { + createdDate: string; + user: User; + commentReq: CommentRequest; + }): Promise { + if (commentReq.type === CommentType.alert) { + if (this.status === CaseStatuses.closed) { + throw Boom.badRequest('Alert cannot be attached to a closed case'); + } + + if (!this.subCase && this.collection.attributes.type === CaseType.collection) { + throw Boom.badRequest('Alert cannot be attached to a collection case'); + } + } + + const [comment, commentableCase] = await Promise.all([ + this.service.postNewComment({ + client: this.soClient, + attributes: transformNewComment({ + associationType: this.subCase ? AssociationType.subCase : AssociationType.case, + createdDate, + ...commentReq, + ...user, + }), + references: this.buildRefsToCase(), + }), + this.update({ date: createdDate, user }), + ]); + return { + comment, + commentableCase, + }; + } + + private formatCollectionForEncoding(totalComment: number) { + return { + id: this.collection.id, + version: this.collection.version ?? '0', + totalComment, + ...this.collection.attributes, + connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), + }; + } + + public async encode(): Promise { + const collectionCommentStats = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }); + + if (this.subCase) { + const subCaseComments = await this.service.getAllSubCaseComments({ + client: this.soClient, + id: this.subCase.id, + }); + + return CollectWithSubCaseResponseRt.encode({ + subCase: flattenSubCaseSavedObject({ + savedObject: this.subCase, + comments: subCaseComments.saved_objects, + totalAlerts: countAlertsForID({ comments: subCaseComments, id: this.subCase.id }), + }), + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }); + } + + const collectionComments = await this.service.getAllCaseComments({ + client: this.soClient, + id: this.collection.id, + options: { + fields: [], + page: 1, + perPage: collectionCommentStats.total, + }, + }); + + return CollectWithSubCaseResponseRt.encode({ + comments: flattenCommentSavedObjects(collectionComments.saved_objects), + totalAlerts: countAlertsForID({ comments: collectionComments, id: this.collection.id }), + ...this.formatCollectionForEncoding(collectionCommentStats.total), + }); + } +} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js b/x-pack/plugins/case/server/common/models/index.ts similarity index 81% rename from x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js rename to x-pack/plugins/case/server/common/models/index.ts index 06588671832800..189090c91c81cd 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/index.js +++ b/x-pack/plugins/case/server/common/models/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { CreateWatchFlyout } from './create_watch_flyout'; +export * from './commentable_case'; diff --git a/x-pack/plugins/case/server/common/utils.test.ts b/x-pack/plugins/case/server/common/utils.test.ts new file mode 100644 index 00000000000000..d89feb009f8069 --- /dev/null +++ b/x-pack/plugins/case/server/common/utils.test.ts @@ -0,0 +1,235 @@ +/* + * 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 { SavedObjectsFindResponse } from 'kibana/server'; +import { AssociationType, CommentAttributes, CommentRequest, CommentType } from '../../common/api'; +import { transformNewComment } from '../routes/api/utils'; +import { combineFilters, countAlerts, countAlertsForID, groupTotalAlertsByID } from './utils'; + +interface CommentReference { + ids: string[]; + comments: CommentRequest[]; +} + +function createCommentFindResponse( + commentRequests: CommentReference[] +): SavedObjectsFindResponse { + const resp: SavedObjectsFindResponse = { + page: 0, + per_page: 0, + total: 0, + saved_objects: [], + }; + + for (const { ids, comments } of commentRequests) { + for (const id of ids) { + for (const comment of comments) { + resp.saved_objects.push({ + id: '', + references: [{ id, type: '', name: '' }], + score: 0, + type: '', + attributes: transformNewComment({ + ...comment, + associationType: AssociationType.case, + createdDate: '', + }), + }); + } + } + } + + return resp; +} + +describe('common utils', () => { + describe('combineFilters', () => { + it("creates a filter string with two values and'd together", () => { + expect(combineFilters(['a', 'b'], 'AND')).toBe('(a AND b)'); + }); + + it('creates a filter string with three values or together', () => { + expect(combineFilters(['a', 'b', 'c'], 'OR')).toBe('(a OR b OR c)'); + }); + + it('ignores empty strings', () => { + expect(combineFilters(['', 'a', '', 'b'], 'AND')).toBe('(a AND b)'); + }); + + it('returns an empty string if all filters are empty strings', () => { + expect(combineFilters(['', ''], 'OR')).toBe(''); + }); + + it('returns an empty string if the filters are undefined', () => { + expect(combineFilters(undefined, 'OR')).toBe(''); + }); + + it('returns a value without parenthesis when only a single filter is provided', () => { + expect(combineFilters(['a'], 'OR')).toBe('a'); + }); + + it('returns a string without parenthesis when only a single non empty filter is provided', () => { + expect(combineFilters(['', ''], 'AND')).toBe(''); + }); + }); + + describe('countAlerts', () => { + it('returns 0 when no alerts are found', () => { + expect( + countAlerts( + createCommentFindResponse([ + { ids: ['1'], comments: [{ comment: '', type: CommentType.user }] }, + ]).saved_objects[0] + ) + ).toBe(0); + }); + + it('returns 3 alerts for a single generated alert comment', () => { + expect( + countAlerts( + createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b', 'c'], + index: '', + type: CommentType.generatedAlert, + }, + ], + }, + ]).saved_objects[0] + ) + ).toBe(3); + }); + + it('returns 3 alerts for a single alert comment', () => { + expect( + countAlerts( + createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b', 'c'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]).saved_objects[0] + ) + ).toBe(3); + }); + }); + + describe('groupTotalAlertsByID', () => { + it('returns a map with one entry and 2 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + { + comment: '', + type: CommentType.user, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([['1', 2]]) + ); + }); + + it('returns a map with two entry, 2 alerts, and 0 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + { + ids: ['2'], + comments: [ + { + comment: '', + type: CommentType.user, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([ + ['1', 2], + ['2', 0], + ]) + ); + }); + + it('returns a map with two entry, 2 alerts, and 2 alerts', () => { + expect( + groupTotalAlertsByID({ + comments: createCommentFindResponse([ + { + ids: ['1', '2'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]), + }) + ).toEqual( + new Map([ + ['1', 2], + ['2', 2], + ]) + ); + }); + }); + + describe('countAlertsForID', () => { + it('returns 2 alerts for id 1 when the map has multiple entries', () => { + expect( + countAlertsForID({ + id: '1', + comments: createCommentFindResponse([ + { + ids: ['1', '2'], + comments: [ + { + alertId: ['a', 'b'], + index: '', + type: CommentType.alert, + }, + ], + }, + ]), + }) + ).toEqual(2); + }); + }); +}); diff --git a/x-pack/plugins/case/server/common/utils.ts b/x-pack/plugins/case/server/common/utils.ts new file mode 100644 index 00000000000000..a3ac0361569d53 --- /dev/null +++ b/x-pack/plugins/case/server/common/utils.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsFindResult, SavedObjectsFindResponse } from 'kibana/server'; +import { CaseStatuses, CommentAttributes, CommentType, User } from '../../common/api'; +import { AlertInfo, getAlertIndicesAndIDs } from '../routes/api/utils'; + +/** + * Default sort field for querying saved objects. + */ +export const defaultSortField = 'created_at'; + +/** + * Default unknown user + */ +export const nullUser: User = { username: null, full_name: null, email: null }; + +/** + * Adds the ids and indices to a map of statuses + */ +export function addAlertInfoToStatusMap({ + comment, + statusMap, + status, +}: { + comment: CommentAttributes; + statusMap: Map; + status: CaseStatuses; +}) { + const newAlertInfo = getAlertIndicesAndIDs([comment]); + + // combine the already accumulated ids and indices with the new ones from this alert comment + if (newAlertInfo.ids.length > 0 && newAlertInfo.indices.size > 0) { + const accAlertInfo = statusMap.get(status) ?? { ids: [], indices: new Set() }; + accAlertInfo.ids.push(...newAlertInfo.ids); + accAlertInfo.indices = new Set([ + ...accAlertInfo.indices.values(), + ...newAlertInfo.indices.values(), + ]); + statusMap.set(status, accAlertInfo); + } +} + +/** + * Combines multiple filter expressions using the specified operator and parenthesis if multiple expressions exist. + * This will ignore empty string filters. If a single valid filter is found it will not wrap in parenthesis. + * + * @param filters an array of filters to combine using the specified operator + * @param operator AND or OR + */ +export const combineFilters = (filters: string[] | undefined, operator: 'OR' | 'AND'): string => { + const noEmptyStrings = filters?.filter((value) => value !== ''); + const joinedExp = noEmptyStrings?.join(` ${operator} `); + // if undefined or an empty string + if (!joinedExp) { + return ''; + } else if ((noEmptyStrings?.length ?? 0) > 1) { + // if there were multiple filters, wrap them in () + return `(${joinedExp})`; + } else { + // return a single value not wrapped in () + return joinedExp; + } +}; + +/** + * Counts the total alert IDs within a single comment. + */ +export const countAlerts = (comment: SavedObjectsFindResult) => { + let totalAlerts = 0; + if ( + comment.attributes.type === CommentType.alert || + comment.attributes.type === CommentType.generatedAlert + ) { + if (Array.isArray(comment.attributes.alertId)) { + totalAlerts += comment.attributes.alertId.length; + } else { + totalAlerts++; + } + } + return totalAlerts; +}; + +/** + * Count the number of alerts for each id in the alert's references. This will result + * in a map with entries for both the collection and the individual sub cases. So the resulting + * size of the map will not equal the total number of sub cases. + */ +export const groupTotalAlertsByID = ({ + comments, +}: { + comments: SavedObjectsFindResponse; +}): Map => { + return comments.saved_objects.reduce((acc, alertsInfo) => { + const alertTotalForComment = countAlerts(alertsInfo); + for (const alert of alertsInfo.references) { + if (alert.id) { + const totalAlerts = acc.get(alert.id); + + if (totalAlerts !== undefined) { + acc.set(alert.id, totalAlerts + alertTotalForComment); + } else { + acc.set(alert.id, alertTotalForComment); + } + } + } + + return acc; + }, new Map()); +}; + +/** + * Counts the total alert IDs for a single case or sub case ID. + */ +export const countAlertsForID = ({ + comments, + id, +}: { + comments: SavedObjectsFindResponse; + id: string; +}): number | undefined => { + return groupTotalAlertsByID({ comments }).get(id); +}; diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index 4a025fd980fe20..6b7e395bae4dc1 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -10,7 +10,16 @@ import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { actionsMock } from '../../../../actions/server/mocks'; import { validateParams } from '../../../../actions/server/lib'; -import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api'; +import { + ConnectorTypes, + CommentType, + CaseStatuses, + CaseType, + AssociationType, + CaseResponse, + CasesResponse, + CollectionWithSubCaseResponse, +} from '../../../common/api'; import { connectorMappingsServiceMock, createCaseServiceMock, @@ -20,12 +29,12 @@ import { } from '../../services/mocks'; import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types'; import { getActionType } from '.'; -import { createCaseClientMock } from '../../client/mocks'; +import { createExternalCaseClientMock } from '../../client/mocks'; -const mockCaseClient = createCaseClientMock(); +const mockCaseClient = createExternalCaseClientMock(); jest.mock('../../client', () => ({ - createCaseClient: () => mockCaseClient, + createExternalCaseClient: () => mockCaseClient, })); const services = actionsMock.createServices(); @@ -699,9 +708,7 @@ describe('case connector', () => { expect(validateParams(caseActionType, params)).toEqual(params); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('succeeds when type is an alert', () => { + it('succeeds when type is an alert', () => { const params: Record = { subAction: 'addComment', subActionParams: { @@ -727,26 +734,6 @@ describe('case connector', () => { }).toThrow(); }); - // TODO: Remove it when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it('fails when type is an alert', () => { - const params: Record = { - subAction: 'addComment', - subActionParams: { - caseId: 'case-id', - comment: { - type: CommentType.alert, - alertId: 'test-id', - index: 'test-index', - }, - }, - }; - - expect(() => { - validateParams(caseActionType, params); - }).toThrow(); - }); - it('fails when missing attributes: type user', () => { const allParams = { type: CommentType.user, @@ -769,9 +756,7 @@ describe('case connector', () => { }); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('fails when missing attributes: type alert', () => { + it('fails when missing attributes: type alert', () => { const allParams = { type: CommentType.alert, comment: 'a comment', @@ -813,9 +798,7 @@ describe('case connector', () => { }); }); - // TODO: Enable when the creation of comments of type alert is supported - // https://github.com/elastic/kibana/issues/85750 - it.skip('fails when excess attributes are provided: type alert', () => { + it('fails when excess attributes are provided: type alert', () => { ['comment'].forEach((attribute) => { const params: Record = { subAction: 'addComment', @@ -863,10 +846,11 @@ describe('case connector', () => { describe('create', () => { it('executes correctly', async () => { - const createReturn = { + const createReturn: CaseResponse = { id: 'mock-it', comments: [], totalComment: 0, + totalAlerts: 0, closed_at: null, closed_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, @@ -878,6 +862,7 @@ describe('case connector', () => { }, title: 'Case from case connector!!', tags: ['case', 'connector'], + type: CaseType.collection, description: 'Yo fields!!', external_service: null, status: CaseStatuses.open, @@ -926,17 +911,15 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: createReturn }); expect(mockCaseClient.create).toHaveBeenCalledWith({ - theCase: { - ...params.subActionParams, - connector: { - id: 'jira', - name: 'Jira', - type: '.jira', - fields: { - issueType: '10006', - priority: 'High', - parent: null, - }, + ...params.subActionParams, + connector: { + id: 'jira', + name: 'Jira', + type: '.jira', + fields: { + issueType: '10006', + priority: 'High', + parent: null, }, }, }); @@ -945,7 +928,7 @@ describe('case connector', () => { describe('update', () => { it('executes correctly', async () => { - const updateReturn = [ + const updateReturn: CasesResponse = [ { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { @@ -973,6 +956,8 @@ describe('case connector', () => { tags: ['defacement'], title: 'Update title', totalComment: 0, + totalAlerts: 0, + type: CaseType.collection, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', @@ -1015,41 +1000,45 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); expect(mockCaseClient.update).toHaveBeenCalledWith({ - caseClient: mockCaseClient, // Null values have been striped out. - cases: { - cases: [ - { - id: 'case-id', - version: '123', - title: 'Update title', - }, - ], - }, + cases: [ + { + id: 'case-id', + version: '123', + title: 'Update title', + }, + ], }); }); }); describe('addComment', () => { it('executes correctly', async () => { - const commentReturn = { + const commentReturn: CollectionWithSubCaseResponse = { id: 'mock-it', totalComment: 0, + version: 'WzksMV0=', + closed_at: null, closed_by: null, connector: { id: 'none', name: 'none', type: ConnectorTypes.none, fields: null }, created_at: '2019-11-25T21:54:48.952Z', - created_by: { full_name: 'Awesome D00d', email: 'd00d@awesome.com', username: 'awesome' }, + created_by: { + full_name: 'Awesome D00d', + email: 'd00d@awesome.com', + username: 'awesome', + }, description: 'This is a brand new case of a bad meanie defacing data', external_service: null, title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.collection, updated_at: null, updated_by: null, - version: 'WzksMV0=', comments: [ { + associationType: AssociationType.case, comment: 'a comment', type: CommentType.user as const, created_at: '2020-10-23T21:54:48.952Z', @@ -1097,7 +1086,6 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); expect(mockCaseClient.addComment).toHaveBeenCalledWith({ - caseClient: mockCaseClient, caseId: 'case-id', comment: { comment: 'a comment', diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 9907aa5b3cd3a8..34b407616cfe48 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -7,11 +7,15 @@ import { curry } from 'lodash'; -import { KibanaRequest, kibanaResponseFactory } from '../../../../../../src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; -import { CasePatchRequest, CasePostRequest } from '../../../common/api'; -import { createCaseClient } from '../../client'; -import { CaseExecutorParamsSchema, CaseConfigurationSchema } from './schema'; +import { + CasePatchRequest, + CasePostRequest, + CommentRequest, + CommentType, +} from '../../../common/api'; +import { createExternalCaseClient } from '../../client'; +import { CaseExecutorParamsSchema, CaseConfigurationSchema, CommentSchemaType } from './schema'; import { CaseExecutorResponse, ExecutorSubActionAddCommentParams, @@ -19,9 +23,9 @@ import { CaseActionTypeExecutorOptions, } from './types'; import * as i18n from './translations'; -import type { CasesRequestHandlerContext } from '../../types'; -import { GetActionTypeParams } from '..'; +import { GetActionTypeParams, isCommentGeneratedAlert } from '..'; +import { nullUser } from '../../common'; const supportedSubActions: string[] = ['create', 'update', 'addComment']; @@ -69,18 +73,17 @@ async function executor( const { subAction, subActionParams } = params; let data: CaseExecutorResponse | null = null; - const { savedObjectsClient } = services; - const caseClient = createCaseClient({ + const { savedObjectsClient, scopedClusterClient } = services; + const caseClient = createExternalCaseClient({ savedObjectsClient, - request: {} as KibanaRequest, - response: kibanaResponseFactory, + scopedClusterClient, + // we might want the user information to be passed as part of the action request + user: nullUser, caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - // TODO: When case connector is enabled we should figure out how to pass the context. - context: {} as CasesRequestHandlerContext, }); if (!supportedSubActions.includes(subAction)) { @@ -90,7 +93,9 @@ async function executor( } if (subAction === 'create') { - data = await caseClient.create({ theCase: subActionParams as CasePostRequest }); + data = await caseClient.create({ + ...(subActionParams as CasePostRequest), + }); } if (subAction === 'update') { @@ -102,16 +107,39 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ - caseClient, - cases: { cases: [updateParamsWithoutNullValues] }, - }); + data = await caseClient.update({ cases: [updateParamsWithoutNullValues] }); } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment({ caseClient, caseId, comment }); + const formattedComment = transformConnectorComment(comment); + data = await caseClient.addComment({ caseId, comment: formattedComment }); } return { status: 'ok', data: data ?? {}, actionId }; } + +/** + * This converts a connector style generated alert ({_id: string} | {_id: string}[]) to the expected format of addComment. + */ +export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => { + if (isCommentGeneratedAlert(comment)) { + const alertId: string[] = []; + if (Array.isArray(comment.alerts)) { + alertId.push( + ...comment.alerts.map((alert: { _id: string }) => { + return alert._id; + }) + ); + } else { + alertId.push(comment.alerts._id); + } + return { + type: CommentType.generatedAlert, + alertId, + index: comment.index, + }; + } else { + return comment; + } +}; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 8d52a344308e1a..cdeb00209f846b 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -6,37 +6,48 @@ */ import { schema } from '@kbn/config-schema'; +import { CommentType } from '../../../common/api'; import { validateConnector } from './validators'; // Reserved for future implementation export const CaseConfigurationSchema = schema.object({}); const ContextTypeUserSchema = schema.object({ - type: schema.literal('user'), + type: schema.literal(CommentType.user), comment: schema.string(), }); -/** - * ContextTypeAlertSchema has been deleted. - * Comments of type alert need the siem signal index. - * Case connector is not being passed the context which contains the - * security solution app client which in turn provides the siem signal index. - * For that reason, we disable comments of type alert for the case connector until - * we figure out how to pass the security solution app client to the connector. - * See: x-pack/plugins/case/server/connectors/case/index.ts L76. - * - * The schema: - * - * const ContextTypeAlertSchema = schema.object({ - * type: schema.literal('alert'), - * alertId: schema.string(), - * index: schema.string(), - * }); - * - * Issue: https://github.com/elastic/kibana/issues/85750 - * */ - -export const CommentSchema = schema.oneOf([ContextTypeUserSchema]); +const AlertIDSchema = schema.object( + { + _id: schema.string(), + }, + { unknowns: 'ignore' } +); + +const ContextTypeAlertGroupSchema = schema.object({ + type: schema.literal(CommentType.generatedAlert), + alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]), + index: schema.string(), +}); + +export type ContextTypeGeneratedAlertType = typeof ContextTypeAlertGroupSchema.type; + +const ContextTypeAlertSchema = schema.object({ + type: schema.literal(CommentType.alert), + // allowing either an array or a single value to preserve the previous API of attaching a single alert ID + alertId: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), + index: schema.string(), +}); + +export type ContextTypeAlertSchemaType = typeof ContextTypeAlertSchema.type; + +export const CommentSchema = schema.oneOf([ + ContextTypeUserSchema, + ContextTypeAlertSchema, + ContextTypeAlertGroupSchema, +]); + +export type CommentSchemaType = typeof CommentSchema.type; const JiraFieldsSchema = schema.object({ issueType: schema.string(), diff --git a/x-pack/plugins/case/server/connectors/case/types.ts b/x-pack/plugins/case/server/connectors/case/types.ts index 6a7dfd9c2e6876..50ff104d7bad02 100644 --- a/x-pack/plugins/case/server/connectors/case/types.ts +++ b/x-pack/plugins/case/server/connectors/case/types.ts @@ -16,7 +16,7 @@ import { ConnectorSchema, CommentSchema, } from './schema'; -import { CaseResponse, CasesResponse } from '../../../common/api'; +import { CaseResponse, CasesResponse, CollectionWithSubCaseResponse } from '../../../common/api'; export type CaseConfiguration = TypeOf; export type Connector = TypeOf; @@ -29,7 +29,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf< >; export type CaseExecutorParams = TypeOf; -export type CaseExecutorResponse = CaseResponse | CasesResponse; +export type CaseExecutorResponse = CaseResponse | CasesResponse | CollectionWithSubCaseResponse; export type CaseActionType = ActionType< CaseConfiguration, diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index 00809d81ca5f25..056ccff2733a76 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -5,14 +5,22 @@ * 2.0. */ -import { RegisterConnectorsArgs, ExternalServiceFormatterMapper } from './types'; +import { + RegisterConnectorsArgs, + ExternalServiceFormatterMapper, + CommentSchemaType, + ContextTypeGeneratedAlertType, + ContextTypeAlertSchemaType, +} from './types'; import { getActionType as getCaseConnector } from './case'; import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_formatter'; import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter'; import { jiraExternalServiceFormatter } from './jira/external_service_formatter'; import { resilientExternalServiceFormatter } from './resilient/external_service_formatter'; +import { CommentRequest, CommentType } from '../../common/api'; export * from './types'; +export { transformConnectorComment } from './case'; export const registerConnectors = ({ actionsRegisterType, @@ -41,3 +49,19 @@ export const externalServiceFormatters: ExternalServiceFormatterMapper = { '.jira': jiraExternalServiceFormatter, '.resilient': resilientExternalServiceFormatter, }; + +export const isCommentGeneratedAlert = ( + comment: CommentSchemaType | CommentRequest +): comment is ContextTypeGeneratedAlertType => { + return ( + comment.type === CommentType.generatedAlert && + 'alerts' in comment && + comment.alerts !== undefined + ); +}; + +export const isCommentAlert = ( + comment: CommentSchemaType +): comment is ContextTypeAlertSchemaType => { + return comment.type === CommentType.alert; +}; diff --git a/x-pack/plugins/case/server/connectors/types.ts b/x-pack/plugins/case/server/connectors/types.ts index 8e7eb91ad2dc66..ffda6f96ae3ba0 100644 --- a/x-pack/plugins/case/server/connectors/types.ts +++ b/x-pack/plugins/case/server/connectors/types.ts @@ -23,6 +23,12 @@ import { AlertServiceContract, } from '../services'; +export { + ContextTypeGeneratedAlertType, + CommentSchemaType, + ContextTypeAlertSchemaType, +} from './case/schema'; + export interface GetActionTypeParams { logger: Logger; caseService: CaseServiceSetup; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 5d05db165f6373..1c00c26a7c0b09 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -5,13 +5,7 @@ * 2.0. */ -import { - IContextProvider, - KibanaRequest, - KibanaResponseFactory, - Logger, - PluginInitializerContext, -} from 'kibana/server'; +import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -26,6 +20,7 @@ import { caseConnectorMappingsSavedObjectType, caseSavedObjectType, caseUserActionSavedObjectType, + subCaseSavedObjectType, } from './saved_object_types'; import { CaseConfigureService, @@ -39,7 +34,7 @@ import { AlertService, AlertServiceContract, } from './services'; -import { createCaseClient } from './client'; +import { CaseClientHandler, createExternalCaseClient } from './client'; import { registerConnectors } from './connectors'; import type { CasesRequestHandlerContext } from './types'; @@ -75,6 +70,7 @@ export class CasePlugin { core.savedObjects.registerType(caseConfigureSavedObjectType); core.savedObjects.registerType(caseConnectorMappingsSavedObjectType); core.savedObjects.registerType(caseSavedObjectType); + core.savedObjects.registerType(subCaseSavedObjectType); core.savedObjects.registerType(caseUserActionSavedObjectType); this.log.debug( @@ -83,9 +79,10 @@ export class CasePlugin { )}] and plugins [${Object.keys(plugins)}]` ); - this.caseService = await new CaseService(this.log).setup({ - authentication: plugins.security != null ? plugins.security.authc : null, - }); + this.caseService = new CaseService( + this.log, + plugins.security != null ? plugins.security.authc : undefined + ); this.caseConfigureService = await new CaseConfigureService(this.log).setup(); this.connectorMappingsService = await new ConnectorMappingsService(this.log).setup(); this.userActionService = await new CaseUserActionService(this.log).setup(); @@ -125,23 +122,21 @@ export class CasePlugin { public start(core: CoreStart) { this.log.debug(`Starting Case Workflow`); - this.alertsService!.initialize(core.elasticsearch.client); const getCaseClientWithRequestAndContext = async ( context: CasesRequestHandlerContext, - request: KibanaRequest, - response: KibanaResponseFactory + request: KibanaRequest ) => { - return createCaseClient({ + const user = await this.caseService!.getUser({ request }); + return createExternalCaseClient({ + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: core.savedObjects.getScopedClient(request), - request, - response, + user, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, connectorMappingsService: this.connectorMappingsService!, userActionService: this.userActionService!, alertsService: this.alertsService!, - context, }); }; @@ -171,18 +166,18 @@ export class CasePlugin { }): IContextProvider => { return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); + const user = await caseService.getUser({ request }); return { getCaseClient: () => { - return createCaseClient({ + return new CaseClientHandler({ + scopedClusterClient: context.core.elasticsearch.client.asCurrentUser, savedObjectsClient: savedObjects.getScopedClient(request), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context, - request, - response, + user, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts index 51ba684bf7a7b3..66d3ffe5f23d16 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/authc_mock.ts @@ -7,6 +7,7 @@ import { AuthenticatedUser } from '../../../../../security/server'; import { securityMock } from '../../../../../security/server/mocks'; +import { nullUser } from '../../../common'; function createAuthenticationMock({ currentUser, @@ -14,7 +15,11 @@ function createAuthenticationMock({ const { authc } = securityMock.createSetup(); authc.getCurrentUser.mockReturnValue( currentUser !== undefined - ? currentUser + ? // if we pass in null then use the null user (has null for each field) this is the default behavior + // for the CaseService getUser method + currentUser !== null + ? currentUser + : nullUser : ({ email: 'd00d@awesome.com', username: 'awesome', diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 18730effdf55a4..a33226bcde8998 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -10,6 +10,7 @@ import { SavedObjectsErrorHelpers, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, + SavedObjectsFindOptions, } from 'src/core/server'; import { @@ -17,6 +18,7 @@ import { CASE_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, CASE_USER_ACTION_SAVED_OBJECT, } from '../../../saved_object_types'; @@ -91,16 +93,29 @@ export const createMockSavedObjectsRepository = ({ throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } return result[0]; - } - - const result = caseSavedObject.filter((s) => s.id === id); - if (!result.length) { + } else if (type === CASE_SAVED_OBJECT) { + const result = caseSavedObject.filter((s) => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + } else { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return result[0]; }), - find: jest.fn((findArgs) => { - if (findArgs.hasReference && findArgs.hasReference.id === 'bad-guy') { + find: jest.fn((findArgs: SavedObjectsFindOptions) => { + // References can be an array so we need to loop through it looking for the bad-guy + const hasReferenceIncludeBadGuy = (args: SavedObjectsFindOptions) => { + const references = args.hasReference; + if (references) { + return Array.isArray(references) + ? references.some((ref) => ref.id === 'bad-guy') + : references.id === 'bad-guy'; + } else { + return false; + } + }; + if (hasReferenceIncludeBadGuy(findArgs)) { throw SavedObjectsErrorHelpers.createBadRequestError('Error thrown for testing'); } @@ -141,6 +156,16 @@ export const createMockSavedObjectsRepository = ({ }; } + // Currently not supporting sub cases in this mock library + if (findArgs.type === SUB_CASE_SAVED_OBJECT) { + return { + page: 1, + per_page: 0, + total: 0, + saved_objects: [], + }; + } + if (findArgs.type === CASE_USER_ACTION_SAVED_OBJECT) { return { page: 1, @@ -206,19 +231,22 @@ export const createMockSavedObjectsRepository = ({ }), update: jest.fn((type, id, attributes) => { if (type === CASE_COMMENT_SAVED_OBJECT) { - if (!caseCommentSavedObject.find((s) => s.id === id)) { + const foundComment = caseCommentSavedObject.findIndex((s: { id: string }) => s.id === id); + if (foundComment === -1) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - caseCommentSavedObject = [ - ...caseCommentSavedObject, - { - id, - type, - updated_at: '2019-11-22T22:50:55.191Z', - version: 'WzE3LDFd', - attributes, + const comment = caseCommentSavedObject[foundComment]; + caseCommentSavedObject.splice(foundComment, 1, { + ...comment, + id, + type, + updated_at: '2019-11-22T22:50:55.191Z', + version: 'WzE3LDFd', + attributes: { + ...comment.attributes, + ...attributes, }, - ]; + }); } else if (type === CASE_SAVED_OBJECT) { if (!caseSavedObject.find((s) => s.id === id)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index ed4d22046c5819..b4230a05749a15 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -19,14 +19,10 @@ export const createRoute = async ( const router = httpService.createRouter(); const log = loggingSystemMock.create().get('case'); - - const caseServicePlugin = new CaseService(log); + const auth = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); + const caseService = new CaseService(log, auth); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); - - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const connectorMappingsService = await connectorMappingsServicePlugin.setup(); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 514f77a8f953dc..2fe0be3e08ede6 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -7,7 +7,9 @@ import { SavedObject } from 'kibana/server'; import { + AssociationType, CaseStatuses, + CaseType, CaseUserActionAttributes, CommentAttributes, CommentType, @@ -46,6 +48,7 @@ export const mockCases: Array> = [ title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + type: CaseType.individual, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', @@ -83,6 +86,7 @@ export const mockCases: Array> = [ title: 'Damaging Data Destruction Detected', status: CaseStatuses.open, tags: ['Data Destruction'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:00.900Z', updated_by: { full_name: 'elastic', @@ -124,6 +128,7 @@ export const mockCases: Array> = [ title: 'Another bad one', status: CaseStatuses.open, tags: ['LOLBins'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', @@ -169,6 +174,7 @@ export const mockCases: Array> = [ status: CaseStatuses.closed, title: 'Another bad one', tags: ['LOLBins'], + type: CaseType.individual, updated_at: '2019-11-25T22:32:17.947Z', updated_by: { full_name: 'elastic', @@ -231,6 +237,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-1', attributes: { + associationType: AssociationType.case, comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user, created_at: '2019-11-25T21:55:00.177Z', @@ -262,6 +269,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-2', attributes: { + associationType: AssociationType.case, comment: 'Well I decided to update my comment. So what? Deal with it.', type: CommentType.user, created_at: '2019-11-25T21:55:14.633Z', @@ -294,6 +302,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-3', attributes: { + associationType: AssociationType.case, comment: 'Wow, good luck catching that bad meanie!', type: CommentType.user, created_at: '2019-11-25T22:32:30.608Z', @@ -325,6 +334,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-4', attributes: { + associationType: AssociationType.case, type: CommentType.alert, index: 'test-index', alertId: 'test-id', @@ -357,6 +367,7 @@ export const mockCaseComments: Array> = [ type: 'cases-comment', id: 'mock-comment-5', attributes: { + associationType: AssociationType.case, type: CommentType.alert, index: 'test-index-2', alertId: 'test-id-2', diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 74665ffdc5b16a..492be96fb4aa92 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,12 +5,8 @@ * 2.0. */ -import { KibanaRequest, kibanaResponseFactory } from '../../../../../../../src/core/server'; -import { - loggingSystemMock, - elasticsearchServiceMock, -} from '../../../../../../../src/core/server/mocks'; -import { createCaseClient } from '../../../client'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { createExternalCaseClient } from '../../../client'; import { AlertService, CaseService, @@ -26,20 +22,18 @@ export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); - const esClientMock = elasticsearchServiceMock.createClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + + const authc = badAuth ? authenticationMock.createInvalid() : authenticationMock.create(); - const caseServicePlugin = new CaseService(log); + const caseService = new CaseService(log, authc); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); const caseUserActionsServicePlugin = new CaseUserActionService(log); - const caseService = await caseServicePlugin.setup({ - authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), - }); const caseConfigureService = await caseConfigureServicePlugin.setup(); const userActionService = await caseUserActionsServicePlugin.setup(); const alertsService = new AlertService(); - alertsService.initialize(esClientMock); const context = ({ core: { @@ -51,24 +45,18 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, - securitySolution: { - getAppClient: () => ({ - getSignalsIndex: () => '.siem-signals', - }), - }, } as unknown) as CasesRequestHandlerContext; const connectorMappingsService = await connectorMappingsServicePlugin.setup(); - const caseClient = createCaseClient({ + const caseClient = createExternalCaseClient({ savedObjectsClient: client, - request: {} as KibanaRequest, - response: kibanaResponseFactory, + user: authc.getCurrentUser(), caseService, caseConfigureService, connectorMappingsService, userActionService, alertsService, - context, + scopedClusterClient: esClient, }); return { context, services: { userActionService } }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index 898e0a14d0e2d9..bcbf1828e1fde4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -11,6 +11,7 @@ import { buildCommentUserActionItem } from '../../../../services/user_actions/he import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { AssociationType } from '../../../../../common/api'; export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { router.delete( @@ -20,19 +21,29 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); - const comments = await caseService.getAllCaseComments({ + const id = request.query?.subCaseID ?? request.params.case_id; + const comments = await caseService.getCommentsByAssociation({ client, - caseId: request.params.case_id, + id, + associationType: request.query?.subCaseID + ? AssociationType.subCase + : AssociationType.case, }); + await Promise.all( comments.saved_objects.map((comment) => caseService.deleteComment({ @@ -50,6 +61,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: request.params.case_id, + subCaseId: request.query?.subCaseID, commentId: comment.id, fields: ['comment'], }) diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 78c7623861b85e..73307753a550de 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -8,7 +8,7 @@ import Boom from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; @@ -23,13 +23,18 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: case_id: schema.string(), comment_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); const myComment = await caseService.getComment({ @@ -41,10 +46,13 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); - if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { + const type = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + const id = request.query?.subCaseID ?? request.params.case_id; + + const caseRef = myComment.references.find((c) => c.type === type); + if (caseRef == null || (caseRef != null && caseRef.id !== id)) { throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${request.params.case_id}).` + `This comment ${request.params.comment_id} does not exist in ${id}).` ); } @@ -60,7 +68,8 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: action: 'delete', actionAt: deleteDate, actionBy: { username, full_name, email }, - caseId: request.params.case_id, + caseId: id, + subCaseId: request.query?.subCaseID, commentId: request.params.comment_id, fields: ['comment'], }), diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index 91ac9259d25686..3431c340c791e3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -5,6 +5,8 @@ * 2.0. */ +import * as rt from 'io-ts'; + import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; @@ -13,6 +15,7 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { + AssociationType, CommentsResponseRt, SavedObjectFindOptionsRt, throwErrors, @@ -20,6 +23,12 @@ import { import { RouteDeps } from '../../types'; import { escapeHatch, transformComments, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { defaultPage, defaultPerPage } from '../..'; + +const FindQueryParamsRt = rt.partial({ + ...SavedObjectFindOptionsRt.props, + subCaseID: rt.string, +}); export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { router.get( @@ -36,25 +45,41 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { try { const client = context.core.savedObjects.client; const query = pipe( - SavedObjectFindOptionsRt.decode(request.query), + FindQueryParamsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) ); + const id = query.subCaseID ?? request.params.case_id; + const associationType = query.subCaseID ? AssociationType.subCase : AssociationType.case; const args = query ? { + caseService, client, - caseId: request.params.case_id, + id, options: { - ...query, + // We need this because the default behavior of getAllCaseComments is to return all the comments + // unless the page and/or perPage is specified. Since we're spreading the query after the request can + // still override this behavior. + page: defaultPage, + perPage: defaultPerPage, sortField: 'created_at', + ...query, }, + associationType, } : { + caseService, client, - caseId: request.params.case_id, + id, + options: { + page: defaultPage, + perPage: defaultPerPage, + sortField: 'created_at', + }, + associationType, }; - const theComments = await caseService.getAllCaseComments(args); + const theComments = await caseService.getCommentsByAssociation(args); return response.ok({ body: CommentsResponseRt.encode(transformComments(theComments)) }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 72105662dafb57..730b1b92a8a076 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -7,10 +7,12 @@ import { schema } from '@kbn/config-schema'; -import { AllCommentsResponseRt } from '../../../../../common/api'; +import { SavedObjectsFindResponse } from 'kibana/server'; +import { AllCommentsResponseRt, CommentAttributes } from '../../../../../common/api'; import { RouteDeps } from '../../types'; import { flattenCommentSavedObjects, wrapError } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { defaultSortField } from '../../../../common'; export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { router.get( @@ -20,15 +22,38 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + includeSubCaseComments: schema.maybe(schema.boolean()), + subCaseID: schema.maybe(schema.string()), + }) + ), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const comments = await caseService.getAllCaseComments({ - client, - caseId: request.params.case_id, - }); + let comments: SavedObjectsFindResponse; + + if (request.query?.subCaseID) { + comments = await caseService.getAllSubCaseComments({ + client, + id: request.query.subCaseID, + options: { + sortField: defaultSortField, + }, + }); + } else { + comments = await caseService.getAllCaseComments({ + client, + id: request.params.case_id, + includeSubCaseComments: request.query?.includeSubCaseComments, + options: { + sortField: defaultSortField, + }, + }); + } + return response.ok({ body: AllCommentsResponseRt.encode(flattenCommentSavedObjects(comments.saved_objects)), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 33dc24d776c701..9dec910f9fc46f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -27,6 +27,7 @@ describe('PATCH comment', () => { }); it(`Patch a comment`, async () => { + const commentID = 'mock-comment-1'; const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, method: 'patch', @@ -36,7 +37,7 @@ describe('PATCH comment', () => { body: { type: CommentType.user, comment: 'Update my comment', - id: 'mock-comment-1', + id: commentID, version: 'WzEsMV0=', }, }); @@ -50,12 +51,14 @@ describe('PATCH comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].comment).toEqual( - 'Update my comment' + const updatedComment = response.payload.comments.find( + (comment: { id: string }) => comment.id === commentID ); + expect(updatedComment.comment).toEqual('Update my comment'); }); it(`Patch an alert`, async () => { + const commentID = 'mock-comment-4'; const request = httpServerMock.createKibanaRequest({ path: CASE_COMMENTS_URL, method: 'patch', @@ -66,7 +69,7 @@ describe('PATCH comment', () => { type: CommentType.alert, alertId: 'new-id', index: 'test-index', - id: 'mock-comment-4', + id: commentID, version: 'WzYsMV0=', }, }); @@ -80,9 +83,10 @@ describe('PATCH comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1].alertId).toEqual( - 'new-id' + const updatedComment = response.payload.comments.find( + (comment: { id: string }) => comment.id === commentID ); + expect(updatedComment.alertId).toEqual('new-id'); }); it(`it throws when missing attributes: type user`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index ac037ce3ead1ff..e8b6f7bc957eb3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -12,12 +12,44 @@ import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import { CommentPatchRequestRt, CaseResponseRt, throwErrors } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { SavedObjectsClientContract } from 'kibana/server'; +import { CommentableCase } from '../../../../common'; +import { CommentPatchRequestRt, throwErrors, User } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError, flattenCaseSavedObject, decodeComment } from '../../utils'; +import { escapeHatch, wrapError, decodeCommentRequest } from '../../utils'; import { CASE_COMMENTS_URL } from '../../../../../common/constants'; +import { CaseServiceSetup } from '../../../../services'; + +interface CombinedCaseParams { + service: CaseServiceSetup; + client: SavedObjectsClientContract; + caseID: string; + subCaseID?: string; +} + +async function getCommentableCase({ service, client, caseID, subCaseID }: CombinedCaseParams) { + if (subCaseID) { + const [caseInfo, subCase] = await Promise.all([ + service.getCase({ + client, + id: caseID, + }), + service.getSubCase({ + client, + id: subCaseID, + }), + ]); + return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client }); + } else { + const caseInfo = await service.getCase({ + client, + id: caseID, + }); + return new CommentableCase({ collection: caseInfo, service, soClient: client }); + } +} export function initPatchCommentApi({ caseConfigureService, @@ -32,24 +64,30 @@ export function initPatchCommentApi({ params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), body: escapeHatch, }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; - const caseId = request.params.case_id; const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const { id: queryCommentId, version: queryCommentVersion, ...queryRestAttributes } = query; - decodeComment(queryRestAttributes); + decodeCommentRequest(queryRestAttributes); - const myCase = await caseService.getCase({ + const commentableCase = await getCommentableCase({ + service: caseService, client, - caseId, + caseID: request.params.case_id, + subCaseID: request.query?.subCaseID, }); const myComment = await caseService.getComment({ @@ -65,9 +103,13 @@ export function initPatchCommentApi({ throw Boom.badRequest(`You cannot change the type of the comment.`); } - const caseRef = myComment.references.find((c) => c.type === CASE_SAVED_OBJECT); - if (caseRef == null || (caseRef != null && caseRef.id !== caseId)) { - throw Boom.notFound(`This comment ${queryCommentId} does not exist in ${caseId}).`); + const saveObjType = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT; + + const caseRef = myComment.references.find((c) => c.type === saveObjType); + if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) { + throw Boom.notFound( + `This comment ${queryCommentId} does not exist in ${commentableCase.id}).` + ); } if (queryCommentVersion !== myComment.version) { @@ -77,84 +119,46 @@ export function initPatchCommentApi({ } // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); + const userInfo: User = { + username, + full_name, + email, + }; + const updatedDate = new Date().toISOString(); - const [updatedComment, updatedCase] = await Promise.all([ - caseService.patchComment({ - client, - commentId: queryCommentId, - updatedAttributes: { - ...queryRestAttributes, - updated_at: updatedDate, - updated_by: { email, full_name, username }, - }, - version: queryCommentVersion, - }), - caseService.patchCase({ - client, - caseId, - updatedAttributes: { - updated_at: updatedDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, - }), - ]); - - const totalCommentsFindByCases = await caseService.getAllCaseComments({ - client, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, + const { + comment: updatedComment, + commentableCase: updatedCase, + } = await commentableCase.updateComment({ + updateRequest: query, + updatedAt: updatedDate, + user: userInfo, }); - const [comments] = await Promise.all([ - caseService.getAllCaseComments({ - client, - caseId: request.params.case_id, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, - }), - userActionService.postUserActions({ - client, - actions: [ - buildCommentUserActionItem({ - action: 'update', - actionAt: updatedDate, - actionBy: { username, full_name, email }, - caseId: request.params.case_id, - commentId: updatedComment.id, - fields: ['comment'], - newValue: JSON.stringify(queryRestAttributes), - oldValue: JSON.stringify( - // We are interested only in ContextBasicRt attributes - // myComment.attribute contains also CommentAttributesBasicRt attributes - pick(Object.keys(queryRestAttributes), myComment.attributes) - ), - }), - ], - }), - ]); + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + subCaseId: request.query?.subCaseID, + commentId: updatedComment.id, + fields: ['comment'], + newValue: JSON.stringify(queryRestAttributes), + oldValue: JSON.stringify( + // We are interested only in ContextBasicRt attributes + // myComment.attribute contains also CommentAttributesBasicRt attributes + pick(Object.keys(queryRestAttributes), myComment.attributes) + ), + }), + ], + }); return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase.attributes }, - version: updatedCase.version ?? myCase.version, - references: myCase.references, - }, - comments: comments.saved_objects, - }) - ), + body: await updatedCase.encode(), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 0ab038a62ac771..fb51b8f76d0ef4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -299,21 +299,24 @@ describe('POST comment', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.comments[response.payload.comments.length - 1]).toEqual({ - comment: 'Wow, good luck catching that bad meanie!', - type: CommentType.user, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - id: 'mock-comment', - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - }); + expect(response.payload.comments[response.payload.comments.length - 1]).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "id": "mock-comment", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 761beb964823a3..95b611950bd411 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -19,6 +19,11 @@ export function initPostCommentApi({ router }: RouteDeps) { params: schema.object({ case_id: schema.string(), }), + query: schema.maybe( + schema.object({ + subCaseID: schema.maybe(schema.string()), + }) + ), body: escapeHatch, }, }, @@ -28,12 +33,12 @@ export function initPostCommentApi({ router }: RouteDeps) { } const caseClient = context.case.getCaseClient(); - const caseId = request.params.case_id; + const caseId = request.query?.subCaseID ?? request.params.case_id; const comment = request.body as CommentRequest; try { return response.ok({ - body: await caseClient.addComment({ caseClient, caseId, comment }), + body: await caseClient.addComment({ caseId, comment }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 17972e129a8257..33226d39a25957 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -40,7 +40,6 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 6925f116136b34..02d39465373f98 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -56,7 +56,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout } // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const updateDate = new Date().toISOString(); @@ -73,7 +73,6 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: connector.id, connectorType: connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index 0bcf2ac18740fc..db3d5cd6a2e56e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -58,14 +58,13 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ); } // eslint-disable-next-line @typescript-eslint/naming-convention - const { email, full_name, username } = await caseService.getUser({ request, response }); + const { email, full_name, username } = await caseService.getUser({ request }); const creationDate = new Date().toISOString(); let mappings: ConnectorMappingsAttributes[] = []; try { mappings = await caseClient.getMappings({ actionsClient, - caseClient, connectorId: query.connector.id, connectorType: query.connector.type, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index d588950bec9aa3..a441a027769bfc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -52,12 +52,15 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCases, - caseCommentSavedObject: mockCaseComments, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseCommentSavedObject: mockCaseComments, + }); + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); @@ -71,12 +74,16 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCaseComments, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCaseComments, + }); + + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); @@ -90,12 +97,16 @@ describe('DELETE case', () => { }, }); - const { context } = await createRouteContext( - createMockSavedObjectsRepository({ - caseSavedObject: mockCasesErrorTriggerData, - caseCommentSavedObject: mockCasesErrorTriggerData, - }) - ); + const mockSO = createMockSavedObjectsRepository({ + caseSavedObject: mockCasesErrorTriggerData, + caseCommentSavedObject: mockCasesErrorTriggerData, + }); + + // Adding this because the delete API needs to get all the cases first to determine if they are removable or not + // so it makes a call to bulkGet first + mockSO.bulkGet.mockImplementation(async () => ({ saved_objects: [] })); + + const { context } = await createRouteContext(mockSO); const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index c72dde9f77bf11..263b814df4146e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -7,10 +7,75 @@ import { schema } from '@kbn/config-schema'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { CaseType } from '../../../../common/api'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; import { CASES_URL } from '../../../../common/constants'; +import { CaseServiceSetup } from '../../../services'; + +async function unremovableCases({ + caseService, + client, + ids, + force, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + ids: string[]; + force: boolean | undefined; +}): Promise { + // if the force flag was included then we can skip checking whether the cases are collections and go ahead + // and delete them + if (force) { + return []; + } + + const cases = await caseService.getCases({ caseIds: ids, client }); + const parentCases = cases.saved_objects.filter( + /** + * getCases will return an array of saved_objects and some can be successful cases where as others + * might have failed to find the ID. If it fails to find it, it will set the error field but not + * the attributes so check that we didn't receive an error. + */ + (caseObj) => !caseObj.error && caseObj.attributes.type === CaseType.collection + ); + + return parentCases.map((parentCase) => parentCase.id); +} + +async function deleteSubCases({ + caseService, + client, + caseIds, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseIds: string[]; +}) { + const subCasesForCaseIds = await caseService.findSubCasesByCaseId({ client, ids: caseIds }); + + const subCaseIDs = subCasesForCaseIds.saved_objects.map((subCase) => subCase.id); + const commentsForSubCases = await caseService.getAllSubCaseComments({ + client, + id: subCaseIDs, + }); + + // This shouldn't actually delete anything because all the comments should be deleted when comments are deleted + // per case ID + await Promise.all( + commentsForSubCases.saved_objects.map((commentSO) => + caseService.deleteComment({ client, commentId: commentSO.id }) + ) + ); + + await Promise.all( + subCasesForCaseIds.saved_objects.map((subCaseSO) => + caseService.deleteSubCase(client, subCaseSO.id) + ) + ); +} export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { router.delete( @@ -19,17 +84,30 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R validate: { query: schema.object({ ids: schema.arrayOf(schema.string()), + force: schema.maybe(schema.boolean()), }), }, }, async (context, request, response) => { try { const client = context.core.savedObjects.client; + const unremovable = await unremovableCases({ + caseService, + client, + ids: request.query.ids, + force: request.query.force, + }); + + if (unremovable.length > 0) { + return response.badRequest({ + body: `Case IDs: [${unremovable.join(' ,')}] are not removable`, + }); + } await Promise.all( request.query.ids.map((id) => caseService.deleteCase({ client, - caseId: id, + id, }) ) ); @@ -37,7 +115,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R request.query.ids.map((id) => caseService.getAllCaseComments({ client, - caseId: id, + id, }) ) ); @@ -56,8 +134,10 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R ) ); } + + await deleteSubCases({ caseService, client, caseIds: request.query.ids }); // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request }); const deleteDate = new Date().toISOString(); await userActionService.postUserActions({ @@ -68,7 +148,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R actionAt: deleteDate, actionBy: { username, full_name, email }, caseId: id, - fields: ['comment', 'description', 'status', 'tags', 'title'], + fields: ['comment', 'description', 'status', 'tags', 'title', 'sub_case'], }) ), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 2bfce8b9088032..8ba83b42c06d70 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -11,40 +11,16 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { isEmpty } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors, - CaseStatuses, caseStatuses, } from '../../../../common/api'; -import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps, TotalCommentByCase } from '../types'; -import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { transformCases, wrapError, escapeHatch } from '../utils'; +import { RouteDeps } from '../types'; import { CASES_URL } from '../../../../common/constants'; - -const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => - filters?.filter((i) => i !== '').join(` ${operator} `); - -const getStatusFilter = (status: CaseStatuses, appendFilter?: string) => - `${CASE_SAVED_OBJECT}.attributes.status: ${status}${ - !isEmpty(appendFilter) ? ` AND ${appendFilter}` : '' - }`; - -const buildFilter = ( - filters: string | string[] | undefined, - field: string, - operator: 'OR' | 'AND' -): string => - filters != null && filters.length > 0 - ? Array.isArray(filters) - ? // Be aware of the surrounding parenthesis (as string inside literal) around filters. - `(${filters - .map((filter) => `${CASE_SAVED_OBJECT}.attributes.${field}: ${filter}`) - ?.join(` ${operator} `)})` - : `${CASE_SAVED_OBJECT}.attributes.${field}: ${filters}` - : ''; +import { constructQueryOptions } from './helpers'; export function initFindCasesApi({ caseService, caseConfigureService, router }: RouteDeps) { router.get( @@ -62,79 +38,42 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }: fold(throwErrors(Boom.badRequest), identity) ); - const { tags, reporters, status, ...query } = queryParams; - const tagsFilter = buildFilter(tags, 'tags', 'OR'); - const reportersFilters = buildFilter(reporters, 'created_by.username', 'OR'); - - const myFilters = combineFilters([tagsFilter, reportersFilters], 'AND'); - const filter = status != null ? getStatusFilter(status, myFilters) : myFilters; + const queryArgs = { + tags: queryParams.tags, + reporters: queryParams.reporters, + sortByField: queryParams.sortField, + status: queryParams.status, + caseType: queryParams.type, + }; - const args = queryParams - ? { - client, - options: { - ...query, - filter, - sortField: sortToSnake(query.sortField ?? ''), - }, - } - : { - client, - }; + const caseQueries = constructQueryOptions(queryArgs); - const statusArgs = caseStatuses.map((caseStatus) => ({ + const cases = await caseService.findCasesGroupedByID({ client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: getStatusFilter(caseStatus, myFilters), - }, - })); - - const [cases, openCases, inProgressCases, closedCases] = await Promise.all([ - caseService.findCases(args), - ...statusArgs.map((arg) => caseService.findCases(arg)), - ]); + caseOptions: { ...queryParams, ...caseQueries.case }, + subCaseOptions: caseQueries.subCase, + }); - const totalCommentsFindByCases = await Promise.all( - cases.saved_objects.map((c) => - caseService.getAllCaseComments({ + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ ...queryArgs, status }); + return caseService.findCaseStatusStats({ client, - caseId: c.id, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }) - ) - ); - - const totalCommentsByCases = totalCommentsFindByCases.reduce( - (acc, itemFind) => { - if (itemFind.saved_objects.length > 0) { - const caseId = - itemFind.saved_objects[0].references.find((r) => r.type === CASE_SAVED_OBJECT) - ?.id ?? null; - if (caseId != null) { - return [...acc, { caseId, totalComments: itemFind.total }]; - } - } - return [...acc]; - }, - [] - ); + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + }); + }), + ]); return response.ok({ body: CasesFindResponseRt.encode( - transformCases( - cases, - openCases.total ?? 0, - inProgressCases.total ?? 0, - closedCases.total ?? 0, - totalCommentsByCases - ) + transformCases({ + ...cases, + countOpenCases: openCases, + countInProgressCases: inProgressCases, + countClosedCases: closedCases, + total: cases.casesMap.size, + }) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 55377d93e528d0..a3311796fa5cd9 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -20,22 +20,22 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro case_id: schema.string(), }), query: schema.object({ - includeComments: schema.string({ defaultValue: 'true' }), + includeComments: schema.boolean({ defaultValue: true }), + includeSubCaseComments: schema.maybe(schema.boolean({ defaultValue: false })), }), }, }, async (context, request, response) => { - if (!context.case) { - return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); - } - const caseClient = context.case.getCaseClient(); - const includeComments = JSON.parse(request.query.includeComments); const id = request.params.case_id; try { return response.ok({ - body: await caseClient.get({ id, includeComments }), + body: await caseClient.get({ + id, + includeComments: request.query.includeComments, + includeSubCaseComments: request.query.includeSubCaseComments, + }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index d888eb21a4946d..a1a7f4f9da8f5b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -12,12 +12,174 @@ import { SavedObjectsFindResponse } from 'kibana/server'; import { CaseConnector, ESCaseConnector, - ESCaseAttributes, - ESCasePatchRequest, ESCasesConfigureAttributes, ConnectorTypes, + CaseStatuses, + CaseType, + SavedObjectFindOptions, } from '../../../../common/api'; import { ESConnectorFields, ConnectorTypeFields } from '../../../../common/api/connectors'; +import { CASE_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../saved_object_types'; +import { sortToSnake } from '../utils'; +import { combineFilters } from '../../../common'; + +export const addStatusFilter = ({ + status, + appendFilter, + type = CASE_SAVED_OBJECT, +}: { + status: CaseStatuses | undefined; + appendFilter?: string; + type?: string; +}) => { + const filters: string[] = []; + if (status) { + filters.push(`${type}.attributes.status: ${status}`); + } + + if (appendFilter) { + filters.push(appendFilter); + } + return combineFilters(filters, 'AND'); +}; + +export const buildFilter = ({ + filters, + field, + operator, + type = CASE_SAVED_OBJECT, +}: { + filters: string | string[] | undefined; + field: string; + operator: 'OR' | 'AND'; + type?: string; +}): string => { + // if it is an empty string, empty array of strings, or undefined just return + if (!filters || filters.length <= 0) { + return ''; + } + + const arrayFilters = !Array.isArray(filters) ? [filters] : filters; + + return combineFilters( + arrayFilters.map((filter) => `${type}.attributes.${field}: ${filter}`), + operator + ); +}; + +/** + * Constructs the filters used for finding cases and sub cases. + * There are a few scenarios that this function tries to handle when constructing the filters used for finding cases + * and sub cases. + * + * Scenario 1: + * Type == Individual + * If the API request specifies that it wants only individual cases (aka not collections) then we need to add that + * specific filter when call the saved objects find api. This will filter out any collection cases. + * + * Scenario 2: + * Type == collection + * If the API request specifies that it only wants collection cases (cases that have sub cases) then we need to add + * the filter for collections AND we need to ignore any status filter for the case find call. This is because a + * collection's status is no longer relevant when it has sub cases. The user cannot change the status for a collection + * only for its sub cases. The status filter will be applied to the find request when looking for sub cases. + * + * Scenario 3: + * No Type is specified + * If the API request does not want to filter on type but instead get both collections and regular individual cases then + * we need to find all cases that match the other filter criteria and sub cases. To do this we construct the following query: + * + * ((status == some_status and type === individual) or type == collection) and (tags == blah) and (reporter == yo) + * This forces us to honor the status request for individual cases but gets us ALL collection cases that match the other + * filter criteria. When we search for sub cases we will use that status filter in that find call as well. + */ +export const constructQueryOptions = ({ + tags, + reporters, + status, + sortByField, + caseType, +}: { + tags?: string | string[]; + reporters?: string | string[]; + status?: CaseStatuses; + sortByField?: string; + caseType?: CaseType; +}): { case: SavedObjectFindOptions; subCase?: SavedObjectFindOptions } => { + const tagsFilter = buildFilter({ filters: tags, field: 'tags', operator: 'OR' }); + const reportersFilter = buildFilter({ + filters: reporters, + field: 'created_by.username', + operator: 'OR', + }); + const sortField = sortToSnake(sortByField); + + switch (caseType) { + case CaseType.individual: { + // The cases filter will result in this structure "status === oh and (type === individual) and (tags === blah) and (reporter === yo)" + // The subCase filter will be undefined because we don't need to find sub cases if type === individual + + // We do not want to support multiple type's being used, so force it to be a single filter value + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const caseFilters = addStatusFilter({ + status, + appendFilter: combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'), + }); + return { + case: { + filter: caseFilters, + sortField, + }, + }; + } + case CaseType.collection: { + // The cases filter will result in this structure "(type == parent) and (tags == blah) and (reporter == yo)" + // The sub case filter will use the query.status if it exists + const typeFilter = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; + const caseFilters = combineFilters([tagsFilter, reportersFilter, typeFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + default: { + /** + * In this scenario no type filter was sent, so we want to honor the status filter if one exists. + * To construct the filter and honor the status portion we need to find all individual cases that + * have that particular status. We also need to find cases that have sub cases but we want to ignore the + * case collection's status because it is not relevant. We only care about the status of the sub cases if the + * case is a collection. + * + * The cases filter will result in this structure "((status == open and type === individual) or type == parent) and (tags == blah) and (reporter == yo)" + * The sub case filter will use the query.status if it exists + */ + const typeIndividual = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.individual}`; + const typeParent = `${CASE_SAVED_OBJECT}.attributes.type: ${CaseType.collection}`; + + const statusFilter = combineFilters([addStatusFilter({ status }), typeIndividual], 'AND'); + const statusAndType = combineFilters([statusFilter, typeParent], 'OR'); + const caseFilters = combineFilters([statusAndType, tagsFilter, reportersFilter], 'AND'); + + return { + case: { + filter: caseFilters, + sortField, + }, + subCase: { + filter: addStatusFilter({ status, type: SUB_CASE_SAVED_OBJECT }), + sortField, + }, + }; + } + } +}; interface CompareArrays { addedItems: string[]; @@ -66,10 +228,16 @@ export const isTwoArraysDifference = ( return null; }; +interface CaseWithIDVersion { + id: string; + version: string; + [key: string]: unknown; +} + export const getCaseToUpdate = ( - currentCase: ESCaseAttributes, - queryCase: ESCasePatchRequest -): ESCasePatchRequest => + currentCase: unknown, + queryCase: CaseWithIDVersion +): CaseWithIDVersion => Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 6d1134b15b65e3..e50d14e5c66c4f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -17,7 +17,7 @@ import { } from '../__fixtures__'; import { initPatchCasesApi } from './patch_cases'; import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects'; -import { ConnectorTypes, CaseStatuses } from '../../../../common/api'; +import { CaseStatuses } from '../../../../common/api'; describe('PATCH cases', () => { let routeHandler: RequestHandler; @@ -52,34 +52,53 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: '2019-11-25T21:54:48.952Z', - closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses.closed, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": "2019-11-25T21:54:48.952Z", + "closed_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Open a case`, async () => { @@ -106,34 +125,53 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: '123', - name: 'My connector', - type: '.jira', - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - created_at: '2019-11-25T22:32:17.947Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'Oh no, a bad meanie going LOLBins all over the place!', - id: 'mock-id-4', - external_service: null, - status: CaseStatuses.open, - tags: ['LOLBins'], - title: 'Another bad one', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Change case to in-progress`, async () => { @@ -159,34 +197,49 @@ describe('PATCH cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual([ - { - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, - description: 'This is a brand new case of a bad meanie defacing data', - id: 'mock-id-1', - external_service: null, - status: CaseStatuses['in-progress'], - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - version: 'WzE3LDFd', - settings: { - syncAlerts: true, + expect(response.payload).toMatchInlineSnapshot(` + Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "in-progress", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "d00d@awesome.com", + "full_name": "Awesome D00d", + "username": "awesome", + }, + "version": "WzE3LDFd", }, - }, - ]); + ] + `); }); it(`Patches a case without a connector.id`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index a320fafe4e5b4e..67d4d21a576340 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -28,7 +28,7 @@ export function initPatchCasesApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.update({ caseClient, cases }), + body: await caseClient.update(cases), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 292e2c6775a801..53829157c5b049 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -189,35 +189,42 @@ describe('POST cases', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload).toEqual({ - closed_at: null, - closed_by: null, - comments: [], - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - email: null, - full_name: null, - username: null, - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - id: 'mock-it', - status: CaseStatuses.open, - tags: ['defacement'], - title: 'Super Bad Security Issue', - totalComment: 0, - updated_at: null, - updated_by: null, - version: 'WzksMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(response.payload).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-it", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "type": "individual", + "updated_at": null, + "updated_by": null, + "version": "WzksMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 09f746a62d58ac..349ed6c3e5af93 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -28,7 +28,7 @@ export function initPostCaseApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.create({ theCase }), + body: await caseClient.create({ ...theCase }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts index 49801ea4e2f3eb..bf398d1ffcf407 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts @@ -131,7 +131,10 @@ describe('Push case', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(caseClient.getAlerts).toHaveBeenCalledWith({ ids: ['test-id'] }); + expect(caseClient.getAlerts).toHaveBeenCalledWith({ + ids: ['test-id'], + indices: new Set(['test-index']), + }); }); it(`Calls execute with correct arguments`, async () => { diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 6d670c38bbf85c..c1f0a2cb59cb1d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -45,7 +45,6 @@ export function initPushCaseApi({ router }: RouteDeps) { return response.ok({ body: await caseClient.push({ - caseClient, actionsClient, caseId: params.case_id, connectorId: params.connector_id, diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts index 9644162629f24b..1c399a415e4704 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts @@ -16,6 +16,7 @@ import { } from '../../__fixtures__'; import { initGetCasesStatusApi } from './get_status'; import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { CaseType } from '../../../../../common/api'; describe('GET status', () => { let routeHandler: RequestHandler; @@ -24,6 +25,7 @@ describe('GET status', () => { page: 1, perPage: 1, type: 'cases', + sortField: 'created_at', }; beforeAll(async () => { @@ -45,17 +47,17 @@ describe('GET status', () => { const response = await routeHandler(context, request, kibanaResponseFactory); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { ...findArgs, - filter: 'cases.attributes.status: open', + filter: `((cases.attributes.status: open AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { ...findArgs, - filter: 'cases.attributes.status: in-progress', + filter: `((cases.attributes.status: in-progress AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { ...findArgs, - filter: 'cases.attributes.status: closed', + filter: `((cases.attributes.status: closed AND cases.attributes.type: individual) OR cases.attributes.type: ${CaseType.collection})`, }); expect(response.payload).toEqual({ diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index 8300e38a568962..f3cd0e2bdda5c2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -9,8 +9,8 @@ import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; import { CASE_STATUS_URL } from '../../../../../common/constants'; +import { constructQueryOptions } from '../helpers'; export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { router.get( @@ -21,25 +21,23 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const args = caseStatuses.map((status) => ({ - client, - options: { - fields: [], - page: 1, - perPage: 1, - filter: `${CASE_SAVED_OBJECT}.attributes.status: ${status}`, - }, - })); - const [openCases, inProgressCases, closesCases] = await Promise.all( - args.map((arg) => caseService.findCases(arg)) - ); + const [openCases, inProgressCases, closedCases] = await Promise.all([ + ...caseStatuses.map((status) => { + const statusQuery = constructQueryOptions({ status }); + return caseService.findCaseStatusStats({ + client, + caseOptions: statusQuery.case, + subCaseOptions: statusQuery.subCase, + }); + }), + ]); return response.ok({ body: CasesStatusResponseRt.encode({ - count_open_cases: openCases.total, - count_in_progress_cases: inProgressCases.total, - count_closed_cases: closesCases.total, + count_open_cases: openCases, + count_in_progress_cases: inProgressCases, + count_closed_cases: closedCases, }), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts new file mode 100644 index 00000000000000..db701dd0fc82b5 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/delete_sub_cases.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { schema } from '@kbn/config-schema'; +import { buildCaseUserActionItem } from '../../../../services/user_actions/helpers'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; + +export function initDeleteSubCasesApi({ caseService, router, userActionService }: RouteDeps) { + router.delete( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + query: schema.object({ + ids: schema.arrayOf(schema.string()), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + + const [comments, subCases] = await Promise.all([ + caseService.getAllSubCaseComments({ client, id: request.query.ids }), + caseService.getSubCases({ client, ids: request.query.ids }), + ]); + + const subCaseErrors = subCases.saved_objects.filter( + (subCase) => subCase.error !== undefined + ); + + if (subCaseErrors.length > 0) { + throw Boom.notFound( + `These sub cases ${subCaseErrors + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + const subCaseIDToParentID = subCases.saved_objects.reduce((acc, subCase) => { + const parentID = subCase.references.find((ref) => ref.type === CASE_SAVED_OBJECT); + acc.set(subCase.id, parentID?.id); + return acc; + }, new Map()); + + await Promise.all( + comments.saved_objects.map((comment) => + caseService.deleteComment({ client, commentId: comment.id }) + ) + ); + + await Promise.all(request.query.ids.map((id) => caseService.deleteSubCase(client, id))); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + const deleteDate = new Date().toISOString(); + + await userActionService.postUserActions({ + client, + actions: request.query.ids.map((id) => + buildCaseUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + // if for some reason the sub case didn't have a reference to its parent, we'll still log a user action + // but we won't have the case ID + caseId: subCaseIDToParentID.get(id) ?? '', + subCaseId: id, + fields: ['sub_case', 'comment', 'status'], + }) + ), + }); + + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts new file mode 100644 index 00000000000000..98052ccaeaba8e --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/find_sub_cases.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from '@hapi/boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + caseStatuses, + SubCasesFindRequestRt, + SubCasesFindResponseRt, + throwErrors, +} from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { escapeHatch, transformSubCases, wrapError } from '../../utils'; +import { SUB_CASES_URL } from '../../../../../common/constants'; +import { constructQueryOptions } from '../helpers'; +import { defaultPage, defaultPerPage } from '../..'; + +export function initFindSubCasesApi({ caseService, router }: RouteDeps) { + router.get( + { + path: `${SUB_CASES_URL}/_find`, + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const queryParams = pipe( + SubCasesFindRequestRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + + const ids = [request.params.case_id]; + const { subCase: subCaseQueryOptions } = constructQueryOptions({ + status: queryParams.status, + sortByField: queryParams.sortField, + }); + + const subCases = await caseService.findSubCasesGroupByCase({ + client, + ids, + options: { + sortField: 'created_at', + page: defaultPage, + perPage: defaultPerPage, + ...queryParams, + ...subCaseQueryOptions, + }, + }); + + const [open, inProgress, closed] = await Promise.all([ + ...caseStatuses.map((status) => { + const { subCase: statusQueryOptions } = constructQueryOptions({ + status, + sortByField: queryParams.sortField, + }); + return caseService.findSubCaseStatusStats({ + client, + options: statusQueryOptions ?? {}, + ids, + }); + }), + ]); + + return response.ok({ + body: SubCasesFindResponseRt.encode( + transformSubCases({ + ...subCases, + open, + inProgress, + closed, + // there should only be one entry in the map for the requested case ID + total: subCases.subCasesMap.get(request.params.case_id)?.length ?? 0, + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts new file mode 100644 index 00000000000000..b6d9a7345dbdd1 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/get_sub_case.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { SubCaseResponseRt } from '../../../../../common/api'; +import { RouteDeps } from '../../types'; +import { flattenSubCaseSavedObject, wrapError } from '../../utils'; +import { SUB_CASE_DETAILS_URL } from '../../../../../common/constants'; +import { countAlertsForID } from '../../../../common'; + +export function initGetSubCaseApi({ caseService, router }: RouteDeps) { + router.get( + { + path: SUB_CASE_DETAILS_URL, + validate: { + params: schema.object({ + case_id: schema.string(), + sub_case_id: schema.string(), + }), + query: schema.object({ + includeComments: schema.boolean({ defaultValue: true }), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const includeComments = request.query.includeComments; + + const subCase = await caseService.getSubCase({ + client, + id: request.params.sub_case_id, + }); + + if (!includeComments) { + return response.ok({ + body: SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + }) + ), + }); + } + + const theComments = await caseService.getAllSubCaseComments({ + client, + id: request.params.sub_case_id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + }); + + return response.ok({ + body: SubCaseResponseRt.encode( + flattenSubCaseSavedObject({ + savedObject: subCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + totalAlerts: countAlertsForID({ + comments: theComments, + id: request.params.sub_case_id, + }), + }) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts new file mode 100644 index 00000000000000..ca5cd657a39f32 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/sub_case/patch_sub_cases.ts @@ -0,0 +1,418 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { + SavedObjectsClientContract, + KibanaRequest, + SavedObject, + SavedObjectsFindResponse, +} from 'kibana/server'; + +import { CaseClient } from '../../../../client'; +import { CASE_COMMENT_SAVED_OBJECT, SUB_CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../../../services'; +import { + CaseStatuses, + SubCasesPatchRequest, + SubCasesPatchRequestRt, + CommentType, + excess, + throwErrors, + SubCasesResponse, + SubCasePatchRequest, + SubCaseAttributes, + ESCaseAttributes, + SubCaseResponse, + SubCasesResponseRt, + User, + CommentAttributes, +} from '../../../../../common/api'; +import { SUB_CASES_PATCH_DEL_URL } from '../../../../../common/constants'; +import { RouteDeps } from '../../types'; +import { + AlertInfo, + escapeHatch, + flattenSubCaseSavedObject, + isCommentRequestTypeAlertOrGenAlert, + wrapError, +} from '../../utils'; +import { getCaseToUpdate } from '../helpers'; +import { buildSubCaseUserActions } from '../../../../services/user_actions/helpers'; +import { addAlertInfoToStatusMap } from '../../../../common'; + +interface UpdateArgs { + client: SavedObjectsClientContract; + caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; + request: KibanaRequest; + caseClient: CaseClient; + subCases: SubCasesPatchRequest; +} + +function checkNonExistingOrConflict( + toUpdate: SubCasePatchRequest[], + fromStorage: Map> +) { + const nonExistingSubCases: SubCasePatchRequest[] = []; + const conflictedSubCases: SubCasePatchRequest[] = []; + for (const subCaseToUpdate of toUpdate) { + const bulkEntry = fromStorage.get(subCaseToUpdate.id); + + if (bulkEntry && bulkEntry.error) { + nonExistingSubCases.push(subCaseToUpdate); + } + + if (!bulkEntry || bulkEntry.version !== subCaseToUpdate.version) { + conflictedSubCases.push(subCaseToUpdate); + } + } + + if (nonExistingSubCases.length > 0) { + throw Boom.notFound( + `These sub cases ${nonExistingSubCases + .map((c) => c.id) + .join(', ')} do not exist. Please check you have the correct ids.` + ); + } + + if (conflictedSubCases.length > 0) { + throw Boom.conflict( + `These sub cases ${conflictedSubCases + .map((c) => c.id) + .join(', ')} has been updated. Please refresh before saving additional updates.` + ); + } +} + +interface GetParentIDsResult { + ids: string[]; + parentIDToSubID: Map; +} + +function getParentIDs({ + subCasesMap, + subCaseIDs, +}: { + subCasesMap: Map>; + subCaseIDs: string[]; +}): GetParentIDsResult { + return subCaseIDs.reduce( + (acc, id) => { + const subCase = subCasesMap.get(id); + if (subCase && subCase.references.length > 0) { + const parentID = subCase.references[0].id; + acc.ids.push(parentID); + let subIDs = acc.parentIDToSubID.get(parentID); + if (subIDs === undefined) { + subIDs = []; + } + subIDs.push(id); + acc.parentIDToSubID.set(parentID, subIDs); + } + return acc; + }, + { ids: [], parentIDToSubID: new Map() } + ); +} + +async function getParentCases({ + caseService, + client, + subCaseIDs, + subCasesMap, +}: { + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + subCaseIDs: string[]; + subCasesMap: Map>; +}): Promise>> { + const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); + + const parentCases = await caseService.getCases({ + client, + caseIds: parentIDInfo.ids, + }); + + const parentCaseErrors = parentCases.saved_objects.filter((so) => so.error !== undefined); + + if (parentCaseErrors.length > 0) { + throw Boom.badRequest( + `Unable to find parent cases: ${parentCaseErrors + .map((c) => c.id) + .join(', ')} for sub cases: ${subCaseIDs.join(', ')}` + ); + } + + return parentCases.saved_objects.reduce((acc, so) => { + const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id); + subCaseIDsWithParent?.forEach((subCaseID) => { + acc.set(subCaseID, so); + }); + return acc; + }, new Map>()); +} + +function getValidUpdateRequests( + toUpdate: SubCasePatchRequest[], + subCasesMap: Map> +): SubCasePatchRequest[] { + const validatedSubCaseAttributes: SubCasePatchRequest[] = toUpdate.map((updateCase) => { + const currentCase = subCasesMap.get(updateCase.id); + return currentCase != null + ? getCaseToUpdate(currentCase.attributes, { + ...updateCase, + }) + : { id: updateCase.id, version: updateCase.version }; + }); + + return validatedSubCaseAttributes.filter((updateCase: SubCasePatchRequest) => { + const { id, version, ...updateCaseAttributes } = updateCase; + return Object.keys(updateCaseAttributes).length > 0; + }); +} + +/** + * Get the id from a reference in a comment for a sub case + */ +function getID(comment: SavedObject): string | undefined { + return comment.references.find((ref) => ref.type === SUB_CASE_SAVED_OBJECT)?.id; +} + +/** + * Get all the alert comments for a set of sub cases + */ +async function getAlertComments({ + subCasesToSync, + caseService, + client, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; +}): Promise> { + const ids = subCasesToSync.map((subCase) => subCase.id); + return caseService.getAllSubCaseComments({ + client, + id: ids, + options: { + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`, + }, + }); +} + +/** + * Updates the status of alerts for the specified sub cases. + */ +async function updateAlerts({ + subCasesToSync, + caseService, + client, + caseClient, +}: { + subCasesToSync: SubCasePatchRequest[]; + caseService: CaseServiceSetup; + client: SavedObjectsClientContract; + caseClient: CaseClient; +}) { + const subCasesToSyncMap = subCasesToSync.reduce((acc, subCase) => { + acc.set(subCase.id, subCase); + return acc; + }, new Map()); + // get all the alerts for all sub cases that need to be synced + const totalAlerts = await getAlertComments({ caseService, client, subCasesToSync }); + // create a map of the status (open, closed, etc) to alert info that needs to be updated + const alertsToUpdate = totalAlerts.saved_objects.reduce((acc, alertComment) => { + if (isCommentRequestTypeAlertOrGenAlert(alertComment.attributes)) { + const id = getID(alertComment); + const status = + id !== undefined + ? subCasesToSyncMap.get(id)?.status ?? CaseStatuses.open + : CaseStatuses.open; + + addAlertInfoToStatusMap({ comment: alertComment.attributes, statusMap: acc, status }); + } + return acc; + }, new Map()); + + // This does at most 3 calls to Elasticsearch to update the status of the alerts to either open, closed, or in-progress + for (const [status, alertInfo] of alertsToUpdate.entries()) { + if (alertInfo.ids.length > 0 && alertInfo.indices.size > 0) { + caseClient.updateAlertsStatus({ + ids: alertInfo.ids, + status, + indices: alertInfo.indices, + }); + } + } +} + +async function update({ + client, + caseService, + userActionService, + request, + caseClient, + subCases, +}: UpdateArgs): Promise { + const query = pipe( + excess(SubCasesPatchRequestRt).decode(subCases), + fold(throwErrors(Boom.badRequest), identity) + ); + + const bulkSubCases = await caseService.getSubCases({ + client, + ids: query.subCases.map((q) => q.id), + }); + + const subCasesMap = bulkSubCases.saved_objects.reduce((acc, so) => { + acc.set(so.id, so); + return acc; + }, new Map>()); + + checkNonExistingOrConflict(query.subCases, subCasesMap); + + const nonEmptySubCaseRequests = getValidUpdateRequests(query.subCases, subCasesMap); + + if (nonEmptySubCaseRequests.length <= 0) { + throw Boom.notAcceptable('All update fields are identical to current version.'); + } + + const subIDToParentCase = await getParentCases({ + client, + caseService, + subCaseIDs: nonEmptySubCaseRequests.map((subCase) => subCase.id), + subCasesMap, + }); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = await caseService.getUser({ request }); + const updatedAt = new Date().toISOString(); + const updatedCases = await caseService.patchSubCases({ + client, + subCases: nonEmptySubCaseRequests.map((thisCase) => { + const { id: subCaseId, version, ...updateSubCaseAttributes } = thisCase; + let closedInfo: { closed_at: string | null; closed_by: User | null } = { + closed_at: null, + closed_by: null, + }; + + if ( + updateSubCaseAttributes.status && + updateSubCaseAttributes.status === CaseStatuses.closed + ) { + closedInfo = { + closed_at: updatedAt, + closed_by: { email, full_name, username }, + }; + } else if ( + updateSubCaseAttributes.status && + (updateSubCaseAttributes.status === CaseStatuses.open || + updateSubCaseAttributes.status === CaseStatuses['in-progress']) + ) { + closedInfo = { + closed_at: null, + closed_by: null, + }; + } + return { + subCaseId, + updatedAttributes: { + ...updateSubCaseAttributes, + ...closedInfo, + updated_at: updatedAt, + updated_by: { email, full_name, username }, + }, + version, + }; + }), + }); + + const subCasesToSyncAlertsFor = nonEmptySubCaseRequests.filter((subCaseToUpdate) => { + const storedSubCase = subCasesMap.get(subCaseToUpdate.id); + const parentCase = subIDToParentCase.get(subCaseToUpdate.id); + return ( + storedSubCase !== undefined && + subCaseToUpdate.status !== undefined && + storedSubCase.attributes.status !== subCaseToUpdate.status && + parentCase?.attributes.settings.syncAlerts + ); + }); + + await updateAlerts({ + caseService, + client, + caseClient, + subCasesToSync: subCasesToSyncAlertsFor, + }); + + const returnUpdatedSubCases = updatedCases.saved_objects.reduce( + (acc, updatedSO) => { + const originalSubCase = subCasesMap.get(updatedSO.id); + if (originalSubCase) { + acc.push( + flattenSubCaseSavedObject({ + savedObject: { + ...originalSubCase, + ...updatedSO, + attributes: { ...originalSubCase.attributes, ...updatedSO.attributes }, + references: originalSubCase.references, + version: updatedSO.version ?? originalSubCase.version, + }, + }) + ); + } + return acc; + }, + [] + ); + + await userActionService.postUserActions({ + client, + actions: buildSubCaseUserActions({ + originalSubCases: bulkSubCases.saved_objects, + updatedSubCases: updatedCases.saved_objects, + actionDate: updatedAt, + actionBy: { email, full_name, username }, + }), + }); + + return SubCasesResponseRt.encode(returnUpdatedSubCases); +} + +export function initPatchSubCasesApi({ router, caseService, userActionService }: RouteDeps) { + router.patch( + { + path: SUB_CASES_PATCH_DEL_URL, + validate: { + body: escapeHatch, + }, + }, + async (context, request, response) => { + const caseClient = context.case.getCaseClient(); + const subCases = request.body as SubCasesPatchRequest; + + try { + return response.ok({ + body: await update({ + request, + subCases, + caseClient, + client: context.core.savedObjects.client, + caseService, + userActionService, + }), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 00660e08bbd83a..f2fd986dd8a3ae 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -30,6 +30,19 @@ import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; import { RouteDeps } from './types'; +import { initGetSubCaseApi } from './cases/sub_case/get_sub_case'; +import { initPatchSubCasesApi } from './cases/sub_case/patch_sub_cases'; +import { initFindSubCasesApi } from './cases/sub_case/find_sub_cases'; +import { initDeleteSubCasesApi } from './cases/sub_case/delete_sub_cases'; + +/** + * Default page number when interacting with the saved objects API. + */ +export const defaultPage = 1; +/** + * Default number of results when interacting with the saved objects API. + */ +export const defaultPerPage = 20; export function initCaseApi(deps: RouteDeps) { // Cases @@ -40,6 +53,11 @@ export function initCaseApi(deps: RouteDeps) { initPostCaseApi(deps); initPushCaseApi(deps); initGetAllUserActionsApi(deps); + // Sub cases + initGetSubCaseApi(deps); + initPatchSubCasesApi(deps); + initFindSubCasesApi(deps); + initDeleteSubCasesApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 163116e1316f12..1efec927efb62f 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -10,7 +10,6 @@ import { transformNewComment, wrapError, transformCases, - flattenCaseSavedObjects, flattenCaseSavedObject, flattenCommentSavedObjects, transformComments, @@ -24,7 +23,14 @@ import { mockCaseComments, mockCaseNoConnectorId, } from './__fixtures__/mock_saved_objects'; -import { ConnectorTypes, ESCaseConnector, CommentType, CaseStatuses } from '../../../common/api'; +import { + ConnectorTypes, + ESCaseConnector, + CommentType, + AssociationType, + CaseType, + CaseResponse, +} from '../../../common/api'; describe('Utils', () => { describe('transformNewCase', () => { @@ -40,7 +46,7 @@ describe('Utils', () => { }; it('transform correctly', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', email: 'elastic@elastic.co', @@ -50,46 +56,112 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly without optional fields', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', }; const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: undefined, full_name: undefined, username: undefined }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly with optional fields as null', () => { const myCase = { - newCase, + newCase: { ...newCase, type: CaseType.individual }, connector, createdDate: '2020-04-09T09:43:51.778Z', email: null, @@ -99,18 +171,51 @@ describe('Utils', () => { const res = transformNewCase(myCase); - expect(res).toEqual({ - ...myCase.newCase, - closed_at: null, - closed_by: null, - connector, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - external_service: null, - status: CaseStatuses.open, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "Task", + }, + Object { + "key": "priority", + "value": "High", + }, + Object { + "key": "parent", + "value": null, + }, + ], + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "description": "A description", + "external_service": null, + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "type": "individual", + "updated_at": null, + "updated_by": null, + } + `); }); }); @@ -123,19 +228,27 @@ describe('Utils', () => { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic', + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly without optional fields', () => { @@ -143,20 +256,28 @@ describe('Utils', () => { comment: 'A comment', type: CommentType.user as const, createdDate: '2020-04-09T09:43:51.778Z', + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: undefined, full_name: undefined, username: undefined }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": undefined, + "full_name": undefined, + "username": undefined, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); it('transform correctly with optional fields as null', () => { @@ -167,20 +288,28 @@ describe('Utils', () => { email: null, full_name: null, username: null, + associationType: AssociationType.case, }; const res = transformNewComment(comment); - expect(res).toEqual({ - comment: 'A comment', - type: CommentType.user, - created_at: '2020-04-09T09:43:51.778Z', - created_by: { email: null, full_name: null, username: null }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "associationType": "case", + "comment": "A comment", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": null, + "full_name": null, + "username": null, + }, + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + } + `); }); }); @@ -232,173 +361,200 @@ describe('Utils', () => { describe('transformCases', () => { it('transforms correctly', () => { - const extraCaseData = [ - { caseId: mockCases[0].id, totalComments: 2 }, - { caseId: mockCases[1].id, totalComments: 2 }, - { caseId: mockCases[2].id, totalComments: 2 }, - { caseId: mockCases[3].id, totalComments: 2 }, - ]; - - const res = transformCases( - { - saved_objects: mockCases.map((obj) => ({ ...obj, score: 1 })), - total: mockCases.length, - per_page: 10, - page: 1, - }, - 2, - 2, - 2, - extraCaseData + const casesMap = new Map( + mockCases.map((obj) => { + return [obj.id, flattenCaseSavedObject({ savedObject: obj, totalComment: 2 })]; + }) ); - expect(res).toEqual({ + const res = transformCases({ + casesMap, + countOpenCases: 2, + countInProgressCases: 2, + countClosedCases: 2, page: 1, - per_page: 10, - total: mockCases.length, - cases: flattenCaseSavedObjects( - mockCases.map((obj) => ({ ...obj, score: 1 })), - extraCaseData - ), - count_open_cases: 2, - count_closed_cases: 2, - count_in_progress_cases: 2, + perPage: 10, + total: casesMap.size, }); - }); - }); - - describe('flattenCaseSavedObjects', () => { - it('flattens correctly', () => { - const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 2 }]; - - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - - expect(res).toEqual([ - { - id: 'mock-id-1', - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); - }); - - it('it handles total comments correctly when caseId is not in extraCaseData', () => { - const extraCaseData = [{ caseId: mockCases[0].id, totalComments: 0 }]; - const res = flattenCaseSavedObjects([mockCases[0]], extraCaseData); - - expect(res).toEqual([ - { - id: 'mock-id-1', - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); - }); - - it('inserts missing connector', () => { - const extraCaseData = [ - { - caseId: mockCaseNoConnectorId.id, - totalComment: 0, - }, - ]; - - // @ts-ignore this is to update old case saved objects to include connector - const res = flattenCaseSavedObjects([mockCaseNoConnectorId], extraCaseData); - - expect(res).toEqual([ - { - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 0, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }, - ]); + expect(res).toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-id-1", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T22:32:00.900Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie destroying data!", + "external_service": null, + "id": "mock-id-2", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "Data Destruction", + ], + "title": "Damaging Data Destruction Detected", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:00.900Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzQsMV0=", + }, + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + Object { + "closed_at": "2019-11-25T22:32:17.947Z", + "closed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-4", + "settings": Object { + "syncAlerts": true, + }, + "status": "closed", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + }, + ], + "count_closed_cases": 2, + "count_in_progress_cases": 2, + "count_open_cases": 2, + "page": 1, + "per_page": 10, + "total": 4, + } + `); }); }); @@ -410,17 +566,51 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: myCase.version, - comments: [], - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); }); it('flattens correctly without version', () => { @@ -431,17 +621,51 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: '0', - comments: [], - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "0", + } + `); }); it('flattens correctly with comments', () => { @@ -453,17 +677,73 @@ describe('Utils', () => { totalComment: 2, }); - expect(res).toEqual({ - id: myCase.id, - version: myCase.version, - comments: flattenCommentSavedObjects(comments), - totalComment: 2, - ...myCase.attributes, - connector: { - ...myCase.attributes.connector, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [ + Object { + "associationType": "case", + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2019-11-25T21:55:00.177Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "id": "mock-comment-1", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": "2019-11-25T21:55:00.177Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzEsMV0=", + }, + ], + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2019-11-25T22:32:17.947Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "Oh no, a bad meanie going LOLBins all over the place!", + "external_service": null, + "id": "mock-id-3", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "LOLBins", + ], + "title": "Another bad one", + "totalAlerts": 0, + "totalComment": 2, + "type": "individual", + "updated_at": "2019-11-25T22:32:17.947Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzUsMV0=", + } + `); }); it('inserts missing connector', () => { @@ -477,40 +757,46 @@ describe('Utils', () => { ...extraCaseData, }); - expect(res).toEqual({ - id: mockCaseNoConnectorId.id, - closed_at: null, - closed_by: null, - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - comments: [], - totalComment: 2, - version: 'WzAsMV0=', - settings: { - syncAlerts: true, - }, - }); + expect(res).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": null, + "id": "mock-no-connector_id", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "subCases": undefined, + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + } + `); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index e2751c05d880ac..bc82f656f477b3 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -18,7 +18,6 @@ import { } from 'kibana/server'; import { - CasePostRequest, CaseResponse, CasesFindResponse, CommentResponse, @@ -28,17 +27,41 @@ import { ESCaseAttributes, CommentRequest, ContextTypeUserRt, - ContextTypeAlertRt, CommentRequestUserType, CommentRequestAlertType, CommentType, excess, throwErrors, CaseStatuses, + CaseClientPostRequest, + AssociationType, + SubCaseAttributes, + SubCaseResponse, + SubCasesFindResponse, + User, + AlertCommentRequestRt, } from '../../../common/api'; import { transformESConnectorToCaseConnector } from './cases/helpers'; -import { SortFieldCase, TotalCommentByCase } from './types'; +import { SortFieldCase } from './types'; + +export const transformNewSubCase = ({ + createdAt, + createdBy, +}: { + createdAt: string; + createdBy: User; +}): SubCaseAttributes => { + return { + closed_at: null, + closed_by: null, + created_at: createdAt, + created_by: createdBy, + status: CaseStatuses.open, + updated_at: null, + updated_by: null, + }; +}; export const transformNewCase = ({ connector, @@ -53,7 +76,7 @@ export const transformNewCase = ({ createdDate: string; email?: string | null; full_name?: string | null; - newCase: CasePostRequest; + newCase: CaseClientPostRequest; username?: string | null; }): ESCaseAttributes => ({ ...newCase, @@ -69,28 +92,93 @@ export const transformNewCase = ({ }); type NewCommentArgs = CommentRequest & { + associationType: AssociationType; createdDate: string; email?: string | null; full_name?: string | null; username?: string | null; }; +/** + * Return the alert IDs from the comment if it is an alert style comment. Otherwise return an empty array. + */ +export const getAlertIds = (comment: CommentRequest): string[] => { + if (isCommentRequestTypeAlertOrGenAlert(comment)) { + return Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + } + return []; +}; + +/** + * This structure holds the alert IDs and indices found from multiple alert comments + */ +export interface AlertInfo { + ids: string[]; + indices: Set; +} + +const accumulateIndicesAndIDs = (comment: CommentAttributes, acc: AlertInfo): AlertInfo => { + if (isCommentRequestTypeAlertOrGenAlert(comment)) { + acc.ids.push(...getAlertIds(comment)); + acc.indices.add(comment.index); + } + return acc; +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alerts. + */ +export const getAlertIndicesAndIDs = (comments: CommentAttributes[] | undefined): AlertInfo => { + if (comments === undefined) { + return { ids: [], indices: new Set() }; + } + + return comments.reduce( + (acc: AlertInfo, comment) => { + return accumulateIndicesAndIDs(comment, acc); + }, + { ids: [], indices: new Set() } + ); +}; + +/** + * Builds an AlertInfo object accumulating the alert IDs and indices for the passed in alert saved objects. + */ +export const getAlertIndicesAndIDsFromSO = ( + comments: SavedObjectsFindResponse | undefined +): AlertInfo => { + if (comments === undefined) { + return { ids: [], indices: new Set() }; + } + + return comments.saved_objects.reduce( + (acc: AlertInfo, comment) => { + return accumulateIndicesAndIDs(comment.attributes, acc); + }, + { ids: [], indices: new Set() } + ); +}; + export const transformNewComment = ({ + associationType, createdDate, email, // eslint-disable-next-line @typescript-eslint/naming-convention full_name, username, ...comment -}: NewCommentArgs): CommentAttributes => ({ - ...comment, - created_at: createdDate, - created_by: { email, full_name, username }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, -}); +}: NewCommentArgs): CommentAttributes => { + return { + associationType, + ...comment, + created_at: createdDate, + created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }; +}; export function wrapError(error: any): CustomHttpResponseOptions { const options = { statusCode: error.statusCode ?? 500 }; @@ -102,52 +190,99 @@ export function wrapError(error: any): CustomHttpResponseOptions }; } -export const transformCases = ( - cases: SavedObjectsFindResponse, - countOpenCases: number, - countInProgressCases: number, - countClosedCases: number, - totalCommentByCase: TotalCommentByCase[] -): CasesFindResponse => ({ - page: cases.page, - per_page: cases.per_page, - total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), +export const transformCases = ({ + casesMap, + countOpenCases, + countInProgressCases, + countClosedCases, + page, + perPage, + total, +}: { + casesMap: Map; + countOpenCases: number; + countInProgressCases: number; + countClosedCases: number; + page: number; + perPage: number; + total: number; +}): CasesFindResponse => ({ + page, + per_page: perPage, + total, + cases: Array.from(casesMap.values()), count_open_cases: countOpenCases, count_in_progress_cases: countInProgressCases, count_closed_cases: countClosedCases, }); -export const flattenCaseSavedObjects = ( - savedObjects: Array>, - totalCommentByCase: TotalCommentByCase[] -): CaseResponse[] => - savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { - return [ - ...acc, - flattenCaseSavedObject({ - savedObject, - totalComment: - totalCommentByCase.find((tc) => tc.caseId === savedObject.id)?.totalComments ?? 0, - }), - ]; - }, []); +export const transformSubCases = ({ + subCasesMap, + open, + inProgress, + closed, + page, + perPage, + total, +}: { + subCasesMap: Map; + open: number; + inProgress: number; + closed: number; + page: number; + perPage: number; + total: number; +}): SubCasesFindResponse => ({ + page, + per_page: perPage, + total, + // Squish all the entries in the map together as one array + subCases: Array.from(subCasesMap.values()).flat(), + count_open_cases: open, + count_in_progress_cases: inProgress, + count_closed_cases: closed, +}); export const flattenCaseSavedObject = ({ savedObject, comments = [], totalComment = comments.length, + totalAlerts = 0, + subCases, }: { savedObject: SavedObject; comments?: Array>; totalComment?: number; + totalAlerts?: number; + subCases?: SubCaseResponse[]; }): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), totalComment, + totalAlerts, ...savedObject.attributes, connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), + subCases, +}); + +export const flattenSubCaseSavedObject = ({ + savedObject, + comments = [], + totalComment = comments.length, + totalAlerts = 0, +}: { + savedObject: SavedObject; + comments?: Array>; + totalComment?: number; + totalAlerts?: number; +}): SubCaseResponse => ({ + id: savedObject.id, + version: savedObject.version ?? '0', + comments: flattenCommentSavedObjects(comments), + totalComment, + totalAlerts, + ...savedObject.attributes, }); export const transformComments = ( @@ -174,7 +309,7 @@ export const flattenCommentSavedObject = ( ...savedObject.attributes, }); -export const sortToSnake = (sortField: string): SortFieldCase => { +export const sortToSnake = (sortField: string | undefined): SortFieldCase => { switch (sortField) { case 'status': return SortFieldCase.status; @@ -191,18 +326,41 @@ export const sortToSnake = (sortField: string): SortFieldCase => { export const escapeHatch = schema.object({}, { unknowns: 'allow' }); -export const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { +/** + * A type narrowing function for user comments. Exporting so integration tests can use it. + */ +export const isCommentRequestTypeUser = ( + context: CommentRequest +): context is CommentRequestUserType => { return context.type === CommentType.user; }; -export const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { - return context.type === CommentType.alert; +/** + * A type narrowing function for alert comments. Exporting so integration tests can use it. + */ +export const isCommentRequestTypeAlertOrGenAlert = ( + context: CommentRequest +): context is CommentRequestAlertType => { + return context.type === CommentType.alert || context.type === CommentType.generatedAlert; +}; + +/** + * This is used to test if the posted comment is an generated alert. A generated alert will have one or many alerts. + * An alert is essentially an object with a _id field. This differs from a regular attached alert because the _id is + * passed directly in the request, it won't be in an object. Internally case will strip off the outer object and store + * both a generated and user attached alert in the same structure but this function is useful to determine which + * structure the new alert in the request has. + */ +export const isCommentRequestTypeGenAlert = ( + context: CommentRequest +): context is CommentRequestAlertType => { + return context.type === CommentType.generatedAlert; }; -export const decodeComment = (comment: CommentRequest) => { - if (isUserContext(comment)) { +export const decodeCommentRequest = (comment: CommentRequest) => { + if (isCommentRequestTypeUser(comment)) { pipe(excess(ContextTypeUserRt).decode(comment), fold(throwErrors(badRequest), identity)); - } else if (isAlertContext(comment)) { - pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); + } else if (isCommentRequestTypeAlertOrGenAlert(comment)) { + pipe(excess(AlertCommentRequestRt).decode(comment), fold(throwErrors(badRequest), identity)); } }; diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 64ee692d447986..5f413ea27c4a75 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -118,7 +118,10 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, - + // collection or individual + type: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index 336a1bdd172c60..9eabf744f2e132 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -16,6 +16,9 @@ export const caseCommentSavedObjectType: SavedObjectsType = { namespaceType: 'single', mappings: { properties: { + associationType: { + type: 'keyword', + }, comment: { type: 'text', }, diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 9599cbef9709d7..91f104335df8b8 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -6,6 +6,7 @@ */ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; +export { subCaseSavedObjectType, SUB_CASE_SAVED_OBJECT } from './sub_case'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index fc7ffb34776ae9..a0b22c49d0bc6d 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -8,7 +8,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; -import { ConnectorTypes, CommentType } from '../../common/api'; +import { ConnectorTypes, CommentType, CaseType, AssociationType } from '../../common/api'; interface UnsanitizedCaseConnector { connector_id: string; @@ -49,6 +49,10 @@ interface SanitizedCaseSettings { }; } +interface SanitizedCaseType { + type: string; +} + export const caseMigrations = { '7.10.0': ( doc: SavedObjectUnsanitizedDoc @@ -83,6 +87,18 @@ export const caseMigrations = { references: doc.references || [], }; }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + type: CaseType.individual, + }, + references: doc.references || [], + }; + }, }; export const configureMigrations = { @@ -157,6 +173,10 @@ interface SanitizedComment { type: CommentType; } +interface SanitizedCommentAssociationType { + associationType: AssociationType; +} + export const commentsMigrations = { '7.11.0': ( doc: SavedObjectUnsanitizedDoc @@ -170,4 +190,16 @@ export const commentsMigrations = { references: doc.references || [], }; }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + associationType: AssociationType.case, + }, + references: doc.references || [], + }; + }, }; diff --git a/x-pack/plugins/case/server/saved_object_types/sub_case.ts b/x-pack/plugins/case/server/saved_object_types/sub_case.ts new file mode 100644 index 00000000000000..da89b19346e4e1 --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/sub_case.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsType } from 'src/core/server'; + +export const SUB_CASE_SAVED_OBJECT = 'cases-sub-case'; + +export const subCaseSavedObjectType: SavedObjectsType = { + name: SUB_CASE_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + mappings: { + properties: { + closed_at: { + type: 'date', + }, + closed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + created_at: { + type: 'date', + }, + created_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + status: { + type: 'keyword', + }, + updated_at: { + type: 'date', + }, + updated_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case.json b/x-pack/plugins/case/server/scripts/mock/case/post_case.json index 743fa396295ca7..bed342dd69fe9d 100644 --- a/x-pack/plugins/case/server/scripts/mock/case/post_case.json +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case.json @@ -3,5 +3,14 @@ "title": "Bad meanie defacing data", "tags": [ "defacement" - ] + ], + "connector": { + "id": "none", + "name": "none", + "type": ".none", + "fields": null + }, + "settings": { + "syncAlerts": true + } } diff --git a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json index 13efe436a640d5..58fee92859bf97 100644 --- a/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json +++ b/x-pack/plugins/case/server/scripts/mock/case/post_case_v2.json @@ -3,5 +3,14 @@ "title": "Another bad dude", "tags": [ "phishing" - ] + ], + "connector": { + "id": "none", + "name": "none", + "type": ".none", + "fields": null + }, + "settings": { + "syncAlerts": true + } } diff --git a/x-pack/plugins/case/server/scripts/sub_cases/README.md b/x-pack/plugins/case/server/scripts/sub_cases/README.md new file mode 100644 index 00000000000000..92873b8f037f3f --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/README.md @@ -0,0 +1,80 @@ +# Sub Cases Helper Script + +This script makes interacting with sub cases easier (creating, deleting, retrieving, etc). + +To run the script, first `cd x-pack/plugins/case/server/scripts` + +## Showing the help + +```bash +yarn test:sub-cases help +``` + +Sub command help + +```bash +yarn test:sub-cases help +``` + +## Generating alerts + +This will generate a new case and sub case if one does not exist and then attach a group +of alerts to it. + +```bash +yarn test:sub-cases alerts --ids id1 id2 id3 +``` + +## Deleting a collection + +This will delete a case that has sub cases. + +```bash +yarn test:sub-cases delete +``` + +## Find sub cases + +This will find sub cases attached to a collection. + +```bash +yarn test:sub-cases find [status] +``` + +Example: + +```bash +yarn test:sub-cases find 6c9e0490-64dc-11eb-92be-09d246866276 +``` + +Response: + +```bash +{ + "page": 1, + "per_page": 1, + "total": 1, + "subCases": [ + { + "id": "6dd6d2b0-64dc-11eb-92be-09d246866276", + "version": "WzUzNDMsMV0=", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "closed_at": null, + "closed_by": null, + "created_at": "2021-02-01T22:25:46.323Z", + "status": "open", + "updated_at": "2021-02-01T22:25:46.323Z", + "updated_by": { + "full_name": null, + "email": null, + "username": "elastic" + } + } + ], + "count_open_cases": 0, + "count_in_progress_cases": 0, + "count_closed_cases": 0 +} +``` diff --git a/x-pack/plugins/case/server/scripts/sub_cases/generator.js b/x-pack/plugins/case/server/scripts/sub_cases/generator.js new file mode 100644 index 00000000000000..0c5b8bfc8550b1 --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/generator.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +require('../../../../../../src/setup_node_env'); +require('./index'); diff --git a/x-pack/plugins/case/server/scripts/sub_cases/index.ts b/x-pack/plugins/case/server/scripts/sub_cases/index.ts new file mode 100644 index 00000000000000..2ea9718d18487e --- /dev/null +++ b/x-pack/plugins/case/server/scripts/sub_cases/index.ts @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* eslint-disable no-console */ +import yargs from 'yargs'; +import { KbnClient, ToolingLog } from '@kbn/dev-utils'; +import { + CaseResponse, + CaseType, + CollectionWithSubCaseResponse, + ConnectorTypes, +} from '../../../common/api'; +import { CommentType } from '../../../common/api/cases/comment'; +import { CASES_URL } from '../../../common/constants'; +import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common'; + +main(); + +function createClient(argv: any): KbnClient { + return new KbnClient({ + log: new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }), + url: argv.kibana, + }); +} + +async function handleFind(argv: any) { + const client = createClient(argv); + + try { + const res = await client.request({ + path: `${CASES_URL}/${argv.caseID}/sub_cases/_find`, + method: 'GET', + query: { + status: argv.status, + }, + }); + console.log(JSON.stringify(res.data, null, 2)); + } catch (e) { + console.error(e); + throw e; + } +} + +async function handleDelete(argv: any) { + const client = createClient(argv); + + try { + await client.request({ + path: `${CASES_URL}?ids=["${argv.id}"]`, + method: 'DELETE', + query: { + force: true, + }, + }); + } catch (e) { + console.error(e); + throw e; + } +} + +async function handleGenGroupAlerts(argv: any) { + const client = createClient(argv); + + try { + const createdAction = await client.request({ + path: '/api/actions/action', + method: 'POST', + body: { + name: 'A case connector', + actionTypeId: '.case', + config: {}, + }, + }); + + let caseID: string | undefined = argv.caseID as string | undefined; + + if (!caseID) { + console.log('Creating new case'); + const newCase = await client.request({ + path: CASES_URL, + method: 'POST', + body: { + description: 'This is a brand new case from generator script', + type: CaseType.collection, + title: 'Super Bad Security Issue', + tags: ['defacement'], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + settings: { + syncAlerts: true, + }, + }, + }); + caseID = newCase.data.id; + } + + console.log('Case id: ', caseID); + const executeResp = await client.request< + ActionTypeExecutorResult + >({ + path: `/api/actions/action/${createdAction.data.id}/_execute`, + method: 'POST', + body: { + params: { + subAction: 'addComment', + subActionParams: { + caseId: caseID, + comment: { + type: CommentType.generatedAlert, + alerts: argv.ids.map((id: string) => ({ _id: id })), + index: argv.signalsIndex, + }, + }, + }, + }, + }); + + if (executeResp.data.status !== 'ok') { + console.log( + 'Error received from actions api during execute: ', + JSON.stringify(executeResp.data, null, 2) + ); + process.exit(1); + } + + console.log('Execution response ', JSON.stringify(executeResp.data, null, 2)); + } catch (e) { + console.error(e); + throw e; + } +} + +async function main() { + // This returns before the async handlers do + // We need to convert this to commander instead I think + yargs(process.argv.slice(2)) + .help() + .options({ + kibana: { + alias: 'k', + describe: 'kibana url', + default: 'http://elastic:changeme@localhost:5601', + type: 'string', + }, + }) + .command({ + command: 'alerts', + aliases: ['gen', 'genAlerts'], + describe: 'generate a group of alerts', + builder: (args) => { + return args + .options({ + caseID: { + alias: 'c', + describe: 'case ID', + }, + ids: { + alias: 'a', + describe: 'alert ids', + type: 'array', + }, + signalsIndex: { + alias: 'i', + describe: 'siem signals index', + type: 'string', + default: '.siem-signals-default', + }, + }) + .demandOption(['ids']); + }, + handler: async (args) => { + return handleGenGroupAlerts(args); + }, + }) + .command({ + command: 'delete ', + describe: 'deletes a case', + builder: (args) => { + return args.positional('id', { + describe: 'case id', + type: 'string', + }); + }, + handler: async (args) => { + return handleDelete(args); + }, + }) + .command({ + command: 'find [status]', + describe: 'gets all sub cases', + builder: (args) => { + return args + .positional('caseID', { describe: 'case id', type: 'string' }) + .positional('status', { + describe: 'filter by status', + type: 'string', + }); + }, + handler: async (args) => { + return handleFind(args); + }, + }) + .demandCommand() + .parse(); + + console.log('completed'); +} diff --git a/x-pack/plugins/case/server/services/alerts/index.test.ts b/x-pack/plugins/case/server/services/alerts/index.test.ts index 2787d855a4c0de..35aa3ff80efc1e 100644 --- a/x-pack/plugins/case/server/services/alerts/index.test.ts +++ b/x-pack/plugins/case/server/services/alerts/index.test.ts @@ -6,20 +6,21 @@ */ import { KibanaRequest } from 'kibana/server'; -import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; import { CaseStatuses } from '../../../common/api'; import { AlertService, AlertServiceContract } from '.'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; describe('updateAlertsStatus', () => { - const esClientMock = elasticsearchServiceMock.createClusterClient(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); describe('happy path', () => { let alertService: AlertServiceContract; const args = { ids: ['alert-id-1'], - index: '.siem-signals', + indices: new Set(['.siem-signals']), request: {} as KibanaRequest, status: CaseStatuses.closed, + scopedClusterClient: esClient, }; beforeEach(async () => { @@ -28,30 +29,29 @@ describe('updateAlertsStatus', () => { }); test('it update the status of the alert correctly', async () => { - alertService.initialize(esClientMock); await alertService.updateAlertsStatus(args); - expect(esClientMock.asScoped().asCurrentUser.updateByQuery).toHaveBeenCalledWith({ + expect(esClient.updateByQuery).toHaveBeenCalledWith({ body: { query: { ids: { values: args.ids } }, script: { lang: 'painless', source: `ctx._source.signal.status = '${args.status}'` }, }, conflicts: 'abort', ignore_unavailable: true, - index: args.index, + index: [...args.indices], }); }); describe('unhappy path', () => { - test('it throws when service is already initialized', async () => { - alertService.initialize(esClientMock); - expect(() => { - alertService.initialize(esClientMock); - }).toThrow(); - }); - - test('it throws when service is not initialized and try to update the status', async () => { - await expect(alertService.updateAlertsStatus(args)).rejects.toThrow(); + it('ignores empty indices', async () => { + expect( + await alertService.updateAlertsStatus({ + ids: ['alert-id-1'], + status: CaseStatuses.closed, + indices: new Set(['']), + scopedClusterClient: esClient, + }) + ).toBeUndefined(); }); }); }); diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 2776d6b40761e1..320d32ac0d7887 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -5,24 +5,26 @@ * 2.0. */ +import _ from 'lodash'; + import type { PublicMethodsOf } from '@kbn/utility-types'; -import { IClusterClient, KibanaRequest } from 'kibana/server'; +import { ElasticsearchClient } from 'kibana/server'; import { CaseStatuses } from '../../../common/api'; export type AlertServiceContract = PublicMethodsOf; interface UpdateAlertsStatusArgs { - request: KibanaRequest; ids: string[]; status: CaseStatuses; - index: string; + indices: Set; + scopedClusterClient: ElasticsearchClient; } interface GetAlertsArgs { - request: KibanaRequest; ids: string[]; - index: string; + indices: Set; + scopedClusterClient: ElasticsearchClient; } interface Alert { @@ -37,29 +39,32 @@ interface AlertsResponse { }; } -export class AlertService { - private isInitialized = false; - private esClient?: IClusterClient; +/** + * remove empty strings from the indices, I'm not sure how likely this is but in the case that + * the document doesn't have _index set the security_solution code sets the value to an empty string + * instead + */ +function getValidIndices(indices: Set): string[] { + return [...indices].filter((index) => !_.isEmpty(index)); +} +export class AlertService { constructor() {} - public initialize(esClient: IClusterClient) { - if (this.isInitialized) { - throw new Error('AlertService already initialized'); + public async updateAlertsStatus({ + ids, + status, + indices, + scopedClusterClient, + }: UpdateAlertsStatusArgs) { + const sanitizedIndices = getValidIndices(indices); + if (sanitizedIndices.length <= 0) { + // log that we only had invalid indices + return; } - this.isInitialized = true; - this.esClient = esClient; - } - - public async updateAlertsStatus({ request, ids, status, index }: UpdateAlertsStatusArgs) { - if (!this.isInitialized) { - throw new Error('AlertService not initialized'); - } - - // The above check makes sure that esClient is defined. - const result = await this.esClient!.asScoped(request).asCurrentUser.updateByQuery({ - index, + const result = await scopedClusterClient.updateByQuery({ + index: sanitizedIndices, conflicts: 'abort', body: { script: { @@ -74,13 +79,17 @@ export class AlertService { return result; } - public async getAlerts({ request, ids, index }: GetAlertsArgs): Promise { - if (!this.isInitialized) { - throw new Error('AlertService not initialized'); + public async getAlerts({ + scopedClusterClient, + ids, + indices, + }: GetAlertsArgs): Promise { + const index = getValidIndices(indices); + if (index.length <= 0) { + return; } - // The above check makes sure that esClient is defined. - const result = await this.esClient!.asScoped(request).asCurrentUser.search({ + const result = await scopedClusterClient.search({ index, body: { query: { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index a2ad98b6035525..a9e5c269608308 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -7,7 +7,6 @@ import { KibanaRequest, - KibanaResponseFactory, Logger, SavedObject, SavedObjectsClientContract, @@ -16,6 +15,7 @@ import { SavedObjectReference, SavedObjectsBulkUpdateResponse, SavedObjectsBulkResponse, + SavedObjectsFindResult, } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; @@ -25,8 +25,26 @@ import { SavedObjectFindOptions, User, CommentPatchAttributes, + SubCaseAttributes, + AssociationType, + SubCaseResponse, + CommentType, + CaseType, + CaseResponse, + caseTypeField, } from '../../common/api'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; +import { combineFilters, defaultSortField, groupTotalAlertsByID } from '../common'; +import { defaultPage, defaultPerPage } from '../routes/api'; +import { + flattenCaseSavedObject, + flattenSubCaseSavedObject, + transformNewSubCase, +} from '../routes/api/utils'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../saved_object_types'; import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; @@ -45,20 +63,50 @@ interface PushedArgs { } interface GetCaseArgs extends ClientArgs { - caseId: string; + id: string; } interface GetCasesArgs extends ClientArgs { caseIds: string[]; } -interface FindCommentsArgs extends GetCaseArgs { +interface GetSubCasesArgs extends ClientArgs { + ids: string[]; +} + +interface FindCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptions; +} + +interface FindCaseCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; + options?: SavedObjectFindOptions; + includeSubCaseComments?: boolean; +} + +interface FindSubCaseCommentsArgs { + client: SavedObjectsClientContract; + id: string | string[]; options?: SavedObjectFindOptions; } interface FindCasesArgs extends ClientArgs { options?: SavedObjectFindOptions; } + +interface FindSubCasesByIDArgs extends FindCasesArgs { + ids: string[]; +} + +interface FindSubCasesStatusStats { + client: SavedObjectsClientContract; + options: SavedObjectFindOptions; + ids: string[]; +} + interface GetCommentArgs extends ClientArgs { commentId: string; } @@ -67,6 +115,12 @@ interface PostCaseArgs extends ClientArgs { attributes: ESCaseAttributes; } +interface CreateSubCaseArgs extends ClientArgs { + createdAt: string; + caseId: string; + createdBy: User; +} + interface PostCommentArgs extends ClientArgs { attributes: CommentAttributes; references: SavedObjectReference[]; @@ -95,20 +149,69 @@ interface PatchComments extends ClientArgs { comments: PatchComment[]; } +interface PatchSubCase { + client: SavedObjectsClientContract; + subCaseId: string; + updatedAttributes: Partial; + version?: string; +} + +interface PatchSubCases { + client: SavedObjectsClientContract; + subCases: Array>; +} + interface GetUserArgs { request: KibanaRequest; - response?: KibanaResponseFactory; } -interface CaseServiceDeps { - authentication: SecurityPluginSetup['authc'] | null; +interface SubCasesMapWithPageInfo { + subCasesMap: Map; + page: number; + perPage: number; +} + +interface CaseCommentStats { + commentTotals: Map; + alertTotals: Map; +} + +interface FindCommentsByAssociationArgs { + client: SavedObjectsClientContract; + id: string | string[]; + associationType: AssociationType; + options?: SavedObjectFindOptions; +} + +interface Collection { + case: SavedObjectsFindResult; + subCases?: SubCaseResponse[]; } + +interface CasesMapWithPageInfo { + casesMap: Map; + page: number; + perPage: number; +} + export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; + deleteSubCase(client: SavedObjectsClientContract, id: string): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - getAllCaseComments(args: FindCommentsArgs): Promise>; + findSubCases(args: FindCasesArgs): Promise>; + findSubCasesByCaseId( + args: FindSubCasesByIDArgs + ): Promise>; + getAllCaseComments( + args: FindCaseCommentsArgs + ): Promise>; + getAllSubCaseComments( + args: FindSubCaseCommentsArgs + ): Promise>; getCase(args: GetCaseArgs): Promise>; + getSubCase(args: GetCaseArgs): Promise>; + getSubCases(args: GetSubCasesArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; getTags(args: ClientArgs): Promise; @@ -120,205 +223,902 @@ export interface CaseServiceSetup { patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; patchComments(args: PatchComments): Promise>; + getMostRecentSubCase( + client: SavedObjectsClientContract, + caseId: string + ): Promise | undefined>; + createSubCase(args: CreateSubCaseArgs): Promise>; + patchSubCase(args: PatchSubCase): Promise>; + patchSubCases(args: PatchSubCases): Promise>; + findSubCaseStatusStats(args: FindSubCasesStatusStats): Promise; + getCommentsByAssociation( + args: FindCommentsByAssociationArgs + ): Promise>; + getCaseCommentStats(args: { + client: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise; + findSubCasesGroupByCase(args: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + ids: string[]; + }): Promise; + findCaseStatusStats(args: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise; + findCasesGroupedByID(args: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise; } -export class CaseService { - constructor(private readonly log: Logger) {} - public setup = async ({ authentication }: CaseServiceDeps): Promise => ({ - deleteCase: async ({ client, caseId }: GetCaseArgs) => { - try { - this.log.debug(`Attempting to GET case ${caseId}`); - return await client.delete(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); - throw error; - } - }, - deleteComment: async ({ client, commentId }: GetCommentArgs) => { - try { - this.log.debug(`Attempting to GET comment ${commentId}`); - return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); - } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); - throw error; - } - }, - getCase: async ({ client, caseId }: GetCaseArgs) => { - try { - this.log.debug(`Attempting to GET case ${caseId}`); - return await client.get(CASE_SAVED_OBJECT, caseId); - } catch (error) { - this.log.debug(`Error on GET case ${caseId}: ${error}`); - throw error; +export class CaseService implements CaseServiceSetup { + constructor( + private readonly log: Logger, + private readonly authentication?: SecurityPluginSetup['authc'] + ) {} + + /** + * Returns a map of all cases combined with their sub cases if they are collections. + */ + public async findCasesGroupedByID({ + client, + caseOptions, + subCaseOptions, + }: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise { + const cases = await this.findCases({ + client, + options: caseOptions, + }); + + const subCasesResp = await this.findSubCasesGroupByCase({ + client, + options: subCaseOptions, + ids: cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id), + }); + + const casesMap = cases.saved_objects.reduce((accMap, caseInfo) => { + const subCasesForCase = subCasesResp.subCasesMap.get(caseInfo.id); + + /** + * This will include empty collections unless the query explicitly requested type === CaseType.individual, in which + * case we'd not have any collections anyway. + */ + accMap.set(caseInfo.id, { case: caseInfo, subCases: subCasesForCase }); + return accMap; + }, new Map()); + + /** + * One potential optimization here is to get all comment stats for individual cases, parent cases, and sub cases + * in a single request. This can be done because comments that are for sub cases have a reference to both the sub case + * and the parent. The associationType field allows us to determine which type of case the comment is attached to. + * + * So we could use the ids for all the valid cases (individual cases and parents with sub cases) to grab everything. + * Once we have it we can build the maps. + * + * Currently we get all comment stats for all sub cases in one go and we get all comment stats for cases (individual and parent) + * in another request (the one below this comment). + */ + const totalCommentsForCases = await this.getCaseCommentStats({ + client, + ids: Array.from(casesMap.keys()), + associationType: AssociationType.case, + }); + + const casesWithComments = new Map(); + for (const [id, caseInfo] of casesMap.entries()) { + casesWithComments.set( + id, + flattenCaseSavedObject({ + savedObject: caseInfo.case, + totalComment: totalCommentsForCases.commentTotals.get(id) ?? 0, + totalAlerts: totalCommentsForCases.alertTotals.get(id) ?? 0, + subCases: caseInfo.subCases, + }) + ); + } + + return { + casesMap: casesWithComments, + page: cases.page, + perPage: cases.per_page, + }; + } + + /** + * Retrieves the number of cases that exist with a given status (open, closed, etc). + * This also counts sub cases. Parent cases are excluded from the statistics. + */ + public async findCaseStatusStats({ + client, + caseOptions, + subCaseOptions, + }: { + client: SavedObjectsClientContract; + caseOptions: SavedObjectFindOptions; + subCaseOptions?: SavedObjectFindOptions; + }): Promise { + const casesStats = await this.findCases({ + client, + options: { + ...caseOptions, + fields: [], + page: 1, + perPage: 1, + }, + }); + + /** + * This could be made more performant. What we're doing here is retrieving all cases + * that match the API request's filters instead of just counts. This is because we need to grab + * the ids for the parent cases that match those filters. Then we use those IDS to count how many + * sub cases those parents have to calculate the total amount of cases that are open, closed, or in-progress. + * + * Another solution would be to store ALL filterable fields on both a case and sub case. That we could do a single + * query for each type to calculate the totals using the filters. This has drawbacks though: + * + * We'd have to sync up the parent case's editable attributes with the sub case any time they were change to avoid + * them getting out of sync and causing issues when we do these types of stats aggregations. This would result in a lot + * of update requests if the user is editing their case details often. Which could potentially cause conflict failures. + * + * Another option is to prevent the ability from update the parent case's details all together once it's created. A user + * could instead modify the sub case details directly. This could be weird though because individual sub cases for the same + * parent would have different titles, tags, etc. + * + * Another potential issue with this approach is when you push a case and all its sub case information. If the sub cases + * don't have the same title and tags, we'd need to account for that as well. + */ + const cases = await this.findCases({ + client, + options: { + ...caseOptions, + fields: [caseTypeField], + page: 1, + perPage: casesStats.total, + }, + }); + + const caseIds = cases.saved_objects + .filter((caseInfo) => caseInfo.attributes.type === CaseType.collection) + .map((caseInfo) => caseInfo.id); + + let subCasesTotal = 0; + + if (subCaseOptions) { + subCasesTotal = await this.findSubCaseStatusStats({ + client, + options: subCaseOptions, + ids: caseIds, + }); + } + + const total = + cases.saved_objects.filter((caseInfo) => caseInfo.attributes.type !== CaseType.collection) + .length + subCasesTotal; + + return total; + } + + /** + * Retrieves the comments attached to a case or sub case. + */ + public async getCommentsByAssociation({ + client, + id, + associationType, + options, + }: FindCommentsByAssociationArgs): Promise> { + if (associationType === AssociationType.subCase) { + return this.getAllSubCaseComments({ + client, + id, + options, + }); + } else { + return this.getAllCaseComments({ + client, + id, + options, + }); + } + } + + /** + * Returns the number of total comments and alerts for a case (or sub case) + */ + public async getCaseCommentStats({ + client, + ids, + associationType, + }: { + client: SavedObjectsClientContract; + ids: string[]; + associationType: AssociationType; + }): Promise { + if (ids.length <= 0) { + return { + commentTotals: new Map(), + alertTotals: new Map(), + }; + } + + const refType = + associationType === AssociationType.case ? CASE_SAVED_OBJECT : SUB_CASE_SAVED_OBJECT; + + const allComments = await Promise.all( + ids.map((id) => + this.getCommentsByAssociation({ + client, + associationType, + id, + options: { page: 1, perPage: 1 }, + }) + ) + ); + + const alerts = await this.getCommentsByAssociation({ + client, + associationType, + id: ids, + options: { + filter: `(${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.alert} OR ${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert})`, + }, + }); + + const getID = (comments: SavedObjectsFindResponse) => { + return comments.saved_objects.length > 0 + ? comments.saved_objects[0].references.find((ref) => ref.type === refType)?.id + : undefined; + }; + + const groupedComments = allComments.reduce((acc, comments) => { + const id = getID(comments); + if (id) { + acc.set(id, comments.total); } - }, - getCases: async ({ client, caseIds }: GetCasesArgs) => { - try { - this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); - return await client.bulkGet( - caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) - ); - } catch (error) { - this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); - throw error; + return acc; + }, new Map()); + + const groupedAlerts = groupTotalAlertsByID({ comments: alerts }); + return { commentTotals: groupedComments, alertTotals: groupedAlerts }; + } + + /** + * Returns all the sub cases for a set of case IDs. Comment statistics are also returned. + */ + public async findSubCasesGroupByCase({ + client, + options, + ids, + }: { + client: SavedObjectsClientContract; + options?: SavedObjectFindOptions; + ids: string[]; + }): Promise { + const getCaseID = (subCase: SavedObjectsFindResult): string | undefined => { + return subCase.references.length > 0 ? subCase.references[0].id : undefined; + }; + + const emptyResponse = { + subCasesMap: new Map(), + page: 0, + perPage: 0, + }; + + if (!options) { + return emptyResponse; + } + + if (ids.length <= 0) { + return emptyResponse; + } + + const subCases = await this.findSubCases({ + client, + options: { + ...options, + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + const subCaseComments = await this.getCaseCommentStats({ + client, + ids: subCases.saved_objects.map((subCase) => subCase.id), + associationType: AssociationType.subCase, + }); + + const subCasesMap = subCases.saved_objects.reduce((accMap, subCase) => { + const parentCaseID = getCaseID(subCase); + if (parentCaseID) { + const subCaseFromMap = accMap.get(parentCaseID); + + if (subCaseFromMap === undefined) { + const subCasesForID = [ + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }), + ]; + accMap.set(parentCaseID, subCasesForID); + } else { + subCaseFromMap.push( + flattenSubCaseSavedObject({ + savedObject: subCase, + totalComment: subCaseComments.commentTotals.get(subCase.id) ?? 0, + totalAlerts: subCaseComments.alertTotals.get(subCase.id) ?? 0, + }) + ); + } } - }, - getComment: async ({ client, commentId }: GetCommentArgs) => { - try { - this.log.debug(`Attempting to GET comment ${commentId}`); - return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); - } catch (error) { - this.log.debug(`Error on GET comment ${commentId}: ${error}`); - throw error; + return accMap; + }, new Map()); + + return { subCasesMap, page: subCases.page, perPage: subCases.per_page }; + } + + /** + * Calculates the number of sub cases for a given set of options for a set of case IDs. + */ + public async findSubCaseStatusStats({ + client, + options, + ids, + }: FindSubCasesStatusStats): Promise { + if (ids.length <= 0) { + return 0; + } + + const subCases = await this.findSubCases({ + client, + options: { + ...options, + page: 1, + perPage: 1, + fields: [], + hasReference: ids.map((id) => { + return { + id, + type: CASE_SAVED_OBJECT, + }; + }), + }, + }); + + return subCases.total; + } + + public async createSubCase({ + client, + createdAt, + caseId, + createdBy, + }: CreateSubCaseArgs): Promise> { + try { + this.log.debug(`Attempting to POST a new sub case`); + return client.create(SUB_CASE_SAVED_OBJECT, transformNewSubCase({ createdAt, createdBy }), { + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], + }); + } catch (error) { + this.log.debug(`Error on POST a new sub case: ${error}`); + throw error; + } + } + + public async getMostRecentSubCase(client: SavedObjectsClientContract, caseId: string) { + try { + this.log.debug(`Attempting to find most recent sub case for caseID: ${caseId}`); + const subCases: SavedObjectsFindResponse = await client.find({ + perPage: 1, + sortField: 'created_at', + sortOrder: 'desc', + type: SUB_CASE_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + }); + if (subCases.saved_objects.length <= 0) { + return; } - }, - findCases: async ({ client, options }: FindCasesArgs) => { - try { - this.log.debug(`Attempting to GET all cases`); - return await client.find({ ...options, type: CASE_SAVED_OBJECT }); - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; + + return subCases.saved_objects[0]; + } catch (error) { + this.log.debug(`Error finding the most recent sub case for case: ${caseId}`); + throw error; + } + } + + public async deleteSubCase(client: SavedObjectsClientContract, id: string) { + try { + this.log.debug(`Attempting to DELETE sub case ${id}`); + return await client.delete(SUB_CASE_SAVED_OBJECT, id); + } catch (error) { + this.log.debug(`Error on DELETE sub case ${id}: ${error}`); + throw error; + } + } + + public async deleteCase({ client, id: caseId }: GetCaseArgs) { + try { + this.log.debug(`Attempting to DELETE case ${caseId}`); + return await client.delete(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on DELETE case ${caseId}: ${error}`); + throw error; + } + } + public async deleteComment({ client, commentId }: GetCommentArgs) { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.delete(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + } + public async getCase({ + client, + id: caseId, + }: GetCaseArgs): Promise> { + try { + this.log.debug(`Attempting to GET case ${caseId}`); + return await client.get(CASE_SAVED_OBJECT, caseId); + } catch (error) { + this.log.debug(`Error on GET case ${caseId}: ${error}`); + throw error; + } + } + public async getSubCase({ client, id }: GetCaseArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub case ${id}`); + return await client.get(SUB_CASE_SAVED_OBJECT, id); + } catch (error) { + this.log.debug(`Error on GET sub case ${id}: ${error}`); + throw error; + } + } + + public async getSubCases({ + client, + ids, + }: GetSubCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET sub cases ${ids.join(', ')}`); + return await client.bulkGet(ids.map((id) => ({ type: SUB_CASE_SAVED_OBJECT, id }))); + } catch (error) { + this.log.debug(`Error on GET cases ${ids.join(', ')}: ${error}`); + throw error; + } + } + + public async getCases({ + client, + caseIds, + }: GetCasesArgs): Promise> { + try { + this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); + return await client.bulkGet( + caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) + ); + } catch (error) { + this.log.debug(`Error on GET cases ${caseIds.join(', ')}: ${error}`); + throw error; + } + } + public async getComment({ + client, + commentId, + }: GetCommentArgs): Promise> { + try { + this.log.debug(`Attempting to GET comment ${commentId}`); + return await client.get(CASE_COMMENT_SAVED_OBJECT, commentId); + } catch (error) { + this.log.debug(`Error on GET comment ${commentId}: ${error}`); + throw error; + } + } + + public async findCases({ + client, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find cases`); + return await client.find({ + sortField: defaultSortField, + ...options, + type: CASE_SAVED_OBJECT, + }); + } catch (error) { + this.log.debug(`Error on find cases: ${error}`); + throw error; + } + } + + public async findSubCases({ + client, + options, + }: FindCasesArgs): Promise> { + try { + this.log.debug(`Attempting to find sub cases`); + // if the page or perPage options are set then respect those instead of trying to + // grab all sub cases + if (options?.page !== undefined || options?.perPage !== undefined) { + return client.find({ + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); } - }, - getAllCaseComments: async ({ client, caseId, options }: FindCommentsArgs) => { - try { - this.log.debug(`Attempting to GET all comments for case ${caseId}`); - return await client.find({ + + const stats = await client.find({ + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); + return client.find({ + page: 1, + perPage: stats.total, + sortField: defaultSortField, + ...options, + type: SUB_CASE_SAVED_OBJECT, + }); + } catch (error) { + this.log.debug(`Error on find sub cases: ${error}`); + throw error; + } + } + + /** + * Find sub cases using a collection's ID. This would try to retrieve the maximum amount of sub cases + * by default. + * + * @param id the saved object ID of the parent collection to find sub cases for. + */ + public async findSubCasesByCaseId({ + client, + ids, + options, + }: FindSubCasesByIDArgs): Promise> { + if (ids.length <= 0) { + return { + total: 0, + saved_objects: [], + page: options?.page ?? defaultPage, + per_page: options?.perPage ?? defaultPerPage, + }; + } + + try { + this.log.debug(`Attempting to GET sub cases for case collection id ${ids.join(', ')}`); + return this.findSubCases({ + client, + options: { ...options, + hasReference: ids.map((id) => ({ + type: CASE_SAVED_OBJECT, + id, + })), + }, + }); + } catch (error) { + this.log.debug( + `Error on GET all sub cases for case collection id ${ids.join(', ')}: ${error}` + ); + throw error; + } + } + + private asArray(id: string | string[] | undefined): string[] { + if (id === undefined) { + return []; + } else if (Array.isArray(id)) { + return id; + } else { + return [id]; + } + } + + private async getAllComments({ + client, + id, + options, + }: FindCommentsArgs): Promise> { + try { + this.log.debug(`Attempting to GET all comments for id ${id}`); + if (options?.page !== undefined || options?.perPage !== undefined) { + return client.find({ type: CASE_COMMENT_SAVED_OBJECT, - hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + sortField: defaultSortField, + ...options, }); - } catch (error) { - this.log.debug(`Error on GET all comments for case ${caseId}: ${error}`); - throw error; - } - }, - getReporters: async ({ client }: ClientArgs) => { - try { - this.log.debug(`Attempting to GET all reporters`); - return await readReporters({ client }); - } catch (error) { - this.log.debug(`Error on GET all reporters: ${error}`); - throw error; } - }, - getTags: async ({ client }: ClientArgs) => { - try { - this.log.debug(`Attempting to GET all cases`); - return await readTags({ client }); - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; - } - }, - getUser: async ({ request, response }: GetUserArgs) => { - try { - this.log.debug(`Attempting to authenticate a user`); - if (authentication != null) { - const user = authentication.getCurrentUser(request); - if (!user) { - return { - username: null, - full_name: null, - email: null, - }; - } - return user; - } + // get the total number of comments that are in ES then we'll grab them all in one go + const stats = await client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + fields: [], + page: 1, + perPage: 1, + sortField: defaultSortField, + // spread the options after so the caller can override the default behavior if they want + ...options, + }); + + return client.find({ + type: CASE_COMMENT_SAVED_OBJECT, + page: 1, + perPage: stats.total, + sortField: defaultSortField, + ...options, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for ${id}: ${error}`); + throw error; + } + } + + /** + * Default behavior is to retrieve all comments that adhere to a given filter (if one is included). + * to override this pass in the either the page or perPage options. + * + * @param includeSubCaseComments is a flag to indicate that sub case comments should be included as well, by default + * sub case comments are excluded. If the `filter` field is included in the options, it will override this behavior + */ + public async getAllCaseComments({ + client, + id, + options, + includeSubCaseComments = false, + }: FindCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { return { - username: null, - full_name: null, - email: null, + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, }; - } catch (error) { - this.log.debug(`Error on GET cases: ${error}`); - throw error; } - }, - postNewCase: async ({ client, attributes }: PostCaseArgs) => { - try { - this.log.debug(`Attempting to POST a new case`); - return await client.create(CASE_SAVED_OBJECT, { ...attributes }); - } catch (error) { - this.log.debug(`Error on POST a new case: ${error}`); - throw error; - } - }, - postNewComment: async ({ client, attributes, references }: PostCommentArgs) => { - try { - this.log.debug(`Attempting to POST a new comment`); - return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); - } catch (error) { - this.log.debug(`Error on POST a new comment: ${error}`); - throw error; - } - }, - patchCase: async ({ client, caseId, updatedAttributes, version }: PatchCaseArgs) => { - try { - this.log.debug(`Attempting to UPDATE case ${caseId}`); - return await client.update( - CASE_SAVED_OBJECT, - caseId, - { ...updatedAttributes }, - { version } - ); - } catch (error) { - this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); - throw error; - } - }, - patchCases: async ({ client, cases }: PatchCasesArgs) => { - try { - this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); - return await client.bulkUpdate( - cases.map((c) => ({ - type: CASE_SAVED_OBJECT, - id: c.caseId, - attributes: c.updatedAttributes, - version: c.version, - })) + + let filter: string | undefined; + if (!includeSubCaseComments) { + // if other filters were passed in then combine them to filter out sub case comments + filter = combineFilters( + [ + options?.filter ?? '', + `${CASE_COMMENT_SAVED_OBJECT}.attributes.associationType: ${AssociationType.case}`, + ], + 'AND' ); - } catch (error) { - this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); - throw error; } - }, - patchComment: async ({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) => { - try { - this.log.debug(`Attempting to UPDATE comment ${commentId}`); - return await client.update( - CASE_COMMENT_SAVED_OBJECT, - commentId, - { - ...updatedAttributes, - }, - { version } - ); - } catch (error) { - this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); - throw error; + + this.log.debug(`Attempting to GET all comments for case caseID ${id}`); + return this.getAllComments({ + client, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + filter, + ...options, + }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for case ${id}: ${error}`); + throw error; + } + } + + public async getAllSubCaseComments({ + client, + id, + options, + }: FindSubCaseCommentsArgs): Promise> { + try { + const refs = this.asArray(id).map((caseID) => ({ type: SUB_CASE_SAVED_OBJECT, id: caseID })); + if (refs.length <= 0) { + return { + saved_objects: [], + total: 0, + per_page: options?.perPage ?? defaultPerPage, + page: options?.page ?? defaultPage, + }; } - }, - patchComments: async ({ client, comments }: PatchComments) => { - try { - this.log.debug( - `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` - ); - return await client.bulkUpdate( - comments.map((c) => ({ - type: CASE_COMMENT_SAVED_OBJECT, - id: c.commentId, - attributes: c.updatedAttributes, - version: c.version, - })) - ); - } catch (error) { - this.log.debug( - `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` - ); - throw error; + + this.log.debug(`Attempting to GET all comments for sub case caseID ${id}`); + return this.getAllComments({ + client, + id, + options: { + hasReferenceOperator: 'OR', + hasReference: refs, + ...options, + }, + }); + } catch (error) { + this.log.debug(`Error on GET all comments for sub case ${id}: ${error}`); + throw error; + } + } + + public async getReporters({ client }: ClientArgs) { + try { + this.log.debug(`Attempting to GET all reporters`); + return await readReporters({ client }); + } catch (error) { + this.log.debug(`Error on GET all reporters: ${error}`); + throw error; + } + } + public async getTags({ client }: ClientArgs) { + try { + this.log.debug(`Attempting to GET all cases`); + return await readTags({ client }); + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + + public async getUser({ request }: GetUserArgs) { + try { + this.log.debug(`Attempting to authenticate a user`); + if (this.authentication != null) { + const user = this.authentication.getCurrentUser(request); + if (!user) { + return { + username: null, + full_name: null, + email: null, + }; + } + return user; } - }, - }); + return { + username: null, + full_name: null, + email: null, + }; + } catch (error) { + this.log.debug(`Error on GET cases: ${error}`); + throw error; + } + } + public async postNewCase({ client, attributes }: PostCaseArgs) { + try { + this.log.debug(`Attempting to POST a new case`); + return await client.create(CASE_SAVED_OBJECT, { ...attributes }); + } catch (error) { + this.log.debug(`Error on POST a new case: ${error}`); + throw error; + } + } + public async postNewComment({ client, attributes, references }: PostCommentArgs) { + try { + this.log.debug(`Attempting to POST a new comment`); + return await client.create(CASE_COMMENT_SAVED_OBJECT, attributes, { references }); + } catch (error) { + this.log.debug(`Error on POST a new comment: ${error}`); + throw error; + } + } + public async patchCase({ client, caseId, updatedAttributes, version }: PatchCaseArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${caseId}`); + return await client.update(CASE_SAVED_OBJECT, caseId, { ...updatedAttributes }, { version }); + } catch (error) { + this.log.debug(`Error on UPDATE case ${caseId}: ${error}`); + throw error; + } + } + public async patchCases({ client, cases }: PatchCasesArgs) { + try { + this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); + return await client.bulkUpdate( + cases.map((c) => ({ + type: CASE_SAVED_OBJECT, + id: c.caseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); + throw error; + } + } + public async patchComment({ client, commentId, updatedAttributes, version }: UpdateCommentArgs) { + try { + this.log.debug(`Attempting to UPDATE comment ${commentId}`); + return await client.update( + CASE_COMMENT_SAVED_OBJECT, + commentId, + { + ...updatedAttributes, + }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE comment ${commentId}: ${error}`); + throw error; + } + } + public async patchComments({ client, comments }: PatchComments) { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map((c) => c.commentId).join(', ')}` + ); + return await client.bulkUpdate( + comments.map((c) => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.commentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE comments ${comments.map((c) => c.commentId).join(', ')}: ${error}` + ); + throw error; + } + } + public async patchSubCase({ client, subCaseId, updatedAttributes, version }: PatchSubCase) { + try { + this.log.debug(`Attempting to UPDATE sub case ${subCaseId}`); + return await client.update( + SUB_CASE_SAVED_OBJECT, + subCaseId, + { ...updatedAttributes }, + { version } + ); + } catch (error) { + this.log.debug(`Error on UPDATE sub case ${subCaseId}: ${error}`); + throw error; + } + } + + public async patchSubCases({ client, subCases }: PatchSubCases) { + try { + this.log.debug( + `Attempting to UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}` + ); + return await client.bulkUpdate( + subCases.map((c) => ({ + type: SUB_CASE_SAVED_OBJECT, + id: c.subCaseId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE sub case ${subCases.map((c) => c.subCaseId).join(', ')}: ${error}` + ); + throw error; + } + } } diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 0b3615793ef85e..51eb0bbb1a7e43 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -20,13 +20,21 @@ export type CaseUserActionServiceMock = jest.Mocked; export type AlertServiceMock = jest.Mocked; export const createCaseServiceMock = (): CaseServiceMock => ({ + createSubCase: jest.fn(), deleteCase: jest.fn(), deleteComment: jest.fn(), + deleteSubCase: jest.fn(), findCases: jest.fn(), + findSubCases: jest.fn(), + findSubCasesByCaseId: jest.fn(), getAllCaseComments: jest.fn(), + getAllSubCaseComments: jest.fn(), getCase: jest.fn(), getCases: jest.fn(), getComment: jest.fn(), + getMostRecentSubCase: jest.fn(), + getSubCase: jest.fn(), + getSubCases: jest.fn(), getTags: jest.fn(), getReporters: jest.fn(), getUser: jest.fn(), @@ -36,6 +44,14 @@ export const createCaseServiceMock = (): CaseServiceMock => ({ patchCases: jest.fn(), patchComment: jest.fn(), patchComments: jest.fn(), + patchSubCase: jest.fn(), + patchSubCases: jest.fn(), + findSubCaseStatusStats: jest.fn(), + getCommentsByAssociation: jest.fn(), + getCaseCommentStats: jest.fn(), + findSubCasesGroupByCase: jest.fn(), + findCaseStatusStats: jest.fn(), + findCasesGroupedByID: jest.fn(), }); export const createConfigureServiceMock = (): CaseConfigureServiceMock => ({ @@ -57,7 +73,6 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ }); export const createAlertServiceMock = (): AlertServiceMock => ({ - initialize: jest.fn(), updateAlertsStatus: jest.fn(), getAlerts: jest.fn(), }); diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index 9a32a04d62300e..c600a96234b3d7 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -15,13 +15,19 @@ import { UserActionField, ESCaseAttributes, User, + UserActionFieldType, + SubCaseAttributes, } from '../../../common/api'; import { isTwoArraysDifference, transformESConnectorToCaseConnector, } from '../../routes/api/cases/helpers'; import { UserActionItem } from '.'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { + CASE_SAVED_OBJECT, + CASE_COMMENT_SAVED_OBJECT, + SUB_CASE_SAVED_OBJECT, +} from '../../saved_object_types'; export const transformNewUserAction = ({ actionField, @@ -59,6 +65,7 @@ interface BuildCaseUserAction { fields: UserActionField | unknown[]; newValue?: string | unknown; oldValue?: string | unknown; + subCaseId?: string; } interface BuildCommentUserActionItem extends BuildCaseUserAction { @@ -74,6 +81,7 @@ export const buildCommentUserActionItem = ({ fields, newValue, oldValue, + subCaseId, }: BuildCommentUserActionItem): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, @@ -94,6 +102,15 @@ export const buildCommentUserActionItem = ({ name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, id: commentId, }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + id: subCaseId, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + }, + ] + : []), ], }); @@ -105,6 +122,7 @@ export const buildCaseUserActionItem = ({ fields, newValue, oldValue, + subCaseId, }: BuildCaseUserAction): UserActionItem => ({ attributes: transformNewUserAction({ actionField: fields as UserActionField, @@ -120,6 +138,15 @@ export const buildCaseUserActionItem = ({ name: `associated-${CASE_SAVED_OBJECT}`, id: caseId, }, + ...(subCaseId + ? [ + { + type: SUB_CASE_SAVED_OBJECT, + name: `associated-${SUB_CASE_SAVED_OBJECT}`, + id: subCaseId, + }, + ] + : []), ], }); @@ -131,35 +158,57 @@ const userActionFieldsAllowed: UserActionField = [ 'title', 'status', 'settings', + 'sub_case', ]; -export const buildCaseUserActions = ({ +interface CaseSubIDs { + caseId: string; + subCaseId?: string; +} + +type GetCaseAndSubID = (so: SavedObjectsUpdateResponse) => CaseSubIDs; +type GetField = ( + attributes: Pick, 'attributes'>, + field: UserActionFieldType +) => unknown; + +/** + * Abstraction functions to retrieve a given field and the caseId and subCaseId depending on + * whether we're interacting with a case or a sub case. + */ +interface Getters { + getField: GetField; + getCaseAndSubID: GetCaseAndSubID; +} + +const buildGenericCaseUserActions = ({ actionDate, actionBy, originalCases, updatedCases, + allowedFields, + getters, }: { actionDate: string; actionBy: User; - originalCases: Array>; - updatedCases: Array>; -}): UserActionItem[] => - updatedCases.reduce((acc, updatedItem) => { + originalCases: Array>; + updatedCases: Array>; + allowedFields: UserActionField; + getters: Getters; +}): UserActionItem[] => { + const { getCaseAndSubID, getField } = getters; + return updatedCases.reduce((acc, updatedItem) => { + const { caseId, subCaseId } = getCaseAndSubID(updatedItem); + // regardless of whether we're looking at a sub case or case, the id field will always be used to match between + // the original and the updated saved object const originalItem = originalCases.find((oItem) => oItem.id === updatedItem.id); if (originalItem != null) { let userActions: UserActionItem[] = []; const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; updatedFields.forEach((field) => { - if (userActionFieldsAllowed.includes(field)) { - const origValue = - field === 'connector' && originalItem.attributes.connector - ? transformESConnectorToCaseConnector(originalItem.attributes.connector) - : get(originalItem, ['attributes', field]); - - const updatedValue = - field === 'connector' && updatedItem.attributes.connector - ? transformESConnectorToCaseConnector(updatedItem.attributes.connector) - : get(updatedItem, ['attributes', field]); + if (allowedFields.includes(field)) { + const origValue = getField(originalItem, field); + const updatedValue = getField(updatedItem, field); if (isString(origValue) && isString(updatedValue) && origValue !== updatedValue) { userActions = [ @@ -168,7 +217,8 @@ export const buildCaseUserActions = ({ action: 'update', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: updatedValue, oldValue: origValue, @@ -183,7 +233,8 @@ export const buildCaseUserActions = ({ action: 'add', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: compareValues.addedItems.join(', '), }), @@ -197,7 +248,8 @@ export const buildCaseUserActions = ({ action: 'delete', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: compareValues.deletedItems.join(', '), }), @@ -214,7 +266,8 @@ export const buildCaseUserActions = ({ action: 'update', actionAt: actionDate, actionBy, - caseId: updatedItem.id, + caseId, + subCaseId, fields: [field], newValue: JSON.stringify(updatedValue), oldValue: JSON.stringify(origValue), @@ -227,3 +280,68 @@ export const buildCaseUserActions = ({ } return acc; }, []); +}; + +/** + * Create a user action for an updated sub case. + */ +export const buildSubCaseUserActions = (args: { + actionDate: string; + actionBy: User; + originalSubCases: Array>; + updatedSubCases: Array>; +}): UserActionItem[] => { + const getField = ( + so: Pick, 'attributes'>, + field: UserActionFieldType + ) => get(so, ['attributes', field]); + + const getCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { + const caseId = so.references?.find((ref) => ref.type === CASE_SAVED_OBJECT)?.id ?? ''; + return { caseId, subCaseId: so.id }; + }; + + const getters: Getters = { + getField, + getCaseAndSubID, + }; + + return buildGenericCaseUserActions({ + actionDate: args.actionDate, + actionBy: args.actionBy, + originalCases: args.originalSubCases, + updatedCases: args.updatedSubCases, + allowedFields: ['status'], + getters, + }); +}; + +/** + * Create a user action for an updated case. + */ +export const buildCaseUserActions = (args: { + actionDate: string; + actionBy: User; + originalCases: Array>; + updatedCases: Array>; +}): UserActionItem[] => { + const getField = ( + so: Pick, 'attributes'>, + field: UserActionFieldType + ) => { + return field === 'connector' && so.attributes.connector + ? transformESConnectorToCaseConnector(so.attributes.connector) + : get(so, ['attributes', field]); + }; + + const caseGetIds: GetCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { + return { caseId: so.id }; + }; + + const getters: Getters = { + getField, + getCaseAndSubID: caseGetIds, + }; + + return buildGenericCaseUserActions({ ...args, allowedFields: userActionFieldsAllowed, getters }); +}; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js index b07583837636ed..034e08b5c6ab84 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/auto_follow_pattern_delete_provider.js @@ -9,7 +9,7 @@ import React, { PureComponent, Fragment } from 'react'; import { connect } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { deleteAutoFollowPattern } from '../store/actions'; import { arrify } from '../../../common/services/utils'; @@ -61,45 +61,43 @@ class AutoFollowPatternDeleteProviderUi extends PureComponent { ); return ( - - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} - - {!isSingle && ( - -

- -

-
    - {ids.map((id) => ( -
  • {id}
  • - ))} -
-
- )} -
-
+ // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events + + {!isSingle && ( + +

+ +

+
    + {ids.map((id) => ( +
  • {id}
  • + ))} +
+
+ )} +
); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js index e84816d0d71af9..34697a80121ccf 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_pause_provider.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { pauseFollowerIndex } from '../../store/actions'; import { arrify } from '../../../../common/services/utils'; @@ -69,64 +69,62 @@ class FollowerIndexPauseProviderUi extends PureComponent { const hasCustomSettings = indices.some((index) => !areAllSettingsDefault(index)); return ( - - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} - - {hasCustomSettings && ( -

- {isSingle ? ( - + {hasCustomSettings && ( +

+ {isSingle ? ( + - ) : ( - - )} + /> + )} +

+ )} + + {!isSingle && ( + +

+

- )} - - {!isSingle && ( - -

- -

- -
    - {indices.map((index) => ( -
  • {index.name}
  • - ))} -
-
- )} -
-
+ +
    + {indices.map((index) => ( +
  • {index.name}
  • + ))} +
+ + )} + ); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js index 0517f841599122..91c6cb6e243acd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_resume_provider.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiLink, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EuiLink } from '@elastic/eui'; import { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; import { routing } from '../../services/routing'; import { resumeFollowerIndex } from '../../store/actions'; @@ -68,77 +68,75 @@ class FollowerIndexResumeProviderUi extends PureComponent { ); return ( - - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} - - {isSingle ? ( + // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events + + {isSingle ? ( +

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

+ ) : ( +

- - - ), - }} + id="xpack.crossClusterReplication.resumeFollowerIndex.confirmModal.multipleResumeDescriptionWithSettingWarning" + defaultMessage="Replication resumes using the default advanced settings." />

- ) : ( - -

- -

-

- -

+

+ +

-
    - {ids.map((id) => ( -
  • {id}
  • - ))} -
-
- )} -
-
+
    + {ids.map((id) => ( +
  • {id}
  • + ))} +
+ + )} + ); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js index 9b0f0ad3111e03..72d262bcf7af32 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/follower_index_actions_providers/follower_index_unfollow_provider.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { unfollowLeaderIndex } from '../../store/actions'; import { arrify } from '../../../../common/services/utils'; @@ -67,58 +67,56 @@ class FollowerIndexUnfollowProviderUi extends PureComponent { ); return ( - - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} - - {isSingle ? ( - -

- + {isSingle ? ( + +

+ -

-
- ) : ( - -

- -

-
    - {ids.map((id) => ( -
  • {id}
  • - ))} -
-
- )} -
-
+ /> +

+
    + {ids.map((id) => ( +
  • {id}
  • + ))} +
+ + )} + ); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js index 8fb4fb27006cb9..8d6e47d4004b6e 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/follower_index_edit/follower_index_edit.js @@ -15,7 +15,6 @@ import { EuiConfirmModal, EuiFlexGroup, EuiFlexItem, - EuiOverlayMask, EuiPageContent, EuiSpacer, } from '@elastic/eui'; @@ -182,47 +181,45 @@ export class FollowerIndexEdit extends PureComponent { ); return ( - - - ) : ( - - ) + -

- {isPaused ? ( - - ) : ( - + ) : ( + + ) + } + > +

+ {isPaused ? ( + + ) : ( + - )} -

-
-
+ /> + )} +

+ ); }; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js index e1be717db221c2..8067b2cc11b9a0 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/services/api.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/services/api.js @@ -48,7 +48,8 @@ export const getHttpClient = () => { const createIdString = (ids) => ids.map((id) => encodeURIComponent(id)).join(','); /* Auto Follow Pattern */ -export const loadAutoFollowPatterns = () => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`); +export const loadAutoFollowPatterns = (asSystemRequest) => + httpClient.get(`${API_BASE_PATH}/auto_follow_patterns`, { asSystemRequest }); export const getAutoFollowPattern = (id) => httpClient.get(`${API_BASE_PATH}/auto_follow_patterns/${encodeURIComponent(id)}`); @@ -100,7 +101,8 @@ export const resumeAutoFollowPattern = (id) => { }; /* Follower Index */ -export const loadFollowerIndices = () => httpClient.get(`${API_BASE_PATH}/follower_indices`); +export const loadFollowerIndices = (asSystemRequest) => + httpClient.get(`${API_BASE_PATH}/follower_indices`, { asSystemRequest }); export const getFollowerIndex = (id) => httpClient.get(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`); diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js index e6a9f02b913ca6..79d0eeabb817dd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/auto_follow_pattern.js @@ -39,7 +39,7 @@ export const loadAutoFollowPatterns = (isUpdating = false) => label: t.AUTO_FOLLOW_PATTERN_LOAD, scope, status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING, - handler: async () => await loadAutoFollowPatternsRequest(), + handler: async () => await loadAutoFollowPatternsRequest(isUpdating), }); export const getAutoFollowPattern = (id) => diff --git a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js index 9f8b20622d6ece..7422ba6c84491f 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/store/actions/follower_index.js @@ -40,7 +40,7 @@ export const loadFollowerIndices = (isUpdating = false) => label: t.FOLLOWER_INDEX_LOAD, scope, status: isUpdating ? API_STATUS.UPDATING : API_STATUS.LOADING, - handler: async () => await loadFollowerIndicesRequest(), + handler: async () => await loadFollowerIndicesRequest(isUpdating), }); export const getFollowerIndex = (id) => diff --git a/x-pack/plugins/data_enhanced/common/search/session/types.ts b/x-pack/plugins/data_enhanced/common/search/session/types.ts index 4c5fe846cebd2f..788ab30756e1c4 100644 --- a/x-pack/plugins/data_enhanced/common/search/session/types.ts +++ b/x-pack/plugins/data_enhanced/common/search/session/types.ts @@ -57,6 +57,12 @@ export interface SearchSessionSavedObjectAttributes { * This value is true if the session was actively stored by the user. If it is false, the session may be purged by the system. */ persisted: boolean; + /** + * The realm type/name & username uniquely identifies the user who created this search session + */ + realmType?: string; + realmName?: string; + username?: string; } export interface SearchSessionRequestInfo { diff --git a/x-pack/plugins/data_enhanced/config.ts b/x-pack/plugins/data_enhanced/config.ts index a3eb3cffb85a76..fc1f22d50b09fa 100644 --- a/x-pack/plugins/data_enhanced/config.ts +++ b/x-pack/plugins/data_enhanced/config.ts @@ -13,7 +13,7 @@ export const configSchema = schema.object({ /** * Turns the feature on \ off (incl. removing indicator and management screens) */ - enabled: schema.boolean({ defaultValue: false }), + enabled: schema.boolean({ defaultValue: true }), /** * pageSize controls how many search session objects we load at once while monitoring * session completion diff --git a/x-pack/plugins/data_enhanced/kibana.json b/x-pack/plugins/data_enhanced/kibana.json index 037f52fcb4b05a..a0489ecd30aaac 100644 --- a/x-pack/plugins/data_enhanced/kibana.json +++ b/x-pack/plugins/data_enhanced/kibana.json @@ -4,7 +4,7 @@ "kibanaVersion": "kibana", "configPath": ["xpack", "data_enhanced"], "requiredPlugins": ["bfetch", "data", "features", "management", "share", "taskManager"], - "optionalPlugins": ["kibanaUtils", "usageCollection"], + "optionalPlugins": ["kibanaUtils", "usageCollection", "security"], "server": true, "ui": true, "requiredBundles": ["kibanaUtils", "kibanaReact"] diff --git a/x-pack/plugins/data_enhanced/public/plugin.ts b/x-pack/plugins/data_enhanced/public/plugin.ts index 0a116545e6e366..29f3494433befe 100644 --- a/x-pack/plugins/data_enhanced/public/plugin.ts +++ b/x-pack/plugins/data_enhanced/public/plugin.ts @@ -8,7 +8,11 @@ import React from 'react'; import moment from 'moment'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; -import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { + DataPublicPluginSetup, + DataPublicPluginStart, + SearchUsageCollector, +} from '../../../../src/plugins/data/public'; import { BfetchPublicSetup } from '../../../../src/plugins/bfetch/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; @@ -40,6 +44,7 @@ export class DataEnhancedPlugin private enhancedSearchInterceptor!: EnhancedSearchInterceptor; private config!: ConfigSchema; private readonly storage = new Storage(window.localStorage); + private usageCollector?: SearchUsageCollector; constructor(private initializerContext: PluginInitializerContext) {} @@ -71,8 +76,10 @@ export class DataEnhancedPlugin this.config = this.initializerContext.config.get(); if (this.config.search.sessions.enabled) { const sessionsConfig = this.config.search.sessions; - registerSearchSessionsMgmt(core, sessionsConfig, { management }); + registerSearchSessionsMgmt(core, sessionsConfig, { data, management }); } + + this.usageCollector = data.search.usageCollector; } public start(core: CoreStart, plugins: DataEnhancedStartDependencies) { @@ -90,6 +97,7 @@ export class DataEnhancedPlugin disableSaveAfterSessionCompletesTimeout: moment .duration(this.config.search.sessions.notTouchedTimeout) .asMilliseconds(), + usageCollector: this.usageCollector, }) ) ), diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 04a777b9b6897e..02671974e50536 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -16,6 +16,7 @@ import { SearchTimeoutError, SearchSessionState, PainlessError, + DataPublicPluginSetup, } from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; @@ -51,14 +52,15 @@ function mockFetchImplementation(responses: any[]) { } describe('EnhancedSearchInterceptor', () => { - let mockUsageCollector: any; let sessionService: jest.Mocked; let sessionState$: BehaviorSubject; + let dataPluginMockSetup: DataPublicPluginSetup; beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); sessionState$ = new BehaviorSubject(SearchSessionState.None); + dataPluginMockSetup = dataPluginMock.createSetupContract(); const dataPluginMockStart = dataPluginMock.createStartContract(); sessionService = { ...(dataPluginMockStart.search.session as jest.Mocked), @@ -80,11 +82,6 @@ describe('EnhancedSearchInterceptor', () => { complete.mockClear(); jest.clearAllTimers(); - mockUsageCollector = { - trackQueryTimedOut: jest.fn(), - trackQueriesCancelled: jest.fn(), - }; - const mockPromise = new Promise((resolve) => { resolve([ { @@ -102,7 +99,7 @@ describe('EnhancedSearchInterceptor', () => { startServices: mockPromise as any, http: mockCoreSetup.http, uiSettings: mockCoreSetup.uiSettings, - usageCollector: mockUsageCollector, + usageCollector: dataPluginMockSetup.search.usageCollector, session: sessionService, }); }); @@ -455,39 +452,6 @@ describe('EnhancedSearchInterceptor', () => { }); }); - describe('cancelPending', () => { - test('should abort all pending requests', async () => { - mockFetchImplementation([ - { - time: 10, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - { - time: 20, - value: { - isPartial: false, - isRunning: false, - id: 1, - }, - }, - ]); - - searchInterceptor.search({}).subscribe({ next, error }); - searchInterceptor.search({}).subscribe({ next, error }); - searchInterceptor.cancelPending(); - - await timeTravel(); - - const areAllRequestsAborted = fetchMock.mock.calls.every(([_, signal]) => signal?.aborted); - expect(areAllRequestsAborted).toBe(true); - expect(mockUsageCollector.trackQueriesCancelled).toBeCalledTimes(1); - }); - }); - describe('session', () => { beforeEach(() => { const responses = [ diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts index f211021e457734..0dfec1a35d9006 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.ts @@ -46,15 +46,6 @@ export class EnhancedSearchInterceptor extends SearchInterceptor { : TimeoutErrorMode.CONTACT; } - /** - * Abort our `AbortController`, which in turn aborts any intercepted searches. - */ - public cancelPending = () => { - this.abortController.abort(); - this.abortController = new AbortController(); - if (this.deps.usageCollector) this.deps.usageCollector.trackQueriesCancelled(); - }; - public search({ id, ...request }: IKibanaSearchRequest, options: IAsyncSearchOptions = {}) { const { combinedSignal, timeoutSignal, cleanup, abort } = this.setupAbortSignal({ abortSignal: options.abortSignal, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx index 177cfbbb4fd7e2..2dfca534c20b5c 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/application/index.tsx @@ -50,6 +50,7 @@ export class SearchSessionsMgmtApp { notifications, urls: share.urlGenerators, application, + usageCollector: pluginsSetup.data.search.usageCollector, }); const documentation = new AsyncSearchIntroDocumentation(docLinks); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx index d505752ec3fad9..6a952d2f8d9d74 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/delete_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useState } from 'react'; @@ -44,24 +44,22 @@ const DeleteConfirm = ({ }); return ( - - { - setIsLoading(true); - await api.sendCancel(id); - onActionComplete(); - }} - confirmButtonText={confirm} - confirmButtonDisabled={isLoading} - cancelButtonText={cancel} - defaultFocusedButton="confirm" - buttonColor="danger" - > - {message} - - + { + setIsLoading(true); + await api.sendCancel(id); + onActionComplete(); + }} + confirmButtonText={confirm} + confirmButtonDisabled={isLoading} + cancelButtonText={cancel} + defaultFocusedButton="confirm" + buttonColor="danger" + > + {message} + ); }; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx index 381c44b1bf7bef..856e7c8d434835 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useState } from 'react'; @@ -52,26 +52,24 @@ const ExtendConfirm = ({ }); return ( - - { - setIsLoading(true); - await api.sendExtend(id, `${newExpiration.toISOString()}`); - setIsLoading(false); - onConfirmDismiss(); - onActionComplete(); - }} - confirmButtonText={confirm} - confirmButtonDisabled={isLoading} - cancelButtonText={extend} - defaultFocusedButton="confirm" - buttonColor="primary" - > - {message} - - + { + setIsLoading(true); + await api.sendExtend(id, `${newExpiration.toISOString()}`); + setIsLoading(false); + onConfirmDismiss(); + onActionComplete(); + }} + confirmButtonText={confirm} + confirmButtonDisabled={isLoading} + cancelButtonText={extend} + defaultFocusedButton="confirm" + buttonColor="primary" + > + {message} + ); }; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx index 1f8f603400c9fd..6b94eccc4e7076 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/main.test.tsx @@ -13,14 +13,17 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { coreMock } from 'src/core/public/mocks'; import { SessionsClient } from 'src/plugins/data/public/search'; -import { SessionsConfigSchema } from '..'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '..'; import { SearchSessionsMgmtAPI } from '../lib/api'; import { AsyncSearchIntroDocumentation } from '../lib/documentation'; import { LocaleWrapper, mockUrls } from '../__mocks__'; import { SearchSessionsMgmtMain } from './main'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { managementPluginMock } from '../../../../../../../src/plugins/management/public/mocks'; let mockCoreSetup: MockedKeys; let mockCoreStart: MockedKeys; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let sessionsClient: SessionsClient; let api: SearchSessionsMgmtAPI; @@ -29,6 +32,10 @@ describe('Background Search Session Management Main', () => { beforeEach(() => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -67,6 +74,7 @@ describe('Background Search Session Management Main', () => { ; let mockCoreStart: CoreStart; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let sessionsClient: SessionsClient; let api: SearchSessionsMgmtAPI; @@ -29,6 +32,10 @@ describe('Background Search Session Management Table', () => { beforeEach(async () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -79,6 +86,7 @@ describe('Background Search Session Management Table', () => { { { { ([]); const [isLoading, setIsLoading] = useState(false); const [debouncedIsLoading, setDebouncedIsLoading] = useState(false); @@ -71,7 +72,8 @@ export function SearchSessionsMgmtTable({ core, api, timezone, config, ...props // initial data load useEffect(() => { doRefresh(); - }, [doRefresh]); + plugins.data.search.usageCollector?.trackSessionsListLoaded(); + }, [doRefresh, plugins]); useInterval(doRefresh, refreshInterval); @@ -110,7 +112,7 @@ export function SearchSessionsMgmtTable({ core, api, timezone, config, ...props rowProps={() => ({ 'data-test-subj': 'searchSessionsRow', })} - columns={getColumns(core, api, config, timezone, onActionComplete)} + columns={getColumns(core, plugins, api, config, timezone, onActionComplete)} items={tableData} pagination={pagination} search={search} diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts index e13cd06f52a4d7..0ac8fa798cc925 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/index.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { CoreStart, HttpStart, I18nStart, IUiSettingsClient } from 'kibana/public'; import { CoreSetup } from 'kibana/public'; -import type { DataPublicPluginStart } from 'src/plugins/data/public'; +import type { DataPublicPluginSetup, DataPublicPluginStart } from 'src/plugins/data/public'; import type { ManagementSetup } from 'src/plugins/management/public'; import type { SharePluginStart } from 'src/plugins/share/public'; import type { ConfigSchema } from '../../../config'; @@ -18,6 +18,7 @@ import type { AsyncSearchIntroDocumentation } from './lib/documentation'; import { SEARCH_SESSIONS_MANAGEMENT_ID } from '../../../../../../src/plugins/data/public'; export interface IManagementSectionsPluginsSetup { + data: DataPublicPluginSetup; management: ManagementSetup; } @@ -56,7 +57,7 @@ export function registerSearchSessionsMgmt( services.management.sections.section.kibana.registerApp({ id: APP.id, title: APP.getI18nName(), - order: 2, + order: 1.75, mount: async (params) => { const { SearchSessionsMgmtApp: MgmtApp } = await import('./application'); const mgmtApp = new MgmtApp(coreSetup, config, params, services); 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 39da58cb769182..838b51994aa715 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 @@ -11,7 +11,10 @@ import moment from 'moment'; import { from, race, timer } from 'rxjs'; import { mapTo, tap } from 'rxjs/operators'; import type { SharePluginStart } from 'src/plugins/share/public'; -import { ISessionsClient } from '../../../../../../../src/plugins/data/public'; +import { + ISessionsClient, + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; import { SearchSessionStatus } from '../../../../common/search'; import { ACTION } from '../components/actions'; import { PersistedSearchSessionSavedObjectAttributes, UISession } from '../types'; @@ -84,17 +87,18 @@ const mapToUISession = (urls: UrlGeneratorsStart, config: SessionsConfigSchema) }; }; -interface SearcgSessuibManagementDeps { +interface SearchSessionManagementDeps { urls: UrlGeneratorsStart; notifications: NotificationsStart; application: ApplicationStart; + usageCollector?: SearchUsageCollector; } export class SearchSessionsMgmtAPI { constructor( private sessionsClient: ISessionsClient, private config: SessionsConfigSchema, - private deps: SearcgSessuibManagementDeps + private deps: SearchSessionManagementDeps ) {} public async fetchTableData(): Promise { @@ -151,6 +155,7 @@ export class SearchSessionsMgmtAPI { } public reloadSearchSession(reloadUrl: string) { + this.deps.usageCollector?.trackSessionReloaded(); this.deps.application.navigateToUrl(reloadUrl); } @@ -160,6 +165,7 @@ export class SearchSessionsMgmtAPI { // Cancel and expire public async sendCancel(id: string): Promise { + this.deps.usageCollector?.trackSessionDeleted(); try { await this.sessionsClient.delete(id); @@ -179,6 +185,7 @@ export class SearchSessionsMgmtAPI { // Extend public async sendExtend(id: string, expires: string): Promise { + this.deps.usageCollector?.trackSessionExtended(); try { await this.sessionsClient.extend(id, expires); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx index fc0a8849006d3c..29f0033aaf0121 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx @@ -13,16 +13,19 @@ import moment from 'moment'; import { ReactElement } from 'react'; import { coreMock } from 'src/core/public/mocks'; import { SessionsClient } from 'src/plugins/data/public/search'; -import { SessionsConfigSchema } from '../'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '../'; import { SearchSessionStatus } from '../../../../common/search'; import { OnActionComplete } from '../components'; import { UISession } from '../types'; import { mockUrls } from '../__mocks__'; import { SearchSessionsMgmtAPI } from './api'; import { getColumns } from './get_columns'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { managementPluginMock } from '../../../../../../../src/plugins/management/public/mocks'; let mockCoreSetup: MockedKeys; let mockCoreStart: CoreStart; +let mockPluginsSetup: IManagementSectionsPluginsSetup; let mockConfig: SessionsConfigSchema; let api: SearchSessionsMgmtAPI; let sessionsClient: SessionsClient; @@ -35,6 +38,10 @@ describe('Search Sessions Management table column factory', () => { beforeEach(async () => { mockCoreSetup = coreMock.createSetup(); mockCoreStart = coreMock.createStart(); + mockPluginsSetup = { + data: dataPluginMock.createSetupContract(), + management: managementPluginMock.createSetupContract(), + }; mockConfig = { defaultExpiration: moment.duration('7d'), management: { @@ -72,7 +79,7 @@ describe('Search Sessions Management table column factory', () => { }); test('returns columns', () => { - const columns = getColumns(mockCoreStart, api, mockConfig, tz, handleAction); + const columns = getColumns(mockCoreStart, mockPluginsSetup, api, mockConfig, tz, handleAction); expect(columns).toMatchInlineSnapshot(` Array [ Object { @@ -124,9 +131,14 @@ describe('Search Sessions Management table column factory', () => { describe('name', () => { test('rendering', () => { - const [, nameColumn] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, nameColumn] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; const name = mount(nameColumn.render!(mockSession.name, mockSession) as ReactElement); @@ -137,9 +149,14 @@ describe('Search Sessions Management table column factory', () => { // Status column describe('status', () => { test('render in_progress', () => { - const [, , status] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, , status] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; const statusLine = mount(status.render!(mockSession.status, mockSession) as ReactElement); expect( @@ -148,9 +165,14 @@ describe('Search Sessions Management table column factory', () => { }); test('error handling', () => { - const [, , status] = getColumns(mockCoreStart, api, mockConfig, tz, handleAction) as Array< - EuiTableFieldDataColumnType - >; + const [, , status] = getColumns( + mockCoreStart, + mockPluginsSetup, + api, + mockConfig, + tz, + handleAction + ) as Array>; mockSession.status = 'INVALID' as SearchSessionStatus; const statusLine = mount(status.render!(mockSession.status, mockSession) as ReactElement); @@ -168,6 +190,7 @@ describe('Search Sessions Management table column factory', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, @@ -184,6 +207,7 @@ describe('Search Sessions Management table column factory', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, @@ -198,6 +222,7 @@ describe('Search Sessions Management table column factory', () => { test('error handling', () => { const [, , , createdDateCol] = getColumns( mockCoreStart, + mockPluginsSetup, api, mockConfig, tz, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx index cbd42ec56bb8b1..d34998d023178c 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.tsx @@ -21,7 +21,7 @@ import { capitalize } from 'lodash'; import React from 'react'; import { FormattedMessage } from 'react-intl'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; -import { SessionsConfigSchema } from '../'; +import { IManagementSectionsPluginsSetup, SessionsConfigSchema } from '../'; import { SearchSessionStatus } from '../../../../common/search'; import { TableText } from '../components'; import { OnActionComplete, PopoverActionsMenu } from '../components'; @@ -45,6 +45,7 @@ function isSessionRestorable(status: SearchSessionStatus) { export const getColumns = ( core: CoreStart, + plugins: IManagementSectionsPluginsSetup, api: SearchSessionsMgmtAPI, config: SessionsConfigSchema, timezone: string, @@ -83,6 +84,10 @@ export const getColumns = ( width: '20%', render: (name: UISession['name'], { restoreUrl, reloadUrl, status }) => { const isRestorable = isSessionRestorable(status); + const href = isRestorable ? restoreUrl : reloadUrl; + const trackAction = isRestorable + ? plugins.data.search.usageCollector?.trackSessionViewRestored + : plugins.data.search.usageCollector?.trackSessionReloaded; const notRestorableWarning = isRestorable ? null : ( <> {' '} @@ -99,8 +104,10 @@ export const getColumns = ( ); return ( + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} trackAction?.()} data-test-subj="sessionManagementNameCol" > diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx index aacb86f269727a..0aef27310e0906 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.test.tsx @@ -16,18 +16,22 @@ import { ISessionService, RefreshInterval, SearchSessionState, + SearchUsageCollector, TimefilterContract, } from '../../../../../../../src/plugins/data/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { TOUR_RESTORE_STEP_KEY, TOUR_TAKING_TOO_LONG_STEP_KEY } from './search_session_tour'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from 'react-intl'; +import { createSearchUsageCollectorMock } from '../../../../../../../src/plugins/data/public/search/collectors/mocks'; const coreStart = coreMock.createStart(); const application = coreStart.application; const dataStart = dataPluginMock.createStartContract(); const sessionService = dataStart.search.session as jest.Mocked; let storage: Storage; +let usageCollector: jest.Mocked; + const refreshInterval$ = new BehaviorSubject({ value: 0, pause: true }); const timeFilter = dataStart.query.timefilter.timefilter as jest.Mocked; timeFilter.getRefreshIntervalUpdate$.mockImplementation(() => refreshInterval$); @@ -41,6 +45,7 @@ function Container({ children }: { children?: ReactNode }) { beforeEach(() => { storage = new Storage(new StubBrowserStorage()); + usageCollector = createSearchUsageCollectorMock(); refreshInterval$.next({ value: 0, pause: true }); sessionService.isSessionStorageReady.mockImplementation(() => true); sessionService.getSearchSessionIndicatorUiConfig.mockImplementation(() => ({ @@ -57,6 +62,7 @@ test("shouldn't show indicator in case no active search session", async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId, container } = render( @@ -84,6 +90,7 @@ test("shouldn't show indicator in case app hasn't opt-in", async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId, container } = render( @@ -113,6 +120,7 @@ test('should show indicator in case there is an active search session', async () timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const { getByTestId } = render( @@ -137,6 +145,7 @@ test('should be disabled in case uiConfig says so ', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -185,6 +194,7 @@ test('should be disabled during auto-refresh', async () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -222,6 +232,7 @@ describe('Completed inactivity', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); render( @@ -253,12 +264,14 @@ describe('Completed inactivity', () => { }); expect(screen.getByRole('button', { name: 'Save session' })).not.toBeDisabled(); + expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(0); act(() => { jest.advanceTimersByTime(2.5 * 60 * 1000); }); expect(screen.getByRole('button', { name: 'Save session' })).toBeDisabled(); + expect(usageCollector.trackSessionIndicatorSaveDisabled).toHaveBeenCalledTimes(1); }); }); @@ -280,6 +293,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -307,6 +321,9 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(1); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); test("doesn't show tour step if state changed before delay", async () => { @@ -317,6 +334,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -337,6 +355,9 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); }); @@ -348,6 +369,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -360,6 +382,10 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeTruthy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeTruthy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIsRestored).toHaveBeenCalledTimes(1); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(1); }); test("doesn't show tour for irrelevant state", async () => { @@ -370,6 +396,7 @@ describe('tour steps', () => { timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }); const rendered = render( @@ -383,5 +410,8 @@ describe('tour steps', () => { expect(storage.get(TOUR_RESTORE_STEP_KEY)).toBeFalsy(); expect(storage.get(TOUR_TAKING_TOO_LONG_STEP_KEY)).toBeFalsy(); + + expect(usageCollector.trackSessionIndicatorTourLoading).toHaveBeenCalledTimes(0); + expect(usageCollector.trackSessionIndicatorTourRestored).toHaveBeenCalledTimes(0); }); }); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx index 81769e5a25544f..7c70a270bd30a2 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/connected_search_session_indicator.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; -import { debounce, distinctUntilChanged, map, mapTo, switchMap } from 'rxjs/operators'; +import React, { useCallback, useEffect, useState } from 'react'; +import { debounce, distinctUntilChanged, map, mapTo, switchMap, tap } from 'rxjs/operators'; import { merge, of, timer } from 'rxjs'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; @@ -15,7 +15,8 @@ import { ISessionService, SearchSessionState, TimefilterContract, -} from '../../../../../../../src/plugins/data/public/'; + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; import { RedirectAppLinks } from '../../../../../../../src/plugins/kibana_react/public'; import { ApplicationStart } from '../../../../../../../src/core/public'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; @@ -31,6 +32,7 @@ export interface SearchSessionIndicatorDeps { * after the last search in the session has completed */ disableSaveAfterSessionCompletesTimeout: number; + usageCollector?: SearchUsageCollector; } export const createConnectedSearchSessionIndicator = ({ @@ -39,6 +41,7 @@ export const createConnectedSearchSessionIndicator = ({ timeFilter, storage, disableSaveAfterSessionCompletesTimeout, + usageCollector, }: SearchSessionIndicatorDeps): React.FC => { const isAutoRefreshEnabled = () => !timeFilter.getRefreshInterval().pause; const isAutoRefreshEnabled$ = timeFilter @@ -55,7 +58,10 @@ export const createConnectedSearchSessionIndicator = ({ ? merge(of(false), timer(disableSaveAfterSessionCompletesTimeout).pipe(mapTo(true))) : of(false) ), - distinctUntilChanged() + distinctUntilChanged(), + tap((value) => { + if (value) usageCollector?.trackSessionIndicatorSaveDisabled(); + }) ); return () => { @@ -123,7 +129,8 @@ export const createConnectedSearchSessionIndicator = ({ storage, searchSessionIndicator, state, - saveDisabled + saveDisabled, + usageCollector ); const onOpened = useCallback( @@ -138,18 +145,31 @@ export const createConnectedSearchSessionIndicator = ({ const onContinueInBackground = useCallback(() => { if (saveDisabled) return; + usageCollector?.trackSessionSentToBackground(); sessionService.save(); }, [saveDisabled]); const onSaveResults = useCallback(() => { if (saveDisabled) return; + usageCollector?.trackSessionSavedResults(); sessionService.save(); }, [saveDisabled]); const onCancel = useCallback(() => { + usageCollector?.trackSessionCancelled(); sessionService.cancel(); }, []); + const onViewSearchSessions = useCallback(() => { + usageCollector?.trackViewSessionsList(); + }, []); + + useEffect(() => { + if (state === SearchSessionState.Restored) { + usageCollector?.trackSessionIsRestored(); + } + }, [state]); + if (!sessionService.isSessionStorageReady()) return null; return ( @@ -164,6 +184,7 @@ export const createConnectedSearchSessionIndicator = ({ onSaveResults={onSaveResults} onCancel={onCancel} onOpened={onOpened} + onViewSearchSessions={onViewSearchSessions} /> ); diff --git a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx index 7987278f400ff9..1568d54962eca7 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/connected_search_session_indicator/search_session_tour.tsx @@ -6,9 +6,13 @@ */ import { useCallback, useEffect } from 'react'; +import { once } from 'lodash'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; import { SearchSessionIndicatorRef } from '../search_session_indicator'; -import { SearchSessionState } from '../../../../../../../src/plugins/data/public'; +import { + SearchSessionState, + SearchUsageCollector, +} from '../../../../../../../src/plugins/data/public'; const TOUR_TAKING_TOO_LONG_TIMEOUT = 10000; export const TOUR_TAKING_TOO_LONG_STEP_KEY = `data.searchSession.tour.takingTooLong`; @@ -18,7 +22,8 @@ export function useSearchSessionTour( storage: IStorageWrapper, searchSessionIndicatorRef: SearchSessionIndicatorRef | null, state: SearchSessionState, - searchSessionsDisabled: boolean + searchSessionsDisabled: boolean, + usageCollector?: SearchUsageCollector ) { const markOpenedDone = useCallback(() => { safeSet(storage, TOUR_TAKING_TOO_LONG_STEP_KEY); @@ -28,6 +33,26 @@ export function useSearchSessionTour( safeSet(storage, TOUR_RESTORE_STEP_KEY); }, [storage]); + // Makes sure `trackSessionIndicatorTourLoading` is called only once per sessionId + // if to call `usageCollector?.trackSessionIndicatorTourLoading()` directly inside the `useEffect` below + // it might happen that we cause excessive logging + // ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + const trackSessionIndicatorTourLoading = useCallback( + once(() => usageCollector?.trackSessionIndicatorTourLoading()), + [usageCollector, state] + ); + + // Makes sure `trackSessionIndicatorTourRestored` is called only once per sessionId + // if to call `usageCollector?.trackSessionIndicatorTourRestored()` directly inside the `useEffect` below + // it might happen that we cause excessive logging + // ESLint: React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead. + // eslint-disable-next-line react-hooks/exhaustive-deps + const trackSessionIndicatorTourRestored = useCallback( + once(() => usageCollector?.trackSessionIndicatorTourRestored()), + [usageCollector, state] + ); + useEffect(() => { if (searchSessionsDisabled) return; if (!searchSessionIndicatorRef) return; @@ -36,6 +61,7 @@ export function useSearchSessionTour( if (state === SearchSessionState.Loading) { if (!safeHas(storage, TOUR_TAKING_TOO_LONG_STEP_KEY)) { timeoutHandle = window.setTimeout(() => { + trackSessionIndicatorTourLoading(); searchSessionIndicatorRef.openPopover(); }, TOUR_TAKING_TOO_LONG_TIMEOUT); } @@ -43,6 +69,7 @@ export function useSearchSessionTour( if (state === SearchSessionState.Restored) { if (!safeHas(storage, TOUR_RESTORE_STEP_KEY)) { + trackSessionIndicatorTourRestored(); searchSessionIndicatorRef.openPopover(); } } @@ -57,6 +84,9 @@ export function useSearchSessionTour( searchSessionsDisabled, markOpenedDone, markRestoredDone, + usageCollector, + trackSessionIndicatorTourRestored, + trackSessionIndicatorTourLoading, ]); return { diff --git a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx index 0d31ce0c98f194..24ffc1359acae4 100644 --- a/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx +++ b/x-pack/plugins/data_enhanced/public/search/ui/search_session_indicator/search_session_indicator.tsx @@ -30,6 +30,7 @@ export interface SearchSessionIndicatorProps { onContinueInBackground?: () => void; onCancel?: () => void; viewSearchSessionsLink?: string; + onViewSearchSessions?: () => void; onSaveResults?: () => void; managementDisabled?: boolean; managementDisabledReasonText?: string; @@ -78,13 +79,16 @@ const ContinueInBackgroundButton = ({ const ViewAllSearchSessionsButton = ({ viewSearchSessionsLink = 'management/kibana/search_sessions', + onViewSearchSessions = () => {}, buttonProps = {}, managementDisabled, managementDisabledReasonText, }: ActionButtonProps) => ( + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} { + let fetchFn: any; + let esClient: jest.Mocked; + let mockLogger: Logger; + + beforeEach(async () => { + const config$ = new BehaviorSubject({ + kibana: { + index: '123', + }, + } as any); + mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + } as any; + esClient = elasticsearchServiceMock.createElasticsearchClient(); + fetchFn = fetchProvider(config$, mockLogger); + }); + + test('returns when ES returns no results', async () => { + esClient.search.mockResolvedValue({ + statusCode: 200, + body: { + aggregations: { + persisted: { + buckets: [], + }, + }, + }, + } as any); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(0); + expect(collRes.persistedCount).toBe(0); + expect(collRes.totalCount).toBe(0); + expect(mockLogger.warn).not.toBeCalled(); + }); + + test('returns when ES throws an error', async () => { + esClient.search.mockRejectedValue( + SavedObjectsErrorHelpers.createTooManyRequestsError('a', 'b') + ); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(0); + expect(collRes.persistedCount).toBe(0); + expect(collRes.totalCount).toBe(0); + expect(mockLogger.warn).toBeCalledTimes(1); + }); + + test('returns when ES returns full buckets', async () => { + esClient.search.mockResolvedValue({ + statusCode: 200, + body: { + aggregations: { + persisted: { + buckets: [ + { + key_as_string: 'true', + doc_count: 10, + }, + { + key_as_string: 'false', + doc_count: 7, + }, + ], + }, + }, + }, + } as any); + + const collRes = await fetchFn({ esClient }); + expect(collRes.transientCount).toBe(7); + expect(collRes.persistedCount).toBe(10); + expect(collRes.totalCount).toBe(17); + }); +}); diff --git a/x-pack/plugins/data_enhanced/server/collectors/fetch.ts b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts new file mode 100644 index 00000000000000..428de148fdd4f0 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/fetch.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { SearchResponse } from 'elasticsearch'; +import { SharedGlobalConfig, Logger } from 'kibana/server'; +import { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; +import { SEARCH_SESSION_TYPE } from '../../common'; +import { ReportedUsage } from './register'; + +interface SessionPersistedTermsBucket { + key_as_string: 'false' | 'true'; + doc_count: number; +} + +export function fetchProvider(config$: Observable, logger: Logger) { + return async ({ esClient }: CollectorFetchContext): Promise => { + try { + const config = await config$.pipe(first()).toPromise(); + const { body: esResponse } = await esClient.search>({ + index: config.kibana.index, + body: { + size: 0, + aggs: { + persisted: { + terms: { + field: `${SEARCH_SESSION_TYPE}.persisted`, + }, + }, + }, + }, + }); + + const { buckets } = esResponse.aggregations.persisted; + if (!buckets.length) { + return { transientCount: 0, persistedCount: 0, totalCount: 0 }; + } + + const { transientCount = 0, persistedCount = 0 } = buckets.reduce( + (usage: Partial, bucket: SessionPersistedTermsBucket) => { + const key = bucket.key_as_string === 'false' ? 'transientCount' : 'persistedCount'; + return { ...usage, [key]: bucket.doc_count }; + }, + {} + ); + const totalCount = transientCount + persistedCount; + logger.debug(`fetchProvider | ${persistedCount} persisted | ${transientCount} transient`); + return { transientCount, persistedCount, totalCount }; + } catch (e) { + logger.warn(`fetchProvider | error | ${e.message}`); + return { transientCount: 0, persistedCount: 0, totalCount: 0 }; + } + }; +} diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts b/x-pack/plugins/data_enhanced/server/collectors/index.ts similarity index 68% rename from x-pack/plugins/ml/public/application/contexts/spaces/index.ts rename to x-pack/plugins/data_enhanced/server/collectors/index.ts index 7b87bab8057e9c..4a82c76e96dee8 100644 --- a/x-pack/plugins/ml/public/application/contexts/spaces/index.ts +++ b/x-pack/plugins/data_enhanced/server/collectors/index.ts @@ -5,9 +5,4 @@ * 2.0. */ -export { - SpacesContext, - SpacesContextValue, - createSpacesContext, - useSpacesContext, -} from './spaces_context'; +export { registerUsageCollector } from './register'; diff --git a/x-pack/plugins/data_enhanced/server/collectors/register.ts b/x-pack/plugins/data_enhanced/server/collectors/register.ts new file mode 100644 index 00000000000000..fe96b7f7ced1b4 --- /dev/null +++ b/x-pack/plugins/data_enhanced/server/collectors/register.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, Logger } from 'kibana/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { fetchProvider } from './fetch'; + +export interface ReportedUsage { + transientCount: number; + persistedCount: number; + totalCount: number; +} + +export async function registerUsageCollector( + usageCollection: UsageCollectionSetup, + context: PluginInitializerContext, + logger: Logger +) { + try { + const collector = usageCollection.makeUsageCollector({ + type: 'search-session', + isReady: () => true, + fetch: fetchProvider(context.config.legacy.globalConfig$, logger), + schema: { + transientCount: { type: 'long' }, + persistedCount: { type: 'long' }, + totalCount: { type: 'long' }, + }, + }); + usageCollection.registerCollector(collector); + } catch (err) { + return; // kibana plugin is not enabled (test environment) + } +} diff --git a/x-pack/plugins/data_enhanced/server/plugin.ts b/x-pack/plugins/data_enhanced/server/plugin.ts index 3aaf50fbeb3e69..1037de4f79ea72 100644 --- a/x-pack/plugins/data_enhanced/server/plugin.ts +++ b/x-pack/plugins/data_enhanced/server/plugin.ts @@ -24,12 +24,16 @@ import { import { getUiSettings } from './ui_settings'; import type { DataEnhancedRequestHandlerContext } from './type'; import { ConfigSchema } from '../config'; +import { registerUsageCollector } from './collectors'; +import { SecurityPluginSetup } from '../../security/server'; interface SetupDependencies { data: DataPluginSetup; usageCollection?: UsageCollectionSetup; taskManager: TaskManagerSetupContract; + security?: SecurityPluginSetup; } + export interface StartDependencies { data: DataPluginStart; taskManager: TaskManagerStartContract; @@ -67,7 +71,7 @@ export class EnhancedDataServerPlugin eqlSearchStrategyProvider(this.logger) ); - this.sessionService = new SearchSessionService(this.logger, this.config); + this.sessionService = new SearchSessionService(this.logger, this.config, deps.security); deps.data.__enhance({ search: { @@ -82,6 +86,10 @@ export class EnhancedDataServerPlugin this.sessionService.setup(core, { taskManager: deps.taskManager, }); + + if (deps.usageCollection) { + registerUsageCollector(deps.usageCollection, this.initializerContext, this.logger); + } } public start(core: CoreStart, { taskManager }: StartDependencies) { diff --git a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts index fe522005e45581..fd3d24b71f97da 100644 --- a/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts +++ b/x-pack/plugins/data_enhanced/server/saved_objects/search_session.ts @@ -53,6 +53,15 @@ export const searchSessionMapping: SavedObjectsType = { type: 'object', enabled: false, }, + realmType: { + type: 'keyword', + }, + realmName: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, }, }, }; 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 b195a32ad481f2..f61d89e2301abd 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 @@ -19,6 +19,8 @@ import { coreMock } from 'src/core/server/mocks'; import { ConfigSchema } from '../../../config'; // @ts-ignore import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { AuthenticatedUser } from '../../../../security/common/model'; +import { nodeBuilder } from '../../../../../../src/plugins/data/common'; const MAX_UPDATE_RETRIES = 3; @@ -31,7 +33,21 @@ describe('SearchSessionService', () => { const MOCK_STRATEGY = 'ese'; const sessionId = 'd7170a35-7e2c-48d6-8dec-9a056721b489'; - const mockSavedObject: SavedObject = { + const mockUser1 = { + username: 'my_username', + authentication_realm: { + type: 'my_realm_type', + name: 'my_realm_name', + }, + } as AuthenticatedUser; + const mockUser2 = { + username: 'bar', + authentication_realm: { + type: 'bar', + name: 'bar', + }, + } as AuthenticatedUser; + const mockSavedObject: SavedObject = { id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', type: SEARCH_SESSION_TYPE, attributes: { @@ -39,6 +55,9 @@ describe('SearchSessionService', () => { appId: 'my_app_id', urlGeneratorId: 'my_url_generator_id', idMapping: {}, + realmType: mockUser1.authentication_realm.type, + realmName: mockUser1.authentication_realm.name, + username: mockUser1.username, }, references: [], }; @@ -77,66 +96,551 @@ describe('SearchSessionService', () => { service.stop(); }); - it('get calls saved objects client', async () => { - savedObjectsClient.get.mockResolvedValue(mockSavedObject); + describe('save', () => { + it('throws if `name` is not provided', () => { + expect(() => + service.save({ savedObjectsClient }, mockUser1, sessionId, {}) + ).rejects.toMatchInlineSnapshot(`[Error: Name is required]`); + }); + + it('throws if `appId` is not provided', () => { + expect( + service.save({ savedObjectsClient }, mockUser1, sessionId, { name: 'banana' }) + ).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`); + }); + + it('throws if `generator id` is not provided', () => { + expect( + service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + }) + ).rejects.toMatchInlineSnapshot(`[Error: UrlGeneratorId is required]`); + }); + + it('saving updates an existing saved object and persists it', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + + await service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + urlGeneratorId: 'panama', + }); + + expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).not.toHaveProperty('idMapping'); + expect(callAttributes).toHaveProperty('touched'); + expect(callAttributes).toHaveProperty('persisted', true); + expect(callAttributes).toHaveProperty('name', 'banana'); + expect(callAttributes).toHaveProperty('appId', 'nanana'); + expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); + expect(callAttributes).toHaveProperty('initialState', {}); + expect(callAttributes).toHaveProperty('restoreState', {}); + }); + + it('saving creates a new persisted saved object, if it did not exist', async () => { + const mockCreatedSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + + savedObjectsClient.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) + ); + savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); + + await service.save({ savedObjectsClient }, mockUser1, sessionId, { + name: 'banana', + appId: 'nanana', + urlGeneratorId: 'panama', + }); + + expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + + const [type, callAttributes, options] = savedObjectsClient.create.mock.calls[0]; + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(options?.id).toBe(sessionId); + expect(callAttributes).toHaveProperty('idMapping', {}); + expect(callAttributes).toHaveProperty('touched'); + expect(callAttributes).toHaveProperty('expires'); + expect(callAttributes).toHaveProperty('created'); + expect(callAttributes).toHaveProperty('persisted', true); + expect(callAttributes).toHaveProperty('name', 'banana'); + expect(callAttributes).toHaveProperty('appId', 'nanana'); + expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); + expect(callAttributes).toHaveProperty('initialState', {}); + expect(callAttributes).toHaveProperty('restoreState', {}); + expect(callAttributes).toHaveProperty('realmType', mockUser1.authentication_realm.type); + expect(callAttributes).toHaveProperty('realmName', mockUser1.authentication_realm.name); + expect(callAttributes).toHaveProperty('username', mockUser1.username); + }); + + it('throws error if user conflicts', () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + expect( + service.get({ savedObjectsClient }, mockUser2, sessionId) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); + + it('works without security', async () => { + savedObjectsClient.update.mockRejectedValue( + SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) + ); + + await service.save( + { savedObjectsClient }, + + null, + sessionId, + { + name: 'my_name', + appId: 'my_app_id', + urlGeneratorId: 'my_url_generator_id', + } + ); + + expect(savedObjectsClient.create).toHaveBeenCalled(); + const [[, attributes]] = savedObjectsClient.create.mock.calls; + expect(attributes).toHaveProperty('realmType', undefined); + expect(attributes).toHaveProperty('realmName', undefined); + expect(attributes).toHaveProperty('username', undefined); + }); + }); + + describe('get', () => { + it('calls saved objects client', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + const response = await service.get({ savedObjectsClient }, mockUser1, sessionId); + + expect(response).toBe(mockSavedObject); + expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + }); - const response = await service.get({ savedObjectsClient }, sessionId); + it('works without security', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); - expect(response).toBe(mockSavedObject); - expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + const response = await service.get({ savedObjectsClient }, null, sessionId); + + expect(response).toBe(mockSavedObject); + expect(savedObjectsClient.get).toHaveBeenCalledWith(SEARCH_SESSION_TYPE, sessionId); + }); }); - it('find calls saved objects client', async () => { - const mockFindSavedObject = { - ...mockSavedObject, - score: 1, - }; - const mockResponse = { - saved_objects: [mockFindSavedObject], - total: 1, - per_page: 1, - page: 0, - }; - savedObjectsClient.find.mockResolvedValue(mockResponse); + describe('find', () => { + it('calls saved objects client with user filter', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options = { page: 0, perPage: 5 }; + const response = await service.find({ savedObjectsClient }, mockUser1, options); + + expect(response).toBe(mockResponse); + const [[findOptions]] = savedObjectsClient.find.mock.calls; + expect(findOptions).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "page": 0, + "perPage": 5, + "type": "search-session", + } + `); + }); - const options = { page: 0, perPage: 5 }; - const response = await service.find({ savedObjectsClient }, options); + it('mixes in passed-in filter as string and KQL node', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options1 = { filter: 'foobar' }; + const response1 = await service.find({ savedObjectsClient }, mockUser1, options1); + + const options2 = { filter: nodeBuilder.is('foo', 'bar') }; + const response2 = await service.find({ savedObjectsClient }, mockUser1, options2); + + expect(response1).toBe(mockResponse); + expect(response2).toBe(mockResponse); + + const [[findOptions1], [findOptions2]] = savedObjectsClient.find.mock.calls; + expect(findOptions1).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": null, + }, + Object { + "type": "literal", + "value": "foobar", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "type": "search-session", + } + `); + expect(findOptions2).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmType", + }, + Object { + "type": "literal", + "value": "my_realm_type", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.realmName", + }, + Object { + "type": "literal", + "value": "my_realm_name", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "search-session.attributes.username", + }, + Object { + "type": "literal", + "value": "my_username", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "foo", + }, + Object { + "type": "literal", + "value": "bar", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "type": "search-session", + } + `); + }); - expect(response).toBe(mockResponse); - expect(savedObjectsClient.find).toHaveBeenCalledWith({ - ...options, - type: SEARCH_SESSION_TYPE, + it('has no filter without security', async () => { + const mockFindSavedObject = { + ...mockSavedObject, + score: 1, + }; + const mockResponse = { + saved_objects: [mockFindSavedObject], + total: 1, + per_page: 1, + page: 0, + }; + savedObjectsClient.find.mockResolvedValue(mockResponse); + + const options = { page: 0, perPage: 5 }; + const response = await service.find({ savedObjectsClient }, null, options); + + expect(response).toBe(mockResponse); + const [[findOptions]] = savedObjectsClient.find.mock.calls; + expect(findOptions).toMatchInlineSnapshot(` + Object { + "filter": undefined, + "page": 0, + "perPage": 5, + "type": "search-session", + } + `); }); }); - it('update calls saved objects client with added touch time', async () => { - const mockUpdateSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + describe('update', () => { + it('update calls saved objects client with added touch time', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); + + const attributes = { name: 'new_name' }; + const response = await service.update( + { savedObjectsClient }, + mockUser1, + sessionId, + attributes + ); + + expect(response).toBe(mockUpdateSavedObject); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('name', attributes.name); + expect(callAttributes).toHaveProperty('touched'); + }); + + it('throws if user conflicts', () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - const attributes = { name: 'new_name' }; - const response = await service.update({ savedObjectsClient }, sessionId, attributes); + const attributes = { name: 'new_name' }; + expect( + service.update({ savedObjectsClient }, mockUser2, sessionId, attributes) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); - expect(response).toBe(mockUpdateSavedObject); + it('works without security', async () => { + const mockUpdateSavedObject = { + ...mockSavedObject, + attributes: {}, + }; + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + const attributes = { name: 'new_name' }; + const response = await service.update({ savedObjectsClient }, null, sessionId, attributes); + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).toHaveProperty('name', attributes.name); - expect(callAttributes).toHaveProperty('touched'); + expect(response).toBe(mockUpdateSavedObject); + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('name', 'new_name'); + expect(callAttributes).toHaveProperty('touched'); + }); }); - it('cancel updates object status', async () => { - await service.cancel({ savedObjectsClient }, sessionId); - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + describe('cancel', () => { + it('updates object status', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + await service.cancel({ savedObjectsClient }, mockUser1, sessionId); + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); - expect(callAttributes).toHaveProperty('touched'); + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); + expect(callAttributes).toHaveProperty('touched'); + }); + + it('throws if user conflicts', () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + expect( + service.cancel({ savedObjectsClient }, mockUser2, sessionId) + ).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + }); + + it('works without security', async () => { + savedObjectsClient.get.mockResolvedValue(mockSavedObject); + + await service.cancel({ savedObjectsClient }, null, sessionId); + + const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; + + expect(type).toBe(SEARCH_SESSION_TYPE); + expect(id).toBe(sessionId); + expect(callAttributes).toHaveProperty('status', SearchSessionStatus.CANCELLED); + expect(callAttributes).toHaveProperty('touched'); + }); }); describe('trackId', () => { @@ -151,7 +655,7 @@ describe('SearchSessionService', () => { }; savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -194,7 +698,7 @@ describe('SearchSessionService', () => { }); }); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -213,7 +717,7 @@ describe('SearchSessionService', () => { }); }); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -238,7 +742,7 @@ describe('SearchSessionService', () => { ); savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -289,7 +793,7 @@ describe('SearchSessionService', () => { SavedObjectsErrorHelpers.createConflictError(SEARCH_SESSION_TYPE, searchId) ); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -309,7 +813,7 @@ describe('SearchSessionService', () => { SavedObjectsErrorHelpers.createConflictError(SEARCH_SESSION_TYPE, searchId) ); - await service.trackId({ savedObjectsClient }, searchRequest, searchId, { + await service.trackId({ savedObjectsClient }, mockUser1, searchRequest, searchId, { sessionId, strategy: MOCK_STRATEGY, }); @@ -341,15 +845,15 @@ describe('SearchSessionService', () => { savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); await Promise.all([ - service.trackId({ savedObjectsClient }, searchRequest1, searchId1, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest1, searchId1, { sessionId: sessionId1, strategy: MOCK_STRATEGY, }), - service.trackId({ savedObjectsClient }, searchRequest2, searchId2, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest2, searchId2, { sessionId: sessionId1, strategy: MOCK_STRATEGY, }), - service.trackId({ savedObjectsClient }, searchRequest3, searchId3, { + service.trackId({ savedObjectsClient }, mockUser1, searchRequest3, searchId3, { sessionId: sessionId2, strategy: MOCK_STRATEGY, }), @@ -394,7 +898,7 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, {}) + service.getId({ savedObjectsClient }, mockUser1, searchRequest, {}) ).rejects.toMatchInlineSnapshot(`[Error: Session ID is required]`); }); @@ -402,7 +906,10 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, { sessionId, isStored: false }) + service.getId({ savedObjectsClient }, mockUser1, searchRequest, { + sessionId, + isStored: false, + }) ).rejects.toMatchInlineSnapshot( `[Error: Cannot get search ID from a session that is not stored]` ); @@ -412,7 +919,7 @@ describe('SearchSessionService', () => { const searchRequest = { params: {} }; expect(() => - service.getId({ savedObjectsClient }, searchRequest, { + service.getId({ savedObjectsClient }, mockUser1, searchRequest, { sessionId, isStored: true, isRestore: false, @@ -427,24 +934,19 @@ describe('SearchSessionService', () => { const requestHash = createRequestHash(searchRequest.params); const searchId = 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0'; const mockSession = { - id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', - type: SEARCH_SESSION_TYPE, + ...mockSavedObject, attributes: { - name: 'my_name', - appId: 'my_app_id', - urlGeneratorId: 'my_url_generator_id', + ...mockSavedObject.attributes, idMapping: { [requestHash]: { id: searchId, - strategy: MOCK_STRATEGY, }, }, }, - references: [], }; savedObjectsClient.get.mockResolvedValue(mockSession); - const id = await service.getId({ savedObjectsClient }, searchRequest, { + const id = await service.getId({ savedObjectsClient }, mockUser1, searchRequest, { sessionId, isStored: true, isRestore: true, @@ -457,12 +959,9 @@ describe('SearchSessionService', () => { describe('getSearchIdMapping', () => { it('retrieves the search IDs and strategies from the saved object', async () => { const mockSession = { - id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', - type: SEARCH_SESSION_TYPE, + ...mockSavedObject, attributes: { - name: 'my_name', - appId: 'my_app_id', - urlGeneratorId: 'my_url_generator_id', + ...mockSavedObject.attributes, idMapping: { foo: { id: 'FnpFYlBpeXdCUTMyZXhCLTc1TWFKX0EbdDFDTzJzTE1Sck9PVTBIcW1iU05CZzo4MDA0', @@ -470,11 +969,11 @@ describe('SearchSessionService', () => { }, }, }, - references: [], }; savedObjectsClient.get.mockResolvedValue(mockSession); const searchIdMapping = await service.getSearchIdMapping( { savedObjectsClient }, + mockUser1, mockSession.id ); expect(searchIdMapping).toMatchInlineSnapshot(` @@ -484,88 +983,4 @@ describe('SearchSessionService', () => { `); }); }); - - describe('save', () => { - it('save throws if `name` is not provided', () => { - expect(service.save({ savedObjectsClient }, sessionId, {})).rejects.toMatchInlineSnapshot( - `[Error: Name is required]` - ); - }); - - it('save throws if `appId` is not provided', () => { - expect( - service.save({ savedObjectsClient }, sessionId, { name: 'banana' }) - ).rejects.toMatchInlineSnapshot(`[Error: AppId is required]`); - }); - - it('save throws if `generator id` is not provided', () => { - expect( - service.save({ savedObjectsClient }, sessionId, { name: 'banana', appId: 'nanana' }) - ).rejects.toMatchInlineSnapshot(`[Error: UrlGeneratorId is required]`); - }); - - it('saving updates an existing saved object and persists it', async () => { - const mockUpdateSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - savedObjectsClient.update.mockResolvedValue(mockUpdateSavedObject); - - await service.save({ savedObjectsClient }, sessionId, { - name: 'banana', - appId: 'nanana', - urlGeneratorId: 'panama', - }); - - expect(savedObjectsClient.update).toHaveBeenCalled(); - expect(savedObjectsClient.create).not.toHaveBeenCalled(); - - const [type, id, callAttributes] = savedObjectsClient.update.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(id).toBe(sessionId); - expect(callAttributes).not.toHaveProperty('idMapping'); - expect(callAttributes).toHaveProperty('touched'); - expect(callAttributes).toHaveProperty('persisted', true); - expect(callAttributes).toHaveProperty('name', 'banana'); - expect(callAttributes).toHaveProperty('appId', 'nanana'); - expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); - expect(callAttributes).toHaveProperty('initialState', {}); - expect(callAttributes).toHaveProperty('restoreState', {}); - }); - - it('saving creates a new persisted saved object, if it did not exist', async () => { - const mockCreatedSavedObject = { - ...mockSavedObject, - attributes: {}, - }; - - savedObjectsClient.update.mockRejectedValue( - SavedObjectsErrorHelpers.createGenericNotFoundError(sessionId) - ); - savedObjectsClient.create.mockResolvedValue(mockCreatedSavedObject); - - await service.save({ savedObjectsClient }, sessionId, { - name: 'banana', - appId: 'nanana', - urlGeneratorId: 'panama', - }); - - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); - - const [type, callAttributes, options] = savedObjectsClient.create.mock.calls[0]; - expect(type).toBe(SEARCH_SESSION_TYPE); - expect(options?.id).toBe(sessionId); - expect(callAttributes).toHaveProperty('idMapping', {}); - expect(callAttributes).toHaveProperty('touched'); - expect(callAttributes).toHaveProperty('expires'); - expect(callAttributes).toHaveProperty('created'); - expect(callAttributes).toHaveProperty('persisted', true); - expect(callAttributes).toHaveProperty('name', 'banana'); - expect(callAttributes).toHaveProperty('appId', 'nanana'); - expect(callAttributes).toHaveProperty('urlGeneratorId', 'panama'); - expect(callAttributes).toHaveProperty('initialState', {}); - expect(callAttributes).toHaveProperty('restoreState', {}); - }); - }); }); 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 6a36b1b4859ed3..c95c58a8dc06ba 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,6 +5,7 @@ * 2.0. */ +import { notFound } from '@hapi/boom'; import { debounce } from 'lodash'; import { CoreSetup, @@ -16,8 +17,13 @@ import { SavedObjectsFindOptions, SavedObjectsErrorHelpers, } from '../../../../../../src/core/server'; -import { IKibanaSearchRequest, ISearchOptions } from '../../../../../../src/plugins/data/common'; -import { ISearchSessionService } from '../../../../../../src/plugins/data/server'; +import { + IKibanaSearchRequest, + ISearchOptions, + nodeBuilder, +} from '../../../../../../src/plugins/data/common'; +import { esKuery, ISearchSessionService } from '../../../../../../src/plugins/data/server'; +import { AuthenticatedUser, SecurityPluginSetup } from '../../../../security/server'; import { TaskManagerSetupContract, TaskManagerStartContract, @@ -49,6 +55,7 @@ const DEBOUNCE_UPDATE_OR_CREATE_MAX_WAIT = 5000; interface UpdateOrCreateQueueEntry { deps: SearchSessionDependencies; + user: AuthenticatedUser | null; sessionId: string; attributes: Partial; resolve: () => void; @@ -63,7 +70,11 @@ export class SearchSessionService private sessionConfig: SearchSessionsConfig; private readonly updateOrCreateBatchQueue: UpdateOrCreateQueueEntry[] = []; - constructor(private readonly logger: Logger, private readonly config: ConfigSchema) { + constructor( + private readonly logger: Logger, + private readonly config: ConfigSchema, + private readonly security?: SecurityPluginSetup + ) { this.sessionConfig = this.config.search.sessions; } @@ -114,7 +125,12 @@ export class SearchSessionService Object.keys(batchedSessionAttributes).forEach((sessionId) => { const thisSession = queue.filter((s) => s.sessionId === sessionId); - this.updateOrCreate(thisSession[0].deps, sessionId, batchedSessionAttributes[sessionId]) + this.updateOrCreate( + thisSession[0].deps, + thisSession[0].user, + sessionId, + batchedSessionAttributes[sessionId] + ) .then(() => { thisSession.forEach((s) => s.resolve()); }) @@ -128,11 +144,12 @@ export class SearchSessionService ); private scheduleUpdateOrCreate = ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ): Promise => { return new Promise((resolve, reject) => { - this.updateOrCreateBatchQueue.push({ deps, sessionId, attributes, resolve, reject }); + this.updateOrCreateBatchQueue.push({ deps, user, sessionId, attributes, resolve, reject }); // TODO: this would be better if we'd debounce per sessionId this.processUpdateOrCreateBatchQueue(); }); @@ -140,6 +157,7 @@ export class SearchSessionService private updateOrCreate = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial, retry: number = 1 @@ -148,13 +166,14 @@ export class SearchSessionService this.logger.debug(`Conflict error | ${sessionId}`); // Randomize sleep to spread updates out in case of conflicts await sleep(100 + Math.random() * 50); - return await this.updateOrCreate(deps, sessionId, attributes, retry + 1); + return await this.updateOrCreate(deps, user, sessionId, attributes, retry + 1); }; this.logger.debug(`updateOrCreate | ${sessionId} | ${retry}`); try { return (await this.update( deps, + user, sessionId, attributes )) as SavedObject; @@ -162,7 +181,7 @@ export class SearchSessionService if (SavedObjectsErrorHelpers.isNotFoundError(e)) { try { this.logger.debug(`Object not found | ${sessionId}`); - return await this.create(deps, sessionId, attributes); + return await this.create(deps, user, sessionId, attributes); } catch (createError) { if ( SavedObjectsErrorHelpers.isConflictError(createError) && @@ -188,6 +207,7 @@ export class SearchSessionService public save = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, { name, @@ -201,7 +221,7 @@ export class SearchSessionService if (!appId) throw new Error('AppId is required'); if (!urlGeneratorId) throw new Error('UrlGeneratorId is required'); - return this.updateOrCreate(deps, sessionId, { + return this.updateOrCreate(deps, user, sessionId, { name, appId, urlGeneratorId, @@ -213,10 +233,16 @@ export class SearchSessionService private create = ( { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ) => { this.logger.debug(`create | ${sessionId}`); + + const realmType = user?.authentication_realm.type; + const realmName = user?.authentication_realm.name; + const username = user?.username; + return savedObjectsClient.create( SEARCH_SESSION_TYPE, { @@ -229,40 +255,69 @@ export class SearchSessionService touched: new Date().toISOString(), idMapping: {}, persisted: false, + realmType, + realmName, + username, ...attributes, }, { id: sessionId } ); }; - // TODO: Throw an error if this session doesn't belong to this user - public get = ({ savedObjectsClient }: SearchSessionDependencies, sessionId: string) => { + public get = async ( + { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { this.logger.debug(`get | ${sessionId}`); - return savedObjectsClient.get( + const session = await savedObjectsClient.get( SEARCH_SESSION_TYPE, sessionId ); + this.throwOnUserConflict(user, session); + return session; }; - // TODO: Throw an error if this session doesn't belong to this user public find = ( { savedObjectsClient }: SearchSessionDependencies, + user: AuthenticatedUser | null, options: Omit ) => { + const userFilters = + user === null + ? [] + : [ + nodeBuilder.is( + `${SEARCH_SESSION_TYPE}.attributes.realmType`, + `${user.authentication_realm.type}` + ), + nodeBuilder.is( + `${SEARCH_SESSION_TYPE}.attributes.realmName`, + `${user.authentication_realm.name}` + ), + nodeBuilder.is(`${SEARCH_SESSION_TYPE}.attributes.username`, `${user.username}`), + ]; + const filterKueryNode = + typeof options.filter === 'string' + ? esKuery.fromKueryExpression(options.filter) + : options.filter; + const filter = nodeBuilder.and(userFilters.concat(filterKueryNode ?? [])); return savedObjectsClient.find({ ...options, + filter, type: SEARCH_SESSION_TYPE, }); }; - // TODO: Throw an error if this session doesn't belong to this user - public update = ( - { savedObjectsClient }: SearchSessionDependencies, + public update = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, sessionId: string, attributes: Partial ) => { this.logger.debug(`update | ${sessionId}`); - return savedObjectsClient.update( + await this.get(deps, user, sessionId); // Verify correct user + return deps.savedObjectsClient.update( SEARCH_SESSION_TYPE, sessionId, { @@ -272,22 +327,35 @@ export class SearchSessionService ); }; - public extend(deps: SearchSessionDependencies, sessionId: string, expires: Date) { + public async extend( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string, + expires: Date + ) { this.logger.debug(`extend | ${sessionId}`); - - return this.update(deps, sessionId, { expires: expires.toISOString() }); + return this.update(deps, user, sessionId, { expires: expires.toISOString() }); } - // TODO: Throw an error if this session doesn't belong to this user - public cancel = (deps: SearchSessionDependencies, sessionId: string) => { - return this.update(deps, sessionId, { + public cancel = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { + this.logger.debug(`delete | ${sessionId}`); + return this.update(deps, user, sessionId, { status: SearchSessionStatus.CANCELLED, }); }; - // 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); + public delete = async ( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) => { + this.logger.debug(`delete | ${sessionId}`); + await this.get(deps, user, sessionId); // Verify correct user + return deps.savedObjectsClient.delete(SEARCH_SESSION_TYPE, sessionId); }; /** @@ -296,6 +364,7 @@ export class SearchSessionService */ public trackId = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, searchRequest: IKibanaSearchRequest, searchId: string, { sessionId, strategy }: ISearchOptions @@ -315,11 +384,15 @@ export class SearchSessionService idMapping = { [requestHash]: searchInfo }; } - await this.scheduleUpdateOrCreate(deps, sessionId, { idMapping }); + await this.scheduleUpdateOrCreate(deps, user, sessionId, { idMapping }); }; - public async getSearchIdMapping(deps: SearchSessionDependencies, sessionId: string) { - const searchSession = await this.get(deps, sessionId); + public async getSearchIdMapping( + deps: SearchSessionDependencies, + user: AuthenticatedUser | null, + sessionId: string + ) { + const searchSession = await this.get(deps, user, sessionId); const searchIdMapping = new Map(); Object.values(searchSession.attributes.idMapping).forEach((requestInfo) => { searchIdMapping.set(requestInfo.id, requestInfo.strategy); @@ -334,6 +407,7 @@ export class SearchSessionService */ public getId = async ( deps: SearchSessionDependencies, + user: AuthenticatedUser | null, searchRequest: IKibanaSearchRequest, { sessionId, isStored, isRestore }: ISearchOptions ) => { @@ -345,7 +419,7 @@ export class SearchSessionService throw new Error('Get search ID is only supported when restoring a session'); } - const session = await this.get(deps, sessionId); + const session = await this.get(deps, user, sessionId); const requestHash = createRequestHash(searchRequest.params); if (!session.attributes.idMapping.hasOwnProperty(requestHash)) { this.logger.error(`getId | ${sessionId} | ${requestHash} not found`); @@ -358,22 +432,40 @@ export class SearchSessionService public asScopedProvider = ({ savedObjects }: CoreStart) => { return (request: KibanaRequest) => { + const user = this.security?.authc.getCurrentUser(request) ?? null; const savedObjectsClient = savedObjects.getScopedClient(request, { includedHiddenTypes: [SEARCH_SESSION_TYPE], }); const deps = { savedObjectsClient }; return { - getId: this.getId.bind(this, deps), - trackId: this.trackId.bind(this, deps), - getSearchIdMapping: this.getSearchIdMapping.bind(this, deps), - save: this.save.bind(this, deps), - get: this.get.bind(this, deps), - find: this.find.bind(this, deps), - update: this.update.bind(this, deps), - extend: this.extend.bind(this, deps), - cancel: this.cancel.bind(this, deps), - delete: this.delete.bind(this, deps), + getId: this.getId.bind(this, deps, user), + trackId: this.trackId.bind(this, deps, user), + getSearchIdMapping: this.getSearchIdMapping.bind(this, deps, user), + save: this.save.bind(this, deps, user), + get: this.get.bind(this, deps, user), + find: this.find.bind(this, deps, user), + update: this.update.bind(this, deps, user), + extend: this.extend.bind(this, deps, user), + cancel: this.cancel.bind(this, deps, user), + delete: this.delete.bind(this, deps, user), }; }; }; + + private throwOnUserConflict = ( + user: AuthenticatedUser | null, + session?: SavedObject + ) => { + if (user === null || !session) return; + if ( + user.authentication_realm.type !== session.attributes.realmType || + user.authentication_realm.name !== session.attributes.realmName || + user.username !== session.attributes.username + ) { + this.logger.debug( + `User ${user.username} has no access to search session ${session.attributes.sessionId}` + ); + throw notFound(); + } + }; } diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index 29bfd71cb32b40..216c115545a451 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -25,6 +25,7 @@ { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, { "path": "../task_manager/tsconfig.json" }, { "path": "../features/tsconfig.json" }, diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index 580b4120ae46dd..07c6addda27674 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -6,6 +6,7 @@ */ import { IExternalUrl } from 'src/core/public'; +import { uiSettingsServiceMock } from 'src/core/public/mocks'; import { UrlDrilldown, ActionContext, Config } from './url_drilldown'; import { IEmbeddable, VALUE_CLICK_TRIGGER } from '../../../../../../src/plugins/embeddable/public'; import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common'; @@ -74,6 +75,7 @@ const createDrilldown = (isExternalUrlValid: boolean = true) => { getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', getVariablesHelpDocsLink: () => 'http://localhost:5601/docs', navigateToUrl: mockNavigateToUrl, + uiSettings: uiSettingsServiceMock.createSetupContract(), }); return drilldown; }; @@ -408,7 +410,7 @@ describe('UrlDrilldown', () => { ]; for (const expectedItem of expectedList) { - expect(list.includes(expectedItem)).toBe(true); + expect(!!list.find(({ label }) => label === expectedItem)).toBe(true); } }); @@ -438,7 +440,7 @@ describe('UrlDrilldown', () => { ]; for (const expectedItem of expectedList) { - expect(list.includes(expectedItem)).toBe(true); + expect(!!list.find(({ label }) => label === expectedItem)).toBe(true); } }); }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index a8c34a871310bb..bf2ed8c2a45d1b 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -6,9 +6,7 @@ */ import React from 'react'; -import { getFlattenedObject } from '@kbn/std'; -import { IExternalUrl } from 'src/core/public'; -import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { IExternalUrl, IUiSettingsClient } from 'src/core/public'; import { ChartActionContext, CONTEXT_MENU_TRIGGER, @@ -20,6 +18,11 @@ import { import { ROW_CLICK_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; import { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; +import { + reactToUiComponent, + UrlTemplateEditorVariable, + KibanaContextProvider, +} from '../../../../../../src/plugins/kibana_react/public'; import { UiActionsEnhancedDrilldownDefinition as Drilldown, UrlDrilldownGlobalScope, @@ -29,8 +32,10 @@ import { urlDrilldownCompileUrl, UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, } from '../../../../ui_actions_enhanced/public'; -import { getPanelVariables, getEventScope, getEventVariableList } from './url_drilldown_scope'; import { txtUrlDrilldownDisplayName } from './i18n'; +import { getEventVariableList, getEventScopeValues } from './variables/event_variables'; +import { getContextVariableList, getContextScopeValues } from './variables/context_variables'; +import { getGlobalVariableList } from './variables/global_variables'; interface EmbeddableQueryInput extends EmbeddableInput { query?: Query; @@ -47,6 +52,7 @@ interface UrlDrilldownDeps { navigateToUrl: (url: string) => Promise; getSyntaxHelpDocsLink: () => string; getVariablesHelpDocsLink: () => string; + uiSettings: IUiSettingsClient; } export type ActionContext = ChartActionContext; @@ -90,21 +96,30 @@ export class UrlDrilldown implements Drilldown { // eslint-disable-next-line react-hooks/rules-of-hooks const variables = React.useMemo(() => this.getVariableList(context), [context]); + return ( - + + + ); }; public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); public readonly createConfig = () => ({ - url: { template: '' }, + url: { + template: 'https://example.com/?{{event.key}}={{event.value}}', + }, openInNewTab: true, encodeUrl: true, }); @@ -167,21 +182,20 @@ export class UrlDrilldown implements Drilldown { return { + event: getEventScopeValues(context), + context: getContextScopeValues(context), ...this.deps.getGlobalScope(), - context: { - panel: getPanelVariables(context), - }, - event: getEventScope(context), }; }; - public readonly getVariableList = (context: ActionFactoryContext): string[] => { + public readonly getVariableList = ( + context: ActionFactoryContext + ): UrlTemplateEditorVariable[] => { + const globalScopeValues = this.deps.getGlobalScope(); const eventVariables = getEventVariableList(context); - const contextVariables = Object.keys(getFlattenedObject(getPanelVariables(context))).map( - (key) => 'context.panel.' + key - ); - const globalVariables = Object.keys(getFlattenedObject(this.deps.getGlobalScope())); + const contextVariables = getContextVariableList(context); + const globalVariables = getGlobalVariableList(globalScopeValues); - return [...eventVariables.sort(), ...contextVariables.sort(), ...globalVariables.sort()]; + return [...eventVariables, ...contextVariables, ...globalVariables]; }; } diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts deleted file mode 100644 index 5c639a61ba4c24..00000000000000 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts +++ /dev/null @@ -1,294 +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 { - getEventScope, - ValueClickTriggerEventScope, - getEventVariableList, - getPanelVariables, -} from './url_drilldown_scope'; -import { - RowClickContext, - ROW_CLICK_TRIGGER, -} from '../../../../../../src/plugins/ui_actions/public'; -import { createPoint, rowClickData, TestEmbeddable } from './test/data'; - -describe('VALUE_CLICK_TRIGGER', () => { - describe('supports `points[]`', () => { - test('getEventScope()', () => { - const mockDataPoints = [ - createPoint({ field: 'field0', value: 'value0' }), - createPoint({ field: 'field1', value: 'value1' }), - createPoint({ field: 'field2', value: 'value2' }), - ]; - - const eventScope = getEventScope({ - data: { data: mockDataPoints }, - }) as ValueClickTriggerEventScope; - - expect(eventScope.key).toBe('field0'); - expect(eventScope.value).toBe('value0'); - expect(eventScope.points).toHaveLength(mockDataPoints.length); - expect(eventScope.points).toMatchInlineSnapshot(` - Array [ - Object { - "key": "field0", - "value": "value0", - }, - Object { - "key": "field1", - "value": "value1", - }, - Object { - "key": "field2", - "value": "value2", - }, - ] - `); - }); - }); - - describe('handles undefined, null or missing values', () => { - test('undefined or missing values are removed from the result scope', () => { - const point = createPoint({ field: undefined } as any); - const eventScope = getEventScope({ - data: { data: [point] }, - }) as ValueClickTriggerEventScope; - - expect('key' in eventScope).toBeFalsy(); - expect('value' in eventScope).toBeFalsy(); - }); - - test('null value stays in the result scope', () => { - const point = createPoint({ field: 'field', value: null }); - const eventScope = getEventScope({ - data: { data: [point] }, - }) as ValueClickTriggerEventScope; - - expect(eventScope.value).toBeNull(); - }); - }); -}); - -describe('ROW_CLICK_TRIGGER', () => { - test('getEventVariableList() returns correct list of runtime variables', () => { - const vars = getEventVariableList({ - triggers: [ROW_CLICK_TRIGGER], - }); - expect(vars).toEqual(['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']); - }); - - test('getEventScope() returns correct variables for row click trigger', () => { - const context = ({ - embeddable: {}, - data: rowClickData as any, - } as unknown) as RowClickContext; - const res = getEventScope(context); - - expect(res).toEqual({ - rowIndex: 1, - values: ['IT', '2.25', 3, 0, 2], - keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], - columnNames: [ - 'Top values of DestCountry', - 'Top values of FlightTimeHour', - 'Count of records', - 'Average of DistanceMiles', - 'Unique count of OriginAirportID', - ], - }); - }); -}); - -describe('getPanelVariables()', () => { - test('returns only ID for empty embeddable', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - }); - }); - - test('returns title as specified in input', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - title: 'title1', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title1', - }); - }); - - test('returns output title if input and output titles are specified', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - title: 'title1', - }, - { - title: 'title2', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title2', - }); - }); - - test('returns title from output if title in input is missing', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - title: 'title2', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - title: 'title2', - }); - }); - - test('returns saved object ID from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - savedObjectId: '5678', - }, - { - savedObjectId: '1234', - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - savedObjectId: '1234', - }); - }); - - test('returns saved object ID from input if it is not set on output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - savedObjectId: '5678', - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - savedObjectId: '5678', - }); - }); - - test('returns query, timeRange and filters from input', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - query: { - language: 'C++', - query: 'std::cout << 123;', - }, - timeRange: { - from: 'FROM', - to: 'TO', - }, - filters: [ - { - meta: { - alias: 'asdf', - disabled: false, - negate: false, - }, - }, - ], - }, - {} - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - query: { - language: 'C++', - query: 'std::cout << 123;', - }, - timeRange: { - from: 'FROM', - to: 'TO', - }, - filters: [ - { - meta: { - alias: 'asdf', - disabled: false, - negate: false, - }, - }, - ], - }); - }); - - test('returns a single index pattern from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }], - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - }); - }); - - test('returns multiple index patterns from output', () => { - const embeddable = new TestEmbeddable( - { - id: 'test', - }, - { - indexPatterns: [ - { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, - { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, - ], - } - ); - const vars = getPanelVariables({ embeddable }); - - expect(vars).toEqual({ - id: 'test', - indexPatternIds: [ - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', - 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', - ], - }); - }); -}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts deleted file mode 100644 index 0ee388c321feb2..00000000000000 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts +++ /dev/null @@ -1,266 +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. - */ - -/** - * This file contains all the logic for mapping from trigger's context and action factory context to variables for URL drilldown scope, - * Please refer to ./README.md for explanation of different scope sources - */ - -import type { Filter, Query, TimeRange } from '../../../../../../src/plugins/data/public'; -import { - isRangeSelectTriggerContext, - isValueClickTriggerContext, - isRowClickTriggerContext, - isContextMenuTriggerContext, - RangeSelectContext, - SELECT_RANGE_TRIGGER, - ValueClickContext, - VALUE_CLICK_TRIGGER, - EmbeddableInput, - EmbeddableOutput, -} from '../../../../../../src/plugins/embeddable/public'; -import type { - ActionContext, - ActionFactoryContext, - EmbeddableWithQueryInput, -} from './url_drilldown'; -import { - RowClickContext, - ROW_CLICK_TRIGGER, -} from '../../../../../../src/plugins/ui_actions/public'; - -/** - * Part of context scope extracted from an embeddable - * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` - */ -interface EmbeddableUrlDrilldownContextScope extends EmbeddableInput { - /** - * ID of the embeddable panel. - */ - id: string; - - /** - * Title of the embeddable panel. - */ - title?: string; - - /** - * In case panel supports only 1 index pattern. - */ - indexPatternId?: string; - - /** - * In case panel supports more then 1 index pattern. - */ - indexPatternIds?: string[]; - - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; - savedObjectId?: string; -} - -export function getPanelVariables(contextScopeInput: unknown): EmbeddableUrlDrilldownContextScope { - function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } { - if (val && typeof val === 'object' && 'embeddable' in val) return true; - return false; - } - if (!hasEmbeddable(contextScopeInput)) - throw new Error( - "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" - ); - const embeddable = contextScopeInput.embeddable; - - return getEmbeddableVariables(embeddable); -} - -function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { - return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; -} - -/** - * @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts, - * combine both implementations into a common approach. - */ -function getIndexPatternIds(output: EmbeddableOutput): string[] { - function hasIndexPatterns( - _output: Record - ): _output is { indexPatterns: Array<{ id?: string }> } { - return ( - 'indexPatterns' in _output && - Array.isArray(_output.indexPatterns) && - _output.indexPatterns.length > 0 - ); - } - return hasIndexPatterns(output) - ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) - : []; -} - -export function getEmbeddableVariables( - embeddable: EmbeddableWithQueryInput -): EmbeddableUrlDrilldownContextScope { - const input = embeddable.getInput(); - const output = embeddable.getOutput(); - const indexPatternsIds = getIndexPatternIds(output); - - return deleteUndefinedKeys({ - id: input.id, - title: output.title ?? input.title, - savedObjectId: - output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), - query: input.query, - timeRange: input.timeRange, - filters: input.filters, - indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, - indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, - }); -} - -/** - * URL drilldown event scope, - * available as {{event.$}} - */ -export type UrlDrilldownEventScope = - | ValueClickTriggerEventScope - | RangeSelectTriggerEventScope - | RowClickTriggerEventScope - | ContextMenuTriggerEventScope; - -export type EventScopeInput = ActionContext; -export interface ValueClickTriggerEventScope { - key?: string; - value: Primitive; - negate: boolean; - points: Array<{ key?: string; value: Primitive }>; -} -export interface RangeSelectTriggerEventScope { - key: string; - from?: string | number; - to?: string | number; -} - -export interface RowClickTriggerEventScope { - rowIndex: number; - values: Primitive[]; - keys: string[]; - columnNames: string[]; -} -export type ContextMenuTriggerEventScope = object; - -export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEventScope { - if (isRangeSelectTriggerContext(eventScopeInput)) { - return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); - } else if (isValueClickTriggerContext(eventScopeInput)) { - return getEventScopeFromValueClickTriggerContext(eventScopeInput); - } else if (isRowClickTriggerContext(eventScopeInput)) { - return getEventScopeFromRowClickTriggerContext(eventScopeInput); - } else if (isContextMenuTriggerContext(eventScopeInput)) { - return {}; - } else { - throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); - } -} - -function getEventScopeFromRangeSelectTriggerContext( - eventScopeInput: RangeSelectContext -): RangeSelectTriggerEventScope { - const { table, column: columnIndex, range } = eventScopeInput.data; - const column = table.columns[columnIndex]; - return deleteUndefinedKeys({ - key: toPrimitiveOrUndefined(column?.meta.field) as string, - from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, - to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, - }); -} - -function getEventScopeFromValueClickTriggerContext( - eventScopeInput: ValueClickContext -): ValueClickTriggerEventScope { - const negate = eventScopeInput.data.negate ?? false; - const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => { - const column = table.columns[columnIndex]; - return { - value: toPrimitiveOrUndefined(value) as Primitive, - key: column?.meta?.field, - }; - }); - - return deleteUndefinedKeys({ - key: points[0]?.key, - value: points[0]?.value, - negate, - points, - }); -} - -function getEventScopeFromRowClickTriggerContext(ctx: RowClickContext): RowClickTriggerEventScope { - const { data } = ctx; - const embeddable = ctx.embeddable as EmbeddableWithQueryInput; - - const { rowIndex } = data; - const columns = data.columns || data.table.columns.map(({ id }) => id); - const values: Primitive[] = []; - const keys: string[] = []; - const columnNames: string[] = []; - const row = data.table.rows[rowIndex]; - - for (const columnId of columns) { - const column = data.table.columns.find(({ id }) => id === columnId); - if (!column) { - // This should never happe, but in case it does we log data necessary for debugging. - // eslint-disable-next-line no-console - console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null); - throw new Error('Could not find a datatable column.'); - } - values.push(row[columnId]); - keys.push(column.meta.field || ''); - columnNames.push(column.name || column.meta.field || ''); - } - - const scope: RowClickTriggerEventScope = { - rowIndex, - values, - keys, - columnNames, - }; - - return scope; -} - -export function getEventVariableList(context: ActionFactoryContext): string[] { - const [trigger] = context.triggers; - - switch (trigger) { - case SELECT_RANGE_TRIGGER: - return ['event.key', 'event.from', 'event.to']; - case VALUE_CLICK_TRIGGER: - return ['event.key', 'event.value', 'event.negate', 'event.points']; - case ROW_CLICK_TRIGGER: - return ['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']; - } - - return []; -} - -type Primitive = string | number | boolean | null; -function toPrimitiveOrUndefined(v: unknown): Primitive | undefined { - if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null) - return v; - if (typeof v === 'object' && v instanceof Date) return v.toISOString(); - if (typeof v === 'undefined') return undefined; - return String(v); -} - -function deleteUndefinedKeys>(obj: T): T { - Object.keys(obj).forEach((key) => { - if (obj[key] === undefined) { - delete obj[key]; - } - }); - return obj; -} diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts new file mode 100644 index 00000000000000..c3c41ef082ffc5 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.test.ts @@ -0,0 +1,216 @@ +/* + * 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 { getContextScopeValues } from './context_variables'; +import { TestEmbeddable } from '../test/data'; + +describe('getContextScopeValues()', () => { + test('returns only ID for empty embeddable', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + }, + }); + }); + + test('returns title as specified in input', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + title: 'title1', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title1', + }, + }); + }); + + test('returns output title if input and output titles are specified', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + title: 'title1', + }, + { + title: 'title2', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title2', + }, + }); + }); + + test('returns title from output if title in input is missing', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + title: 'title2', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + title: 'title2', + }, + }); + }); + + test('returns saved object ID from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + savedObjectId: '5678', + }, + { + savedObjectId: '1234', + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + savedObjectId: '1234', + }, + }); + }); + + test('returns saved object ID from input if it is not set on output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + savedObjectId: '5678', + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + savedObjectId: '5678', + }, + }); + }); + + test('returns query, timeRange and filters from input', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }, + {} + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }, + }); + }); + + test('returns a single index pattern from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }], + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + }, + }); + }); + + test('returns multiple index patterns from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + indexPatterns: [ + { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, + { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, + ], + } + ); + const vars = getContextScopeValues({ embeddable }); + + expect(vars).toEqual({ + panel: { + id: 'test', + indexPatternIds: [ + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts new file mode 100644 index 00000000000000..65c665a182e18a --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/context_variables.ts @@ -0,0 +1,249 @@ +/* + * 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 { monaco } from '@kbn/monaco'; +import { getFlattenedObject } from '@kbn/std'; +import { txtValue } from './i18n'; +import type { Filter, Query, TimeRange } from '../../../../../../../src/plugins/data/public'; +import { + EmbeddableInput, + EmbeddableOutput, +} from '../../../../../../../src/plugins/embeddable/public'; +import type { EmbeddableWithQueryInput } from '../url_drilldown'; +import { deleteUndefinedKeys } from './util'; +import type { ActionFactoryContext } from '../url_drilldown'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; + +/** + * Part of context scope extracted from an embeddable + * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` + */ +interface PanelValues extends EmbeddableInput { + /** + * ID of the embeddable panel. + */ + id: string; + + /** + * Title of the embeddable panel. + */ + title?: string; + + /** + * In case panel supports only 1 index pattern. + */ + indexPatternId?: string; + + /** + * In case panel supports more then 1 index pattern. + */ + indexPatternIds?: string[]; + + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + savedObjectId?: string; +} + +export interface ContextValues { + panel: PanelValues; +} + +function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { + return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; +} + +/** + * @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts, + * combine both implementations into a common approach. + */ +function getIndexPatternIds(output: EmbeddableOutput): string[] { + function hasIndexPatterns( + _output: Record + ): _output is { indexPatterns: Array<{ id?: string }> } { + return ( + 'indexPatterns' in _output && + Array.isArray(_output.indexPatterns) && + _output.indexPatterns.length > 0 + ); + } + return hasIndexPatterns(output) + ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) + : []; +} + +export function getEmbeddableVariables(embeddable: EmbeddableWithQueryInput): PanelValues { + const input = embeddable.getInput(); + const output = embeddable.getOutput(); + const indexPatternsIds = getIndexPatternIds(output); + + return deleteUndefinedKeys({ + id: input.id, + title: output.title ?? input.title, + savedObjectId: + output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), + query: input.query, + timeRange: input.timeRange, + filters: input.filters, + indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, + indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, + }); +} + +const getContextPanelScopeValues = (contextScopeInput: unknown): PanelValues => { + function hasEmbeddable(val: unknown): val is { embeddable: EmbeddableWithQueryInput } { + if (val && typeof val === 'object' && 'embeddable' in val) return true; + return false; + } + if (!hasEmbeddable(contextScopeInput)) + throw new Error( + "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" + ); + const embeddable = contextScopeInput.embeddable; + + return getEmbeddableVariables(embeddable); +}; + +export const getContextScopeValues = (contextScopeInput: unknown): ContextValues => { + return { + panel: getContextPanelScopeValues(contextScopeInput), + }; +}; + +type VariableDescription = Pick; + +const variableDescriptions: Record = { + id: { + title: i18n.translate('xpack.urlDrilldown.context.panel.id.title', { + defaultMessage: 'Panel ID.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.id.documentation', { + defaultMessage: 'ID of the panel where drilldown is executed.', + }), + }, + title: { + title: i18n.translate('xpack.urlDrilldown.context.panel.title.title', { + defaultMessage: 'Panel title.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.title.documentation', { + defaultMessage: 'Title of the panel where drilldown is executed.', + }), + }, + filters: { + title: i18n.translate('xpack.urlDrilldown.context.panel.filters.title', { + defaultMessage: 'Panel filters.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.filters.documentation', { + defaultMessage: 'List of Kibana filters applied to a panel.', + }), + }, + 'query.query': { + title: i18n.translate('xpack.urlDrilldown.context.panel.query.query.title', { + defaultMessage: 'Query string.', + }), + }, + 'query.language': { + title: i18n.translate('xpack.urlDrilldown.context.panel.query.language.title', { + defaultMessage: 'Query language.', + }), + }, + 'timeRange.from': { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.from.title', { + defaultMessage: 'Time picker "from" value.', + }), + }, + 'timeRange.to': { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.to.title', { + defaultMessage: 'Time picker "to" value.', + }), + }, + indexPatternId: { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.indexPatternId.title', { + defaultMessage: 'Index pattern ID.', + }), + documentation: i18n.translate( + 'xpack.urlDrilldown.context.panel.timeRange.indexPatternId.documentation', + { + defaultMessage: 'First index pattern ID used by the panel.', + } + ), + }, + indexPatternIds: { + title: i18n.translate('xpack.urlDrilldown.context.panel.timeRange.indexPatternIds.title', { + defaultMessage: 'Index pattern IDs.', + }), + documentation: i18n.translate( + 'xpack.urlDrilldown.context.panel.timeRange.indexPatternIds.documentation', + { + defaultMessage: 'List of all index pattern IDs used by the panel.', + } + ), + }, + savedObjectId: { + title: i18n.translate('xpack.urlDrilldown.context.panel.savedObjectId.title', { + defaultMessage: 'Saved object ID.', + }), + documentation: i18n.translate('xpack.urlDrilldown.context.panel.savedObjectId.documentation', { + defaultMessage: 'ID of the saved object behind the panel.', + }), + }, +}; + +const kind = monaco.languages.CompletionItemKind.Variable; +const sortPrefix = '2.'; + +const formatValue = (value: unknown) => { + if (typeof value === 'object') { + return '\n' + JSON.stringify(value, null, 4); + } + + return String(value); +}; + +const getPanelVariableList = (values: PanelValues): UrlTemplateEditorVariable[] => { + const variables: UrlTemplateEditorVariable[] = []; + const flattenedValues = getFlattenedObject(values); + const keys = Object.keys(flattenedValues).sort(); + + for (const key of keys) { + const description = variableDescriptions[key]; + const label = 'context.panel.' + key; + + if (!description) { + variables.push({ + label, + sortText: sortPrefix + label, + documentation: !!flattenedValues[key] ? txtValue(formatValue(flattenedValues[key])) : '', + kind, + }); + continue; + } + + variables.push({ + label, + sortText: sortPrefix + label, + title: description.title, + documentation: + (description.documentation || '') + + (!!description.documentation && !!flattenedValues[key] ? '\n\n' : '') + + (!!flattenedValues[key] ? txtValue(formatValue(flattenedValues[key])) : ''), + kind, + }); + } + + return variables; +}; + +export const getContextVariableList = ( + context: ActionFactoryContext +): UrlTemplateEditorVariable[] => { + const values = getContextScopeValues(context); + const variables: UrlTemplateEditorVariable[] = getPanelVariableList(values.panel); + + return variables; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts new file mode 100644 index 00000000000000..3d0c55a08d1bff --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getEventScopeValues, + getEventVariableList, + ValueClickTriggerEventScope, +} from './event_variables'; +import { + RowClickContext, + ROW_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/ui_actions/public'; +import { createPoint, rowClickData } from '../test/data'; + +describe('VALUE_CLICK_TRIGGER', () => { + describe('supports `points[]`', () => { + test('getEventScopeValues()', () => { + const mockDataPoints = [ + createPoint({ field: 'field0', value: 'value0' }), + createPoint({ field: 'field1', value: 'value1' }), + createPoint({ field: 'field2', value: 'value2' }), + ]; + + const eventScope = getEventScopeValues({ + data: { data: mockDataPoints }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.key).toBe('field0'); + expect(eventScope.value).toBe('value0'); + expect(eventScope.points).toHaveLength(mockDataPoints.length); + expect(eventScope.points).toMatchInlineSnapshot(` + Array [ + Object { + "key": "field0", + "value": "value0", + }, + Object { + "key": "field1", + "value": "value1", + }, + Object { + "key": "field2", + "value": "value2", + }, + ] + `); + }); + }); + + describe('handles undefined, null or missing values', () => { + test('undefined or missing values are removed from the result scope', () => { + const point = createPoint({ field: undefined } as any); + const eventScope = getEventScopeValues({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect('key' in eventScope).toBeFalsy(); + expect('value' in eventScope).toBeFalsy(); + }); + + test('null value stays in the result scope', () => { + const point = createPoint({ field: 'field', value: null }); + const eventScope = getEventScopeValues({ + data: { data: [point] }, + }) as ValueClickTriggerEventScope; + + expect(eventScope.value).toBeNull(); + }); + }); +}); + +describe('ROW_CLICK_TRIGGER', () => { + test('getEventVariableList() returns correct list of runtime variables', () => { + const vars = getEventVariableList({ + triggers: [ROW_CLICK_TRIGGER], + }); + expect(vars.map(({ label }) => label)).toEqual([ + 'event.values', + 'event.keys', + 'event.columnNames', + 'event.rowIndex', + ]); + }); + + test('getEventScopeValues() returns correct variables for row click trigger', () => { + const context = ({ + embeddable: {}, + data: rowClickData as any, + } as unknown) as RowClickContext; + const res = getEventScopeValues(context); + + expect(res).toEqual({ + rowIndex: 1, + values: ['IT', '2.25', 3, 0, 2], + keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], + columnNames: [ + 'Top values of DestCountry', + 'Top values of FlightTimeHour', + 'Count of records', + 'Average of DistanceMiles', + 'Unique count of OriginAirportID', + ], + }); + }); +}); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts new file mode 100644 index 00000000000000..8eb798eea74cbe --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/event_variables.ts @@ -0,0 +1,298 @@ +/* + * 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 { monaco } from '@kbn/monaco'; +import { + isRangeSelectTriggerContext, + isValueClickTriggerContext, + isRowClickTriggerContext, + isContextMenuTriggerContext, + RangeSelectContext, + SELECT_RANGE_TRIGGER, + ValueClickContext, + VALUE_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/embeddable/public'; +import type { + ActionContext, + ActionFactoryContext, + EmbeddableWithQueryInput, +} from '../url_drilldown'; +import { + RowClickContext, + ROW_CLICK_TRIGGER, +} from '../../../../../../../src/plugins/ui_actions/public'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; +import { deleteUndefinedKeys, toPrimitiveOrUndefined, Primitive } from './util'; + +/** + * URL drilldown event scope, available as `{{event.*}}` Handlebars variables. + */ +export type UrlDrilldownEventScope = + | ValueClickTriggerEventScope + | RangeSelectTriggerEventScope + | RowClickTriggerEventScope + | ContextMenuTriggerEventScope; + +export type EventScopeInput = ActionContext; + +export interface ValueClickTriggerEventScope { + key?: string; + value: Primitive; + negate: boolean; + points: Array<{ key?: string; value: Primitive }>; +} + +export interface RangeSelectTriggerEventScope { + key: string; + from?: string | number; + to?: string | number; +} + +export interface RowClickTriggerEventScope { + rowIndex: number; + values: Primitive[]; + keys: string[]; + columnNames: string[]; +} + +export type ContextMenuTriggerEventScope = object; + +const getEventScopeFromRangeSelectTriggerContext = ( + eventScopeInput: RangeSelectContext +): RangeSelectTriggerEventScope => { + const { table, column: columnIndex, range } = eventScopeInput.data; + const column = table.columns[columnIndex]; + return deleteUndefinedKeys({ + key: toPrimitiveOrUndefined(column?.meta.field) as string, + from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, + to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, + }); +}; + +const getEventScopeFromValueClickTriggerContext = ( + eventScopeInput: ValueClickContext +): ValueClickTriggerEventScope => { + const negate = eventScopeInput.data.negate ?? false; + const points = eventScopeInput.data.data.map(({ table, value, column: columnIndex }) => { + const column = table.columns[columnIndex]; + return { + value: toPrimitiveOrUndefined(value) as Primitive, + key: column?.meta?.field, + }; + }); + + return deleteUndefinedKeys({ + key: points[0]?.key, + value: points[0]?.value, + negate, + points, + }); +}; + +const getEventScopeFromRowClickTriggerContext = ( + ctx: RowClickContext +): RowClickTriggerEventScope => { + const { data } = ctx; + const embeddable = ctx.embeddable as EmbeddableWithQueryInput; + + const { rowIndex } = data; + const columns = data.columns || data.table.columns.map(({ id }) => id); + const values: Primitive[] = []; + const keys: string[] = []; + const columnNames: string[] = []; + const row = data.table.rows[rowIndex]; + + for (const columnId of columns) { + const column = data.table.columns.find(({ id }) => id === columnId); + if (!column) { + // This should never happe, but in case it does we log data necessary for debugging. + // eslint-disable-next-line no-console + console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null); + throw new Error('Could not find a datatable column.'); + } + values.push(row[columnId]); + keys.push(column.meta.field || ''); + columnNames.push(column.name || column.meta.field || ''); + } + + const scope: RowClickTriggerEventScope = { + rowIndex, + values, + keys, + columnNames, + }; + + return scope; +}; + +export const getEventScopeValues = (eventScopeInput: EventScopeInput): UrlDrilldownEventScope => { + if (isRangeSelectTriggerContext(eventScopeInput)) { + return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); + } else if (isValueClickTriggerContext(eventScopeInput)) { + return getEventScopeFromValueClickTriggerContext(eventScopeInput); + } else if (isRowClickTriggerContext(eventScopeInput)) { + return getEventScopeFromRowClickTriggerContext(eventScopeInput); + } else if (isContextMenuTriggerContext(eventScopeInput)) { + return {}; + } else { + throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); + } +}; + +const kind = monaco.languages.CompletionItemKind.Event; +const sortPrefix = '1.'; + +const valueClickVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.value', + sortText: sortPrefix + 'event.value', + title: i18n.translate('xpack.urlDrilldown.click.event.value.title', { + defaultMessage: 'Click value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.value.documentation', { + defaultMessage: 'Value behind clicked data point.', + }), + kind, + }, + { + label: 'event.key', + sortText: sortPrefix + 'event.key', + title: i18n.translate('xpack.urlDrilldown.click.event.key.title', { + defaultMessage: 'Name of clicked field.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.key.documentation', { + defaultMessage: 'Field name behind clicked data point.', + }), + kind, + }, + { + label: 'event.negate', + sortText: sortPrefix + 'event.negate', + title: i18n.translate('xpack.urlDrilldown.click.event.negate.title', { + defaultMessage: 'Whether the filter is negated.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.negate.documentation', { + defaultMessage: 'Boolean, indicating whether clicked data point resulted in negative filter.', + }), + kind, + }, + { + label: 'event.points', + sortText: sortPrefix + 'event.points', + title: i18n.translate('xpack.urlDrilldown.click.event.points.title', { + defaultMessage: 'List of all clicked points.', + }), + documentation: i18n.translate('xpack.urlDrilldown.click.event.points.documentation', { + defaultMessage: + 'Some visualizations have clickable points that emit more than one data point. Use list of data points in case a single value is insufficient.', + }), + kind, + }, +]; + +const rowClickVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.values', + sortText: sortPrefix + 'event.values', + title: i18n.translate('xpack.urlDrilldown.row.event.values.title', { + defaultMessage: 'List of row cell values.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.values.documentation', { + defaultMessage: 'An array of all cell values for the raw on which the action will execute.', + }), + kind, + }, + { + label: 'event.keys', + sortText: sortPrefix + 'event.keys', + title: i18n.translate('xpack.urlDrilldown.row.event.keys.title', { + defaultMessage: 'List of row cell fields.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.keys.documentation', { + defaultMessage: 'An array of field names for each column.', + }), + kind, + }, + { + label: 'event.columnNames', + sortText: sortPrefix + 'event.columnNames', + title: i18n.translate('xpack.urlDrilldown.row.event.columnNames.title', { + defaultMessage: 'List of table column names.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.columnNames.documentation', { + defaultMessage: 'An array of column names.', + }), + kind, + }, + { + label: 'event.rowIndex', + sortText: sortPrefix + 'event.rowIndex', + title: i18n.translate('xpack.urlDrilldown.row.event.rowIndex.title', { + defaultMessage: 'Clicked row index.', + }), + documentation: i18n.translate('xpack.urlDrilldown.row.event.rowIndex.documentation', { + defaultMessage: 'Number, representing the row that was clicked, starting from 0.', + }), + kind, + }, +]; + +const selectRangeVariables: readonly UrlTemplateEditorVariable[] = [ + { + label: 'event.key', + sortText: sortPrefix + 'event.key', + title: i18n.translate('xpack.urlDrilldown.range.event.key.title', { + defaultMessage: 'Name of aggregation field.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.key.documentation', { + defaultMessage: 'Aggregation field behind the selected range, if available.', + }), + kind, + }, + { + label: 'event.from', + sortText: sortPrefix + 'event.from', + title: i18n.translate('xpack.urlDrilldown.range.event.from.title', { + defaultMessage: 'Range start value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.from.documentation', { + defaultMessage: + '`from` value of the selected range. Depending on your data, could be either a date or number.', + }), + kind, + }, + { + label: 'event.to', + sortText: sortPrefix + 'event.to', + title: i18n.translate('xpack.urlDrilldown.range.event.to.title', { + defaultMessage: 'Range end value.', + }), + documentation: i18n.translate('xpack.urlDrilldown.range.event.to.documentation', { + defaultMessage: + '`to` value of the selected range. Depending on your data, could be either a date or number.', + }), + kind, + }, +]; + +export const getEventVariableList = ( + context: ActionFactoryContext +): UrlTemplateEditorVariable[] => { + const [trigger] = context.triggers; + + switch (trigger) { + case VALUE_CLICK_TRIGGER: + return [...valueClickVariables]; + case ROW_CLICK_TRIGGER: + return [...rowClickVariables]; + case SELECT_RANGE_TRIGGER: + return [...selectRangeVariables]; + } + + return []; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts new file mode 100644 index 00000000000000..7338e6b471211a --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/global_variables.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { monaco } from '@kbn/monaco'; +import { txtValue } from './i18n'; +import { UrlDrilldownGlobalScope } from '../../../../../ui_actions_enhanced/public'; +import type { UrlTemplateEditorVariable } from '../../../../../../../src/plugins/kibana_react/public'; + +const kind = monaco.languages.CompletionItemKind.Constant; +const sortPrefix = '3.'; + +export const getGlobalVariableList = ( + values: UrlDrilldownGlobalScope +): UrlTemplateEditorVariable[] => { + const globalVariables: UrlTemplateEditorVariable[] = [ + { + label: 'kibanaUrl', + sortText: sortPrefix + 'kibanaUrl', + title: i18n.translate('xpack.urlDrilldown.global.kibanaUrl.documentation.title', { + defaultMessage: 'Link to Kibana homepage.', + }), + documentation: + i18n.translate('xpack.urlDrilldown.global.kibanaUrl.documentation', { + defaultMessage: + 'Kibana base URL. Useful for creating URL drilldowns that navigate within Kibana.', + }) + + '\n\n' + + txtValue(values.kibanaUrl), + kind, + }, + ]; + + return globalVariables; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts new file mode 100644 index 00000000000000..b7b7cab535702a --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/i18n.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtValue = (value: string) => + i18n.translate('xpack.urlDrilldown.valuePreview', { + defaultMessage: 'Value: {value}', + values: { + value, + }, + }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts new file mode 100644 index 00000000000000..ef9045b9ba1083 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/variables/util.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type Primitive = string | number | boolean | null; + +export const toPrimitiveOrUndefined = (v: unknown): Primitive | undefined => { + if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string' || v === null) + return v; + if (typeof v === 'object' && v instanceof Date) return v.toISOString(); + if (typeof v === 'undefined') return undefined; + return String(v); +}; + +export const deleteUndefinedKeys = >(obj: T): T => { + Object.keys(obj).forEach((key) => { + if (obj[key] === undefined) { + delete obj[key]; + } + }); + return obj; +}; diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts index 4b8e26c4a866b6..b733691c639b6f 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/plugin.ts @@ -47,6 +47,7 @@ export class UrlDrilldownPlugin startServices().core.docLinks.links.dashboard.urlDrilldownTemplateSyntax, getVariablesHelpDocsLink: () => startServices().core.docLinks.links.dashboard.urlDrilldownVariables, + uiSettings: core.uiSettings, }) ); diff --git a/x-pack/plugins/drilldowns/url_drilldown/tsconfig.json b/x-pack/plugins/drilldowns/url_drilldown/tsconfig.json new file mode 100644 index 00000000000000..50fe41c49b0c88 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["public/**/*"], + "references": [ + { "path": "../../../../src/core/tsconfig.json" }, + { "path": "../../ui_actions_enhanced/tsconfig.json" }, + { "path": "../../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../../src/plugins/expressions/tsconfig.json" }, + { "path": "../../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../../src/plugins/ui_actions/tsconfig.json" } + ] +} diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts index 508879c3596e54..4df51af8b16b02 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.test.ts @@ -15,7 +15,7 @@ afterEach(() => { }); describe('createMigration()', () => { - const { log } = migrationMocks.createContext(); + const migrationContext = migrationMocks.createContext(); const inputType = { type: 'known-type-1', attributesToEncrypt: new Set(['firstAttr']) }; const migrationType = { type: 'known-type-1', @@ -88,7 +88,7 @@ describe('createMigration()', () => { namespace: 'namespace', attributes, }, - { log } + migrationContext ); expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith( @@ -97,7 +97,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: 'namespace', }, - attributes + attributes, + { convertToMultiNamespaceType: false } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -112,7 +113,7 @@ describe('createMigration()', () => { }); describe('migration of a single legacy type', () => { - it('uses the input type as the mirgation type when omitted', async () => { + it('uses the input type as the migration type when omitted', async () => { const serviceWithLegacyType = encryptedSavedObjectsServiceMock.create(); const instantiateServiceWithLegacyType = jest.fn(() => serviceWithLegacyType); @@ -142,7 +143,7 @@ describe('createMigration()', () => { namespace: 'namespace', attributes, }, - { log } + migrationContext ); expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith( @@ -151,7 +152,8 @@ describe('createMigration()', () => { type: 'known-type-1', namespace: 'namespace', }, - attributes + attributes, + { convertToMultiNamespaceType: false } ); expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( @@ -163,6 +165,81 @@ describe('createMigration()', () => { attributes ); }); + + describe('uses the object `namespaces` field to populate the descriptor when the migration context indicates this type is being converted', () => { + const doTest = ({ + objectNamespace, + decryptDescriptorNamespace, + }: { + objectNamespace: string | undefined; + decryptDescriptorNamespace: string | undefined; + }) => { + const instantiateServiceWithLegacyType = jest.fn(() => + encryptedSavedObjectsServiceMock.create() + ); + + const migrationCreator = getCreateMigration( + encryptionSavedObjectService, + instantiateServiceWithLegacyType + ); + const noopMigration = migrationCreator( + function (doc): doc is SavedObjectUnsanitizedDoc { + return true; + }, + (doc) => doc + ); + + const attributes = { + firstAttr: 'first_attr', + }; + + encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes); + encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes); + + noopMigration( + { + id: '123', + type: 'known-type-1', + namespaces: objectNamespace ? [objectNamespace] : [], + attributes, + }, + migrationMocks.createContext({ + migrationVersion: '8.0.0', + convertToMultiNamespaceTypeVersion: '8.0.0', + }) + ); + + expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith( + { + id: '123', + type: 'known-type-1', + namespace: decryptDescriptorNamespace, + }, + attributes, + { convertToMultiNamespaceType: true } + ); + + expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith( + { + id: '123', + type: 'known-type-1', + }, + attributes + ); + }; + + it('when namespaces is an empty array', () => { + doTest({ objectNamespace: undefined, decryptDescriptorNamespace: undefined }); + }); + + it('when the first namespace element is "default"', () => { + doTest({ objectNamespace: 'default', decryptDescriptorNamespace: undefined }); + }); + + it('when the first namespace element is another string', () => { + doTest({ objectNamespace: 'foo', decryptDescriptorNamespace: 'foo' }); + }); + }); }); describe('migration across two legacy types', () => { @@ -216,7 +293,7 @@ describe('createMigration()', () => { firstAttr: '#####', }, }, - { log } + migrationContext ) ).toMatchObject({ id: '123', @@ -257,7 +334,7 @@ describe('createMigration()', () => { nonEncryptedAttr: 'non encrypted', }, }, - { log } + migrationContext ) ).toMatchObject({ id: '123', @@ -278,7 +355,8 @@ describe('createMigration()', () => { { firstAttr: '#####', nonEncryptedAttr: 'non encrypted', - } + }, + { convertToMultiNamespaceType: false } ); expect(serviceWithMigrationLegacyType.encryptAttributesSync).toHaveBeenCalledWith( diff --git a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts index eb262997a8e451..cf5357c40fa20a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/create_migration.ts @@ -11,6 +11,7 @@ import { SavedObjectMigrationContext, } from 'src/core/server'; import { EncryptedSavedObjectTypeRegistration, EncryptedSavedObjectsService } from './crypto'; +import { normalizeNamespace } from './saved_objects'; type SavedObjectOptionalMigrationFn = ( doc: SavedObjectUnsanitizedDoc | SavedObjectUnsanitizedDoc, @@ -63,11 +64,19 @@ export const getCreateMigration = ( return encryptedDoc; } - const descriptor = { - id: encryptedDoc.id!, - type: encryptedDoc.type, - namespace: encryptedDoc.namespace, - }; + // If an object has been converted right before this migration function is called, it will no longer have a `namespace` field, but it + // will have a `namespaces` field; in that case, the first/only element in that array should be used as the namespace in the descriptor + // during decryption. + const convertToMultiNamespaceType = + context.convertToMultiNamespaceTypeVersion === context.migrationVersion; + const decryptDescriptorNamespace = convertToMultiNamespaceType + ? normalizeNamespace(encryptedDoc.namespaces?.[0]) // `namespaces` contains string values, but we need to normalize this to the namespace ID representation + : encryptedDoc.namespace; + + const { id, type } = encryptedDoc; + // These descriptors might have a `namespace` that is undefined. That is expected for multi-namespace and namespace-agnostic types. + const decryptDescriptor = { id, type, namespace: decryptDescriptorNamespace }; + const encryptDescriptor = { id, type, namespace: encryptedDoc.namespace }; // decrypt the attributes using the input type definition // then migrate the document @@ -75,12 +84,14 @@ export const getCreateMigration = ( return mapAttributes( migration( mapAttributes(encryptedDoc, (inputAttributes) => - inputService.decryptAttributesSync(descriptor, inputAttributes) + inputService.decryptAttributesSync(decryptDescriptor, inputAttributes, { + convertToMultiNamespaceType, + }) ), context ), (migratedAttributes) => - migratedService.encryptAttributesSync(descriptor, migratedAttributes) + migratedService.encryptAttributesSync(encryptDescriptor, migratedAttributes) ); }; }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index f70810943d179f..7bc08d0e7b30fa 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -819,6 +819,55 @@ describe('#decryptAttributes', () => { ); }); + it('retries decryption without namespace if incorrect namespace is provided and convertToMultiNamespaceType option is enabled', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrThree']), + }); + + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, // namespace was not included in descriptor during encryption + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + await expect( + service.decryptAttributes( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).resolves.toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (success) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('decrypts even if no attributes are included into AAD', async () => { const attributes = { attrOne: 'one', attrThree: 'three' }; service.registerType({ @@ -1017,6 +1066,47 @@ describe('#decryptAttributes', () => { ); }); + it('fails if retry decryption without namespace is not correct', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' }, + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + await expect(() => + service.decryptAttributes( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).rejects.toThrowError(EncryptionError); + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decrypt).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (fail) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('fails to decrypt if encrypted attribute is defined, but not a string', async () => { const mockUser = mockAuthenticatedUser(); await expect( @@ -1707,6 +1797,55 @@ describe('#decryptAttributesSync', () => { }); }); + it('retries decryption without namespace if incorrect namespace is provided and convertToMultiNamespaceType option is enabled', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrThree']), + }); + + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, // namespace was not included in descriptor during encryption + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + expect( + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (success) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('decrypts even if no attributes are included into AAD', () => { const attributes = { attrOne: 'one', attrThree: 'three' }; service.registerType({ @@ -1852,6 +1991,47 @@ describe('#decryptAttributesSync', () => { ).toThrowError(EncryptionError); }); + it('fails if retry decryption without namespace is not correct', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'some-other-ns' }, + attributes + ); + expect(encryptedAttributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: expect.not.stringMatching(/^three$/), + }); + + const mockUser = mockAuthenticatedUser(); + expect(() => + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + encryptedAttributes, + { user: mockUser, convertToMultiNamespaceType: true } + ) + ).toThrowError(EncryptionError); + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 1, // first attempted to decrypt with the namespace in the descriptor (fail) + expect.anything(), + `["object-ns","known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + expect(mockNodeCrypto.decryptSync).toHaveBeenNthCalledWith( + 2, // then attempted to decrypt without the namespace in the descriptor (fail) + expect.anything(), + `["known-type-1","object-id",{"attrOne":"one","attrTwo":"two"}]` + ); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); + }); + it('fails to decrypt if encrypted attribute is defined, but not a string', () => { expect(() => service.decryptAttributesSync( diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 23aef07ff8781f..17757c9d8b2ba3 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -61,6 +61,14 @@ interface DecryptParameters extends CommonParameters { * Indicates whether decryption should only be performed using secondary decryption-only keys. */ omitPrimaryEncryptionKey?: boolean; + /** + * Indicates whether the object to be decrypted is being converted from a single-namespace type to a multi-namespace type. In this case, + * we may need to attempt decryption twice: once with a namespace in the descriptor (for use during index migration), and again without a + * namespace in the descriptor (for use during object migration). In other words, if the object is being decrypted during index migration, + * the object was previously encrypted with its namespace in the descriptor portion of the AAD; on the other hand, if the object is being + * decrypted during object migration, the object was never encrypted with its namespace in the descriptor portion of the AAD. + */ + convertToMultiNamespaceType?: boolean; } interface EncryptedSavedObjectsServiceOptions { @@ -366,14 +374,17 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { - const [attributeValue, encryptionAAD] = iteratorResult.value; + const [attributeValue, encryptionAADs] = iteratorResult.value; // We check this inside of the iterator to throw only if we do need to decrypt anything. let decryptionError = decrypters.length === 0 ? new Error('Decryption is disabled because of missing decryption keys.') : undefined; - for (const decrypter of decrypters) { + const decryptersPerAAD = decrypters.flatMap((decr) => + encryptionAADs.map((aad) => [decr, aad] as [Crypto, string]) + ); + for (const [decrypter, encryptionAAD] of decryptersPerAAD) { try { iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); decryptionError = undefined; @@ -414,14 +425,17 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { - const [attributeValue, encryptionAAD] = iteratorResult.value; + const [attributeValue, encryptionAADs] = iteratorResult.value; // We check this inside of the iterator to throw only if we do need to decrypt anything. let decryptionError = decrypters.length === 0 ? new Error('Decryption is disabled because of missing decryption keys.') : undefined; - for (const decrypter of decrypters) { + const decryptersPerAAD = decrypters.flatMap((decr) => + encryptionAADs.map((aad) => [decr, aad] as [Crypto, string]) + ); + for (const [decrypter, encryptionAAD] of decryptersPerAAD) { try { iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); decryptionError = undefined; @@ -445,13 +459,13 @@ export class EncryptedSavedObjectsService { private *attributesToDecryptIterator>( descriptor: SavedObjectDescriptor, attributes: T, - params?: CommonParameters - ): Iterator<[string, string], T, EncryptOutput> { + params?: DecryptParameters + ): Iterator<[string, string[]], T, EncryptOutput> { const typeDefinition = this.typeDefinitions.get(descriptor.type); if (typeDefinition === undefined) { return attributes; } - let encryptionAAD: string | undefined; + const encryptionAADs: string[] = []; const decryptedAttributes: Record = {}; for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; @@ -467,11 +481,16 @@ export class EncryptedSavedObjectsService { )}` ); } - if (!encryptionAAD) { - encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); + if (!encryptionAADs.length) { + encryptionAADs.push(this.getAAD(typeDefinition, descriptor, attributes)); + if (params?.convertToMultiNamespaceType && descriptor.namespace) { + // This is happening during a migration; create an alternate AAD for decrypting the object attributes by stripping out the namespace from the descriptor. + const { namespace, ...alternateDescriptor } = descriptor; + encryptionAADs.push(this.getAAD(typeDefinition, alternateDescriptor, attributes)); + } } try { - decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; + decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAADs])!; } catch (err) { this.options.logger.error( `Failed to decrypt "${attributeName}" attribute: ${err.message || err}` diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts index 627e15e591a81a..0f737995e8d2af 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts @@ -24,5 +24,5 @@ export const getDescriptorNamespace = ( * Ensure that a namespace is always in its namespace ID representation. * This allows `'default'` to be used interchangeably with `undefined`. */ -const normalizeNamespace = (namespace?: string) => +export const normalizeNamespace = (namespace?: string) => namespace === undefined ? namespace : SavedObjectsUtils.namespaceStringToId(namespace); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index cac7b9ba9d5cc4..9e7c1f65922907 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -17,7 +17,9 @@ import { import { SecurityPluginSetup } from '../../../security/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; -import { getDescriptorNamespace } from './get_descriptor_namespace'; +import { getDescriptorNamespace, normalizeNamespace } from './get_descriptor_namespace'; + +export { normalizeNamespace }; interface SetupSavedObjectsParams { service: PublicMethodsOf; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx index 2eac65fc210917..593f70cda404c1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx @@ -5,18 +5,18 @@ * 2.0. */ -import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import { mountWithIntl } from '../../../../../__mocks__'; import '../../../../__mocks__/engine_logic.mock'; import React from 'react'; import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; +import { runActionColumnTests } from './shared_columns_tests'; + import { AnalyticsTable } from './'; describe('AnalyticsTable', () => { - const { navigateToUrl } = mockKibanaValues; - const items = [ { key: 'some search', @@ -69,18 +69,9 @@ describe('AnalyticsTable', () => { expect(tableContent).toContain('0'); }); - it('renders an action column', () => { + describe('renders an action column', () => { const wrapper = mountWithIntl(); - const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); - const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); - - viewQuery.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith( - '/engines/some-engine/analytics/query_detail/some%20search' - ); - - editQuery.simulate('click'); - // TODO + runActionColumnTests(wrapper); }); it('renders an empty prompt if no items are passed', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx index a5a582d3747bcc..f90d86908d470e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx @@ -5,18 +5,18 @@ * 2.0. */ -import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import { mountWithIntl } from '../../../../../__mocks__'; import '../../../../__mocks__/engine_logic.mock'; import React from 'react'; import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; +import { runActionColumnTests } from './shared_columns_tests'; + import { RecentQueriesTable } from './'; describe('RecentQueriesTable', () => { - const { navigateToUrl } = mockKibanaValues; - const items = [ { query_string: 'some search', @@ -63,18 +63,9 @@ describe('RecentQueriesTable', () => { expect(tableContent).toContain('3'); }); - it('renders an action column', () => { + describe('renders an action column', () => { const wrapper = mountWithIntl(); - const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); - const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); - - viewQuery.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith( - '/engines/some-engine/analytics/query_detail/some%20search' - ); - - editQuery.simulate('click'); - // TODO + runActionColumnTests(wrapper); }); it('renders an empty prompt if no items are passed', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx index 9d8365a2f7af10..6c3d2539035aee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx @@ -9,10 +9,12 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { flashAPIErrors } from '../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../shared/http'; import { KibanaLogic } from '../../../../../shared/kibana'; import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; -import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../../routes'; -import { generateEnginePath } from '../../../engine'; +import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH, ENGINE_CURATION_PATH } from '../../../../routes'; +import { generateEnginePath, EngineLogic } from '../../../engine'; import { Query, RecentQuery } from '../../types'; import { InlineTagsList } from './inline_tags_list'; @@ -63,7 +65,7 @@ export const ACTIONS_COLUMN = { onClick: (item: Query | RecentQuery) => { const { navigateToUrl } = KibanaLogic.values; - const query = (item as Query).key || (item as RecentQuery).query_string; + const query = (item as Query).key || (item as RecentQuery).query_string || '""'; navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })); }, 'data-test-subj': 'AnalyticsTableViewQueryButton', @@ -74,12 +76,25 @@ export const ACTIONS_COLUMN = { }), description: i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.analytics.table.editTooltip', - { defaultMessage: 'Edit query analytics' } + { defaultMessage: 'Edit query' } ), type: 'icon', icon: 'pencil', - onClick: () => { - // TODO: CurationsLogic + onClick: async (item: Query | RecentQuery) => { + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + const { engineName } = EngineLogic.values; + + try { + const query = (item as Query).key || (item as RecentQuery).query_string || '""'; + const response = await http.get( + `/api/app_search/engines/${engineName}/curations/find_or_create`, + { query: { query } } + ); + navigateToUrl(generateEnginePath(ENGINE_CURATION_PATH, { curationId: response.id })); + } catch (e) { + flashAPIErrors(e); + } }, 'data-test-subj': 'AnalyticsTableEditQueryButton', }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.tsx new file mode 100644 index 00000000000000..cb78a6585e43c8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns_tests.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 { + mockHttpValues, + mockKibanaValues, + mockFlashMessageHelpers, +} from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import { ReactWrapper } from 'enzyme'; + +import { nextTick } from '@kbn/test/jest'; + +export const runActionColumnTests = (wrapper: ReactWrapper) => { + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('view action', () => { + it('navigates to the query detail view', () => { + wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first().simulate('click'); + + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some%20search' + ); + }); + + it('falls back to "" for the empty query', () => { + wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').last().simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/%22%22' + ); + }); + }); + + describe('edit action', () => { + it('calls the find_or_create curation API, then navigates the user to the curation', async () => { + http.get.mockReturnValue(Promise.resolve({ id: 'cur-123456789' })); + wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first().simulate('click'); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/some-engine/curations/find_or_create', + { + query: { query: 'some search' }, + } + ); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-123456789'); + }); + + it('falls back to "" for the empty query', async () => { + http.get.mockReturnValue(Promise.resolve({ id: 'cur-987654321' })); + wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').last().simulate('click'); + await nextTick(); + + expect(http.get).toHaveBeenCalledWith( + '/api/app_search/engines/some-engine/curations/find_or_create', + { + query: { query: '""' }, + } + ); + expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/cur-987654321'); + }); + + it('handles API errors', async () => { + http.get.mockReturnValue(Promise.reject()); + wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first().simulate('click'); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalled(); + }); + }); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx new file mode 100644 index 00000000000000..047d00ad98a0d5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { shallow } from 'enzyme'; + +import { CurationsRouter } from './'; + +describe('CurationsRouter', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(5); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.tsx new file mode 100644 index 00000000000000..a7f99044cc1c37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/curations_router.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 React from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { APP_SEARCH_PLUGIN } from '../../../../../common/constants'; +import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; +import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; +import { NotFound } from '../../../shared/not_found'; +import { + ENGINE_CURATIONS_PATH, + ENGINE_CURATIONS_NEW_PATH, + ENGINE_CURATION_PATH, + ENGINE_CURATION_ADD_RESULT_PATH, +} from '../../routes'; + +import { CURATIONS_TITLE } from './constants'; + +interface Props { + engineBreadcrumb: BreadcrumbTrail; +} +export const CurationsRouter: React.FC = ({ engineBreadcrumb }) => { + const CURATIONS_BREADCRUMB = [...engineBreadcrumb, CURATIONS_TITLE]; + + return ( + + + + TODO: Curations overview + + + + TODO: Curation creation view + + + + TODO: Curation view (+ show a NotFound view if ID is invalid) + + + + TODO: Curation Add Result view + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/index.ts index f1eb95a0c878cb..075bc1368b3003 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/index.ts @@ -6,3 +6,4 @@ */ export { CURATIONS_TITLE } from './constants'; +export { CurationsRouter } from './curations_router'; 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 e05fc10053ff1c..9bc838c01f636e 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 @@ -20,7 +20,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -62,97 +61,94 @@ export const CustomizationModal: React.FC = ({ ); return ( - - - - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.title', + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.title', + { + defaultMessage: 'Customize document search', + } + )} + + + + + - - - - - - - - - - - - - - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.cancel', + fullWidth + helpText={i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.filterFields', { - defaultMessage: 'Cancel', + defaultMessage: + 'Faceted values rendered as filters and available as query refinement', } )} - - { - onSave({ - filterFields: selectedFilterFields.map(comboBoxOptionToFieldName), - sortFields: selectedSortFields.map(comboBoxOptionToFieldName), - }); - }} > - {i18n.translate( - 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.save', + + + - - - + > + + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.cancel', + { + defaultMessage: 'Cancel', + } + )} + + { + onSave({ + filterFields: selectedFilterFields.map(comboBoxOptionToFieldName), + sortFields: selectedSortFields.map(comboBoxOptionToFieldName), + }); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.documents.search.customizationModal.save', + { + defaultMessage: 'Save', + } + )} + + + ); }; 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 447e4d678bcdb6..a4ce724fdb0974 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 @@ -220,8 +220,8 @@ export const EngineNav: React.FC = () => { )} {canManageEngineCurations && ( {CURATIONS_TITLE} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx index 3740882dee3db2..e6b829a43dcc1c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.test.tsx @@ -17,6 +17,7 @@ import { shallow } from 'enzyme'; import { Loading } from '../../../shared/loading'; import { AnalyticsRouter } from '../analytics'; +import { CurationsRouter } from '../curations'; import { EngineOverview } from '../engine_overview'; import { RelevanceTuning } from '../relevance_tuning'; @@ -97,7 +98,14 @@ describe('EngineRouter', () => { expect(wrapper.find(AnalyticsRouter)).toHaveLength(1); }); - it('renders an relevance tuning view', () => { + it('renders a curations view', () => { + setMockValues({ ...values, myRole: { canManageEngineCurations: true } }); + const wrapper = shallow(); + + expect(wrapper.find(CurationsRouter)).toHaveLength(1); + }); + + it('renders a relevance tuning view', () => { setMockValues({ ...values, myRole: { canManageEngineRelevanceTuning: true } }); const wrapper = shallow(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx index 2f1c3bc57d331e..305bdf74ae501b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_router.tsx @@ -28,12 +28,13 @@ import { // META_ENGINE_SOURCE_ENGINES_PATH, ENGINE_RELEVANCE_TUNING_PATH, // ENGINE_SYNONYMS_PATH, - // ENGINE_CURATIONS_PATH, + ENGINE_CURATIONS_PATH, // ENGINE_RESULT_SETTINGS_PATH, // ENGINE_SEARCH_UI_PATH, // ENGINE_API_LOGS_PATH, } from '../../routes'; import { AnalyticsRouter } from '../analytics'; +import { CurationsRouter } from '../curations'; import { DocumentDetail, Documents } from '../documents'; import { OVERVIEW_TITLE } from '../engine_overview'; import { EngineOverview } from '../engine_overview'; @@ -46,13 +47,13 @@ export const EngineRouter: React.FC = () => { const { myRole: { canViewEngineAnalytics, - canManageEngineRelevanceTuning, // canViewEngineDocuments, // canViewEngineSchema, // canViewEngineCrawler, // canViewMetaEngineSourceEngines, + canManageEngineRelevanceTuning, // canManageEngineSynonyms, - // canManageEngineCurations, + canManageEngineCurations, // canManageEngineResultSettings, // canManageEngineSearchUi, // canViewEngineApiLogs, @@ -97,6 +98,11 @@ export const EngineRouter: React.FC = () => { + {canManageEngineCurations && ( + + + + )} {canManageEngineRelevanceTuning && ( diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx index ca1fa9a8d0737b..ba79d62cfe615a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/settings/log_retention/log_retention_confirmation_modal.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useActions, useValues } from 'kea'; -import { EuiTextColor, EuiOverlayMask } from '@elastic/eui'; +import { EuiTextColor } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { LogRetentionLogic, LogRetentionOptions } from '../../log_retention'; @@ -40,7 +40,7 @@ export const LogRetentionConfirmationModal: React.FC = () => { } return ( - + <> {openedModal === LogRetentionOptions.Analytics && ( { onSave={() => saveLogRetention(LogRetentionOptions.API, false)} /> )} - + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index dee8858fada8b4..6fe9be083405e0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -44,9 +44,12 @@ export const META_ENGINE_SOURCE_ENGINES_PATH = `${ENGINE_PATH}/engines`; export const ENGINE_RELEVANCE_TUNING_PATH = `${ENGINE_PATH}/relevance_tuning`; export const ENGINE_SYNONYMS_PATH = `${ENGINE_PATH}/synonyms`; -export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; -// TODO: Curations sub-pages export const ENGINE_RESULT_SETTINGS_PATH = `${ENGINE_PATH}/result-settings`; +export const ENGINE_CURATIONS_PATH = `${ENGINE_PATH}/curations`; +export const ENGINE_CURATIONS_NEW_PATH = `${ENGINE_CURATIONS_PATH}/new`; +export const ENGINE_CURATION_PATH = `${ENGINE_CURATIONS_PATH}/:curationId`; +export const ENGINE_CURATION_ADD_RESULT_PATH = `${ENGINE_CURATIONS_PATH}/:curationId/add_result`; + export const ENGINE_SEARCH_UI_PATH = `${ENGINE_PATH}/reference_application/new`; export const ENGINE_API_LOGS_PATH = `${ENGINE_PATH}/api-logs`; 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 bbde6c5d3b55de..bd9b6b51a43b1b 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 @@ -20,7 +20,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSelect, EuiSpacer, } from '@elastic/eui'; @@ -79,71 +78,69 @@ export const SchemaAddFieldModal: React.FC = ({ ); return ( - +
- - - {FIELD_NAME_MODAL_TITLE} - - -

{FIELD_NAME_MODAL_DESCRIPTION}

- - - - - + {FIELD_NAME_MODAL_TITLE} + + +

{FIELD_NAME_MODAL_DESCRIPTION}

+ + + + + + - - - - - - updateNewFieldType(e.target.value)} - data-test-subj="SchemaSelect" - /> - - - - -
- - {FIELD_NAME_MODAL_CANCEL} - - {FIELD_NAME_MODAL_ADD_FIELD} - - -
+ autoFocus + isLoading={loading} + data-test-subj="SchemaAddFieldNameField" + /> + + + + + updateNewFieldType(e.target.value)} + data-test-subj="SchemaSelect" + /> + + + + + + + {FIELD_NAME_MODAL_CANCEL} + + {FIELD_NAME_MODAL_ADD_FIELD} + +
-
+ ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/sources_full_bleed/box.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/sources_full_bleed/box.svg new file mode 100644 index 00000000000000..827f8cf0a55ec8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/sources_full_bleed/box.svg @@ -0,0 +1 @@ + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/sources_full_bleed/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/sources_full_bleed/index.ts index 1c66b7cf1758c6..d9a0975abef7c8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/sources_full_bleed/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/sources_full_bleed/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import box from './box.svg'; import confluence from './confluence.svg'; import custom from './custom.svg'; import dropbox from './dropbox.svg'; @@ -21,6 +22,7 @@ import slack from './slack.svg'; import zendesk from './zendesk.svg'; export const imagesFull = { + box, confluence, confluenceCloud: confluence, confluenceServer: confluence, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 4fd4d480108def..cdfd07b07de912 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -679,6 +679,10 @@ export const DESCRIPTION_LABEL = i18n.translate( } ); +export const AND = i18n.translate('xpack.enterpriseSearch.workplaceSearch.and', { + defaultMessage: 'and', +}); + export const UPDATE_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.update.label', { defaultMessage: 'Update', }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts new file mode 100644 index 00000000000000..37228cf9e7025d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/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 { toSentenceSerial } from './to_sentence_serial'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/to_sentence_serial.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/to_sentence_serial.test.ts new file mode 100644 index 00000000000000..fb6501d3cb943a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/to_sentence_serial.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { toSentenceSerial } from './to_sentence_serial'; + +describe('toSentenceSerial', () => { + it('works correctly for 1 word', () => { + expect(toSentenceSerial(['One'])).toEqual('One'); + }); + + it('works correctly for 2 words', () => { + expect(toSentenceSerial(['One', 'Two'])).toEqual('One and Two'); + }); + + it('works correctly for 3+ words', () => { + expect(toSentenceSerial(['One', 'Two', 'Three'])).toEqual('One, Two, and Three'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/to_sentence_serial.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/to_sentence_serial.ts new file mode 100644 index 00000000000000..ad6383a76adc22 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/to_sentence_serial.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AND } from '../constants'; + +export const toSentenceSerial = (array: string[]) => + array.length === 1 + ? array[0] + : `${array.slice(0, array.length - 1).join(', ')}${ + array.length === 2 ? '' : ',' + } ${AND} ${array.slice(-1)}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss new file mode 100644 index 00000000000000..fbc10b5e8ed0f1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.scss @@ -0,0 +1,110 @@ +/* + * 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. + */ + +// -------------------------------------------------- +// View: Adding a Source flow +// -------------------------------------------------- + +.adding-a-source { + ul { + padding: 0; + } + + li + li { + margin-top: auto !important; + } + + &__acl-tooltip { + cursor: not-allowed; + + .euiSwitch__label { + cursor: not-allowed; + } + } + + &__icon { + width: 3.5em; + height: 3.5em; + } + + &__category { + text-transform: capitalize; + + &:after { + content: ', '; + } + + &:last-child:after { + content: ''; + } + } + + &__outer-box { + border: 1px solid #DBE2EB; + padding-right: 16px; + border-radius: 6px; + overflow: hidden; + background-color: #FFFFFF; + box-shadow: 0 2px 2px -1px rgba(152, 162, 179, .3), + 0 1px 5px -2px rgba(152, 162, 179, .3); + } + + &__intro-image { + background-color: #22272E; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + } + + &__intro-image img { + width: auto; + height: auto; + } + + &__intro-step { + width: 80px; + height: 100%; + display: flex; + background: transparent; + border-radius: 0; + border-right: 2px solid #DBE2EB; + padding: 0 18px 0 0; + text-align: center; + justify-content: center; + align-items: center; + } + + &__config-steps { + p { + margin: 0; + } + } + + &__button-row { + .euiFlexItem:first-child { + align-self: flex-end; + } + .euiFlexItem:last-child { + align-self: flex-start; + } + } + + &__connect-an-instance { + flex-basis: auto; + } + + &__features-list { + flex-basis: 100%; + } + + &__feature-image { + width: 2.5em; + margin: 0; + background: transparent; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index b00f9807f0acdf..64431a800487f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -28,6 +28,8 @@ import { ReAuthenticate } from './re_authenticate'; import { SaveConfig } from './save_config'; import { SaveCustom } from './save_custom'; +import './add_source.scss'; + export const AddSource: React.FC = (props) => { const { initializeAddSource, 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 f12c24feb8e1a9..bf472240d3c89d 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 @@ -52,7 +52,6 @@ export const AddSourceHeader: React.FC = ({ - ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx index 914eee74dfc4e5..917886d69bd19d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx @@ -45,6 +45,7 @@ export const ConfigurationIntro: React.FC = ({ }) => (
{header} + = ({ return (
{header} +
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx index b795b0af09944d..d6b427630e48e1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx @@ -12,9 +12,8 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiBadge, EuiCallOut, EuiSwitch } from '@elastic/eui'; +import { EuiSwitch } from '@elastic/eui'; -import { FeatureIds } from '../../../../types'; import { staticSourceData } from '../../source_data'; import { ConnectInstance } from './connect_instance'; @@ -46,7 +45,6 @@ describe('ConnectInstance', () => { const credentialsSourceData = staticSourceData[13]; const oauthSourceData = staticSourceData[0]; const subdomainSourceData = staticSourceData[16]; - const privateSourceData = staticSourceData[6]; const props = { ...credentialsSourceData, @@ -128,22 +126,6 @@ describe('ConnectInstance', () => { expect(setSourceSubdomainValue).toHaveBeenCalledWith(TEXT); }); - it('shows correct feature badges', () => { - setMockValues({ ...values, isOrganization: false }); - const wrapper = shallow(); - - expect(wrapper.find(EuiBadge)).toHaveLength(2); - }); - - it('shows no feature badges', () => { - setMockValues({ ...values, isOrganization: false }); - const features = { ...privateSourceData.features }; - features.platinumPrivateContext = [FeatureIds.SyncFrequency]; - const wrapper = shallow(); - - expect(wrapper.find(EuiBadge)).toHaveLength(0); - }); - it('calls handler on click', () => { const wrapper = shallow(); wrapper.find(EuiSwitch).simulate('change', { target: { checked: true } }); @@ -163,16 +145,23 @@ describe('ConnectInstance', () => { expect(mockReplace).toHaveBeenCalled(); }); - it('renders permissions link', () => { - const wrapper = shallow(); + it('renders doc-level permissions message when not available', () => { + const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="NeedsPermissionsMessage"]')).toHaveLength(1); + expect(wrapper.find('FormattedMessage')).toHaveLength(1); }); - it('shows permissions callout', () => { + it('renders callout when not synced', () => { setMockValues({ ...values, indexPermissionsValue: false }); - const wrapper = shallow(); + const wrapper = shallow(); + + expect(wrapper.find('EuiCallOut')).toHaveLength(1); + }); + + it('renders documentLevelPermissionsCallout', () => { + setMockValues({ ...values, hasPlatinumLicense: false }); + const wrapper = shallow(); - expect(wrapper.find(EuiCallOut)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="DocumentLevelPermissionsCallout"]')).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index 08b29075f3d0d1..2290a659127974 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -12,38 +12,39 @@ import { useActions, useValues } from 'kea'; import { EuiButton, EuiCallOut, + EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiFieldText, EuiFormRow, + EuiHorizontalRule, + EuiIcon, EuiLink, + EuiPanel, EuiSpacer, EuiSwitch, EuiText, EuiTitle, - EuiTextColor, - EuiBadge, - EuiBadgeGroup, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { LicensingLogic } from '../../../../../shared/licensing'; import { AppLogic } from '../../../../app_logic'; -import { DOCUMENT_PERMISSIONS_DOCS_URL } from '../../../../routes'; +import { DOCUMENT_PERMISSIONS_DOCS_URL, ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../../routes'; import { FeatureIds, Configuration, Features } from '../../../../types'; import { LEARN_MORE_LINK } from '../../constants'; import { AddSourceLogic } from './add_source_logic'; import { - CONNECT_REMOTE, - CONNECT_PRIVATE, CONNECT_WHICH_OPTION_LINK, CONNECT_DOC_PERMISSIONS_LABEL, CONNECT_DOC_PERMISSIONS_TITLE, CONNECT_NEEDS_PERMISSIONS, CONNECT_NOT_SYNCED_TITLE, CONNECT_NOT_SYNCED_TEXT, + SOURCE_FEATURES_DOCUMENT_LEVEL_PERMISSIONS_FEATURE, + SOURCE_FEATURES_DOCUMENT_LEVEL_PERMISSIONS_TITLE, + SOURCE_FEATURES_EXPLORE_BUTTON, } from './constants'; import { SourceFeatures } from './source_features'; @@ -110,6 +111,10 @@ export const ConnectInstance: React.FC = ({ onSubmit(); }; + const permissionsExcluded = features?.basicOrgContextExcludedFeatures?.includes( + FeatureIds.DocumentLevelPermissions + ); + const credentialsFields = ( <> @@ -147,35 +152,6 @@ export const ConnectInstance: React.FC = ({ ); - const featureBadgeGroup = () => { - if (isOrganization) { - return null; - } - - const isRemote = features?.platinumPrivateContext.includes(FeatureIds.Remote); - const isPrivate = features?.platinumPrivateContext.includes(FeatureIds.Private); - - if (isRemote || isPrivate) { - return ( - <> - - {isRemote && {CONNECT_REMOTE}} - {isPrivate && {CONNECT_PRIVATE}} - - - - ); - } - }; - - const descriptionBlock = ( - - {sourceDescription &&

{sourceDescription}

} - {connectStepDescription &&

{connectStepDescription}

} - -
- ); - const whichDocsLink = ( {CONNECT_WHICH_OPTION_LINK} @@ -184,54 +160,87 @@ export const ConnectInstance: React.FC = ({ const permissionField = ( <> - - - {CONNECT_DOC_PERMISSIONS_TITLE} - - - - {CONNECT_DOC_PERMISSIONS_LABEL}} - name="index_permissions" - onChange={(e) => setSourceIndexPermissionsValue(e.target.checked)} - checked={indexPermissionsValue} - disabled={!needsPermissions} - /> - - - {!needsPermissions && ( - - - {LEARN_MORE_LINK} - - ), - }} - /> - - )} - {needsPermissions && indexPermissionsValue && ( - - {CONNECT_NEEDS_PERMISSIONS} -
- {whichDocsLink} -
+ + +

+ {CONNECT_DOC_PERMISSIONS_TITLE} +

+
+ + + {!needsPermissions && ( + + + {LEARN_MORE_LINK} + + ), + }} + /> + + )} + {needsPermissions && indexPermissionsValue && ( + + {CONNECT_NEEDS_PERMISSIONS} + + {whichDocsLink} + + )} + + + {!indexPermissionsValue && ( + <> + + +

+ {CONNECT_NOT_SYNCED_TEXT} + {needsPermissions && whichDocsLink} +

+
+ )} -
- - {!indexPermissionsValue && ( - -

- {CONNECT_NOT_SYNCED_TEXT} - {needsPermissions && whichDocsLink} -

-
- )} - + + {CONNECT_DOC_PERMISSIONS_LABEL}} + name="index_permissions" + onChange={(e) => setSourceIndexPermissionsValue(e.target.checked)} + checked={indexPermissionsValue} + disabled={!needsPermissions} + /> + + + + ); + + const documentLevelPermissionsCallout = ( + <> + + + + + + + + {SOURCE_FEATURES_DOCUMENT_LEVEL_PERMISSIONS_TITLE} + + + + + +

{SOURCE_FEATURES_DOCUMENT_LEVEL_PERMISSIONS_FEATURE}

+
+ + + + {SOURCE_FEATURES_EXPLORE_BUTTON} + + +
+ ); @@ -240,6 +249,7 @@ export const ConnectInstance: React.FC = ({ {isOrganization && hasPlatinumLicense && permissionField} {!hasOauthRedirect && credentialsFields} {needsSubdomain && subdomainField} + {permissionsExcluded && !hasPlatinumLicense && documentLevelPermissionsCallout} @@ -262,15 +272,19 @@ export const ConnectInstance: React.FC = ({ gutterSize="xl" responsive={false} > - - {header} - {featureBadgeGroup()} - {descriptionBlock} + + + + {header} + + + + + + + {formFields} - - -
diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts index dcede11fbdd3ac..dd756a51fded37 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts @@ -338,6 +338,13 @@ export const SOURCE_FEATURES_GLOBAL_ACCESS_PERMISSIONS_FEATURE = i18n.translate( } ); +export const SOURCE_FEATURES_DOCUMENT_LEVEL_PERMISSIONS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.sourceFeatures.documentLevelPermissions.title', + { + defaultMessage: 'Document-level permissions available with Platinum license', + } +); + export const SOURCE_FEATURES_DOCUMENT_LEVEL_PERMISSIONS_FEATURE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.sourceFeatures.documentLevelPermissions.text', { @@ -352,28 +359,6 @@ export const SOURCE_FEATURES_EXPLORE_BUTTON = i18n.translate( defaultMessage: 'Explore Platinum features', } ); - -export const SOURCE_FEATURES_INCLUDED_FEATURES_TITLE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.sourceFeatures.included.title', - { - defaultMessage: 'Included features', - } -); - -export const CONNECT_REMOTE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.connect.remote.text', - { - defaultMessage: 'Remote', - } -); - -export const CONNECT_PRIVATE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.connect.private.text', - { - defaultMessage: 'Private', - } -); - export const CONNECT_WHICH_OPTION_LINK = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.connect.whichOption.link', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/re_authenticate.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/re_authenticate.tsx index eb6736d84a197e..61682dbb87d587 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/re_authenticate.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/re_authenticate.tsx @@ -51,6 +51,7 @@ export const ReAuthenticate: React.FC = ({ name, header }) return (
{header} +
; + export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); const { isOrganization } = useValues(AppLogic); - const Feature = ({ title, children }: { title: string; children: React.ReactElement }) => ( - <> - - - {title} - - - {children} - - ); + const Feature = ({ + icon, + title, + children, + }: { + icon: string; + title: string; + children: React.ReactElement; + }) => { + return ( + <> + + {icon && ( + <> + + + + + )} + + + {title} + + + + + {children} + + ); + }; const SyncFrequencyFeature = ( - +

= ({ features, objTy ); const SyncedItemsFeature = ( - + <>

{SOURCE_FEATURES_SEARCHABLE}

@@ -93,7 +110,7 @@ export const SourceFeatures: React.FC = ({ features, objTy ); const SearchableContentFeature = ( - +

{SOURCE_FEATURES_SEARCHABLE}

@@ -109,7 +126,7 @@ export const SourceFeatures: React.FC = ({ features, objTy ); const RemoteFeature = ( - +

{SOURCE_FEATURES_REMOTE_FEATURE}

@@ -117,7 +134,7 @@ export const SourceFeatures: React.FC = ({ features, objTy ); const PrivateFeature = ( - +

{SOURCE_FEATURES_PRIVATE_FEATURE}

@@ -125,25 +142,14 @@ export const SourceFeatures: React.FC = ({ features, objTy ); const GlobalAccessPermissionsFeature = ( - +

{SOURCE_FEATURES_GLOBAL_ACCESS_PERMISSIONS_FEATURE}

); - const DocumentLevelPermissionsFeature = ( - - -

{SOURCE_FEATURES_DOCUMENT_LEVEL_PERMISSIONS_FEATURE}

- - {SOURCE_FEATURES_EXPLORE_BUTTON} - -
-
- ); - - const FeaturesRouter = ({ featureId }: { featureId: FeatureIds }) => + const FeaturesRouter = ({ featureId }: { featureId: IncludedFeatureIds }) => ({ [FeatureIds.SyncFrequency]: SyncFrequencyFeature, [FeatureIds.SearchableContent]: SearchableContentFeature, @@ -151,10 +157,9 @@ export const SourceFeatures: React.FC = ({ features, objTy [FeatureIds.Remote]: RemoteFeature, [FeatureIds.Private]: PrivateFeature, [FeatureIds.GlobalAccessPermissions]: GlobalAccessPermissionsFeature, - [FeatureIds.DocumentLevelPermissions]: DocumentLevelPermissionsFeature, }[featureId]); - const IncludedFeatures = () => { + const IncludedFeatureIds = () => { let includedFeatures: FeatureIds[] | undefined; if (!hasPlatinumLicense && isOrganization) { @@ -172,55 +177,40 @@ export const SourceFeatures: React.FC = ({ features, objTy } return ( - + <> -

{SOURCE_FEATURES_INCLUDED_FEATURES_TITLE}

+

+ Included features +

- {includedFeatures.map((featureId, i) => ( - - ))} -
- ); - }; - - const ExcludedFeatures = () => { - let excludedFeatures: FeatureIds[] | undefined; - - if (!hasPlatinumLicense && isOrganization) { - excludedFeatures = features?.basicOrgContextExcludedFeatures; - } - - if (!excludedFeatures?.length) { - return null; - } - - return ( - - - {excludedFeatures.map((featureId, i) => ( - - ))} - + + + {includedFeatures.map((featureId, i) => ( + + + + + + ))} + + ); }; return ( - - - - - + ); 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 9a6af035c1c8d8..717eebf5cf8733 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 @@ -20,7 +20,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSelect, } from '@elastic/eui'; @@ -59,48 +58,46 @@ export const FieldEditorModal: React.FC = () => { const ACTION_LABEL = isEditing ? UPDATE_LABEL : ADD_LABEL; return ( - + - - - - {ACTION_LABEL} {FIELD_LABEL} - - - - - - setName(e.target.value)} - /> - - - setLabel(e.target.value)} - /> - - - - - {CANCEL_BUTTON} - - {ACTION_LABEL} {FIELD_LABEL} - - - + + + {ACTION_LABEL} {FIELD_LABEL} + + + + + + setName(e.target.value)} + /> + + + setLabel(e.target.value)} + /> + + + + + {CANCEL_BUTTON} + + {ACTION_LABEL} {FIELD_LABEL} + + - + ); }; 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 75a1779a1fda8e..d99f9a4cb1a463 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 @@ -15,7 +15,6 @@ import { EuiButton, EuiButtonEmpty, EuiConfirmModal, - EuiOverlayMask, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -101,26 +100,24 @@ export const SourceSettings: React.FC = () => { }; const confirmModal = ( - - - , - }} - /> - - + + , + }} + /> + ); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts index 8efa61c1ed524f..3e1290292704e3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -413,10 +413,6 @@ export const SHARED_EMPTY_DESCRIPTION = i18n.translate( } ); -export const AND = i18n.translate('xpack.enterpriseSearch.workplaceSearch.and', { - defaultMessage: 'and', -}); - export const LICENSE_CALLOUT_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.sources.licenseCallout.title', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx new file mode 100644 index 00000000000000..e6f19ff13b3ccf --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCallOut, EuiEmptyPrompt } from '@elastic/eui'; + +import { Loading } from '../../../shared/loading'; +import { ContentSection } from '../../components/shared/content_section'; +import { SourcesTable } from '../../components/shared/sources_table'; + +import { PrivateSources } from './private_sources'; +import { SourcesView } from './sources_view'; + +describe('PrivateSources', () => { + const mockValues = { + account: { canCreatePersonalSources: false, groups: [] }, + dataLoading: false, + contentSources: [], + privateContentSources: [], + serviceTypes: [], + hasPlatinumLicense: true, + }; + + beforeEach(() => { + setMockActions({ initializeSources: jest.fn() }); + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SourcesView)).toHaveLength(1); + }); + + it('renders Loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('renders only shared sources section when canCreatePersonalSources is false', () => { + setMockValues({ ...mockValues }); + const wrapper = shallow(); + + expect(wrapper.find(ContentSection)).toHaveLength(1); + }); + + it('renders both shared and private sources sections when canCreatePersonalSources is true', () => { + setMockValues({ ...mockValues, account: { canCreatePersonalSources: true, groups: [] } }); + const wrapper = shallow(); + + expect(wrapper.find(ContentSection)).toHaveLength(2); + }); + + it('renders license callout when has private sources with non-Platinum license', () => { + setMockValues({ + ...mockValues, + privateContentSources: ['source1', 'source2'], + hasPlatinumLicense: false, + account: { canCreatePersonalSources: true, groups: [] }, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); + + it('renders an action button when user can add private sources', () => { + setMockValues({ + ...mockValues, + account: { canCreatePersonalSources: true, groups: [] }, + serviceTypes: [{ configured: true }], + }); + const wrapper = shallow(); + + expect(wrapper.find(ContentSection).first().prop('action')).toBeTruthy(); + }); + + it('renders empty prompts if no sources are available', () => { + setMockValues({ + ...mockValues, + account: { canCreatePersonalSources: true, groups: [] }, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(2); + }); + + it('renders SourcesTable if sources are available', () => { + setMockValues({ + ...mockValues, + account: { canCreatePersonalSources: true, groups: [] }, + contentSources: ['1', '2'], + privateContentSources: ['1', '2'], + }); + const wrapper = shallow(); + + expect(wrapper.find(SourcesTable)).toHaveLength(2); + }); +}); 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 3ff14602b979d6..114df3cf41e399 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 @@ -20,9 +20,9 @@ import noSharedSourcesIcon from '../../assets/share_circle.svg'; import { ContentSection } from '../../components/shared/content_section'; import { SourcesTable } from '../../components/shared/sources_table'; import { ADD_SOURCE_PATH, getSourcesPath } from '../../routes'; +import { toSentenceSerial } from '../../utils'; import { - AND, PRIVATE_LINK_TITLE, PRIVATE_HEADER_TITLE, PRIVATE_HEADER_DESCRIPTION, @@ -122,13 +122,6 @@ export const PrivateSources: React.FC = () => { ); - const groupsSentence = - groups.length === 1 - ? `${groups}` - : `${groups.slice(0, groups.length - 1).join(', ')}${ - groups.length === 2 ? '' : ',' - } ${AND} ${groups.slice(-1)}`; - const sharedSourcesSection = ( { }} + values={{ + groups: groups.length, + groupsSentence: toSentenceSerial(groups), + newline:
, + }} /> ) } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources_layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources_layout.test.tsx new file mode 100644 index 00000000000000..488eb4b49853bd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/private_sources_layout.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues } from '../../../__mocks__'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiCallOut } from '@elastic/eui'; + +import { ViewContentHeader } from '../../components/shared/view_content_header'; + +import { + PRIVATE_CAN_CREATE_PAGE_TITLE, + PRIVATE_VIEW_ONLY_PAGE_TITLE, + PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION, + PRIVATE_CAN_CREATE_PAGE_DESCRIPTION, +} from './constants'; +import { PrivateSourcesLayout } from './private_sources_layout'; + +describe('PrivateSourcesLayout', () => { + const mockValues = { + account: { canCreatePersonalSources: true }, + }; + + const children =

test

; + + beforeEach(() => { + setMockValues({ ...mockValues }); + }); + + it('renders', () => { + const wrapper = shallow({children}); + + expect(wrapper.find('[data-test-subj="TestChildren"]')).toHaveLength(1); + }); + + it('uses correct title and description when private sources are enabled', () => { + const wrapper = shallow({children}); + + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_CAN_CREATE_PAGE_TITLE); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + PRIVATE_CAN_CREATE_PAGE_DESCRIPTION + ); + }); + + it('uses correct title and description when private sources are disabled', () => { + setMockValues({ account: { canCreatePersonalSources: false } }); + const wrapper = shallow({children}); + + expect(wrapper.find(ViewContentHeader).prop('title')).toEqual(PRIVATE_VIEW_ONLY_PAGE_TITLE); + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual( + PRIVATE_VIEW_ONLY_PAGE_DESCRIPTION + ); + }); + + it('renders callout when in read-only mode', () => { + const wrapper = shallow({children}); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx index c62f0b00258d65..247df5556ada01 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_view.tsx @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -53,70 +52,65 @@ export const SourcesView: React.FC = ({ children }) => { addedSourceName: string; serviceType: string; }) => ( - - - - - - - - - - {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sourcesView.modal.heading', - { - defaultMessage: '{addedSourceName} requires additional configuration', - values: { addedSourceName }, - } - )} - - - - - - -

- - {EXTERNAL_IDENTITIES_LINK} - - ), - }} - /> -

+ + + + + + + + + {i18n.translate('xpack.enterpriseSearch.workplaceSearch.sourcesView.modal.heading', { + defaultMessage: '{addedSourceName} requires additional configuration', + values: { addedSourceName }, + })} + + + + + + +

+ + {EXTERNAL_IDENTITIES_LINK} + + ), + }} + /> +

-

- - {DOCUMENT_PERMISSIONS_LINK} - - ), - }} - /> -

-
-
- - - {UNDERSTAND_BUTTON} - - -
-
+

+ + {DOCUMENT_PERMISSIONS_LINK} + + ), + }} + /> +

+
+ + + + {UNDERSTAND_BUTTON} + + + ); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx index 26ac5e484f0d77..784544b0001fa0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.test.tsx @@ -11,7 +11,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiModal } from '@elastic/eui'; import { AddGroupModal } from './add_group_modal'; @@ -36,7 +36,6 @@ describe('AddGroupModal', () => { const wrapper = shallow(); expect(wrapper.find(EuiModal)).toHaveLength(1); - expect(wrapper.find(EuiOverlayMask)).toHaveLength(1); }); it('updates the input value', () => { 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 fb82e9393f2a22..2c5732b4b71573 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 @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -49,37 +48,35 @@ export const AddGroupModal: React.FC<{}> = () => { }; return ( - - -
- - {ADD_GROUP_HEADER} - + + + + {ADD_GROUP_HEADER} + - - - setNewGroupName(e.target.value)} - /> - - + + + setNewGroupName(e.target.value)} + /> + + - - {CANCEL_BUTTON} - - {ADD_GROUP_SUBMIT} - - - -
-
+ + {CANCEL_BUTTON} + + {ADD_GROUP_SUBMIT} + + + + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx index 949ae9d502e73a..7c39414f158eff 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.test.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { EuiOverlayMask, EuiModal, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiModal, EuiEmptyPrompt } from '@elastic/eui'; import { GroupManagerModal } from './group_manager_modal'; @@ -46,7 +46,6 @@ describe('GroupManagerModal', () => { const wrapper = shallow(); expect(wrapper.find(EuiModal)).toHaveLength(1); - expect(wrapper.find(EuiOverlayMask)).toHaveLength(1); }); it('renders empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx index b4317ed9bd417c..1b051394dcdcf6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -21,7 +21,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -161,14 +160,12 @@ export const GroupManagerModal: React.FC = ({ ); return ( - - - {showEmptyState ? emptyState : modalContent} - - + + {showEmptyState ? emptyState : modalContent} + ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx index df9c0b5db9b7d1..375ac7476f9b69 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx @@ -12,7 +12,6 @@ import { useActions, useValues } from 'kea'; import { EuiButton, EuiConfirmModal, - EuiOverlayMask, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -226,18 +225,16 @@ export const GroupOverview: React.FC = () => { {confirmDeleteModalVisible && ( - - - {CONFIRM_REMOVE_DESCRIPTION} - - + + {CONFIRM_REMOVE_DESCRIPTION} + )} { ); const confirmModal = ( - - - {PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT} - - + + {PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT} + ); return ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx index 28e7e2a33eaa18..3f2e55d23722c2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx @@ -18,7 +18,6 @@ import { EuiSwitch, EuiCode, EuiSpacer, - EuiOverlayMask, EuiLink, EuiModal, EuiModalBody, @@ -93,25 +92,28 @@ export const OauthApplication: React.FC = () => { }; const licenseModal = ( - - - - - - - -

{LICENSE_MODAL_TITLE}

-
- - {LICENSE_MODAL_DESCRIPTION} - - - {LICENSE_MODAL_LINK} - - -
-
-
+ + + + + + +

{LICENSE_MODAL_TITLE}

+
+ + {LICENSE_MODAL_DESCRIPTION} + + + {LICENSE_MODAL_LINK} + + +
+
); return ( 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 4ed223931d6a46..47a24e7912c3c9 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 @@ -9,7 +9,7 @@ import React, { useEffect, useState } from 'react'; import { useActions, useValues } from 'kea'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Loading } from '../../../../shared/loading'; @@ -56,22 +56,19 @@ export const SourceConfig: React.FC = ({ sourceIndex }) => { header={header} /> {confirmModalVisible && ( - - deleteSourceConfig(serviceType, name)} - onCancel={hideConfirmModal} - buttonColor="danger" - > - {i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.settings.confirmRemoveConfig.message', - { - defaultMessage: - 'Are you sure you want to remove the OAuth configuration for {name}?', - values: { name }, - } - )} - - + deleteSourceConfig(serviceType, name)} + onCancel={hideConfirmModal} + buttonColor="danger" + > + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.confirmRemoveConfig.message', + { + defaultMessage: 'Are you sure you want to remove the OAuth configuration for {name}?', + values: { name }, + } + )} + )} ); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts new file mode 100644 index 00000000000000..5b5d132591f4ef --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerCurationsRoutes } from './curations'; + +describe('curations routes', () => { + describe('GET /api/app_search/engines/{engineName}/curations/find_or_create', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/curations/find_or_create', + }); + + registerCurationsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/curations/find_or_create', + }); + }); + + describe('validates', () => { + it('required query param', () => { + const request = { query: { query: 'some query' } }; + mockRouter.shouldValidate(request); + }); + + it('missing query', () => { + const request = { query: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts new file mode 100644 index 00000000000000..a4addb3ad0d3ab --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/curations.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerCurationsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/curations/find_or_create', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object({ + query: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/curations/find_or_create', + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 92fdcb689db1d2..90b86138a4a6d3 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -9,6 +9,7 @@ import { RouteDependencies } from '../../plugin'; import { registerAnalyticsRoutes } from './analytics'; import { registerCredentialsRoutes } from './credentials'; +import { registerCurationsRoutes } from './curations'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; import { registerEnginesRoutes } from './engines'; import { registerSearchSettingsRoutes } from './search_settings'; @@ -21,5 +22,6 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerAnalyticsRoutes(dependencies); registerDocumentsRoutes(dependencies); registerDocumentRoutes(dependencies); + registerCurationsRoutes(dependencies); registerSearchSettingsRoutes(dependencies); }; diff --git a/x-pack/plugins/fleet/common/constants/agent_policy.ts b/x-pack/plugins/fleet/common/constants/agent_policy.ts index 96b6249585bfcd..bed9b6e8390b87 100644 --- a/x-pack/plugins/fleet/common/constants/agent_policy.ts +++ b/x-pack/plugins/fleet/common/constants/agent_policy.ts @@ -28,4 +28,19 @@ export const DEFAULT_AGENT_POLICY: Omit< monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, }; +export const DEFAULT_FLEET_SERVER_AGENT_POLICY: Omit< + AgentPolicy, + 'id' | 'updated_at' | 'updated_by' | 'revision' +> = { + name: 'Default Fleet Server policy', + namespace: 'default', + description: 'Default Fleet Server agent policy created by Kibana', + status: agentPolicyStatuses.Active, + package_policies: [], + is_default: false, + is_default_fleet_server: true, + is_managed: false, + monitoring_enabled: ['logs', 'metrics'] as Array<'logs' | 'metrics'>, +}; + export const DEFAULT_AGENT_POLICIES_PACKAGES = [defaultPackages.System]; diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index dcddbe3539abd1..d95bc9cf736a6b 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -22,6 +22,8 @@ export * from './settings'; // setting in the future? export const SO_SEARCH_LIMIT = 10000; +export const FLEET_SERVER_INDICES_VERSION = 1; + export const FLEET_SERVER_INDICES = [ '.fleet-actions', '.fleet-agents', 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 5f41b0f70ca74f..bc139537400cc4 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_default_fleet_server?: boolean; // Optional when creating a policy is_managed?: boolean; // Optional when creating a policy monitoring_enabled?: Array>; } diff --git a/x-pack/plugins/fleet/common/types/rest_spec/common.ts b/x-pack/plugins/fleet/common/types/rest_spec/common.ts index d03129efd8fad0..de5e87d2e59a5d 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/common.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/common.ts @@ -14,3 +14,10 @@ export interface ListWithKuery extends HttpFetchQuery { sortOrder?: 'desc' | 'asc'; kuery?: string; } + +export interface ListResult { + items: T[]; + total: number; + page: number; + perPage: number; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx index d161dfcc5894c1..2b7ecc75195b0c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_copy_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useRef, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { EuiConfirmModal, EuiFormRow, EuiFieldText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentPolicy } from '../../../types'; @@ -92,75 +92,71 @@ export const AgentPolicyCopyProvider: React.FunctionComponent = ({ childr } return ( - - - - - } - onCancel={closeModal} - onConfirm={copyAgentPolicy} - cancelButtonText={ + - } - confirmButtonText={ + + } + onCancel={closeModal} + onConfirm={copyAgentPolicy} + cancelButtonText={ + + } + confirmButtonText={ + + } + confirmButtonDisabled={isLoading || !newAgentPolicy.name.trim()} + > +

+ +

+ } - confirmButtonDisabled={isLoading || !newAgentPolicy.name.trim()} + fullWidth > -

- -

- - } + - setNewAgentPolicy({ ...newAgentPolicy, name: e.target.value })} + value={newAgentPolicy.name} + onChange={(e) => setNewAgentPolicy({ ...newAgentPolicy, name: e.target.value })} + /> + + - - - } + } + fullWidth + > + - - setNewAgentPolicy({ ...newAgentPolicy, description: e.target.value }) - } - /> - -
-
+ value={newAgentPolicy.description} + onChange={(e) => setNewAgentPolicy({ ...newAgentPolicy, description: e.target.value })} + /> + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx index b03d70a78c51a3..014af7f54d020b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_delete_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useRef, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiCallOut } from '@elastic/eui'; +import { EuiConfirmModal, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; @@ -110,69 +110,67 @@ export const AgentPolicyDeleteProvider: React.FunctionComponent = ({ chil } return ( - - - } - onCancel={closeModal} - onConfirm={deleteAgentPolicy} - cancelButtonText={ + + } + onCancel={closeModal} + onConfirm={deleteAgentPolicy} + cancelButtonText={ + + } + confirmButtonText={ + isLoading || isLoadingAgentsCount ? ( - } - confirmButtonText={ - isLoading || isLoadingAgentsCount ? ( - - ) : ( - - ) - } - buttonColor="danger" - confirmButtonDisabled={isLoading || isLoadingAgentsCount || !!agentsCount} - > - {isLoadingAgentsCount ? ( + ) : ( - ) : agentsCount ? ( - - - - ) : ( + ) + } + buttonColor="danger" + confirmButtonDisabled={isLoading || isLoadingAgentsCount || !!agentsCount} + > + {isLoadingAgentsCount ? ( + + ) : agentsCount ? ( + - )} - - + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx index 63fb1f5b4b6385..9ed4bb6ff6ff4f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_yaml_flyout.tsx @@ -52,8 +52,6 @@ export const AgentPolicyYamlFlyout = memo<{ policyId: string; onClose: () => voi {error.message} ) : ( - // Property 'whiteSpace' does not exist on type 'IntrinsicAttributes & CommonProps & OwnProps & HTMLAttributes & { children?: ReactNode; }'. - // @ts-expect-error linter complains whiteSpace isn't available but docs show it on EuiCodeBlockImpl {fullAgentPolicyToYaml(yamlData!.item)} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/confirm_deploy_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/confirm_deploy_modal.tsx index 02d7a6423edc84..f3d01e6b528cae 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/confirm_deploy_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/confirm_deploy_modal.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiCallOut, EuiOverlayMask, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { AgentPolicy } from '../../../types'; @@ -18,58 +18,56 @@ export const ConfirmDeployAgentPolicyModal: React.FunctionComponent<{ agentPolicy: AgentPolicy; }> = ({ onConfirm, onCancel, agentCount, agentPolicy }) => { return ( - - - } - onCancel={onCancel} - onConfirm={onConfirm} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor="primary" + + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="primary" + > + - -
- + {agentPolicy.name}, - }} - /> -
-
- - -
-
+ values={{ + policyName: {agentPolicy.name}, + }} + /> +
+ + + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx index 2ea94e88ed8c61..80952fee05bb4d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/package_policy_delete_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useMemo, useRef, useState } from 'react'; -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { useStartServices, sendRequest, sendDeletePackagePolicy, useConfig } from '../../../hooks'; @@ -142,78 +142,76 @@ export const PackagePolicyDeleteProvider: React.FunctionComponent = ({ } return ( - - + } + onCancel={closeModal} + onConfirm={deletePackagePolicies} + cancelButtonText={ + + } + confirmButtonText={ + isLoading || isLoadingAgentsCount ? ( - } - onCancel={closeModal} - onConfirm={deletePackagePolicies} - cancelButtonText={ + ) : ( - } - confirmButtonText={ - isLoading || isLoadingAgentsCount ? ( - - ) : ( + ) + } + buttonColor="danger" + confirmButtonDisabled={isLoading || isLoadingAgentsCount} + > + {isLoadingAgentsCount ? ( + + ) : agentsCount ? ( + <> + + } + > {agentPolicy.name}, }} /> - ) - } - buttonColor="danger" - confirmButtonDisabled={isLoading || isLoadingAgentsCount} - > - {isLoadingAgentsCount ? ( - - ) : agentsCount ? ( - <> - - } - > - {agentPolicy.name}, - }} - /> - - - - ) : null} - {!isLoadingAgentsCount && ( - - )} - - + + + + ) : null} + {!isLoadingAgentsCount && ( + + )} + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx index 69bff78d604132..a50cc18d46f550 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_unenroll_modal/index.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiOverlayMask, EuiFormFieldset, EuiCheckbox } from '@elastic/eui'; +import { EuiConfirmModal, EuiFormFieldset, EuiCheckbox } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; import { @@ -81,90 +81,88 @@ export const AgentUnenrollAgentModal: React.FunctionComponent = ({ } return ( - - - ) : ( - - ) - } - onCancel={onClose} - onConfirm={onSubmit} - cancelButtonText={ + - } - confirmButtonDisabled={isSubmitting} - confirmButtonText={ - isSingleAgent ? ( - - ) : ( + ) : ( + + ) + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={isSubmitting} + confirmButtonText={ + isSingleAgent ? ( + + ) : ( + + ) + } + buttonColor="danger" + > +

+ {isSingleAgent ? ( + + ) : ( + + )} +

+ - ) - } - buttonColor="danger" + ), + }} > -

- {isSingleAgent ? ( + - ) : ( - - )} -

- - ), - }} - > - - } - checked={forceUnenroll} - onChange={(e) => setForceUnenroll(e.target.checked)} - disabled={useForceUnenroll} - /> - -
-
+ values={{ count: agentCount }} + /> + } + checked={forceUnenroll} + onChange={(e) => setForceUnenroll(e.target.checked)} + disabled={useForceUnenroll} + /> + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx index a836e3ec3149bc..57f4007a002740 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/agent_upgrade_modal/index.tsx @@ -7,13 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiConfirmModal, - EuiOverlayMask, - EuiBetaBadge, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { EuiConfirmModal, EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { Agent } from '../../../../types'; import { @@ -74,85 +68,83 @@ export const AgentUpgradeAgentModal: React.FunctionComponent = ({ } return ( - - - - {isSingleAgent ? ( + + + {isSingleAgent ? ( + + ) : ( + + )} + + + - ) : ( + } + tooltipContent={ - )} - - - - } - tooltipContent={ - - } - /> - -
- } - onCancel={onClose} - onConfirm={onSubmit} - cancelButtonText={ + } + /> + + + } + onCancel={onClose} + onConfirm={onSubmit} + cancelButtonText={ + + } + confirmButtonDisabled={isSubmitting} + confirmButtonText={ + isSingleAgent ? ( - } - confirmButtonDisabled={isSubmitting} - confirmButtonText={ - isSingleAgent ? ( - - ) : ( - - ) - } - > -

- {isSingleAgent ? ( - - ) : ( - - )} -

- - + ) : ( + + ) + } + > +

+ {isSingleAgent ? ( + + ) : ( + + )} +

+ ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/confirm_delete_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/confirm_delete_modal.tsx index 22e2e68e6d83e9..565657c70e17f6 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/confirm_delete_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/components/confirm_delete_modal.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiCallOut, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EuiCallOut } from '@elastic/eui'; import { EnrollmentAPIKey } from '../../../../types'; interface Props { @@ -19,33 +19,31 @@ interface Props { export const ConfirmEnrollmentTokenDelete = (props: Props) => { const { onCancel, onConfirm, enrollmentKey } = props; return ( - - + - - - + color="danger" + /> + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx index ef3ca3ce664c11..5144b2a6487862 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_install.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -18,50 +18,48 @@ interface ConfirmPackageInstallProps { export const ConfirmPackageInstall = (props: ConfirmPackageInstallProps) => { const { onCancel, onConfirm, packageName, numOfAssets } = props; return ( - - + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + > + - } - onCancel={onCancel} - onConfirm={onConfirm} - cancelButtonText={ - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - > - - } + /> + +

+ - -

- -

-
-
+

+ ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx index 7688c0269d3588..2def57b0409447 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/epm/screens/detail/settings/confirm_package_uninstall.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -18,58 +18,56 @@ interface ConfirmPackageUninstallProps { export const ConfirmPackageUninstall = (props: ConfirmPackageUninstallProps) => { const { onCancel, onConfirm, packageName, numOfAssets } = props; return ( - - + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + buttonColor="danger" + > + } - onCancel={onCancel} - onConfirm={onConfirm} - cancelButtonText={ - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - buttonColor="danger" > - - } - > -

- -

-
-

-
-
+ + +

+ +

+ ); }; diff --git a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts index 8aa66c4ae5f4ac..154e78feae2832 100644 --- a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts +++ b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts @@ -7,7 +7,8 @@ import { ElasticsearchClient, SavedObjectsClient } from 'kibana/server'; import * as AgentService from '../services/agents'; -import { isFleetServerSetup } from '../services/fleet_server_migration'; +import { isFleetServerSetup } from '../services/fleet_server'; + export interface AgentUsage { total: number; online: number; diff --git a/x-pack/plugins/fleet/server/mocks.ts b/x-pack/plugins/fleet/server/mocks.ts index c650995c809cbb..430e38bd1bc3ef 100644 --- a/x-pack/plugins/fleet/server/mocks.ts +++ b/x-pack/plugins/fleet/server/mocks.ts @@ -53,6 +53,7 @@ export const createPackagePolicyServiceMock = () => { get: jest.fn(), getByIDs: jest.fn(), list: jest.fn(), + listIds: jest.fn(), update: jest.fn(), runExternalCallbacks: jest.fn(), } as jest.Mocked; diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index d89db7f1ac3415..d4cd39b274f052 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -83,7 +83,7 @@ import { agentCheckinState } from './services/agents/checkin/state'; import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation } from './services/epm/packages'; import { makeRouterEnforcingSuperuser } from './routes/security'; -import { isFleetServerSetup } from './services/fleet_server_migration'; +import { startFleetServerSetup } from './services/fleet_server'; export interface FleetSetupDeps { licensing: LicensingPluginSetup; @@ -297,18 +297,9 @@ export class FleetPlugin licenseService.start(this.licensing$); agentCheckinState.start(); - const fleetServerEnabled = appContextService.getConfig()?.agents?.fleetServerEnabled; - if (fleetServerEnabled) { - // We need licence to be initialized before using the SO service. - await this.licensing$.pipe(first()).toPromise(); - - const fleetSetup = await isFleetServerSetup(); - - if (!fleetSetup) { - this.logger?.warn( - 'Extra setup is needed to be able to use central management for agent, please visit the Fleet app in Kibana.' - ); - } + if (appContextService.getConfig()?.agents?.fleetServerEnabled) { + // Break the promise chain, the error handling is done in startFleetServerSetup + startFleetServerSetup(); } return { diff --git a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts index 2b44975cc3b4de..813279f2a800fc 100644 --- a/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/package_policy/handlers.test.ts @@ -47,6 +47,7 @@ jest.mock('../../services/package_policy', (): { get: jest.fn(), getByIDs: jest.fn(), list: jest.fn(), + listIds: jest.fn(), update: jest.fn(), runExternalCallbacks: jest.fn((callbackType, newPackagePolicy, context, request) => Promise.resolve(newPackagePolicy) diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index f2eb8be5c030c6..5b851c692ad3f6 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -162,6 +162,7 @@ const getSavedObjectTypes = ( description: { type: 'text' }, namespace: { type: 'keyword' }, is_default: { type: 'boolean' }, + is_default_fleet_server: { type: 'boolean' }, is_managed: { type: 'boolean' }, status: { type: 'keyword' }, package_policies: { type: 'keyword' }, 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 49a0d6fc7737fa..15e68ace987b98 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 @@ -17,12 +17,11 @@ export const migrateAgentToV7120: SavedObjectMigrationFn, + Exclude, AgentPolicy > = (agentPolicyDoc) => { - const isV12 = 'is_managed' in agentPolicyDoc.attributes; - if (!isV12) { - agentPolicyDoc.attributes.is_managed = false; - } + agentPolicyDoc.attributes.is_managed = false; + agentPolicyDoc.attributes.is_default_fleet_server = false; + return agentPolicyDoc; }; diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 31e9a63175d181..44962ea31c56c5 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -35,6 +35,7 @@ import { dataTypes, FleetServerPolicy, AGENT_POLICY_INDEX, + DEFAULT_FLEET_SERVER_AGENT_POLICY, } from '../../common'; import { AgentPolicyNameExistsError, @@ -133,6 +134,39 @@ class AgentPolicyService { }; } + public async ensureDefaultFleetServerAgentPolicy( + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient + ): Promise<{ + created: boolean; + policy: AgentPolicy; + }> { + const agentPolicies = await soClient.find({ + type: AGENT_POLICY_SAVED_OBJECT_TYPE, + searchFields: ['is_default_fleet_server'], + search: 'true', + }); + + if (agentPolicies.total === 0) { + const newDefaultAgentPolicy: NewAgentPolicy = { + ...DEFAULT_FLEET_SERVER_AGENT_POLICY, + }; + + return { + created: true, + policy: await this.create(soClient, esClient, newDefaultAgentPolicy), + }; + } + + return { + created: false, + policy: { + id: agentPolicies.saved_objects[0].id, + ...agentPolicies.saved_objects[0].attributes, + }, + }; + } + public async create( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, @@ -209,6 +243,21 @@ class AgentPolicyService { return agentPolicy; } + public async getByIDs( + soClient: SavedObjectsClientContract, + ids: string[], + options: { fields?: string[] } = {} + ): Promise { + const objects = ids.map((id) => ({ ...options, id, type: SAVED_OBJECT_TYPE })); + const agentPolicySO = await soClient.bulkGet(objects); + + return agentPolicySO.saved_objects.map((so) => ({ + id: so.id, + version: so.version, + ...so.attributes, + })); + } + public async list( soClient: SavedObjectsClientContract, options: ListWithKuery & { @@ -554,18 +603,19 @@ class AgentPolicyService { if (!(await isAgentsSetup(soClient))) { return; } - const policy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId); - if (!policy || !policy.revision) { + const policy = await agentPolicyService.get(soClient, agentPolicyId); + const fullPolicy = await agentPolicyService.getFullAgentPolicy(soClient, agentPolicyId); + if (!policy || !fullPolicy || !fullPolicy.revision) { return; } const fleetServerPolicy: FleetServerPolicy = { '@timestamp': new Date().toISOString(), - revision_idx: policy.revision, + revision_idx: fullPolicy.revision, coordinator_idx: 0, - data: (policy as unknown) as FleetServerPolicy['data'], - policy_id: policy.id, - default_fleet_server: false, + data: (fullPolicy as unknown) as FleetServerPolicy['data'], + policy_id: fullPolicy.id, + default_fleet_server: policy.is_default_fleet_server === true, }; await esClient.create({ diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 5105e145309827..d73cc38e32c39d 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -8,8 +8,16 @@ import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { AgentAction, AgentActionSOAttributes } from '../../types'; import { AGENT_ACTION_SAVED_OBJECT_TYPE } from '../../constants'; +import { agentPolicyService } from '../../services'; +import { IngestManagerError } from '../../errors'; import { bulkCreateAgentActions, createAgentAction } from './actions'; -import { getAgents, listAllAgents, updateAgent, bulkUpdateAgents } from './crud'; +import { + getAgents, + listAllAgents, + updateAgent, + bulkUpdateAgents, + getAgentPolicyForAgent, +} from './crud'; import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; @@ -31,6 +39,14 @@ export async function sendUpgradeAgentAction({ version, source_uri: sourceUri, }; + + const agentPolicy = await getAgentPolicyForAgent(soClient, esClient, agentId); + if (agentPolicy?.is_managed) { + throw new IngestManagerError( + `Cannot upgrade agent ${agentId} in managed policy ${agentPolicy.id}` + ); + } + await createAgentAction(soClient, esClient, { agent_id: agentId, created_at: now, @@ -89,19 +105,40 @@ export async function sendUpgradeAgentsActions( showInactive: false, }) ).agents; - const agentsToUpdate = options.force + + // upgradeable if they pass the version check + const upgradeableAgents = options.force ? agents : agents.filter((agent) => isAgentUpgradeable(agent, kibanaVersion)); + + // get any policy ids from upgradable agents + const policyIdsToGet = new Set( + upgradeableAgents.filter((agent) => agent.policy_id).map((agent) => agent.policy_id!) + ); + + // get the agent policies for those ids + const agentPolicies = await agentPolicyService.getByIDs(soClient, Array.from(policyIdsToGet), { + fields: ['is_managed'], + }); + + // throw if any of those agent policies are managed + for (const policy of agentPolicies) { + if (policy.is_managed) { + throw new IngestManagerError(`Cannot upgrade agent in managed policy ${policy.id}`); + } + } + + // Create upgrade action for each agent const now = new Date().toISOString(); const data = { version: options.version, source_uri: options.sourceUri, }; - // Create upgrade action for each agent + await bulkCreateAgentActions( soClient, esClient, - agentsToUpdate.map((agent) => ({ + upgradeableAgents.map((agent) => ({ agent_id: agent.id, created_at: now, data, @@ -113,7 +150,7 @@ export async function sendUpgradeAgentsActions( return await bulkUpdateAgents( soClient, esClient, - agentsToUpdate.map((agent) => ({ + upgradeableAgents.map((agent) => ({ agentId: agent.id, data: { upgraded_at: null, diff --git a/x-pack/plugins/fleet/server/services/app_context.ts b/x-pack/plugins/fleet/server/services/app_context.ts index 02e4fceea54f9e..1ada940dd793c9 100644 --- a/x-pack/plugins/fleet/server/services/app_context.ts +++ b/x-pack/plugins/fleet/server/services/app_context.ts @@ -7,7 +7,7 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { kibanaPackageJSON } from '@kbn/utils'; +import { kibanaPackageJson } from '@kbn/utils'; import { ElasticsearchClient, @@ -34,8 +34,8 @@ class AppContextService { private configSubject$?: BehaviorSubject; private savedObjects: SavedObjectsServiceStart | undefined; private isProductionMode: FleetAppContext['isProductionMode'] = false; - private kibanaVersion: FleetAppContext['kibanaVersion'] = kibanaPackageJSON.version; - private kibanaBranch: FleetAppContext['kibanaBranch'] = kibanaPackageJSON.branch; + private kibanaVersion: FleetAppContext['kibanaVersion'] = kibanaPackageJson.version; + private kibanaBranch: FleetAppContext['kibanaBranch'] = kibanaPackageJson.branch; private cloud?: CloudSetup; private logger: Logger | undefined; private httpSetup?: HttpServiceSetup; @@ -80,6 +80,10 @@ class AppContextService { return this.security; } + public hasSecurity() { + return !!this.security; + } + public getCloud() { return this.cloud; } 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 7ab904b2f15e13..4509deee0d00f7 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 @@ -179,4 +179,79 @@ input: logs input: 'logs', }); }); + + it('should escape string values when necessary', () => { + const stringTemplate = ` +my-package: + opencurly: {{opencurly}} + closecurly: {{closecurly}} + opensquare: {{opensquare}} + closesquare: {{closesquare}} + ampersand: {{ampersand}} + asterisk: {{asterisk}} + question: {{question}} + pipe: {{pipe}} + hyphen: {{hyphen}} + openangle: {{openangle}} + closeangle: {{closeangle}} + equals: {{equals}} + exclamation: {{exclamation}} + percent: {{percent}} + at: {{at}} + colon: {{colon}} + numeric: {{numeric}} + mixed: {{mixed}}`; + + // List of special chars that may lead to YAML parsing errors when not quoted. + // See YAML specification section 5.3 Indicator characters + // https://yaml.org/spec/1.2/spec.html#id2772075 + // {,},[,],&,*,?,|,-,<,>,=,!,%,@,: + const vars = { + opencurly: { value: '{', type: 'string' }, + closecurly: { value: '}', type: 'string' }, + opensquare: { value: '[', type: 'string' }, + closesquare: { value: ']', type: 'string' }, + comma: { value: ',', type: 'string' }, + ampersand: { value: '&', type: 'string' }, + asterisk: { value: '*', type: 'string' }, + question: { value: '?', type: 'string' }, + pipe: { value: '|', type: 'string' }, + hyphen: { value: '-', type: 'string' }, + openangle: { value: '<', type: 'string' }, + closeangle: { value: '>', type: 'string' }, + equals: { value: '=', type: 'string' }, + exclamation: { value: '!', type: 'string' }, + percent: { value: '%', type: 'string' }, + at: { value: '@', type: 'string' }, + colon: { value: ':', type: 'string' }, + numeric: { value: '100', type: 'string' }, + mixed: { value: '1s', type: 'string' }, + }; + + const targetOutput = { + 'my-package': { + opencurly: '{', + closecurly: '}', + opensquare: '[', + closesquare: ']', + ampersand: '&', + asterisk: '*', + question: '?', + pipe: '|', + hyphen: '-', + openangle: '<', + closeangle: '>', + equals: '=', + exclamation: '!', + percent: '%', + at: '@', + colon: ':', + numeric: '100', + mixed: '1s', + }, + }; + + const output = compileTemplate(vars, stringTemplate); + expect(output).toEqual(targetOutput); + }); }); 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 4f39da5b0b70d7..a71776af245f77 100644 --- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts @@ -13,7 +13,6 @@ const handlebars = Handlebars.create(); export function compileTemplate(variables: PackagePolicyConfigRecord, templateStr: string) { const { vars, yamlValues } = buildTemplateVariables(variables, templateStr); - const template = handlebars.compile(templateStr, { noEscape: true }); let compiledTemplate = template(vars); compiledTemplate = replaceRootLevelYamlVariables(yamlValues, compiledTemplate); @@ -58,8 +57,17 @@ function replaceVariablesInYaml(yamlVariables: { [k: string]: any }, yaml: any) return yaml; } -const maybeEscapeNumericString = (value: string) => { - return value.length && !isNaN(+value) ? `"${value}"` : value; +const maybeEscapeString = (value: string) => { + // List of special chars that may lead to YAML parsing errors when not quoted. + // See YAML specification section 5.3 Indicator characters + // https://yaml.org/spec/1.2/spec.html#id2772075 + const yamlSpecialCharsRegex = /[{}\[\],&*?|\-<>=!%@:]/; + + // In addition, numeric strings need to be quoted to stay strings. + if ((value.length && !isNaN(+value)) || yamlSpecialCharsRegex.test(value)) { + return `"${value}"`; + } + return value; }; function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateStr: string) { @@ -88,13 +96,15 @@ 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) { + } else if ( + recordEntry.type && + (recordEntry.type === 'text' || recordEntry.type === 'string') && + recordEntry.value?.length + ) { if (Array.isArray(recordEntry.value)) { - varPart[lastKeyPart] = recordEntry.value.map((value: string) => - maybeEscapeNumericString(value) - ); + varPart[lastKeyPart] = recordEntry.value.map((value: string) => maybeEscapeString(value)); } else { - varPart[lastKeyPart] = maybeEscapeNumericString(recordEntry.value); + varPart[lastKeyPart] = maybeEscapeString(recordEntry.value); } } else { varPart[lastKeyPart] = recordEntry.value; diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts new file mode 100644 index 00000000000000..96e642ba9884e8 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { elasticsearchServiceMock } from 'src/core/server/mocks'; +import hash from 'object-hash'; +import { setupFleetServerIndexes } from './elastic_index'; +import ESFleetAgentIndex from './elasticsearch/fleet_agents.json'; +import ESFleetPoliciesIndex from './elasticsearch/fleet_policies.json'; +import ESFleetPoliciesLeaderIndex from './elasticsearch/fleet_policies_leader.json'; +import ESFleetServersIndex from './elasticsearch/fleet_servers.json'; +import ESFleetEnrollmentApiKeysIndex from './elasticsearch/fleet_enrollment_api_keys.json'; +import EsFleetActionsIndex from './elasticsearch/fleet_actions.json'; + +const FLEET_INDEXES_MIGRATION_HASH = { + '.fleet-actions': hash(EsFleetActionsIndex), + '.fleet-agents': hash(ESFleetAgentIndex), + '.fleet-enrollment-apy-keys': hash(ESFleetEnrollmentApiKeysIndex), + '.fleet-policies': hash(ESFleetPoliciesIndex), + '.fleet-policies-leader': hash(ESFleetPoliciesLeaderIndex), + '.fleet-servers': hash(ESFleetServersIndex), +}; + +describe('setupFleetServerIndexes ', () => { + it('should create all the indices and aliases if nothings exists', async () => { + const esMock = elasticsearchServiceMock.createInternalClient(); + await setupFleetServerIndexes(esMock); + + const indexesCreated = esMock.indices.create.mock.calls.map((call) => call[0].index).sort(); + expect(indexesCreated).toEqual([ + '.fleet-actions_1', + '.fleet-agents_1', + '.fleet-enrollment-api-keys_1', + '.fleet-policies-leader_1', + '.fleet-policies_1', + '.fleet-servers_1', + ]); + const aliasesCreated = esMock.indices.updateAliases.mock.calls + .map((call) => (call[0].body as any)?.actions[0].add.alias) + .sort(); + + expect(aliasesCreated).toEqual([ + '.fleet-actions', + '.fleet-agents', + '.fleet-enrollment-api-keys', + '.fleet-policies', + '.fleet-policies-leader', + '.fleet-servers', + ]); + }); + + it('should not create any indices and create aliases if indices exists but not the aliases', async () => { + const esMock = elasticsearchServiceMock.createInternalClient(); + // @ts-expect-error + esMock.indices.exists.mockResolvedValue({ body: true }); + // @ts-expect-error + esMock.indices.getMapping.mockImplementation((params: { index: string }) => { + return { + body: { + [params.index]: { + mappings: { + _meta: { + // @ts-expect-error + migrationHash: FLEET_INDEXES_MIGRATION_HASH[params.index.replace(/_1$/, '')], + }, + }, + }, + }, + }; + }); + + await setupFleetServerIndexes(esMock); + + expect(esMock.indices.create).not.toBeCalled(); + const aliasesCreated = esMock.indices.updateAliases.mock.calls + .map((call) => (call[0].body as any)?.actions[0].add.alias) + .sort(); + + expect(aliasesCreated).toEqual([ + '.fleet-actions', + '.fleet-agents', + '.fleet-enrollment-api-keys', + '.fleet-policies', + '.fleet-policies-leader', + '.fleet-servers', + ]); + }); + + it('should put new indices mapping if the mapping has been updated ', async () => { + const esMock = elasticsearchServiceMock.createInternalClient(); + // @ts-expect-error + esMock.indices.exists.mockResolvedValue({ body: true }); + // @ts-expect-error + esMock.indices.getMapping.mockImplementation((params: { index: string }) => { + return { + body: { + [params.index]: { + mappings: { + _meta: { + migrationHash: 'NOT_VALID_HASH', + }, + }, + }, + }, + }; + }); + + await setupFleetServerIndexes(esMock); + + expect(esMock.indices.create).not.toBeCalled(); + const indexesMappingUpdated = esMock.indices.putMapping.mock.calls + .map((call) => call[0].index) + .sort(); + + expect(indexesMappingUpdated).toEqual([ + '.fleet-actions_1', + '.fleet-agents_1', + '.fleet-enrollment-api-keys_1', + '.fleet-policies-leader_1', + '.fleet-policies_1', + '.fleet-servers_1', + ]); + }); + + it('should not create any indices or aliases if indices and aliases already exists', async () => { + const esMock = elasticsearchServiceMock.createInternalClient(); + + // @ts-expect-error + esMock.indices.exists.mockResolvedValue({ body: true }); + // @ts-expect-error + esMock.indices.getMapping.mockImplementation((params: { index: string }) => { + return { + body: { + [params.index]: { + mappings: { + _meta: { + // @ts-expect-error + migrationHash: FLEET_INDEXES_MIGRATION_HASH[params.index.replace(/_1$/, '')], + }, + }, + }, + }, + }; + }); + // @ts-expect-error + esMock.indices.existsAlias.mockResolvedValue({ body: true }); + + await setupFleetServerIndexes(esMock); + + expect(esMock.indices.create).not.toBeCalled(); + expect(esMock.indices.updateAliases).not.toBeCalled(); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts new file mode 100644 index 00000000000000..15672be756fe2d --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elastic_index.ts @@ -0,0 +1,117 @@ +/* + * 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 { ElasticsearchClient } from 'kibana/server'; +import hash from 'object-hash'; + +import { FLEET_SERVER_INDICES, FLEET_SERVER_INDICES_VERSION } from '../../../common'; +import { appContextService } from '../app_context'; +import ESFleetAgentIndex from './elasticsearch/fleet_agents.json'; +import ESFleetPoliciesIndex from './elasticsearch/fleet_policies.json'; +import ESFleetPoliciesLeaderIndex from './elasticsearch/fleet_policies_leader.json'; +import ESFleetServersIndex from './elasticsearch/fleet_servers.json'; +import ESFleetEnrollmentApiKeysIndex from './elasticsearch/fleet_enrollment_api_keys.json'; +import EsFleetActionsIndex from './elasticsearch/fleet_actions.json'; + +const FLEET_INDEXES: Array<[typeof FLEET_SERVER_INDICES[number], any]> = [ + ['.fleet-actions', EsFleetActionsIndex], + ['.fleet-agents', ESFleetAgentIndex], + ['.fleet-enrollment-api-keys', ESFleetEnrollmentApiKeysIndex], + ['.fleet-policies', ESFleetPoliciesIndex], + ['.fleet-policies-leader', ESFleetPoliciesLeaderIndex], + ['.fleet-servers', ESFleetServersIndex], +]; + +export async function setupFleetServerIndexes( + esClient = appContextService.getInternalUserESClient() +) { + await Promise.all( + FLEET_INDEXES.map(async ([indexAlias, indexData]) => { + const index = `${indexAlias}_${FLEET_SERVER_INDICES_VERSION}`; + await createOrUpdateIndex(esClient, index, indexData); + await createAliasIfDoNotExists(esClient, indexAlias, index); + }) + ); +} + +export async function createAliasIfDoNotExists( + esClient: ElasticsearchClient, + alias: string, + index: string +) { + const { body: exists } = await esClient.indices.existsAlias({ + name: alias, + }); + + if (exists === true) { + return; + } + await esClient.indices.updateAliases({ + body: { + actions: [ + { + add: { index, alias }, + }, + ], + }, + }); +} + +async function createOrUpdateIndex( + esClient: ElasticsearchClient, + indexName: string, + indexData: any +) { + const resExists = await esClient.indices.exists({ + index: indexName, + }); + + // Support non destructive migration only (adding new field) + if (resExists.body === true) { + return updateIndex(esClient, indexName, indexData); + } + + return createIndex(esClient, indexName, indexData); +} + +async function updateIndex(esClient: ElasticsearchClient, indexName: string, indexData: any) { + const res = await esClient.indices.getMapping({ + index: indexName, + }); + + const migrationHash = hash(indexData); + if (res.body[indexName].mappings?._meta?.migrationHash !== migrationHash) { + await esClient.indices.putMapping({ + index: indexName, + body: Object.assign({ + ...indexData.mappings, + _meta: { ...(indexData.mappings._meta || {}), migrationHash }, + }), + }); + } +} + +async function createIndex(esClient: ElasticsearchClient, indexName: string, indexData: any) { + try { + const migrationHash = hash(indexData); + await esClient.indices.create({ + index: indexName, + body: { + ...indexData, + mappings: Object.assign({ + ...indexData.mappings, + _meta: { ...(indexData.mappings._meta || {}), migrationHash }, + }), + }, + }); + } catch (err) { + // Swallow already exists errors as concurent Kibana can try to create that indice + if (err?.body?.error?.type !== 'resource_already_exists_exception') { + throw err; + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_actions.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_actions.json new file mode 100644 index 00000000000000..3008ee74ab50c8 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_actions.json @@ -0,0 +1,30 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "action_id": { + "type": "keyword" + }, + "agents": { + "type": "keyword" + }, + "data": { + "enabled": false, + "type": "object" + }, + "expiration": { + "type": "date" + }, + "input_type": { + "type": "keyword" + }, + "@timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json new file mode 100644 index 00000000000000..9937e9ad66e56f --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_agents.json @@ -0,0 +1,220 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "action_seq_no": { + "type": "integer" + }, + "active": { + "type": "boolean" + }, + "agent": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "default_api_key": { + "type": "keyword" + }, + "default_api_key_id": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_checkin_status": { + "type": "keyword" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "properties": { + "elastic": { + "properties": { + "agent": { + "properties": { + "build": { + "properties": { + "original": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "id": { + "type": "keyword" + }, + "log_level": { + "type": "keyword" + }, + "snapshot": { + "type": "boolean" + }, + "upgradeable": { + "type": "boolean" + }, + "version": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 16 + } + } + } + } + } + } + }, + "host": { + "properties": { + "architecture": { + "type": "keyword" + }, + "hostname": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "id": { + "type": "keyword" + }, + "ip": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 64 + } + } + }, + "mac": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 17 + } + } + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "os": { + "properties": { + "family": { + "type": "keyword" + }, + "full": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 128 + } + } + }, + "kernel": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 128 + } + } + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "platform": { + "type": "keyword" + }, + "version": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 32 + } + } + } + } + } + } + }, + "packages": { + "type": "keyword" + }, + "policy_coordinator_idx": { + "type": "integer" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision_idx": { + "type": "integer" + }, + "shared_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "unenrolled_at": { + "type": "date" + }, + "unenrollment_started_at": { + "type": "date" + }, + "updated_at": { + "type": "date" + }, + "upgrade_started_at": { + "type": "date" + }, + "upgraded_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "object", + "enabled": false + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_enrollment_api_keys.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_enrollment_api_keys.json new file mode 100644 index 00000000000000..fc3898aff55c66 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_enrollment_api_keys.json @@ -0,0 +1,32 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "keyword" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies.json new file mode 100644 index 00000000000000..50078aaa5ea988 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies.json @@ -0,0 +1,27 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "coordinator_idx": { + "type": "integer" + }, + "data": { + "enabled": false, + "type": "object" + }, + "default_fleet_server": { + "type": "boolean" + }, + "policy_id": { + "type": "keyword" + }, + "revision_idx": { + "type": "integer" + }, + "@timestamp": { + "type": "date" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies_leader.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies_leader.json new file mode 100644 index 00000000000000..ad3dfe64df57c3 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_policies_leader.json @@ -0,0 +1,21 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "server": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "@timestamp": { + "type": "date" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_servers.json b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_servers.json new file mode 100644 index 00000000000000..9ee68735d5b6fc --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/elasticsearch/fleet_servers.json @@ -0,0 +1,47 @@ +{ + "settings": {}, + "mappings": { + "dynamic": false, + "properties": { + "agent": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "ip": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + }, + "server": { + "properties": { + "id": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "@timestamp": { + "type": "date" + } + } + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server/index.ts b/x-pack/plugins/fleet/server/services/fleet_server/index.ts new file mode 100644 index 00000000000000..0b54dc0d168b4f --- /dev/null +++ b/x-pack/plugins/fleet/server/services/fleet_server/index.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 { first } from 'rxjs/operators'; +import { appContextService } from '../app_context'; +import { licenseService } from '../license'; +import { setupFleetServerIndexes } from './elastic_index'; +import { runFleetServerMigration } from './saved_object_migrations'; + +let _isFleetServerSetup = false; +let _isPending = false; +let _status: Promise | undefined; +let _onResolve: (arg?: any) => void; + +export function isFleetServerSetup() { + return _isFleetServerSetup; +} + +export function awaitIfFleetServerSetupPending() { + if (!_isPending) { + return; + } + + return _status; +} + +export async function startFleetServerSetup() { + _isPending = true; + _status = new Promise((resolve) => { + _onResolve = resolve; + }); + const logger = appContextService.getLogger(); + if (!appContextService.hasSecurity()) { + // Fleet will not work if security is not enabled + logger?.warn('Fleet requires the security plugin to be enabled.'); + return; + } + + try { + // We need licence to be initialized before using the SO service. + await licenseService.getLicenseInformation$()?.pipe(first())?.toPromise(); + await setupFleetServerIndexes(); + await runFleetServerMigration(); + _isFleetServerSetup = true; + } catch (err) { + logger?.error('Setup for central management of agents failed.'); + logger?.error(err); + } + _isPending = false; + if (_onResolve) { + _onResolve(); + } +} diff --git a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts b/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts similarity index 84% rename from x-pack/plugins/fleet/server/services/fleet_server_migration.ts rename to x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts index 170bec54983c0e..84e6b06e59844f 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server/saved_object_migrations.ts @@ -17,38 +17,12 @@ import { AgentSOAttributes, FleetServerAgent, SO_SEARCH_LIMIT, - FLEET_SERVER_PACKAGE, - FLEET_SERVER_INDICES, -} from '../../common'; -import { listEnrollmentApiKeys, getEnrollmentAPIKey } from './api_keys/enrollment_api_key_so'; -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(), - pkgName: FLEET_SERVER_PACKAGE, - }); - - if (!pkgInstall) { - return false; - } +} from '../../../common'; +import { listEnrollmentApiKeys, getEnrollmentAPIKey } from '../api_keys/enrollment_api_key_so'; +import { appContextService } from '../app_context'; - const esClient = appContextService.getInternalUserESClient(); - const exists = await Promise.all( - FLEET_SERVER_INDICES.map(async (index) => { - const res = await esClient.indices.exists({ - index, - }); - return res.statusCode !== 404; - }) - ); - - return exists.every((exist) => exist === true); -} +import { isAgentsSetup } from '../agents'; +import { agentPolicyService } from '../agent_policy'; export async function runFleetServerMigration() { // If Agents are not setup skip as there is nothing to migrate diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index a882ceb0037f24..335cd7c956faf9 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -20,6 +20,7 @@ import { PackagePolicyInputStream, PackageInfo, ListWithKuery, + ListResult, packageToPackagePolicy, isPackageLimited, doesAgentPolicyAlreadyIncludePackage, @@ -248,7 +249,7 @@ class PackagePolicyService { public async list( soClient: SavedObjectsClientContract, options: ListWithKuery - ): Promise<{ items: PackagePolicy[]; total: number; page: number; perPage: number }> { + ): Promise> { const { page = 1, perPage = 20, sortField = 'updated_at', sortOrder = 'desc', kuery } = options; const packagePolicies = await soClient.find({ @@ -272,6 +273,30 @@ class PackagePolicyService { }; } + public async listIds( + soClient: SavedObjectsClientContract, + options: ListWithKuery + ): Promise> { + const { page = 1, perPage = 20, sortField = 'updated_at', sortOrder = 'desc', kuery } = options; + + const packagePolicies = await soClient.find<{}>({ + type: SAVED_OBJECT_TYPE, + sortField, + sortOrder, + page, + perPage, + fields: [], + filter: kuery ? normalizeKuery(SAVED_OBJECT_TYPE, kuery) : undefined, + }); + + return { + items: packagePolicies.saved_objects.map((packagePolicySO) => packagePolicySO.id), + total: packagePolicies.total, + page, + perPage, + }; + } + public async update( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 6c8f24e7995742..2a3166e9dc7296 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -23,7 +23,6 @@ import { Output, DEFAULT_AGENT_POLICIES_PACKAGES, FLEET_SERVER_PACKAGE, - FLEET_SERVER_INDICES, } from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; import { getPackageInfo } from './epm/packages'; @@ -34,7 +33,7 @@ import { awaitIfPending } from './setup_utils'; import { createDefaultSettings } from './settings'; import { ensureAgentActionPolicyChangeExists } from './agents'; import { appContextService } from './app_context'; -import { runFleetServerMigration } from './fleet_server_migration'; +import { awaitIfFleetServerSetupPending } from './fleet_server'; const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; @@ -56,15 +55,20 @@ async function createSetupSideEffects( esClient: ElasticsearchClient, callCluster: CallESAsCurrentUser ): Promise { + const isFleetServerEnabled = appContextService.getConfig()?.agents.fleetServerEnabled; const [ installedPackages, defaultOutput, { created: defaultAgentPolicyCreated, defaultAgentPolicy }, + { created: defaultFleetServerPolicyCreated, policy: defaultFleetServerPolicy }, ] = await Promise.all([ // packages installed by default ensureInstalledDefaultPackages(soClient, callCluster), outputService.ensureDefaultOutput(soClient), agentPolicyService.ensureDefaultAgentPolicy(soClient, esClient), + isFleetServerEnabled + ? agentPolicyService.ensureDefaultFleetServerAgentPolicy(soClient, esClient) + : {}, updateFleetRoleIfExists(callCluster), settingsService.getSettings(soClient).catch((e: any) => { if (e.isBoom && e.output.statusCode === 404) { @@ -83,26 +87,30 @@ async function createSetupSideEffects( // By moving this outside of the Promise.all, the upgrade will occur first, and then we'll attempt to reinstall any // packages that are stuck in the installing state. await ensurePackagesCompletedInstall(soClient, callCluster); - if (appContextService.getConfig()?.agents.fleetServerEnabled) { - await ensureInstalledPackage({ - savedObjectsClient: soClient, - pkgName: FLEET_SERVER_PACKAGE, - callCluster, - }); - await ensureFleetServerIndicesCreated(esClient); - await runFleetServerMigration(); - } - if (appContextService.getConfig()?.agents?.fleetServerEnabled) { - await ensureInstalledPackage({ + if (isFleetServerEnabled) { + await awaitIfFleetServerSetupPending(); + + const fleetServerPackage = await ensureInstalledPackage({ savedObjectsClient: soClient, pkgName: FLEET_SERVER_PACKAGE, callCluster, }); - await ensureFleetServerIndicesCreated(esClient); - await runFleetServerMigration(); + + if (defaultFleetServerPolicyCreated) { + await addPackageToAgentPolicy( + soClient, + esClient, + callCluster, + fleetServerPackage, + defaultFleetServerPolicy, + defaultOutput + ); + } } + // If we just created the default fleet server policy add the fleet server package + // If we just created the default policy, ensure default packages are added to it if (defaultAgentPolicyCreated) { const agentPolicyWithPackagePolicies = await agentPolicyService.get( @@ -169,21 +177,6 @@ async function updateFleetRoleIfExists(callCluster: CallESAsCurrentUser) { return putFleetRole(callCluster); } -async function ensureFleetServerIndicesCreated(esClient: ElasticsearchClient) { - await Promise.all( - FLEET_SERVER_INDICES.map(async (index) => { - const res = await esClient.indices.exists({ - index, - }); - if (res.statusCode === 404) { - await esClient.indices.create({ - index, - }); - } - }) - ); -} - async function putFleetRole(callCluster: CallESAsCurrentUser) { return callCluster('transport.request', { method: 'PUT', diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json index 152fb2e132f626..e6dc206912c4bd 100644 --- a/x-pack/plugins/fleet/tsconfig.json +++ b/x-pack/plugins/fleet/tsconfig.json @@ -5,13 +5,14 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - "declarationMap": true + "declarationMap": true, }, "include": [ // add all the folders containg files to be compiled "common/**/*", "public/**/*", "server/**/*", + "server/**/*.json", "scripts/**/*", "package.json", "../../typings/**/*" diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts index 38cfad1a3b188b..36f04be3b30b1d 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts @@ -22,8 +22,8 @@ const PERCENT_SIGN_NAME = 'test%'; const PERCENT_SIGN_WITH_OTHER_CHARS_NAME = 'test%#'; const PERCENT_SIGN_25_SEQUENCE = 'test%25'; -const createPolicyTitle = 'Create Policy'; -const editPolicyTitle = 'Edit Policy'; +const createPolicyTitle = 'Create policy'; +const editPolicyTitle = 'Edit policy'; window.scrollTo = jest.fn(); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 7e1b7c5267a8bf..83a13f0523a403 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -271,6 +271,9 @@ export const setup = async (arg?: { appServicesContext: Partial (): boolean => + exists(`${phase}-rolloverMinAgeInputIconTip`); + return { ...testBed, actions: { @@ -306,6 +309,7 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-warm'), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('warm'), ...createShrinkActions('warm'), ...createForceMergeActions('warm'), setReadonly: setReadonly('warm'), @@ -321,11 +325,13 @@ export const setup = async (arg?: { appServicesContext: Partial exists('phaseErrorIndicator-cold'), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('cold'), ...createIndexPriorityActions('cold'), ...createSearchableSnapshotActions('cold'), }, delete: { ...createToggleDeletePhaseActions(), + hasRolloverTipOnMinAge: hasRolloverTipOnMinAge('delete'), setMinAgeValue: setMinAgeValue('delete'), setMinAgeUnits: setMinAgeUnits('delete'), }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 6f325084938e8d..f1a15d805faf8a 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -769,6 +769,38 @@ describe('', () => { }); }); }); + describe('with rollover', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); + httpRequestsMockHelpers.setListNodes({ + isUsingDeprecatedDataRoleConfig: false, + nodesByAttributes: { test: ['123'] }, + nodesByRoles: { data: ['123'] }, + }); + httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['abc'] }); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); + + await act(async () => { + testBed = await setup(); + }); + + const { component } = testBed; + component.update(); + }); + + test('shows rollover tip on minimum age', async () => { + const { actions } = testBed; + + await actions.warm.enable(true); + await actions.cold.enable(true); + await actions.delete.enablePhase(); + + expect(actions.warm.hasRolloverTipOnMinAge()).toBeTruthy(); + expect(actions.cold.hasRolloverTipOnMinAge()).toBeTruthy(); + expect(actions.delete.hasRolloverTipOnMinAge()).toBeTruthy(); + }); + }); + describe('without rollover', () => { beforeEach(async () => { httpRequestsMockHelpers.setLoadPolicies([getDefaultHotPhasePolicy('my_policy')]); @@ -778,6 +810,7 @@ describe('', () => { nodesByRoles: { data: ['123'] }, }); httpRequestsMockHelpers.setListSnapshotRepos({ repositories: ['found-snapshots'] }); + httpRequestsMockHelpers.setLoadSnapshotPolicies([]); await act(async () => { testBed = await setup({ @@ -799,6 +832,20 @@ describe('', () => { expect(actions.hot.searchableSnapshotsExists()).toBeFalsy(); expect(actions.cold.searchableSnapshotDisabledDueToRollover()).toBeTruthy(); }); + + test('hiding rollover tip on minimum age', async () => { + const { actions } = testBed; + await actions.hot.toggleDefaultRollover(false); + await actions.hot.toggleRollover(false); + + await actions.warm.enable(true); + await actions.cold.enable(true); + await actions.delete.enablePhase(); + + expect(actions.warm.hasRolloverTipOnMinAge()).toBeFalsy(); + expect(actions.cold.hasRolloverTipOnMinAge()).toBeFalsy(); + expect(actions.delete.hasRolloverTipOnMinAge()).toBeFalsy(); + }); }); describe('policy timeline', () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index dc4f1e31d3696e..ccc553c58e8993 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -13,4 +13,5 @@ export { FieldLoadingError } from './field_loading_error'; export { Timeline } from './timeline'; export { FormErrorsCallout } from './form_errors_callout'; export { PhaseFooter } from './phase_footer'; +export { InfinityIcon } from './infinity_icon'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx index 82f0725bfe7d0f..22422ceab8a040 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx @@ -17,6 +17,20 @@ import { usePhaseTimings } from '../../form'; import { InfinityIconSvg } from '../infinity_icon/infinity_icon.svg'; +const deleteDataLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.phaseTiming.beforeDeleteDescription', + { + defaultMessage: 'Delete data after this phase', + } +); + +const keepDataLabel = i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.phaseTiming.foreverTimingDescription', + { + defaultMessage: 'Keep data in this phase forever', + } +); + interface Props { phase: PhasesExceptDelete; } @@ -31,15 +45,6 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { if (!phaseConfiguration.isFinalDataPhase) { return null; } - - const phaseDescription = isDeletePhaseEnabled - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseTiming.beforeDeleteDescription', { - defaultMessage: 'Data will be deleted after this phase', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseTiming.foreverTimingDescription', { - defaultMessage: 'Data will remain in this phase forever', - }); - const selectedButton = isDeletePhaseEnabled ? 'ilmEnableDeletePhaseButton' : 'ilmDisableDeletePhaseButton'; @@ -47,22 +52,12 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { const buttons = [ { id: `ilmDisableDeletePhaseButton`, - label: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.disablePhaseButtonLabel', - { - defaultMessage: 'Keep data in this phase forever', - } - ), + label: keepDataLabel, iconType: InfinityIconSvg, }, { id: `ilmEnableDeletePhaseButton`, - label: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.enablePhaseButtonLabel', - { - defaultMessage: 'Delete data after this phase', - } - ), + label: deleteDataLabel, iconType: 'trash', 'data-test-subj': 'enableDeletePhaseButton', }, @@ -72,7 +67,7 @@ export const PhaseFooter: FunctionComponent = ({ phase }) => { - {phaseDescription} + {isDeletePhaseEnabled ? deleteDataLabel : keepDataLabel} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index c77493476b9295..6d4e2750bb2e88 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -16,7 +16,6 @@ import { EuiCallOut, EuiTextColor, EuiSwitch, - EuiIconTip, EuiText, } from '@elastic/eui'; @@ -121,25 +120,12 @@ export const HotPhase: FunctionComponent = () => {
path="_meta.hot.customRollover.enabled"> {(field) => ( - <> - field.setValue(e.target.checked)} - data-test-subj="rolloverSwitch" - /> -   - - } - /> - + field.setValue(e.target.checked)} + data-test-subj="rolloverSwitch" + /> )} {isUsingRollover && ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx index 2f1a058f5a9436..04b756dc235598 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -17,11 +17,12 @@ import { EuiFormRow, EuiSelect, EuiText, + EuiIconTip, } from '@elastic/eui'; import { getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; -import { UseField } from '../../../../form'; +import { UseField, useConfigurationIssues } from '../../../../form'; import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; @@ -62,6 +63,17 @@ const i18nTexts = { defaultMessage: 'nanoseconds', } ), + rolloverToolTipDescription: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minimumAge.rolloverToolTipDescription', + { + defaultMessage: + 'Data age is calculated from rollover. Rollover is configured in the hot phase.', + } + ), + minAgeUnitFieldSuffix: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.minimumAge.minimumAgeFieldSuffixLabel', + { defaultMessage: 'old' } + ), }; interface Props { @@ -69,6 +81,7 @@ interface Props { } export const MinAgeField: FunctionComponent = ({ phase }): React.ReactElement => { + const { isUsingRollover } = useConfigurationIssues(); return ( {(field) => { @@ -110,6 +123,22 @@ export const MinAgeField: FunctionComponent = ({ phase }): React.ReactEle const { isInvalid: isUnitFieldInvalid } = getFieldValidityAndErrorMessage( unitField ); + const icon = ( + <> + {/* This element is rendered for testing purposes only */} +
+ + + ); + const selectAppendValue: Array< + string | React.ReactElement + > = isUsingRollover + ? [i18nTexts.minAgeUnitFieldSuffix, icon] + : [i18nTexts.minAgeUnitFieldSuffix]; return ( = ({ phase }): React.ReactEle unitField.setValue(e.target.value); }} isInvalid={isUnitFieldInvalid} - append={'old'} + append={selectAppendValue} data-test-subj={`${phase}-selectedMinimumAgeUnits`} aria-label={getUnitsAriaLabelForPhase(phase)} options={[ diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx index 8097ab51eb59e3..c996c45171d2ff 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React, { FunctionComponent, memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiText, EuiIconTip } from '@elastic/eui'; @@ -18,7 +19,7 @@ import { AbsoluteTimings, } from '../../lib'; -import { InfinityIcon } from '../infinity_icon'; +import { InfinityIcon, LearnMoreLink } from '..'; import { TimelinePhaseText } from './components'; @@ -47,7 +48,7 @@ const SCORE_BUFFER_AMOUNT = 50; const i18nTexts = { title: i18n.translate('xpack.indexLifecycleMgmt.timeline.title', { - defaultMessage: 'Policy Summary', + defaultMessage: 'Policy summary', }), description: i18n.translate('xpack.indexLifecycleMgmt.timeline.description', { defaultMessage: 'This policy moves data through the following phases.', @@ -55,13 +56,6 @@ const i18nTexts = { hotPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.hotPhaseSectionTitle', { defaultMessage: 'Hot phase', }), - rolloverTooltip: i18n.translate( - 'xpack.indexLifecycleMgmt.timeline.hotPhaseRolloverToolTipContent', - { - defaultMessage: - 'How long it takes to reach the rollover criteria in the hot phase can vary. Data moves to the next phase when the time since rollover reaches the minimum age.', - } - ), warmPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle', { defaultMessage: 'Warm phase', }), @@ -143,6 +137,16 @@ export const Timeline: FunctionComponent = memo( {i18nTexts.description} +   + + } + /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 0c7b5565372a55..befb8faf51aa19 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -142,10 +142,10 @@ export const EditPolicy: React.FunctionComponent = ({ history }) => {

{isNewPolicy ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create Policy', + defaultMessage: 'Create policy', }) : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit Policy {originalPolicyName}', + defaultMessage: 'Edit policy {originalPolicyName}', values: { originalPolicyName }, })}

@@ -258,33 +258,8 @@ export const EditPolicy: React.FunctionComponent = ({ history }) => { - - - {isShowingPolicyJsonFlyout ? ( - - ) : ( - - )} - - - - - - - - - = ({ history }) => { )} + + + + + + + + + + {isShowingPolicyJsonFlyout ? ( + + ) : ( + + )} + + {isShowingPolicyJsonFlyout ? ( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 3923cf93cd0d33..1d75fb5031216e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -189,27 +189,26 @@ export const i18nTexts = { defaultMessage: 'Cold phase', }), delete: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseTitle', { - defaultMessage: 'Delete Data', + defaultMessage: 'Delete phase', }), }, descriptions: { hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescription', { defaultMessage: - 'You actively store and query data in the hot phase. All policies have a hot phase.', + 'Store your most-recent, most frequently-searched data in the hot tier, which provides the best indexing and search performance at the highest cost.', }), warm: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseDescription', { defaultMessage: - 'You are still querying your index, but it is read-only. You can allocate shards to less performant hardware. For faster searches, you can reduce the number of shards and force merge segments.', + 'Move data to the warm tier, which is optimized for search performance over indexing performance. Data is infrequently added or updated in the warm phase.', }), cold: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescription', { defaultMessage: - 'You are querying your index less frequently, so you can allocate shards on significantly less performant hardware. Because your queries are slower, you can reduce the number of replicas.', + 'Move data to the cold tier, which is optimized for cost savings over search performance. Data is normally read-only in the cold phase.', }), delete: i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseDescription', { - defaultMessage: - 'You no longer need your index. You can define when it is safe to delete it.', + defaultMessage: 'Delete data you no longer need.', } ), }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx index f20ea0f5d1bf46..8971f18ef8e5fe 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/add_policy_to_template_confirm_modal.tsx @@ -13,7 +13,6 @@ import { EuiComboBox, EuiForm, EuiFormRow, - EuiOverlayMask, EuiConfirmModal, EuiFieldText, EuiSpacer, @@ -257,46 +256,44 @@ export const AddPolicyToTemplateConfirmModal: React.FunctionComponent = ( ); return ( - - - -

- {' '} - - } - /> -

-
- - {renderForm()} -
-
+ />{' '} + + } + /> +

+ + + {renderForm()} + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx index 80039a18ef17a9..e42aa97a10d4f1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/confirm_delete.tsx @@ -8,7 +8,7 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { PolicyFromES } from '../../../../../common/types'; import { toasts } from '../../../services/notification'; @@ -50,33 +50,31 @@ export class ConfirmDelete extends Component { values: { name: policyToDelete.name }, }); return ( - - - } - confirmButtonText={ - - } - buttonColor="danger" - > -
- -
-
-
+ + } + confirmButtonText={ + + } + buttonColor="danger" + > +
+ +
+
); } } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx index 550cefb488f667..36df4d9527a5c8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx @@ -16,7 +16,6 @@ import { EuiSelect, EuiForm, EuiFormRow, - EuiOverlayMask, EuiConfirmModal, EuiModal, EuiModalBody, @@ -246,63 +245,59 @@ export class AddLifecyclePolicyConfirmModal extends Component { ); if (!policies.length) { return ( - - - - {title} - + + + {title} + - - + + } + color="warning" + > +

+ - } - color="warning" - > -

- - - -

-
-
-
-
+ +

+ + + ); } return ( - - - } - confirmButtonText={ - - } - > - {this.renderForm()} - - + + } + confirmButtonText={ + + } + > + {this.renderForm()} + ); } } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx index 8ce4ac052fce23..2f22a0b347db95 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/remove_lifecycle_confirm_modal.tsx @@ -8,7 +8,7 @@ import React, { Component, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { removeLifecycleForIndex } from '../../application/services/api'; import { showApiError } from '../../application/services/api_errors'; @@ -57,54 +57,52 @@ export class RemoveLifecyclePolicyConfirmModal extends Component { const { closeModal, indexNames } = this.props; return ( - - + } + onCancel={closeModal} + onConfirm={this.removePolicy} + cancelButtonText={ + + } + buttonColor="danger" + confirmButtonText={ + + } + > + +

- } - onCancel={closeModal} - onConfirm={this.removePolicy} - cancelButtonText={ - - } - buttonColor="danger" - confirmButtonText={ - - } - > - -

- -

+

-
    - {indexNames.map((indexName) => ( -
  • {indexName}
  • - ))} -
-
-
-
+
    + {indexNames.map((indexName) => ( +
  • {indexName}
  • + ))} +
+ + ); } } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx index cac26d948b11aa..0b20bebf431436 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/delete_modal.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -81,49 +81,47 @@ export const ComponentTemplatesDeleteModal = ({ }; return ( - - + } + onCancel={handleOnCancel} + onConfirm={handleDeleteComponentTemplates} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + <> +

- } - onCancel={handleOnCancel} - onConfirm={handleDeleteComponentTemplates} - cancelButtonText={ - - } - confirmButtonText={ - - } - > - <> -

- -

+

-
    - {componentTemplatesToDelete.map((name) => ( -
  • {name}
  • - ))} -
- -
-
+
    + {componentTemplatesToDelete.map((name) => ( +
  • {name}
  • + ))} +
+ + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/modal_confirmation_delete_fields.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/modal_confirmation_delete_fields.tsx index e6a7e42c089365..2a65906ea56b40 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/modal_confirmation_delete_fields.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/modal_confirmation_delete_fields.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiBadge, EuiCode } from '@elastic/eui'; +import { EuiConfirmModal, EuiBadge, EuiCode } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { NormalizedFields, NormalizedField } from '../../../types'; @@ -59,55 +59,53 @@ export const ModalConfirmationDeleteFields = ({ : null; return ( - - + <> + {fieldsTree && ( + <> +

+ {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.confirmationModal.deleteFieldsDescription', + { + defaultMessage: 'This will also delete the following fields.', + } + )} +

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

+ {i18n.translate( + 'xpack.idxMgmt.mappingsEditor.confirmationModal.deleteAliasesDescription', + { + defaultMessage: 'The following aliases will also be deleted.', + } + )} +

+
    + {aliases.map((aliasPath) => ( +
  • + {aliasPath} +
  • + ))} +
+ )} - buttonColor="danger" - confirmButtonText={confirmButtonText} - > - <> - {fieldsTree && ( - <> -

- {i18n.translate( - 'xpack.idxMgmt.mappingsEditor.confirmationModal.deleteFieldsDescription', - { - defaultMessage: 'This will also delete the following fields.', - } - )} -

- - - )} - {aliases && ( - <> -

- {i18n.translate( - 'xpack.idxMgmt.mappingsEditor.confirmationModal.deleteAliasesDescription', - { - defaultMessage: 'The following aliases will also be deleted.', - } - )} -

-
    - {aliases.map((aliasPath) => ( -
  • - {aliasPath} -
  • - ))} -
- - )} - -
-
+ + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.tsx index a4c88b1e61b8b4..8f023156456dcb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/load_mappings_provider.tsx @@ -8,14 +8,7 @@ import React, { useState, useRef, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiConfirmModal, - EuiOverlayMask, - EuiCallOut, - EuiText, - EuiSpacer, - EuiButtonEmpty, -} from '@elastic/eui'; +import { EuiConfirmModal, EuiCallOut, EuiText, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; import { JsonEditor, OnJsonEditorUpdateHandler } from '../../shared_imports'; import { validateMappings, MappingsValidationError } from '../../lib'; @@ -220,61 +213,55 @@ export const LoadMappingsProvider = ({ onJson, children }: Props) => { {children(openModal)} {state.isModalOpen && ( - - - {view === 'json' ? ( - // The CSS override for the EuiCodeEditor requires a parent .application css class -
- - mappings, - }} - /> - - - - - + {view === 'json' ? ( + // The CSS override for the EuiCodeEditor requires a parent .application css class +
+ + mappings, }} /> -
- ) : ( - <> - - -

{i18nTexts.validationErrors.description}

-
- -
    - {state.errors!.slice(0, totalErrorsToDisplay).map((error, i) => ( -
  1. {getErrorMessage(error)}
  2. - ))} -
- {state.errors!.length > MAX_ERRORS_TO_DISPLAY && renderErrorsFilterButton()} -
- - )} - - + + + + + +
+ ) : ( + <> + + +

{i18nTexts.validationErrors.description}

+
+ +
    + {state.errors!.slice(0, totalErrorsToDisplay).map((error, i) => ( +
  1. {getErrorMessage(error)}
  2. + ))} +
+ {state.errors!.length > MAX_ERRORS_TO_DISPLAY && renderErrorsFilterButton()} +
+ + )} +
)} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx index e48172c417d0ad..f9ecca1f8cb61b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/runtime_fields/delete_field_provider.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { useDispatch } from '../../mappings_state_context'; import { NormalizedRuntimeField } from '../../types'; @@ -68,22 +68,20 @@ export const DeleteRuntimeFieldProvider = ({ children }: Props) => { {children(deleteField)} {state.isModalOpen && ( - - - + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx index 2f49e95a1bd62a..d7db98731427db 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/constants/data_types_definition.tsx @@ -86,7 +86,7 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = { id="xpack.idxMgmt.mappingsEditor.dataType.constantKeywordLongDescription" defaultMessage="Constant keyword fields are a special type of keyword fields for fields that contain the same keyword across all documents in the index. Supports the same queries and aggregations as {keyword} fields." values={{ - keyword: {'keyword'}, + keyword: {'keyword'}, }} />

@@ -836,7 +836,7 @@ export const TYPE_DEFINITION: { [key in DataType]: DataTypeDefinition } = { id="xpack.idxMgmt.mappingsEditor.dataType.pointLongDescription" defaultMessage="Point fields enable searching of {code} pairs that fall in a 2-dimensional planar coordinate system." values={{ - code: {'x,y'}, + code: {'x,y'}, }} />

diff --git a/x-pack/plugins/index_management/public/application/components/template_delete_modal.tsx b/x-pack/plugins/index_management/public/application/components/template_delete_modal.tsx index 0dc2407d22c294..f22fa2a3b4f8a5 100644 --- a/x-pack/plugins/index_management/public/application/components/template_delete_modal.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_delete_modal.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiCallOut, EuiCheckbox, EuiBadge } from '@elastic/eui'; +import { EuiConfirmModal, EuiCallOut, EuiCheckbox, EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -81,95 +81,93 @@ export const TemplateDeleteModal = ({ }; return ( - - - } - onCancel={handleOnCancel} - onConfirm={handleDeleteTemplates} - cancelButtonText={ - - } - confirmButtonText={ + + } + onCancel={handleOnCancel} + onConfirm={handleDeleteTemplates} + cancelButtonText={ + + } + confirmButtonText={ + + } + confirmButtonDisabled={hasSystemTemplate ? !isDeleteConfirmed : false} + > + +

- } - confirmButtonDisabled={hasSystemTemplate ? !isDeleteConfirmed : false} - > - -

- -

+

-
    - {templatesToDelete.map(({ name }) => ( -
  • - {name} - {name.startsWith('.') ? ( - - {' '} - - - - - ) : null} -
  • - ))} -
- {hasSystemTemplate && ( - + {templatesToDelete.map(({ name }) => ( +
  • + {name} + {name.startsWith('.') ? ( + + {' '} + + + + + ) : null} +
  • + ))} + + {hasSystemTemplate && ( + + } + color="danger" + iconType="alert" + data-test-subj="deleteSystemTemplateCallOut" + > +

    + +

    + } - color="danger" - iconType="alert" - data-test-subj="deleteSystemTemplateCallOut" - > -

    - -

    - - } - checked={isDeleteConfirmed} - onChange={(e) => setIsDeleteConfirmed(e.target.checked)} - /> -
    - )} -
    -
    -
    + checked={isDeleteConfirmed} + onChange={(e) => setIsDeleteConfirmed(e.target.checked)} + /> + + )} + +
    ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx index 7475a87ca24d99..f555706a28cdd4 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/delete_data_stream_confirmation_modal/delete_data_stream_confirmation_modal.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment } from 'react'; -import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiConfirmModal, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -82,69 +82,67 @@ export const DeleteDataStreamConfirmationModal: React.FunctionComponent = }; return ( - - - } - onCancel={() => onClose()} - onConfirm={handleDeleteDataStreams} - cancelButtonText={ - - } - confirmButtonText={ - - } - > - - - } - color="danger" - iconType="alert" - > -

    - -

    -
    - - - + + } + onCancel={() => onClose()} + onConfirm={handleDeleteDataStreams} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + + + } + color="danger" + iconType="alert" + >

    +
    + + + +

    + +

    -
      - {dataStreams.map((name) => ( -
    • {name}
    • - ))} -
    -
    -
    -
    +
      + {dataStreams.map((name) => ( +
    • {name}
    • + ))} +
    + + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js index 6282469b092669..20a4af59bab111 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_actions_context_menu/index_actions_context_menu.js @@ -20,7 +20,6 @@ import { EuiPopover, EuiSpacer, EuiConfirmModal, - EuiOverlayMask, EuiCheckbox, } from '@elastic/eui'; @@ -301,102 +300,97 @@ export class IndexActionsContextMenu extends Component { const selectedIndexCount = indexNames.length; return ( - - { - if (!this.forcemergeSegmentsError()) { - this.closePopoverAndExecute(() => { - forcemergeIndices(this.state.forcemergeSegments); - this.setState({ - forcemergeSegments: null, - showForcemergeSegmentsModal: null, - }); + { + if (!this.forcemergeSegmentsError()) { + this.closePopoverAndExecute(() => { + forcemergeIndices(this.state.forcemergeSegments); + this.setState({ + forcemergeSegments: null, + showForcemergeSegmentsModal: null, }); - } - }} - cancelButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.forceMerge.confirmModal.cancelButtonText', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.forceMerge.confirmModal.confirmButtonText', + }); + } + }} + cancelButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.forceMerge.confirmModal.cancelButtonText', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.forceMerge.confirmModal.confirmButtonText', + { + defaultMessage: 'Force merge', + } + )} + > +

    + +

    + +
      + {indexNames.map((indexName) => ( +
    • {indexName}
    • + ))} +
    + +

    -

    - -
      - {indexNames.map((indexName) => ( -
    • {indexName}
    • - ))} -
    - - -

    - -

    -
    + /> +

    +
    - + - + - - { - this.setState({ forcemergeSegments: event.target.value }); - }} - min={1} - name="maxNumberSegments" - /> - - -
    -
    + { + this.setState({ forcemergeSegments: event.target.value }); + }} + min={1} + name="maxNumberSegments" + /> + + + ); }; @@ -494,39 +488,37 @@ export class IndexActionsContextMenu extends Component { ); return ( - - { - this.confirmAction(false); - this.closeConfirmModal(); - }} - onConfirm={() => this.closePopoverAndExecute(deleteIndices)} - buttonColor="danger" - confirmButtonDisabled={hasSystemIndex ? !isActionConfirmed : false} - cancelButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.cancelButtonText', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.confirmButtonText', - { - defaultMessage: 'Delete {selectedIndexCount, plural, one {index} other {indices} }', - values: { selectedIndexCount }, - } - )} - > - {hasSystemIndex ? systemIndexModalBody : standardIndexModalBody} - - + { + this.confirmAction(false); + this.closeConfirmModal(); + }} + onConfirm={() => this.closePopoverAndExecute(deleteIndices)} + buttonColor="danger" + confirmButtonDisabled={hasSystemIndex ? !isActionConfirmed : false} + cancelButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.cancelButtonText', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.confirmButtonText', + { + defaultMessage: 'Delete {selectedIndexCount, plural, one {index} other {indices} }', + values: { selectedIndexCount }, + } + )} + > + {hasSystemIndex ? systemIndexModalBody : standardIndexModalBody} + ); }; @@ -536,96 +528,91 @@ export class IndexActionsContextMenu extends Component { const selectedIndexCount = indexNames.length; return ( - - { + this.confirmAction(false); + this.closeConfirmModal(); + }} + onConfirm={() => this.closePopoverAndExecute(closeIndices)} + buttonColor="danger" + confirmButtonDisabled={!isActionConfirmed} + cancelButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.cancelButtonText', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.closeIndex.confirmModal.confirmButtonText', + { + defaultMessage: 'Close {selectedIndexCount, plural, one {index} other {indices} }', + values: { selectedIndexCount }, + } + )} + > +

    + +

    + +
      + {indexNames.map((indexName) => ( +
    • + {indexName} + {isSystemIndexByName[indexName] ? ( + + {' '} + + + + + ) : ( + '' + )} +
    • + ))} +
    + + { - this.confirmAction(false); - this.closeConfirmModal(); - }} - onConfirm={() => this.closePopoverAndExecute(closeIndices)} - buttonColor="danger" - confirmButtonDisabled={!isActionConfirmed} - cancelButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.deleteIndex.confirmModal.cancelButtonText', + 'xpack.idxMgmt.indexActionsMenu.closeIndex.proceedWithCautionCallOutTitle', { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.closeIndex.confirmModal.confirmButtonText', - { - defaultMessage: 'Close {selectedIndexCount, plural, one {index} other {indices} }', - values: { selectedIndexCount }, + defaultMessage: 'Closing a system index can break Kibana', } )} + color="danger" + iconType="alert" >

    - -
      - {indexNames.map((indexName) => ( -
    • - {indexName} - {isSystemIndexByName[indexName] ? ( - - {' '} - - - - - ) : ( - '' - )} -
    • - ))} -
    - - -

    + -

    - - } - checked={isActionConfirmed} - onChange={(e) => this.confirmAction(e.target.checked)} - /> -
    -
    -
    + } + checked={isActionConfirmed} + onChange={(e) => this.confirmAction(e.target.checked)} + /> + + ); }; @@ -633,71 +620,69 @@ export class IndexActionsContextMenu extends Component { const { freezeIndices, indexNames } = this.props; return ( - - this.closePopoverAndExecute(freezeIndices)} + cancelButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.freezeEntity.confirmModal.cancelButtonText', + { + defaultMessage: 'Cancel', + } + )} + confirmButtonText={i18n.translate( + 'xpack.idxMgmt.indexActionsMenu.freezeEntity.confirmModal.confirmButtonText', + { + defaultMessage: 'Freeze {count, plural, one {index} other {indices}}', + values: { + count: indexNames.length, + }, + } + )} + > +

    + +

    + +
      + {indexNames.map((indexName) => ( +
    • {indexName}
    • + ))} +
    + + this.closePopoverAndExecute(freezeIndices)} - cancelButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.freezeEntity.confirmModal.cancelButtonText', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.idxMgmt.indexActionsMenu.freezeEntity.confirmModal.confirmButtonText', + 'xpack.idxMgmt.indexActionsMenu.freezeEntity.proceedWithCautionCallOutTitle', { - defaultMessage: 'Freeze {count, plural, one {index} other {indices}}', - values: { - count: indexNames.length, - }, + defaultMessage: 'Proceed with caution', } )} + color="warning" + iconType="help" >

    -

    - -
      - {indexNames.map((indexName) => ( -
    • {indexName}
    • - ))} -
    - - -

    - -

    -
    -
    -
    + /> +

    + + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js index a435d9be54864a..93ad0e0dc3be54 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.container.js @@ -76,8 +76,8 @@ const mapDispatchToProps = (dispatch) => { loadIndices: () => { dispatch(loadIndices()); }, - reloadIndices: (indexNames) => { - dispatch(reloadIndices(indexNames)); + reloadIndices: (indexNames, options) => { + dispatch(reloadIndices(indexNames, options)); }, }; }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js index d966c39b76c174..f488290692e7ef 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js +++ b/x-pack/plugins/index_management/public/application/sections/home/index_list/index_table/index_table.js @@ -103,7 +103,11 @@ export class IndexTable extends Component { componentDidMount() { this.props.loadIndices(); this.interval = setInterval( - () => this.props.reloadIndices(this.props.indices.map((i) => i.name)), + () => + this.props.reloadIndices( + this.props.indices.map((i) => i.name), + { asSystemRequest: true } + ), REFRESH_RATE_INDEX_LIST ); const { location, filterChanged } = this.props; diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index ad080b0723b1c7..a7109854d676f3 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -40,6 +40,10 @@ import { useRequest, sendRequest } from './use_request'; import { httpService } from './http'; import { UiMetricService } from './ui_metric'; +interface ReloadIndicesOptions { + asSystemRequest?: boolean; +} + // Temporary hack to provide the uiMetricService instance to this file. // TODO: Refactor and export an ApiService instance through the app dependencies context let uiMetricService: UiMetricService; @@ -78,11 +82,17 @@ export async function loadIndices() { return response.data ? response.data : response; } -export async function reloadIndices(indexNames: string[]) { +export async function reloadIndices( + indexNames: string[], + { asSystemRequest }: ReloadIndicesOptions = {} +) { const body = JSON.stringify({ indexNames, }); - const response = await httpService.httpClient.post(`${API_BASE_PATH}/indices/reload`, { body }); + const response = await httpService.httpClient.post(`${API_BASE_PATH}/indices/reload`, { + body, + asSystemRequest, + }); return response.data ? response.data : response; } diff --git a/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js b/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js index 71838d61c20f8a..9498e55154839e 100644 --- a/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js +++ b/x-pack/plugins/index_management/public/application/store/actions/reload_indices.js @@ -12,10 +12,10 @@ import { loadIndices } from './load_indices'; import { notificationService } from '../../services/notification'; export const reloadIndicesSuccess = createAction('INDEX_MANAGEMENT_RELOAD_INDICES_SUCCESS'); -export const reloadIndices = (indexNames) => async (dispatch) => { +export const reloadIndices = (indexNames, options) => async (dispatch) => { let indices; try { - indices = await request(indexNames); + indices = await request(indexNames, options); } catch (error) { // an index has been deleted // or the user does not have privileges for one of the indices on the current page, diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index 70515bde4b3fa7..94ec40dd2847e8 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -51,6 +51,7 @@ export interface MetricAnomalyParams { metric: rt.TypeOf; alertInterval?: string; sourceId?: string; + spaceId?: string; threshold: Exclude; influencerFilter: rt.TypeOf | undefined; } @@ -112,6 +113,7 @@ const metricAnomalyAlertPreviewRequestParamsRT = rt.intersection([ metric: metricAnomalyMetricRT, threshold: rt.number, alertType: rt.literal(METRIC_ANOMALY_ALERT_TYPE_ID), + spaceId: rt.string, }), rt.partial({ influencerFilter: metricAnomalyInfluencerFilterRT, diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx index 3b3bece47e53f8..dd4cbe10b74eea 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx @@ -25,6 +25,12 @@ jest.mock('../../../hooks/use_kibana', () => ({ }), })); +jest.mock('../../../hooks/use_kibana_space', () => ({ + useActiveKibanaSpace: () => ({ + space: { id: 'default' }, + }), +})); + jest.mock('../../../containers/ml/infra_ml_capabilities', () => ({ useInfraMLCapabilities: () => ({ isLoading: false, diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx index 5f034a600ecc68..12cc2bf9fb3a9f 100644 --- a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -38,6 +38,7 @@ import { ANOMALY_THRESHOLD } from '../../../../common/infra_ml'; import { validateMetricAnomaly } from './validation'; import { InfluencerFilter } from './influencer_filter'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { useActiveKibanaSpace } from '../../../hooks/use_kibana_space'; export interface AlertContextMeta { metric?: InfraWaffleMapOptions['metric']; @@ -45,7 +46,7 @@ export interface AlertContextMeta { } type AlertParams = AlertTypeParams & - MetricAnomalyParams & { sourceId: string; hasInfraMLCapabilities: boolean }; + MetricAnomalyParams & { sourceId: string; spaceId: string; hasInfraMLCapabilities: boolean }; type Props = Omit< AlertTypeParamsExpressionProps, @@ -62,6 +63,8 @@ export const defaultExpression = { export const Expression: React.FC = (props) => { const { hasInfraMLCapabilities, isLoading: isLoadingMLCapabilities } = useInfraMLCapabilities(); const { http, notifications } = useKibanaContextForPlugin().services; + const { space } = useActiveKibanaSpace(); + const { setAlertParams, alertParams, @@ -176,7 +179,11 @@ export const Expression: React.FC = (props) => { if (!alertParams.sourceId) { setAlertParams('sourceId', source?.id || 'default'); } - }, [metadata, derivedIndexPattern, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!alertParams.spaceId) { + setAlertParams('spaceId', space?.id || 'default'); + } + }, [metadata, derivedIndexPattern, defaultExpression, source, space]); // eslint-disable-line react-hooks/exhaustive-deps if (isLoadingMLCapabilities) return ; if (!hasInfraMLCapabilities) return ; @@ -263,6 +270,7 @@ export const Expression: React.FC = (props) => { 'threshold', 'nodeType', 'sourceId', + 'spaceId', 'influencerFilter' )} validate={validateMetricAnomaly} diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx index c520243b5b24ea..00c6b1f93ef881 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/analyze_in_ml_button.tsx @@ -7,97 +7,31 @@ import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; -import { encode } from 'rison-node'; -import { TimeRange } from '../../../../common/http_api/shared/time_range'; -import { useLinkProps, LinkDescriptor } from '../../../hooks/use_link_props'; +import React, { useCallback } from 'react'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { shouldHandleLinkEvent } from '../../../hooks/use_link_props'; export const AnalyzeInMlButton: React.FunctionComponent<{ - jobId: string; - partition?: string; - timeRange: TimeRange; -}> = ({ jobId, partition, timeRange }) => { - const linkProps = useLinkProps( - typeof partition === 'string' - ? getEntitySpecificSingleMetricViewerLink(jobId, timeRange, { - 'event.dataset': partition, - }) - : getOverallAnomalyExplorerLinkDescriptor(jobId, timeRange) - ); - const buttonLabel = ( - + href?: string; +}> = ({ href }) => { + const { + services: { application }, + } = useKibanaContextForPlugin(); + + const handleClick = useCallback( + (e) => { + if (!href || !shouldHandleLinkEvent(e)) return; + application.navigateToUrl(href); + }, + [href, application] ); - return typeof partition === 'string' ? ( - - {buttonLabel} - - ) : ( - - {buttonLabel} + + return ( + + ); }; - -export const getOverallAnomalyExplorerLinkDescriptor = ( - jobId: string, - timeRange: TimeRange -): LinkDescriptor => { - const { from, to } = convertTimeRangeToParams(timeRange); - - const _g = encode({ - ml: { - jobIds: [jobId], - }, - time: { - from, - to, - }, - }); - - return { - app: 'ml', - pathname: '/explorer', - search: { _g }, - }; -}; - -export const getEntitySpecificSingleMetricViewerLink = ( - jobId: string, - timeRange: TimeRange, - entities: Record -): LinkDescriptor => { - const { from, to } = convertTimeRangeToParams(timeRange); - - const _g = encode({ - ml: { - jobIds: [jobId], - }, - time: { - from, - to, - mode: 'absolute', - }, - }); - - const _a = encode({ - mlTimeSeriesExplorer: { - entities, - }, - }); - - return { - app: 'ml', - pathname: '/timeseriesexplorer', - search: { _g, _a }, - }; -}; - -const convertTimeRangeToParams = (timeRange: TimeRange): { from: string; to: string } => { - return { - from: new Date(timeRange.startTime).toISOString(), - to: new Date(timeRange.endTime).toISOString(), - }; -}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx index 24179768604c45..3b0eb6fa898567 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx @@ -9,12 +9,23 @@ import { EuiButton, EuiButtonProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import { useLinkProps } from '../../../hooks/use_link_props'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; export const UserManagementLink: React.FunctionComponent = (props) => { + const { + services: { + application: { capabilities }, + }, + } = useKibanaContextForPlugin(); + const canAccessUserManagement = capabilities?.management?.security?.users ?? false; + const linkProps = useLinkProps({ app: 'management', pathname: '/security/users', }); + + if (!canAccessUserManagement) return null; + return ( { }, [includeTime, save, viewName]); return ( - - - - - - - - - - + + + - - - } - checked={includeTime} - onChange={onCheckChange} - /> - - - - - + + - - + + + + - - - - - - - + } + checked={includeTime} + onChange={onCheckChange} + /> + + + + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx b/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx index 15d0d162604a4b..c6d87d9a8ca158 100644 --- a/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/update_modal.tsx @@ -16,7 +16,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiFieldText, EuiSpacer, EuiSwitch, @@ -46,69 +45,67 @@ export function SavedViewUpdateModal - - - - - - - - - + + + - - - } - checked={includeTime} - onChange={onCheckChange} - /> - - - - - + + - - + + + + - - - - - - -
    + } + checked={includeTime} + onChange={onCheckChange} + /> + + + + + + + + + + + + + + + ); } diff --git a/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx b/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx index 9ab742d720eb5e..aad50c4dcb45d0 100644 --- a/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/view_list_modal.tsx @@ -9,13 +9,7 @@ import React, { useCallback, useState, useMemo } from 'react'; import { EuiButtonEmpty, EuiModalFooter, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiOverlayMask, - EuiModal, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, -} from '@elastic/eui'; +import { EuiModal, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody } from '@elastic/eui'; import { EuiSelectable } from '@elastic/eui'; import { EuiSelectableOption } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -64,51 +58,49 @@ export function SavedViewListModal - - - - - - - - - {(list, search) => ( - <> - {search} -
    {list}
    - - )} -
    -
    - - - - - - - - -
    - + + + + + + + + + {(list, search) => ( + <> + {search} +
    {list}
    + + )} +
    +
    + + + + + + + + +
    ); } diff --git a/x-pack/plugins/infra/public/hooks/use_interval.ts b/x-pack/plugins/infra/public/hooks/use_interval.ts deleted file mode 100644 index e2f33c9458e9a5..00000000000000 --- a/x-pack/plugins/infra/public/hooks/use_interval.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect, useRef } from 'react'; - -export function useInterval(callback: () => void, delay: number | null) { - const savedCallback = useRef(callback); - - useEffect(() => { - savedCallback.current = callback; - }, [callback]); - - useEffect(() => { - function tick() { - savedCallback.current(); - } - - if (delay !== null) { - const id = setInterval(tick, delay); - return () => clearInterval(id); - } - }, [delay]); -} diff --git a/x-pack/plugins/infra/public/hooks/use_link_props.tsx b/x-pack/plugins/infra/public/hooks/use_link_props.tsx index 225ed5ae4a1912..72a538cd56281e 100644 --- a/x-pack/plugins/infra/public/hooks/use_link_props.tsx +++ b/x-pack/plugins/infra/public/hooks/use_link_props.tsx @@ -69,9 +69,10 @@ export const useLinkProps = ( const onClick = useMemo(() => { return (e: React.MouseEvent | React.MouseEvent) => { - if (e.defaultPrevented || isModifiedEvent(e)) { + if (!shouldHandleLinkEvent(e)) { return; } + e.preventDefault(); const navigate = () => { @@ -119,3 +120,7 @@ const validateParams = ({ app, pathname, hash, search }: LinkDescriptor) => { const isModifiedEvent = (event: any) => !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); + +export const shouldHandleLinkEvent = ( + e: React.MouseEvent | React.MouseEvent +) => !e.defaultPrevented && !isModifiedEvent(e); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 616e3ed3f11f53..1206e5c3654412 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -10,6 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import useInterval from 'react-use/lib/useInterval'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; @@ -17,7 +18,6 @@ import { TimeRange } from '../../../../common/time/time_range'; import { CategoryJobNoticesSection } from '../../../components/logging/log_analysis_job_status'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; -import { useInterval } from '../../../hooks/use_interval'; import { PageViewLogInContext } from '../stream/page_view_log_in_context'; import { TopCategoriesSection } from './sections/top_categories'; import { useLogEntryCategoriesResults } from './use_log_entry_categories_results'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx index ba3553611c0e65..15e27705395bbe 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx @@ -6,12 +6,14 @@ */ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; -import React from 'react'; - +import React, { useCallback } from 'react'; +import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; import { TimeRange } from '../../../../../../common/time/time_range'; -import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results'; -import { useLinkProps } from '../../../../../hooks/use_link_props'; +import { useMlHref, ML_PAGES } from '../../../../../../../ml/public'; +import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; +import { shouldHandleLinkEvent } from '../../../../../hooks/use_link_props'; export const AnalyzeCategoryDatasetInMlAction: React.FunctionComponent<{ categorizationJobId: string; @@ -19,11 +21,32 @@ export const AnalyzeCategoryDatasetInMlAction: React.FunctionComponent<{ dataset: string; timeRange: TimeRange; }> = ({ categorizationJobId, categoryId, dataset, timeRange }) => { - const linkProps = useLinkProps( - getEntitySpecificSingleMetricViewerLink(categorizationJobId, timeRange, { - 'event.dataset': dataset, - mlcategory: `${categoryId}`, - }) + const { + services: { ml, http, application }, + } = useKibanaContextForPlugin(); + + const viewAnomalyInMachineLearningLink = useMlHref(ml, http.basePath.get(), { + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { + jobIds: [categorizationJobId], + timeRange: { + from: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + to: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + mode: 'absolute', + }, + entities: { + [partitionField]: dataset, + mlcategory: `${categoryId}`, + }, + }, + }); + + const handleClick = useCallback( + (e) => { + if (!viewAnomalyInMachineLearningLink || !shouldHandleLinkEvent(e)) return; + application.navigateToUrl(viewAnomalyInMachineLearningLink); + }, + [application, viewAnomalyInMachineLearningLink] ); return ( @@ -32,7 +55,8 @@ export const AnalyzeCategoryDatasetInMlAction: React.FunctionComponent<{ aria-label={analyseCategoryDatasetInMlButtonLabel} iconType="machineLearningApp" data-test-subj="analyzeCategoryDatasetInMlButton" - {...linkProps} + href={viewAnomalyInMachineLearningLink} + onClick={handleClick} /> ); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx index 1aa6aabf864ccb..f5b94bce74e67e 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -6,6 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiTitle } from '@elastic/eui'; +import moment from 'moment'; import { i18n } from '@kbn/i18n'; import React from 'react'; @@ -18,6 +19,8 @@ import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysi import { DatasetsSelector } from '../../../../../components/logging/log_analysis_results/datasets_selector'; import { TopCategoriesTable } from './top_categories_table'; import { SortOptions, ChangeSortOptions } from '../../use_log_entry_categories_results'; +import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; +import { useMlHref, ML_PAGES } from '../../../../../../../ml/public'; export const TopCategoriesSection: React.FunctionComponent<{ availableDatasets: string[]; @@ -48,6 +51,22 @@ export const TopCategoriesSection: React.FunctionComponent<{ sortOptions, changeSortOptions, }) => { + const { + services: { ml, http }, + } = useKibanaContextForPlugin(); + + const analyzeInMlLink = useMlHref(ml, http.basePath.get(), { + page: ML_PAGES.ANOMALY_EXPLORER, + pageState: { + jobIds: [jobId], + timeRange: { + from: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + to: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + mode: 'absolute', + }, + }, + }); + return ( <> @@ -66,7 +85,7 @@ export const TopCategoriesSection: React.FunctionComponent<{ />
    - + diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx index 5fd00527b8b704..114f8ff9db3b36 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx @@ -7,6 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { memo, useEffect, useCallback } from 'react'; +import useInterval from 'react-use/lib/useInterval'; import { SubscriptionSplashContent } from '../../../components/subscription_splash_content'; import { isJobStatusWithResults } from '../../../../common/log_analysis'; import { LoadingPage } from '../../../components/loading_page'; @@ -27,7 +28,6 @@ import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analy import { useLogSourceContext } from '../../../containers/logs/log_source'; import { LogEntryRateResultsContent } from './page_results_content'; import { LogEntryRateSetupContent } from './page_setup_content'; -import { useInterval } from '../../../hooks/use_interval'; const JOB_STATUS_POLLING_INTERVAL = 30000; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index 1a794e6f78c393..4362f412d5a78c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -9,6 +9,8 @@ import React, { useMemo, useCallback, useState } from 'react'; import moment from 'moment'; import { encode } from 'rison-node'; import { i18n } from '@kbn/i18n'; +import { useMlHref, ML_PAGES } from '../../../../../../../ml/public'; +import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; import { @@ -25,10 +27,9 @@ import { LogColumnHeadersWrapper, LogColumnHeader, } from '../../../../../components/logging/log_text_stream/column_headers'; -import { useLinkProps } from '../../../../../hooks/use_link_props'; +import { useLinkProps, shouldHandleLinkEvent } from '../../../../../hooks/use_link_props'; import { TimeRange } from '../../../../../../common/time/time_range'; import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; -import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button'; import { LogEntryExample, isCategoryAnomaly } from '../../../../../../common/log_analysis'; import { LogColumnConfiguration, @@ -82,6 +83,9 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ timeRange, anomaly, }) => { + const { + services: { ml, http, application }, + } = useKibanaContextForPlugin(); const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); const openMenu = useCallback(() => setIsMenuOpen(true), []); @@ -114,15 +118,32 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ }, }); - const viewAnomalyInMachineLearningLinkProps = useLinkProps( - getEntitySpecificSingleMetricViewerLink(anomaly.jobId, timeRange, { - [partitionField]: dataset, - ...(isCategoryAnomaly(anomaly) ? { mlcategory: anomaly.categoryId } : {}), - }) + const viewAnomalyInMachineLearningLink = useMlHref(ml, http.basePath.get(), { + page: ML_PAGES.SINGLE_METRIC_VIEWER, + pageState: { + jobIds: [anomaly.jobId], + timeRange: { + from: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + to: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + mode: 'absolute', + }, + entities: { + [partitionField]: dataset, + ...(isCategoryAnomaly(anomaly) ? { mlcategory: anomaly.categoryId } : {}), + }, + }, + }); + + const handleMlLinkClick = useCallback( + (e) => { + if (!viewAnomalyInMachineLearningLink || !shouldHandleLinkEvent(e)) return; + application.navigateToUrl(viewAnomalyInMachineLearningLink); + }, + [viewAnomalyInMachineLearningLink, application] ); const menuItems = useMemo(() => { - if (!viewInStreamLinkProps.onClick || !viewAnomalyInMachineLearningLinkProps.onClick) { + if (!viewInStreamLinkProps.onClick || !viewAnomalyInMachineLearningLink) { return undefined; } @@ -140,11 +161,17 @@ export const LogEntryExampleMessage: React.FunctionComponent = ({ }, { label: VIEW_ANOMALY_IN_ML_LABEL, - onClick: viewAnomalyInMachineLearningLinkProps.onClick, - href: viewAnomalyInMachineLearningLinkProps.href, + onClick: handleMlLinkClick, + href: viewAnomalyInMachineLearningLink, }, ]; - }, [id, openLogEntryFlyout, viewInStreamLinkProps, viewAnomalyInMachineLearningLinkProps]); + }, [ + id, + openLogEntryFlyout, + viewInStreamLinkProps, + viewAnomalyInMachineLearningLink, + handleMlLinkClick, + ]); return ( { } return ( - - - - - - - - - - - - - - + + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts index a5297f81bbacac..7a4c93438027ad 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts @@ -51,6 +51,7 @@ export const createMetricAnomalyExecutor = (libs: InfraBackendLibs, ml?: MlPlugi alertInterval, influencerFilter, sourceId, + spaceId, nodeType, threshold, } = params as MetricAnomalyParams; @@ -67,7 +68,7 @@ export const createMetricAnomalyExecutor = (libs: InfraBackendLibs, ml?: MlPlugi const { data } = await evaluateCondition({ sourceId: sourceId ?? 'default', - spaceId: 'default', + spaceId: spaceId ?? 'default', mlSystem, mlAnomalyDetectors, startTime, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts index 8ac62c125515af..d5333f155b5c32 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts @@ -51,6 +51,7 @@ export const registerMetricAnomalyAlertType = ( schema.string({ validate: validateIsStringElasticsearchJSONFilter }) ), sourceId: schema.string(), + spaceId: schema.string(), }, { unknowns: 'allow' } ), diff --git a/x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts b/x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts index 35decbacf2a529..7473907b7410b8 100644 --- a/x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts +++ b/x-pack/plugins/infra/server/routes/snapshot/lib/create_timerange_with_interval.ts @@ -8,7 +8,6 @@ import { uniq } from 'lodash'; import { InfraTimerangeInput } from '../../../../common/http_api'; import { ESSearchClient } from '../../../lib/metrics/types'; -import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; import { getMetricsAggregations, InfraSnapshotRequestOptions } from './get_metrics_aggregations'; import { @@ -19,9 +18,6 @@ import { getDatasetForField } from '../../metrics_explorer/lib/get_dataset_for_f const createInterval = async (client: ESSearchClient, options: InfraSnapshotRequestOptions) => { const { timerange } = options; - if (timerange.forceInterval && timerange.interval) { - return getIntervalInSeconds(timerange.interval); - } const aggregations = getMetricsAggregations(options); const modules = await aggregationsToModules(client, aggregations, options); return Math.max( @@ -44,14 +40,21 @@ export const createTimeRangeWithInterval = async ( options: InfraSnapshotRequestOptions ): Promise => { const { timerange } = options; - const calculatedInterval = await createInterval(client, options); + if (timerange.forceInterval) { + return { + interval: timerange.interval, + from: timerange.from, + to: timerange.to, + }; + } if (timerange.ignoreLookback) { return { - interval: `${calculatedInterval}s`, + interval: 'modules', from: timerange.from, to: timerange.to, }; } + const calculatedInterval = await createInterval(client, options); const lookbackSize = Math.max(timerange.lookbackSize || 5, 5); return { interval: `${calculatedInterval}s`, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx index 55188805d571cd..fabb6a46c49435 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx @@ -154,6 +154,17 @@ const createActions = (testBed: TestBed) => { find(`${processorSelector}.moreMenu.duplicateButton`).simulate('click'); }); }, + openProcessorEditor: (processorSelector: string) => { + act(() => { + find(`${processorSelector}.manageItemButton`).simulate('click'); + }); + component.update(); + }, + submitProcessorForm: async () => { + await act(async () => { + find('editProcessorForm.submitButton').simulate('click'); + }); + }, }; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx index 1c698043a8bc78..e89e91c1cbaa91 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx @@ -22,6 +22,13 @@ const testProcessors: Pick = { replacement: '$17$2', }, }, + { + set: { + field: 'test', + value: 'test', + unknown_field_foo: 'unknown_value', + }, + }, ], }; @@ -79,11 +86,37 @@ describe('Pipeline Editor', () => { await actions.addProcessor('processors', 'test', { if: '1 == 1' }); const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const { processors } = onUpdateResult.getData(); - expect(processors.length).toBe(3); - const [a, b, c] = processors; + expect(processors.length).toBe(4); + const [a, b, c, d] = processors; expect(a).toEqual(testProcessors.processors[0]); expect(b).toEqual(testProcessors.processors[1]); - expect(c).toEqual({ test: { if: '1 == 1' } }); + expect(c).toEqual(testProcessors.processors[2]); + expect(d).toEqual({ test: { if: '1 == 1' } }); + }); + + it('edits a processor without removing unknown processor.options', async () => { + const { actions, exists, form } = testBed; + // Open the edit processor form for the set processor + actions.openProcessorEditor('processors>2'); + expect(exists('editProcessorForm')).toBeTruthy(); + form.setInputValue('editProcessorForm.valueFieldInput', 'test44'); + await actions.submitProcessorForm(); + const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; + const { + processors: { 2: setProcessor }, + } = onUpdateResult.getData(); + // The original field should still be unchanged + expect(testProcessors.processors[2].set.value).toBe('test'); + expect(setProcessor.set).toEqual({ + description: undefined, + field: 'test', + ignore_empty_value: undefined, + ignore_failure: undefined, + override: undefined, + // This unknown_field is not supported in the form + unknown_field_foo: 'unknown_value', + value: 'test44', + }); }); it('removes a processor', () => { @@ -92,7 +125,7 @@ describe('Pipeline Editor', () => { actions.removeProcessor('processors>0'); const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const { processors } = onUpdateResult.getData(); - expect(processors.length).toBe(1); + expect(processors.length).toBe(2); expect(processors[0]).toEqual({ gsub: { field: '_index', @@ -107,7 +140,11 @@ describe('Pipeline Editor', () => { actions.moveProcessor('processors>0', 'dropButtonBelow-processors>1'); const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const { processors } = onUpdateResult.getData(); - expect(processors).toEqual(testProcessors.processors.slice(0).reverse()); + expect(processors).toEqual([ + testProcessors.processors[1], + testProcessors.processors[0], + testProcessors.processors[2], + ]); }); it('adds an on-failure processor to a processor', async () => { @@ -121,7 +158,7 @@ describe('Pipeline Editor', () => { expect(exists(`${processorSelector}.addProcessor`)); const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const { processors } = onUpdateResult.getData(); - expect(processors.length).toBe(2); + expect(processors.length).toBe(3); expect(processors[0]).toEqual(testProcessors.processors[0]); // should be unchanged expect(processors[1].gsub).toEqual({ ...testProcessors.processors[1].gsub, @@ -135,7 +172,7 @@ describe('Pipeline Editor', () => { actions.moveProcessor('processors>0', 'dropButtonBelow-processors>1>onFailure>0'); const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const { processors } = onUpdateResult.getData(); - expect(processors.length).toBe(1); + expect(processors.length).toBe(2); expect(processors[0].gsub.on_failure).toEqual([ { test: { if: '1 == 3' }, @@ -150,7 +187,7 @@ describe('Pipeline Editor', () => { actions.duplicateProcessor('processors>1'); const [onUpdateResult] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const { processors } = onUpdateResult.getData(); - expect(processors.length).toBe(3); + expect(processors.length).toBe(4); const duplicatedProcessor = { gsub: { ...testProcessors.processors[1].gsub, @@ -161,6 +198,7 @@ describe('Pipeline Editor', () => { testProcessors.processors[0], duplicatedProcessor, duplicatedProcessor, + testProcessors.processors[2], ]); }); @@ -182,14 +220,17 @@ describe('Pipeline Editor', () => { actions.moveProcessor('processors>0', 'dropButtonBelow-onFailure>0'); const [onUpdateResult1] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const data1 = onUpdateResult1.getData(); - expect(data1.processors.length).toBe(1); + expect(data1.processors.length).toBe(2); expect(data1.on_failure.length).toBe(2); - expect(data1.processors).toEqual([testProcessors.processors[1]]); + expect(data1.processors).toEqual([ + testProcessors.processors[1], + testProcessors.processors[2], + ]); expect(data1.on_failure).toEqual([{ test: { if: '1 == 5' } }, testProcessors.processors[0]]); actions.moveProcessor('onFailure>1', 'dropButtonAbove-processors>0'); const [onUpdateResult2] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const data2 = onUpdateResult2.getData(); - expect(data2.processors.length).toBe(2); + expect(data2.processors.length).toBe(3); expect(data2.on_failure.length).toBe(1); expect(data2.processors).toEqual(testProcessors.processors); expect(data2.on_failure).toEqual([{ test: { if: '1 == 5' } }]); @@ -208,7 +249,7 @@ describe('Pipeline Editor', () => { actions.moveProcessor('processors>0', 'onFailure.dropButtonEmptyTree'); const [onUpdateResult2] = onUpdate.mock.calls[onUpdate.mock.calls.length - 1]; const data = onUpdateResult2.getData(); - expect(data.processors).toEqual([testProcessors.processors[1]]); + expect(data.processors).toEqual([testProcessors.processors[1], testProcessors.processors[2]]); expect(data.on_failure).toEqual([testProcessors.processors[0]]); }); }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx index 10da326322ad4d..c0f9c758fc2785 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { FunctionComponent, useRef, useState, useCallback } from 'react'; -import { EuiConfirmModal, EuiOverlayMask, EuiSpacer, EuiText, EuiCallOut } from '@elastic/eui'; +import { EuiConfirmModal, EuiSpacer, EuiText, EuiCallOut } from '@elastic/eui'; import { JsonEditor, OnJsonEditorUpdateHandler } from '../../../../../shared_imports'; @@ -78,64 +78,62 @@ export const ModalProvider: FunctionComponent = ({ onDone, children }) => <> {children(() => setIsModalVisible(true))} {isModalVisible ? ( - - { + { + setIsModalVisible(false); + }} + onConfirm={async () => { + try { + const json = jsonContent.current.data.format(); + const { processors, on_failure: onFailure } = json; + // This function will throw if it cannot parse the pipeline object + deserialize({ processors, onFailure }); + onDone(json as any); setIsModalVisible(false); - }} - onConfirm={async () => { - try { - const json = jsonContent.current.data.format(); - const { processors, on_failure: onFailure } = json; - // This function will throw if it cannot parse the pipeline object - deserialize({ processors, onFailure }); - onDone(json as any); - setIsModalVisible(false); - } catch (e) { - setError(e); - } - }} - cancelButtonText={i18nTexts.buttons.cancel} - confirmButtonDisabled={!isValidJson} - confirmButtonText={i18nTexts.buttons.confirm} - maxWidth={600} - > -
    - - - + } catch (e) { + setError(e); + } + }} + cancelButtonText={i18nTexts.buttons.cancel} + confirmButtonDisabled={!isValidJson} + confirmButtonText={i18nTexts.buttons.confirm} + maxWidth={600} + > +
    + + + - + - {error && ( - <> - - {i18nTexts.error.body} - - - - )} + {error && ( + <> + + {i18nTexts.error.body} + + + + )} - -
    - - + +
    +
    ) : undefined} ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_form.container.tsx index 5ce8cf09cc5942..cce473ec9a214f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_form.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_form.container.tsx @@ -7,7 +7,13 @@ import React, { FunctionComponent, useCallback, useEffect, useRef } from 'react'; -import { useForm, OnFormUpdateArg, FormData, useKibana } from '../../../../../shared_imports'; +import { + useForm, + OnFormUpdateArg, + FormData, + FormOptions, + useKibana, +} from '../../../../../shared_imports'; import { ProcessorInternal } from '../../types'; import { EditProcessorForm } from './edit_processor_form'; @@ -33,6 +39,14 @@ interface Props { processor?: ProcessorInternal; } +const formOptions: FormOptions = { + /** + * This is important for allowing configuration of empty text fields in certain processors that + * remove values from their inputs. + */ + stripEmptyFields: false, +}; + export const ProcessorFormContainer: FunctionComponent = ({ processor, onFormUpdate, @@ -81,6 +95,7 @@ export const ProcessorFormContainer: FunctionComponent = ({ const { form } = useForm({ defaultValue: { fields: getProcessor().options }, serializer: formSerializer, + options: formOptions, }); const { subscribe } = form; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx index 12226608a46821..f6449c3cc24d42 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx @@ -38,6 +38,7 @@ const ignoreFailureConfig: FieldConfig = { }; const ifConfig: FieldConfig = { + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.ifFieldLabel', { defaultMessage: 'Condition (optional)', }), @@ -48,6 +49,7 @@ const ifConfig: FieldConfig = { }; const tagConfig: FieldConfig = { + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.tagFieldLabel', { defaultMessage: 'Tag (optional)', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/target_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/target_field.tsx index 1caf5ffd3fb1e5..b603a131e10b09 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/target_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/target_field.tsx @@ -10,12 +10,13 @@ import { i18n } from '@kbn/i18n'; import { Field, FIELD_TYPES, UseField, FieldConfig } from '../../../../../../../shared_imports'; -import { FieldsConfig } from '../shared'; +import { FieldsConfig, from } from '../shared'; const fieldsConfig: FieldsConfig = { target_field: { type: FIELD_TYPES.TEXT, deserializer: String, + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.targetFieldLabel', { defaultMessage: 'Target field (optional)', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx index d6cf2d0ae05e89..b192ee0494bb3c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx @@ -24,7 +24,7 @@ import { FieldsConfig } from './shared'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; import { FieldNameField } from './common_fields/field_name_field'; -import { to } from './shared'; +import { to, from } from './shared'; const { minLengthField } = fieldValidators; @@ -72,7 +72,7 @@ const fieldsConfig: FieldsConfig = { /* Optional fields config */ separator: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v ? v : undefined), + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.convertForm.separatorFieldLabel', { defaultMessage: 'Separator (optional)', }), @@ -85,13 +85,13 @@ const fieldsConfig: FieldsConfig = { {','} }} + values={{ value: {','} }} /> ), }, quote: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v ? v : undefined), + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.convertForm.quoteFieldLabel', { defaultMessage: 'Quote (optional)', }), @@ -104,7 +104,7 @@ const fieldsConfig: FieldsConfig = { {'"'} }} + values={{ value: {'"'} }} /> ), }, @@ -121,6 +121,7 @@ const fieldsConfig: FieldsConfig = { }, empty_value: { type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.convertForm.emptyValueFieldLabel', { defaultMessage: 'Empty value (optional)', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx index 17af47f1569e04..ca541a9e6d6195 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx @@ -18,7 +18,7 @@ import { ComboBoxField, } from '../../../../../../shared_imports'; -import { FieldsConfig, to } from './shared'; +import { FieldsConfig, to, from } from './shared'; import { FieldNameField } from './common_fields/field_name_field'; import { TargetField } from './common_fields/target_field'; @@ -53,7 +53,7 @@ const fieldsConfig: FieldsConfig = { /* Optional fields config */ timezone: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v ? v : undefined), + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dateForm.timezoneFieldLabel', { defaultMessage: 'Timezone (optional)', }), @@ -61,13 +61,13 @@ const fieldsConfig: FieldsConfig = { {'UTC'} }} + values={{ timezone: {'UTC'} }} /> ), }, locale: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v ? v : undefined), + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dateForm.localeFieldLabel', { defaultMessage: 'Locale (optional)', }), @@ -75,7 +75,7 @@ const fieldsConfig: FieldsConfig = { {'ENGLISH'} }} + values={{ timezone: {'ENGLISH'} }} /> ), }, @@ -102,7 +102,7 @@ export const DateProcessor: FunctionComponent = () => { id="xpack.ingestPipelines.pipelineEditor.dateForm.targetFieldHelpText" defaultMessage="Output field. If empty, the input field is updated in place. Defaults to {defaultField}." values={{ - defaultField: {'@timestamp'}, + defaultField: {'@timestamp'}, }} /> } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx index d4b1ec876bfd50..5c5b5ff89fd20c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx @@ -19,7 +19,7 @@ import { SelectField, } from '../../../../../../shared_imports'; -import { FieldsConfig, to } from './shared'; +import { FieldsConfig, to, from } from './shared'; import { FieldNameField } from './common_fields/field_name_field'; const { emptyField } = fieldValidators; @@ -57,7 +57,7 @@ const fieldsConfig: FieldsConfig = { /* Optional fields config */ index_name_prefix: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v ? v : undefined), + serializer: from.emptyStringToUndefined, label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.indexNamePrefixFieldLabel', { @@ -71,7 +71,7 @@ const fieldsConfig: FieldsConfig = { }, index_name_format: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v ? v : undefined), + serializer: from.emptyStringToUndefined, label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.indexNameFormatFieldLabel', { @@ -82,7 +82,7 @@ const fieldsConfig: FieldsConfig = { {'yyyy-MM-dd'} }} + values={{ value: {'yyyy-MM-dd'} }} /> ), }, @@ -102,13 +102,13 @@ const fieldsConfig: FieldsConfig = { {"yyyy-MM-dd'T'HH:mm:ss.SSSXX"} }} + values={{ value: {"yyyy-MM-dd'T'HH:mm:ss.SSSXX"} }} /> ), }, timezone: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v ? v : undefined), + serializer: from.emptyStringToUndefined, label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.timezoneFieldLabel', { @@ -119,13 +119,13 @@ const fieldsConfig: FieldsConfig = { {'UTC'} }} + values={{ timezone: {'UTC'} }} /> ), }, locale: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v ? v : undefined), + serializer: from.emptyStringToUndefined, label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.localeFieldLabel', { @@ -136,7 +136,7 @@ const fieldsConfig: FieldsConfig = { {'ENGLISH'} }} + values={{ locale: {'ENGLISH'} }} /> ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx index 609ce8a1f8ae63..6652ad277cc265 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx @@ -22,7 +22,7 @@ import { import { FieldNameField } from './common_fields/field_name_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; -import { EDITOR_PX_HEIGHT } from './shared'; +import { EDITOR_PX_HEIGHT, from } from './shared'; const { emptyField } = fieldValidators; @@ -72,6 +72,7 @@ const getFieldsConfig = (esDocUrl: string): Record => { /* Optional field config */ append_separator: { type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dissectForm.appendSeparatorparaotrFieldLabel', { @@ -82,7 +83,7 @@ const getFieldsConfig = (esDocUrl: string): Record => { {'""'} }} + values={{ value: {'""'} }} /> ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx index 0bbcb7a2eefb34..4bbc242cf0ef88 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx @@ -12,9 +12,12 @@ import { FieldConfig, FIELD_TYPES, UseField, Field } from '../../../../../../sha import { FieldNameField } from './common_fields/field_name_field'; +import { from } from './shared'; + const fieldsConfig: Record = { path: { type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dotExpanderForm.pathFieldLabel', { defaultMessage: 'Path', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx index 2f0699fac729d8..6a1f86977d8db5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx @@ -22,7 +22,7 @@ const fieldsConfig: FieldsConfig = { /* Optional field config */ database_file: { type: FIELD_TYPES.TEXT, - serializer: (v) => (v === 'GeoLite2-City.mmdb' ? undefined : v), + serializer: (v) => (v === 'GeoLite2-City.mmdb' || v === '' ? undefined : v), label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoIPForm.databaseFileLabel', { defaultMessage: 'Database file (optional)', }), @@ -31,8 +31,8 @@ const fieldsConfig: FieldsConfig = { id="xpack.ingestPipelines.pipelineEditor.geoIPForm.databaseFileHelpText" defaultMessage="GeoIP2 database file in the {ingestGeoIP} configuration directory. Defaults to {databaseFile}." values={{ - databaseFile: {'GeoLite2-City.mmdb'}, - ingestGeoIP: {'ingest-geoip'}, + databaseFile: {'GeoLite2-City.mmdb'}, + ingestGeoIP: {'ingest-geoip'}, }} /> ), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx index 8835f3775a90f0..edfa59ea80281c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx @@ -41,6 +41,7 @@ const fieldsConfig: FieldsConfig = { ], }, + // This is a required field, but we exclude validation because we accept empty values as '' replacement: { type: FIELD_TYPES.TEXT, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.replacementFieldLabel', { @@ -48,17 +49,11 @@ const fieldsConfig: FieldsConfig = { }), helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.gsubForm.replacementFieldHelpText', - { defaultMessage: 'Replacement text for matches.' } - ), - validations: [ { - validator: emptyField( - i18n.translate('xpack.ingestPipelines.pipelineEditor.gsubForm.replacementRequiredError', { - defaultMessage: 'A value is required.', - }) - ), - }, - ], + defaultMessage: + 'Replacement text for matches. A blank value will remove the matched text from the resulting text.', + } + ), }, }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx index 2b14a79afb8df1..9575e6d690e006 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx @@ -168,7 +168,7 @@ export const Inference: FunctionComponent = () => { {'ml.inference.'} }} + values={{ targetField: {'ml.inference.'} }} /> } /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx index 2d17e3600cb79a..694ae4e07070d4 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx @@ -107,6 +107,7 @@ const fieldsConfig: FieldsConfig = { prefix: { type: FIELD_TYPES.TEXT, deserializer: String, + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.prefixFieldLabel', { defaultMessage: 'Prefix', }), @@ -118,6 +119,7 @@ const fieldsConfig: FieldsConfig = { trim_key: { type: FIELD_TYPES.TEXT, deserializer: String, + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.trimKeyFieldLabel', { defaultMessage: 'Trim key', }), @@ -129,6 +131,7 @@ const fieldsConfig: FieldsConfig = { trim_value: { type: FIELD_TYPES.TEXT, deserializer: String, + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.trimValueFieldLabel', { defaultMessage: 'Trim value', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/script.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/script.tsx index 60871fa7ba4ab5..3c662793578439 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/script.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/script.tsx @@ -81,7 +81,7 @@ const fieldsConfig: FieldsConfig = { lang: { type: FIELD_TYPES.TEXT, deserializer: String, - serializer: from.undefinedIfValue('painless'), + serializer: from.emptyStringToUndefined, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.scriptForm.langFieldLabel', { defaultMessage: 'Language (optional)', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx index 9ccfe580a53aed..89ca373b9e6539 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx @@ -10,40 +10,30 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCode } from '@elastic/eui'; -import { - FIELD_TYPES, - fieldValidators, - ToggleField, - UseField, - Field, -} from '../../../../../../shared_imports'; +import { FIELD_TYPES, ToggleField, UseField, Field } from '../../../../../../shared_imports'; import { FieldsConfig, to, from } from './shared'; import { FieldNameField } from './common_fields/field_name_field'; -const { emptyField } = fieldValidators; - const fieldsConfig: FieldsConfig = { /* Required fields config */ + // This is a required field, but we exclude validation because we accept empty values as '' value: { type: FIELD_TYPES.TEXT, deserializer: String, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldLabel', { defaultMessage: 'Value', }), - helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldHelpText', { - defaultMessage: 'Value for the field.', - }), - validations: [ - { - validator: emptyField( - i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError', { - defaultMessage: 'A value is required.', - }) - ), - }, - ], + helpText: ( + {'""'}, + }} + /> + ), }, /* Optional fields config */ override: { @@ -101,7 +91,16 @@ export const SetProcessor: FunctionComponent = () => { })} /> - + diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts index daa0e548ab7280..399da3c05c7831 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts @@ -64,9 +64,11 @@ export const from = { // Ignore } } + return undefined; }, optionalArrayOfStrings: (v: string[]) => (v.length ? v : undefined), - undefinedIfValue: (value: any) => (v: boolean) => (v === value ? undefined : v), + undefinedIfValue: (value: unknown) => (v: boolean) => (v === value ? undefined : v), + emptyStringToUndefined: (v: unknown) => (v === '' ? undefined : v), }; export const EDITOR_PX_HEIGHT = { @@ -78,4 +80,6 @@ export const EDITOR_PX_HEIGHT = { export type FieldsConfig = Record>; -export type FormFieldsComponent = FunctionComponent<{ initialFieldValues?: Record }>; +export type FormFieldsComponent = FunctionComponent<{ + initialFieldValues?: Record; +}>; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/sort.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/sort.tsx index 82589e8777589f..3239f546820417 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/sort.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/sort.tsx @@ -12,7 +12,7 @@ import { FIELD_TYPES, UseField, SelectField } from '../../../../../../shared_imp import { FieldNameField } from './common_fields/field_name_field'; import { TargetField } from './common_fields/target_field'; -import { FieldsConfig, from } from './shared'; +import { FieldsConfig } from './shared'; const fieldsConfig: FieldsConfig = { /* Optional fields config */ @@ -20,7 +20,7 @@ const fieldsConfig: FieldsConfig = { type: FIELD_TYPES.SELECT, defaultValue: 'asc', deserializer: (v) => (v === 'asc' || v === 'desc' ? v : 'asc'), - serializer: from.undefinedIfValue('asc'), + serializer: (v) => (v === 'asc' || v === '' ? undefined : v), label: i18n.translate('xpack.ingestPipelines.pipelineEditor.sortForm.orderFieldLabel', { defaultMessage: 'Order', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx index 4309df214410b2..893e52bcc0073e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx @@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n'; import { EuiComboBoxOptionOption } from '@elastic/eui'; import { FIELD_TYPES, UseField, Field } from '../../../../../../shared_imports'; -import { FieldsConfig } from './shared'; +import { FieldsConfig, from } from './shared'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; import { FieldNameField } from './common_fields/field_name_field'; import { TargetField } from './common_fields/target_field'; @@ -31,6 +31,7 @@ const fieldsConfig: FieldsConfig = { /* Optional fields config */ regex_file: { type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, deserializer: String, label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.userAgentForm.regexFileFieldLabel', @@ -66,7 +67,7 @@ export const UserAgent: FunctionComponent = () => { id="xpack.ingestPipelines.pipelineEditor.userAgentForm.targetFieldHelpText" defaultMessage="Output field. Defaults to {defaultField}." values={{ - defaultField: {'user_agent'}, + defaultField: {'user_agent'}, }} /> } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx index eb5a1baac78fb9..26ae69ead3b5b6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx @@ -7,7 +7,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { ProcessorInternal, ProcessorSelector } from '../types'; interface Props { @@ -18,39 +18,37 @@ interface Props { export const ProcessorRemoveModal = ({ processor, onResult, selector }: Props) => { return ( - - - } - onCancel={() => onResult({ confirmed: false, selector })} - onConfirm={() => onResult({ confirmed: true, selector })} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -

    - -

    -
    -
    + + } + onCancel={() => onResult({ confirmed: false, selector })} + onConfirm={() => onResult({ confirmed: true, selector })} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

    + +

    +
    ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index aac4da7c16bbf0..9095ab1927cb98 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -135,7 +135,7 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { {'my-index-yyyy-MM-dd'} }} + values={{ value: {'my-index-yyyy-MM-dd'} }} /> ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx index 305ccce4e31b5a..d71a6fb80bde1e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; interface Props { confirmResetTestOutput: () => void; @@ -46,18 +46,16 @@ export const ResetDocumentsModal: FunctionComponent = ({ closeModal, }) => { return ( - - -

    {i18nTexts.modalDescription}

    -
    -
    + +

    {i18nTexts.modalDescription}

    +
    ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/processors_context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/processors_context.tsx index 0302ff017f09f9..0c43297e811d3c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/processors_context.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/processors_context.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { omit } from 'lodash'; import React, { createContext, FunctionComponent, @@ -150,12 +150,21 @@ export const PipelineProcessorsContextProvider: FunctionComponent = ({ }); break; case 'managingProcessor': + // These are the option names we get back from our UI + const knownOptionNames = Object.keys(processorTypeAndOptions.options); + // The processor that we are updating may have options configured the UI does not know about + const unknownOptions = omit(mode.arg.processor.options, knownOptionNames); + // In order to keep the options we don't get back from our UI, we merge the known and unknown options + const updatedProcessorOptions = { + ...processorTypeAndOptions.options, + ...unknownOptions, + }; processorsDispatch({ type: 'updateProcessor', payload: { processor: { ...mode.arg.processor, - ...processorTypeAndOptions, + options: updatedProcessorOptions, }, selector: mode.arg.selector, }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx index 230cc52a1c1696..63cf7af2737aa3 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/delete_modal.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -78,49 +78,47 @@ export const PipelineDeleteModal = ({ }; return ( - - + } + onCancel={handleOnCancel} + onConfirm={handleDeletePipelines} + cancelButtonText={ + + } + confirmButtonText={ + + } + > + <> +

    - } - onCancel={handleOnCancel} - onConfirm={handleDeletePipelines} - cancelButtonText={ - - } - confirmButtonText={ - - } - > - <> -

    - -

    +

    -
      - {pipelinesToDelete.map((name) => ( -
    • {name}
    • - ))} -
    - -
    -
    +
      + {pipelinesToDelete.map((name) => ( +
    • {name}
    • + ))} +
    + +
    ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts index d951e2f2a0768a..4afd434b893726 100644 --- a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts +++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts @@ -51,6 +51,7 @@ export { ValidationFunc, ValidationConfig, useFormData, + FormOptions, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { 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 d69af298018e74..992301af13ad04 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 @@ -8,6 +8,11 @@ exports[`DatatableComponent it renders actions column when there are row actions { + const table: Datatable = { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'number', + }, + }, + ], + rows: [{ a: 123 }], + }; + const CellRenderer = createGridCell( + { + a: { convert: (x) => `formatted ${x}` } as FieldFormat, + }, + DataContext + ); + + it('renders formatted value', () => { + const instance = mountWithIntl( + + {}} + isExpandable={false} + isDetails={false} + isExpanded={false} + /> + + ); + expect(instance.text()).toEqual('formatted 123'); + }); + + it('set class with text alignment', () => { + const cell = mountWithIntl( + + {}} + isExpandable={false} + isDetails={false} + isExpanded={false} + /> + + ); + expect(cell.find('.lnsTableCell').prop('className')).toContain('--right'); + }); +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx index a6e1e3386bcf35..2261dd06b532ba 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx @@ -14,19 +14,21 @@ export const createGridCell = ( formatters: Record>, DataContext: React.Context ) => ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { - const { table } = useContext(DataContext); + const { table, alignments } = useContext(DataContext); const rowValue = table?.rows[rowIndex][columnId]; const content = formatters[columnId]?.convert(rowValue, 'html'); + const currentAlignment = alignments && alignments[columnId]; + const alignmentClassName = `lnsTableCell--${currentAlignment}`; return ( - ); }; 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 5ff1e84276ba77..fdb05599c38e99 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -152,14 +152,14 @@ export const createGridColumns = ( ? false : { label: i18n.translate('xpack.lens.table.sort.ascLabel', { - defaultMessage: 'Sort asc', + defaultMessage: 'Sort ascending', }), }, showSortDesc: isReadOnly ? false : { label: i18n.translate('xpack.lens.table.sort.descLabel', { - defaultMessage: 'Sort desc', + defaultMessage: 'Sort descending', }), }, additional: isReadOnly diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx new file mode 100644 index 00000000000000..e0d31a3ed02012 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonGroup } from '@elastic/eui'; +import { FramePublicAPI, VisualizationDimensionEditorProps } from '../../types'; +import { DatatableVisualizationState } from '../visualization'; +import { createMockDatasource, createMockFramePublicAPI } from '../../editor_frame_service/mocks'; +import { mountWithIntl } from '@kbn/test/jest'; +import { TableDimensionEditor } from './dimension_editor'; + +describe('data table dimension editor', () => { + let frame: FramePublicAPI; + let state: DatatableVisualizationState; + let setState: (newState: DatatableVisualizationState) => void; + let props: VisualizationDimensionEditorProps; + + function testState(): DatatableVisualizationState { + return { + layerId: 'first', + columns: [ + { + columnId: 'foo', + }, + ], + }; + } + + beforeEach(() => { + state = testState(); + frame = createMockFramePublicAPI(); + frame.datasourceLayers = { + first: createMockDatasource('test').publicAPIMock, + }; + frame.activeData = { + first: { + type: 'datatable', + columns: [ + { + id: 'foo', + name: 'foo', + meta: { + type: 'string', + }, + }, + ], + rows: [], + }, + }; + setState = jest.fn(); + props = { + accessor: 'foo', + frame, + groupId: 'columns', + layerId: 'first', + state, + setState, + }; + }); + + it('should render default alignment', () => { + const instance = mountWithIntl(); + expect(instance.find(EuiButtonGroup).prop('idSelected')).toEqual( + expect.stringContaining('left') + ); + }); + + it('should render default alignment for number', () => { + frame.activeData!.first.columns[0].meta.type = 'number'; + const instance = mountWithIntl(); + expect(instance.find(EuiButtonGroup).prop('idSelected')).toEqual( + expect.stringContaining('right') + ); + }); + + it('should render specific alignment', () => { + state.columns[0].alignment = 'center'; + const instance = mountWithIntl(); + expect(instance.find(EuiButtonGroup).prop('idSelected')).toEqual( + expect.stringContaining('center') + ); + }); + + it('should set state for the right column', () => { + state.columns = [ + { + columnId: 'foo', + }, + { + columnId: 'bar', + }, + ]; + const instance = mountWithIntl(); + instance.find(EuiButtonGroup).prop('onChange')('center'); + expect(setState).toHaveBeenCalledWith({ + ...state, + columns: [ + { + columnId: 'foo', + alignment: 'center', + }, + { + columnId: 'bar', + }, + ], + }); + }); +}); 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 index 008b805bc8fed3..9c60cd47af3e34 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/dimension_editor.tsx @@ -7,55 +7,121 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSwitch, EuiFormRow } from '@elastic/eui'; +import { EuiFormRow, EuiSwitch, EuiButtonGroup, htmlIdGenerator } from '@elastic/eui'; import { VisualizationDimensionEditorProps } from '../../types'; import { DatatableVisualizationState } from '../visualization'; +const idPrefix = htmlIdGenerator()(); + export function TableDimensionEditor( props: VisualizationDimensionEditorProps ) { - const { state, setState, accessor } = props; - const column = state.columns.find((c) => c.columnId === accessor); + const { state, setState, frame, accessor } = props; + const column = state.columns.find(({ columnId }) => accessor === columnId); - const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length; + if (!column) return null; - if (!column) { - return null; - } + // either read config state or use same logic as chart itself + const currentAlignment = + column?.alignment || + (frame.activeData && + frame.activeData[state.layerId].columns.find((col) => col.id === accessor)?.meta.type === + 'number' + ? 'right' + : 'left'); + + const visibleColumnsCount = state.columns.filter((c) => !c.hidden).length; return ( - + + ); } diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss index 5e5db2c6458095..b99ffb6dce8101 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss @@ -1,3 +1,19 @@ .lnsDataTableContainer { height: 100%; } + +.lnsTableCell { + @include euiTextTruncate; +} + +.lnsTableCell--left { + text-align: left; +} + +.lnsTableCell--right { + text-align: right; +} + +.lnsTableCell--center { + text-align: center; +} \ No newline at end of file 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 588340fbe97fa9..22577e8ef5fd31 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 @@ -12,7 +12,7 @@ import { EuiDataGrid } from '@elastic/eui'; import { IAggType, IFieldFormat } from 'src/plugins/data/public'; import { EmptyPlaceholder } from '../../shared_components'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; -import { DatatableComponent } from './table_basic'; +import { DataContext, DatatableComponent } from './table_basic'; import { LensMultiTable } from '../../types'; import { DatatableProps } from '../expression'; @@ -427,6 +427,39 @@ describe('DatatableComponent', () => { expect(wrapper.find(EuiDataGrid).prop('columns')!.length).toEqual(2); }); + test('it adds alignment data to context', () => { + const { data, args } = sampleArgs(); + + const wrapper = shallow( + ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + /> + ); + + expect(wrapper.find(DataContext.Provider).prop('value').alignments).toEqual({ + // set via args + a: 'center', + // default for date + b: 'left', + // default for number + c: 'right', + }); + }); + 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 f685990f12dd26..e1687ba28f07bb 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 @@ -40,7 +40,7 @@ import { createGridSortingConfig, } from './table_actions'; -const DataContext = React.createContext({}); +export const DataContext = React.createContext({}); const gridStyle: EuiDataGridStyle = { border: 'horizontal', @@ -192,6 +192,21 @@ export const DatatableComponent = (props: DatatableRenderProps) => { ] ); + const alignments: Record = useMemo(() => { + const alignmentMap: Record = {}; + columnConfig.columns.forEach((column) => { + if (column.alignment) { + alignmentMap[column.columnId] = column.alignment; + } else { + const isNumeric = + firstLocalTable.columns.find((dataColumn) => dataColumn.id === column.columnId)?.meta + .type === 'number'; + alignmentMap[column.columnId] = isNumeric ? 'right' : 'left'; + } + }); + return alignmentMap; + }, [firstLocalTable, columnConfig]); + const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { return []; @@ -259,6 +274,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => { value={{ table: firstLocalTable, rowHasRowClickTriggerActions: props.rowHasRowClickTriggerActions, + alignments, }} > ; } diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 7ead7be67947c9..f6a38541cda271 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -138,6 +138,7 @@ export const datatableColumn: ExpressionFunctionDefinition< inputTypes: ['null'], args: { columnId: { types: ['string'], help: '' }, + alignment: { types: ['string'], help: '' }, hidden: { types: ['boolean'], help: '' }, width: { types: ['number'], help: '' }, }, 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 2a6228f16867dc..92136c557ad38d 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -419,11 +419,13 @@ describe('Datatable Visualization', () => { columnId: ['c'], hidden: [], width: [], + alignment: [], }); expect(columnArgs[1].arguments).toEqual({ columnId: ['b'], hidden: [], width: [], + alignment: [], }); }); @@ -459,10 +461,10 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, - frame - ); + const error = datatableVisualization.getErrorMessages({ + layerId: 'a', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }); expect(error).toBeUndefined(); }); @@ -478,10 +480,10 @@ describe('Datatable Visualization', () => { label: 'label', }); - const error = datatableVisualization.getErrorMessages( - { layerId: 'a', columns: [{ columnId: 'b' }, { columnId: 'c' }] }, - frame - ); + const error = datatableVisualization.getErrorMessages({ + layerId: 'a', + columns: [{ columnId: 'b' }, { columnId: 'c' }], + }); expect(error).toBeUndefined(); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 9625a814c79589..fc69c914deb680 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -23,6 +23,7 @@ export interface ColumnState { columnId: string; width?: number; hidden?: boolean; + alignment?: 'left' | 'right' | 'center'; } export interface SortingState { @@ -264,6 +265,7 @@ export const datatableVisualization: Visualization columnId: [column.columnId], hidden: typeof column.hidden === 'undefined' ? [] : [column.hidden], width: typeof column.width === 'undefined' ? [] : [column.width], + alignment: typeof column.alignment === 'undefined' ? [] : [column.alignment], }, }, ], @@ -276,7 +278,7 @@ export const datatableVisualization: Visualization }; }, - getErrorMessages(state, frame) { + getErrorMessages(state) { return undefined; }, 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 b3b695b22ad716..e5594bb0bb7699 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,12 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DragDrop defined dropType is reflected in the className 1`] = ` - + +
    `; exports[`DragDrop items that has dropType=undefined get special styling when another item is dragged 1`] = ` @@ -23,6 +27,7 @@ exports[`DragDrop items that has dropType=undefined get special styling when ano exports[`DragDrop renders if nothing is being dragged 1`] = `
    + ); @@ -96,7 +97,7 @@ describe('DragDrop', () => { jest.runAllTimers(); expect(dataTransfer.setData).toBeCalledWith('text', 'hello'); - expect(setDragging).toBeCalledWith(value); + expect(setDragging).toBeCalledWith({ ...value }); expect(setA11yMessage).toBeCalledWith('Lifted hello'); }); @@ -109,7 +110,7 @@ describe('DragDrop', () => { const component = mount( @@ -125,7 +126,7 @@ describe('DragDrop', () => { expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); expect(setDragging).toBeCalledWith(undefined); - expect(onDrop).toBeCalledWith({ id: '2', humanData: { label: 'label1' } }, 'field_add'); + expect(onDrop).toBeCalledWith({ id: '2', humanData: { label: 'Label1' } }, 'field_add'); }); test('drop function is not called on dropType undefined', async () => { @@ -137,7 +138,7 @@ describe('DragDrop', () => { const component = mount( @@ -175,7 +176,7 @@ describe('DragDrop', () => { test('items that has dropType=undefined get special styling when another item is dragged', () => { const component = mount( - + @@ -194,11 +195,10 @@ describe('DragDrop', () => { }); test('additional styles are reflected in the className until drop', () => { - let dragging: { id: '1'; humanData: { label: 'label1' } } | undefined; + 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={dragging} setA11yMessage={setA11yMessage} setDragging={() => { - dragging = { id: '1', humanData: { label: 'label1' } }; - }} - setActiveDropTarget={(val) => { - activeDropTarget = { activeDropTarget: val }; + dragging = { id: '1', humanData: { label: 'Label1' } }; }} - activeDropTarget={activeDropTarget} > { }); test('additional enter styles are reflected in the className until dragleave', () => { - let dragging: { id: '1'; humanData: { label: 'label1' } } | 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(); @@ -256,7 +252,7 @@ describe('DragDrop', () => { setA11yMessage={jest.fn()} dragging={dragging} setDragging={() => { - dragging = { id: '1', humanData: { label: 'label1' } }; + dragging = { id: '1', humanData: { label: 'Label1' } }; }} setActiveDropTarget={setActiveDropTarget} activeDropTarget={ @@ -307,7 +303,7 @@ describe('DragDrop', () => { draggable: true, value: { id: '1', - humanData: { label: 'label1', position: 1 }, + humanData: { label: 'Label1', position: 1 }, }, children: '1', order: [2, 0, 0, 0], @@ -330,7 +326,7 @@ describe('DragDrop', () => { dragType: 'move' as 'copy' | 'move', value: { id: '3', - humanData: { label: 'label3', position: 1 }, + humanData: { label: 'label3', position: 1, groupLabel: 'Y' }, }, onDrop, dropType: 'replace_compatible' as DropType, @@ -341,7 +337,7 @@ describe('DragDrop', () => { dragType: 'move' as 'copy' | 'move', value: { id: '4', - humanData: { label: 'label4', position: 2 }, + humanData: { label: 'label4', position: 2, groupLabel: 'Y' }, }, order: [2, 0, 2, 1], }, @@ -350,7 +346,7 @@ describe('DragDrop', () => { , style: {} } }, setActiveDropTarget, setA11yMessage, activeDropTarget: { @@ -376,21 +372,115 @@ describe('DragDrop', () => { .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' - ); }); + expect(setActiveDropTarget).toBeCalledWith({ + ...items[2].value, + onDrop, + dropType: items[2].dropType, + }); + keyboardHandler.simulate('keydown', { key: 'Enter' }); + expect(setA11yMessage).toBeCalledWith( + 'Replace label3 in Y group at position 1 with Label1. Press space or enter to replace' + ); + expect(setActiveDropTarget).toBeCalledWith(undefined); + expect(onDrop).toBeCalledWith( + { humanData: { label: 'Label1', position: 1 }, id: '1' }, + 'move_compatible' + ); + }); + + test('Keyboard navigation: dragstart sets dragging in the context and calls it with proper params', async () => { + const setDragging = jest.fn(); + + const setA11yMessage = jest.fn(); + const component = mount( + + + + + + ); + + const keyboardHandler = component + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .first() + .simulate('focus'); + + keyboardHandler.simulate('keydown', { key: 'Enter' }); + jest.runAllTimers(); + + expect(setDragging).toBeCalledWith({ + ...value, + ghost: { + children: , + style: { + height: 0, + width: 0, + }, + }, + }); + expect(setA11yMessage).toBeCalledWith('Lifted hello'); + }); + + test('Keyboard navigation: ActiveDropTarget gets ghost image', () => { + const onDrop = jest.fn(); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const items = [ + { + draggable: true, + value: { + id: '1', + humanData: { label: 'Label1', position: 1 }, + }, + children: '1', + order: [2, 0, 0, 0], + }, + { + draggable: true, + dragType: 'move' as 'copy' | 'move', + + value: { + id: '2', + + humanData: { label: 'label2', position: 1 }, + }, + onDrop, + dropType: 'move_compatible' as DropType, + order: [2, 0, 1, 0], + }, + ]; + const component = mount( + Hello
    , style: {} } }, + setActiveDropTarget, + setA11yMessage, + activeDropTarget: { + activeDropTarget: { ...items[1].value, onDrop, dropType: 'move_compatible' }, + dropTargetsByOrder: { + '2,0,1,0': { ...items[1].value, onDrop, dropType: 'move_compatible' }, + }, + }, + keyboardMode: true, + }} + > + {items.map((props) => ( + +
    + + ))} + + ); + + expect(component.find(DragDrop).at(1).find('.lnsDragDrop_ghost').text()).toEqual('Hello'); }); describe('reordering', () => { @@ -398,19 +488,19 @@ describe('DragDrop', () => { const items = [ { id: '1', - humanData: { label: 'label1', position: 1 }, + humanData: { label: 'Label1', position: 1, groupLabel: 'X' }, onDrop, dropType: 'reorder' as DropType, }, { id: '2', - humanData: { label: 'label2', position: 2 }, + humanData: { label: 'label2', position: 2, groupLabel: 'X' }, onDrop, dropType: 'reorder' as DropType, }, { id: '3', - humanData: { label: 'label3', position: 3 }, + humanData: { label: 'label3', position: 3, groupLabel: 'X' }, onDrop, dropType: 'reorder' as DropType, }, @@ -427,7 +517,7 @@ describe('DragDrop', () => { const registerDropTarget = jest.fn(); const baseContext = { dragging, - setDragging: (val?: DragDropIdentifier) => { + setDragging: (val?: DraggingIdentifier) => { dragging = val; }, keyboardMode, @@ -479,7 +569,11 @@ describe('DragDrop', () => { test(`Reorderable group with lifted element renders properly`, () => { const setA11yMessage = jest.fn(); const setDragging = jest.fn(); - const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage }); + const component = mountComponent({ + dragging: { ...items[0] }, + setDragging, + setA11yMessage, + }); act(() => { component .find('[data-test-subj="lnsDragDrop"]') @@ -488,8 +582,8 @@ describe('DragDrop', () => { jest.runAllTimers(); }); - expect(setDragging).toBeCalledWith(items[0]); - expect(setA11yMessage).toBeCalledWith('Lifted label1'); + expect(setDragging).toBeCalledWith({ ...items[0] }); + expect(setA11yMessage).toBeCalledWith('Lifted Label1'); expect( component .find('[data-test-subj="lnsDragDrop-reorderableGroup"]') @@ -498,7 +592,7 @@ describe('DragDrop', () => { }); test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => { - const component = mountComponent({ dragging: items[0] }); + const component = mountComponent({ dragging: { ...items[0] } }); act(() => { component @@ -545,7 +639,11 @@ describe('DragDrop', () => { const setA11yMessage = jest.fn(); const setDragging = jest.fn(); - const component = mountComponent({ dragging: items[0], setDragging, setA11yMessage }); + const component = mountComponent({ + dragging: { ...items[0] }, + setDragging, + setA11yMessage, + }); component .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') @@ -554,18 +652,18 @@ describe('DragDrop', () => { jest.runAllTimers(); expect(setA11yMessage).toBeCalledWith( - 'You have dropped the item label1. You have moved the item from position 1 to positon 3' + 'Reordered Label1 in X group from position 1 to positon 3' ); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); - expect(onDrop).toBeCalledWith(items[0], 'reorder'); + expect(onDrop).toBeCalledWith({ ...items[0] }, 'reorder'); }); 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], + dragging: { ...items[0] }, keyboardMode: true, activeDropTarget: { activeDropTarget: undefined, @@ -589,12 +687,12 @@ describe('DragDrop', () => { expect(setActiveDropTarget).toBeCalledWith(items[1]); expect(setA11yMessage).toBeCalledWith( - 'You have moved the item label1 from position 1 to position 2' + 'Reorder Label1 in X group from position 1 to position 2. Press space or enter to reorder' ); }); test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { const component = mountComponent({ - dragging: items[0], + dragging: { ...items[0] }, activeDropTarget: { activeDropTarget: { ...items[2], dropType: 'reorder', onDrop }, dropTargetsByOrder: { @@ -621,26 +719,33 @@ describe('DragDrop', () => { 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 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'); + expect(setA11yMessage).toBeCalledWith( + 'Movement cancelled. Label1 returned to X group at position 1' + ); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); keyboardHandler.simulate('blur'); expect(onDropHandler).not.toHaveBeenCalled(); - expect(setA11yMessage).toBeCalledWith('Movement cancelled'); + expect(setA11yMessage).toBeCalledWith( + 'Movement cancelled. Label1 returned to X group at position 1' + ); }); test(`Keyboard Navigation: Reordered elements get extra styles to show the reorder effect`, () => { const setA11yMessage = jest.fn(); const component = mountComponent({ - dragging: items[0], + dragging: { ...items[0] }, keyboardMode: true, activeDropTarget: { activeDropTarget: undefined, @@ -671,7 +776,7 @@ describe('DragDrop', () => { component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') ).toEqual(undefined); expect(setA11yMessage).toBeCalledWith( - 'You have moved the item label1 from position 1 to position 2' + 'Reorder Label1 in X group from position 1 to position 2. Press space or enter to reorder' ); component @@ -704,7 +809,7 @@ describe('DragDrop', () => { '2,0,1,1': { ...items[1], onDrop, dropType: 'reorder' }, }, }} - dragging={items[0]} + dragging={{ ...items[0] }} setActiveDropTarget={setActiveDropTarget} setA11yMessage={setA11yMessage} > @@ -736,7 +841,7 @@ describe('DragDrop', () => { keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); expect(setActiveDropTarget).toBeCalledWith(undefined); - expect(setA11yMessage).toBeCalledWith('You have moved the item label1 back to position 1'); + expect(setA11yMessage).toBeCalledWith('Label1 returned to its initial 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 07c1368e534566..6c6a65ab421b33 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -177,6 +177,7 @@ export const DragDrop = (props: BaseProps) => { ); const dropProps = { ...props, + keyboardMode, setKeyboardMode, dragging, setDragging, @@ -219,7 +220,10 @@ const DragInner = memo(function DragInner({ ariaDescribedBy, setA11yMessage, }: DragInnerProps) { - const dragStart = (e?: DroppableEvent | React.KeyboardEvent) => { + const dragStart = ( + e: DroppableEvent | React.KeyboardEvent, + keyboardModeOn?: boolean + ) => { // Setting stopPropgagation causes Chrome failures, so // we are manually checking if we've already handled this // in a nested child, and doing nothing if so... @@ -237,9 +241,21 @@ const DragInner = memo(function DragInner({ // dragStart event, so we drop a setTimeout to avoid that. const currentTarget = e?.currentTarget; + setTimeout(() => { - setDragging(value); + setDragging({ + ...value, + ghost: keyboardModeOn + ? { + children, + style: { width: currentTarget.offsetWidth, height: currentTarget.offsetHeight }, + } + : undefined, + }); setA11yMessage(announce.lifted(value.humanData)); + if (keyboardModeOn) { + setKeyboardMode(true); + } if (onDragStart) { onDragStart(currentTarget); } @@ -251,7 +267,7 @@ const DragInner = memo(function DragInner({ setDragging(undefined); setActiveDropTarget(undefined); setKeyboardMode(false); - setA11yMessage(announce.cancelled()); + setA11yMessage(announce.cancelled(value.humanData)); if (onDragEnd) { onDragEnd(); } @@ -284,8 +300,19 @@ const DragInner = memo(function DragInner({ : announce.noTarget() ); }; + const shouldShowGhostImageInstead = + isDragging && + dragType === 'move' && + keyboardMode && + activeDropTarget?.activeDropTarget && + activeDropTarget?.activeDropTarget.dropType !== 'reorder'; return ( -
    +
    diff --git a/x-pack/plugins/lens/public/drag_drop/readme.md b/x-pack/plugins/lens/public/drag_drop/readme.md index 55a9e3157c2471..01cc4c7bc85a52 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) => ( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx index f493000aa587a9..1cbd41fff2a8fb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx @@ -64,13 +64,16 @@ export function DraggableDimensionButton({ columnId: string; registerNewButtonRef: (id: string, instance: HTMLDivElement | null) => void; }) { - const dropType = layerDatasource.getDropTypes({ + const dropProps = layerDatasource.getDropProps({ ...layerDatasourceDropProps, columnId, filterOperations: group.filterOperations, groupId: group.groupId, }); + const dropType = dropProps?.dropType; + const nextLabel = dropProps?.nextLabel; + const value = useMemo( () => ({ columnId, @@ -82,9 +85,10 @@ export function DraggableDimensionButton({ label, groupLabel: group.groupLabel, position: accessorIndex + 1, + nextLabel: nextLabel || '', }, }), - [columnId, group.groupId, accessorIndex, layerId, dropType, label, group.groupLabel] + [columnId, group.groupId, accessorIndex, layerId, dropType, label, group.groupLabel, nextLabel] ); // todo: simplify by id and use drop targets? 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 a83d4bde0383c6..c9d0a7b002870c 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 @@ -54,13 +54,16 @@ export function EmptyDimensionButton({ setNewColumnId(generateId()); }, [itemIndex]); - const dropType = layerDatasource.getDropTypes({ + const dropProps = layerDatasource.getDropProps({ ...layerDatasourceDropProps, columnId: newColumnId, filterOperations: group.filterOperations, groupId: group.groupId, }); + const dropType = dropProps?.dropType; + const nextLabel = dropProps?.nextLabel; + const value = useMemo( () => ({ columnId: newColumnId, @@ -72,9 +75,10 @@ export function EmptyDimensionButton({ label, groupLabel: group.groupLabel, position: itemIndex + 1, + nextLabel: nextLabel || '', }, }), - [dropType, newColumnId, group.groupId, layerId, group.groupLabel, itemIndex] + [dropType, newColumnId, group.groupId, layerId, group.groupLabel, itemIndex, nextLabel] ); return ( diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index 6cc49ce5d0ce58..619147987cdd55 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -440,13 +440,20 @@ describe('LayerPanel', () => { ], }); - mockDatasource.getDropTypes.mockReturnValue('field_add'); + mockDatasource.getDropProps.mockReturnValue({ + dropType: 'field_add', + nextLabel: '', + }); const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -455,7 +462,7 @@ describe('LayerPanel', () => { ); - expect(mockDatasource.getDropTypes).toHaveBeenCalledWith( + expect(mockDatasource.getDropProps).toHaveBeenCalledWith( expect.objectContaining({ dragDropContext: expect.objectContaining({ dragging: draggingField, @@ -463,7 +470,7 @@ describe('LayerPanel', () => { }) ); - component.find('[data-test-subj="lnsGroup"] DragDrop').first().simulate('drop'); + component.find('[data-test-subj="lnsGroup"] DragDrop .lnsDragDrop').first().simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ @@ -488,8 +495,8 @@ describe('LayerPanel', () => { ], }); - mockDatasource.getDropTypes.mockImplementation(({ columnId }) => - columnId !== 'a' ? 'field_replace' : undefined + mockDatasource.getDropProps.mockImplementation(({ columnId }) => + columnId !== 'a' ? { dropType: 'field_replace', nextLabel: '' } : undefined ); const draggingField = { @@ -497,6 +504,10 @@ describe('LayerPanel', () => { indexPatternId: 'a', id: '1', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -505,7 +516,7 @@ describe('LayerPanel', () => { ); - expect(mockDatasource.getDropTypes).toHaveBeenCalledWith( + expect(mockDatasource.getDropProps).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'a' }) ); @@ -546,7 +557,10 @@ describe('LayerPanel', () => { ], }); - mockDatasource.getDropTypes.mockReturnValue('replace_compatible'); + mockDatasource.getDropProps.mockReturnValue({ + dropType: 'replace_compatible', + nextLabel: '', + }); const draggingOperation = { layerId: 'first', @@ -554,6 +568,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -562,7 +580,7 @@ describe('LayerPanel', () => { ); - expect(mockDatasource.getDropTypes).toHaveBeenCalledWith( + expect(mockDatasource.getDropProps).toHaveBeenCalledWith( expect.objectContaining({ dragDropContext: expect.objectContaining({ dragging: draggingOperation, @@ -571,7 +589,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the pre-populated dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop').at(0).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(0).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'b', @@ -582,7 +600,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the empty dimension - component.find('[data-test-subj="lnsGroupB"] DragDrop').at(1).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop .lnsDragDrop').at(1).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', @@ -613,6 +631,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -659,6 +681,10 @@ describe('LayerPanel', () => { groupId: 'a', id: 'a', humanData: { label: 'Label' }, + ghost: { + children: , + style: {}, + }, }; const component = mountWithIntl( @@ -677,5 +703,77 @@ describe('LayerPanel', () => { }) ); }); + + it('should call onDrop and update visualization when replacing between compatible groups', () => { + const mockVis = { + ...mockVisualization, + removeDimension: jest.fn(), + setDimension: jest.fn(() => 'modifiedState'), + }; + mockVis.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'a' }, { columnId: 'b' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + { + groupLabel: 'B', + groupId: 'b', + accessors: [{ columnId: 'c' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup2', + }, + ], + }); + + const draggingOperation = { + layerId: 'first', + columnId: 'a', + groupId: 'a', + id: 'a', + humanData: { label: 'Label' }, + }; + + mockDatasource.onDrop.mockReturnValue({ deleted: 'a' }); + const updateVisualization = jest.fn(); + + const component = mountWithIntl( + + + + ); + act(() => { + component.find(DragDrop).at(3).prop('onDrop')!(draggingOperation, 'replace_compatible'); + }); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dropType: 'replace_compatible', + droppedItem: draggingOperation, + }) + ); + expect(mockVis.setDimension).toHaveBeenCalledWith({ + columnId: 'c', + groupId: 'b', + layerId: 'first', + prevState: 'state', + }); + expect(mockVis.removeDimension).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'a', + layerId: 'first', + prevState: 'modifiedState', + }) + ); + expect(updateVisualization).toHaveBeenCalledTimes(1); + }); }); }); 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 5ba73e98b42c12..5d84f826ab988c 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 @@ -161,14 +161,12 @@ export function LayerPanel( dropType, }); if (dropResult) { - updateVisualization( - setDimension({ - columnId, - groupId, - layerId: targetLayerId, - prevState: props.visualizationState, - }) - ); + const newVisState = setDimension({ + columnId, + groupId, + layerId: targetLayerId, + prevState: props.visualizationState, + }); if (typeof dropResult === 'object') { // When a column is moved, we delete the reference to the old @@ -176,9 +174,11 @@ export function LayerPanel( removeDimension({ columnId: dropResult.deleted, layerId: targetLayerId, - prevState: props.visualizationState, + prevState: newVisState, }) ); + } else { + updateVisualization(newVisState); } } }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index da1d7f6eacd028..108e4aa84418fb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -1323,7 +1323,10 @@ describe('editor_frame', () => { getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (!dragging || dragging.id !== 'draggedField') { - setDragging({ id: 'draggedField', humanData: { label: 'draggedField' } }); + setDragging({ + id: 'draggedField', + humanData: { label: 'draggedField' }, + }); } }, }, @@ -1425,7 +1428,10 @@ describe('editor_frame', () => { getDatasourceSuggestionsForVisualizeField: () => [generateSuggestion()], renderDataPanel: (_element, { dragDropContext: { setDragging, dragging } }) => { if (!dragging || dragging.id !== 'draggedField') { - setDragging({ id: 'draggedField', humanData: { label: '1' } }); + setDragging({ + id: 'draggedField', + humanData: { label: '1' }, + }); } }, }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 559e773dbc1673..9c7ef19132c46a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -18,6 +18,9 @@ import { import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; +import { getActiveDatasourceIdFromDoc } from './state_management'; +import { ErrorMessage } from '../types'; +import { getMissingCurrentDatasource, getMissingVisualizationTypeError } from '../error_helper'; export async function initializeDatasources( datasourceMap: Record, @@ -72,7 +75,7 @@ export async function persistedStateToExpression( datasources: Record, visualizations: Record, doc: Document -): Promise { +): Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }> { const { state: { visualization: visualizationState, datasourceStates: persistedDatasourceStates }, visualizationType, @@ -80,7 +83,12 @@ export async function persistedStateToExpression( title, description, } = doc; - if (!visualizationType) return null; + if (!visualizationType) { + return { + ast: null, + errors: [{ shortMessage: '', longMessage: getMissingVisualizationTypeError() }], + }; + } const visualization = visualizations[visualizationType!]; const datasourceStates = await initializeDatasources( datasources, @@ -97,15 +105,33 @@ export async function persistedStateToExpression( const datasourceLayers = createDatasourceLayers(datasources, datasourceStates); - return buildExpression({ - title, - description, + const datasourceId = getActiveDatasourceIdFromDoc(doc); + if (datasourceId == null) { + return { + ast: null, + errors: [{ shortMessage: '', longMessage: getMissingCurrentDatasource() }], + }; + } + const validationResult = validateDatasourceAndVisualization( + datasources[datasourceId], + datasourceStates[datasourceId].state, visualization, visualizationState, - datasourceMap: datasources, - datasourceStates, - datasourceLayers, - }); + { datasourceLayers } + ); + + return { + ast: buildExpression({ + title, + description, + visualization, + visualizationState, + datasourceMap: datasources, + datasourceStates, + datasourceLayers, + }), + errors: validationResult, + }; } export const validateDatasourceAndVisualization = ( @@ -113,13 +139,8 @@ export const validateDatasourceAndVisualization = ( currentDatasourceState: unknown | null, currentVisualization: Visualization | null, currentVisualizationState: unknown | undefined, - frameAPI: FramePublicAPI -): - | Array<{ - shortMessage: string; - longMessage: string; - }> - | undefined => { + frameAPI: Pick +): ErrorMessage[] | undefined => { const layersGroups = currentVisualizationState ? currentVisualization ?.getLayerIds(currentVisualizationState) @@ -141,7 +162,7 @@ export const validateDatasourceAndVisualization = ( : undefined; const visualizationValidationErrors = currentVisualizationState - ? currentVisualization?.getErrorMessages(currentVisualizationState, frameAPI) + ? currentVisualization?.getErrorMessages(currentVisualizationState) : undefined; if (datasourceValidationErrors?.length || visualizationValidationErrors?.length) { 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 2c4cecd356cedc..83d2100a832cfd 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 @@ -10,16 +10,7 @@ import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n/react'; import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiText, - EuiTextColor, - EuiButtonEmpty, - EuiLink, - EuiTitle, -} from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, EuiButtonEmpty, EuiLink } from '@elastic/eui'; import { CoreStart, CoreSetup } from 'kibana/public'; import { DataPublicPluginStart, @@ -155,10 +146,7 @@ export const WorkspacePanel = React.memo(function WorkspacePanel({ datasourceLayers: framePublicAPI.datasourceLayers, }); } catch (e) { - const buildMessages = activeVisualization?.getErrorMessages( - visualizationState, - framePublicAPI - ); + const buildMessages = activeVisualization?.getErrorMessages(visualizationState); const defaultMessage = { shortMessage: i18n.translate('xpack.lens.editorFrame.buildExpressionError', { defaultMessage: 'An unexpected error occurred while preparing the chart', @@ -423,16 +411,6 @@ export const InnerVisualizationWrapper = ({ - - - - - - - {localState.configurationValidationError[0].longMessage} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss index ae9294c474b42e..0ace88b3d3ab75 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.scss @@ -2,7 +2,6 @@ .lnsWorkspacePanelWrapper { @include euiScrollBar; - overflow: hidden; // Override panel size padding padding: 0 !important; // sass-lint:disable-line no-important margin-bottom: $euiSize; @@ -10,6 +9,7 @@ flex-direction: column; position: relative; // For positioning the dnd overlay min-height: $euiSizeXXL * 10; + overflow: visible; .lnsWorkspacePanelWrapper__pageContentBody { @include euiScrollBar; @@ -17,7 +17,6 @@ display: flex; align-items: stretch; justify-content: stretch; - overflow: auto; > * { flex: 1 1 100%; @@ -34,6 +33,8 @@ // Color the whole panel instead background-color: transparent !important; // sass-lint:disable-line no-important border: none !important; // sass-lint:disable-line no-important + width: 100%; + height: 100%; } .lnsExpressionRenderer { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index d2085a4cc8a8b1..227c8b4741501d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -116,11 +116,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { @@ -140,6 +143,36 @@ describe('embeddable', () => { | expression`); }); + it('should not render the visualization if any error arises', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: [{ shortMessage: '', longMessage: 'my validation error' }], + }), + }, + {} as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({} as LensEmbeddableInput); + embeddable.render(mountpoint); + + expect(expressionRenderer).toHaveBeenCalledTimes(0); + }); + it('should initialize output with deduped list of index patterns', async () => { attributeService = attributeServiceMockFromSavedVis({ ...savedVis, @@ -162,11 +195,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, {} as LensEmbeddableInput @@ -194,11 +230,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -232,11 +271,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -265,11 +307,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -312,11 +357,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -359,11 +407,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -405,11 +456,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, input @@ -440,11 +494,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -475,11 +532,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123' } as LensEmbeddableInput @@ -510,11 +570,14 @@ describe('embeddable', () => { getTrigger, documentToExpression: () => Promise.resolve({ - type: 'expression', - chain: [ - { type: 'function', function: 'my', arguments: {} }, - { type: 'function', function: 'expression', arguments: {} }, - ], + ast: { + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }, + errors: undefined, }), }, { id: '123', timeRange, query, filters } as LensEmbeddableInput diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index dc5f9b366e6b52..ef265881f6eb3f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -51,6 +51,7 @@ import { IndexPatternsContract } from '../../../../../../src/plugins/data/public import { getEditPath, DOC_TYPE } from '../../../common'; import { IBasePath } from '../../../../../../src/core/public'; import { LensAttributeService } from '../../lens_attribute_service'; +import type { ErrorMessage } from '../types'; export type LensSavedObjectAttributes = Omit; @@ -77,7 +78,9 @@ export interface LensEmbeddableOutput extends EmbeddableOutput { export interface LensEmbeddableDeps { attributeService: LensAttributeService; - documentToExpression: (doc: Document) => Promise; + documentToExpression: ( + doc: Document + ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; editable: boolean; indexPatternService: IndexPatternsContract; expressionRenderer: ReactExpressionRendererType; @@ -99,6 +102,7 @@ export class Embeddable private subscription: Subscription; private isInitialized = false; private activeData: Partial | undefined; + private errors: ErrorMessage[] | undefined; private externalSearchContext: { timeRange?: TimeRange; @@ -225,8 +229,9 @@ export class Embeddable type: this.type, savedObjectId: (input as LensByReferenceInput)?.savedObjectId, }; - const expression = await this.deps.documentToExpression(this.savedVis); - this.expression = expression ? toExpression(expression) : null; + const { ast, errors } = await this.deps.documentToExpression(this.savedVis); + this.errors = errors; + this.expression = ast ? toExpression(ast) : null; await this.initializeOutput(); this.isInitialized = true; } @@ -279,6 +284,7 @@ export class Embeddable Promise; + documentToExpression: ( + doc: Document + ) => Promise<{ ast: Ast | null; errors: ErrorMessage[] | undefined }>; } export class EmbeddableFactory implements EmbeddableFactoryDefinition { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 8873388633552a..a559e6a02419d6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon, EuiEmptyPrompt } from '@elastic/eui'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -18,10 +18,12 @@ import { ExecutionContextSearch } from 'src/plugins/data/public'; import { DefaultInspectorAdapters, RenderMode } from 'src/plugins/expressions'; import classNames from 'classnames'; import { getOriginalRequestErrorMessage } from '../error_helper'; +import { ErrorMessage } from '../types'; export interface ExpressionWrapperProps { ExpressionRenderer: ReactExpressionRendererType; expression: string | null; + errors: ErrorMessage[] | undefined; variables?: Record; searchContext: ExecutionContextSearch; searchSessionId?: string; @@ -37,6 +39,46 @@ export interface ExpressionWrapperProps { className?: string; } +interface VisualizationErrorProps { + errors: ExpressionWrapperProps['errors']; +} + +export function VisualizationErrorPanel({ errors }: VisualizationErrorProps) { + return ( +
    + + {errors ? ( + <> +

    {errors[0].longMessage}

    + {errors.length > 1 ? ( +

    + +

    + ) : null} + + ) : ( +

    + +

    + )} + + } + /> +
    + ); +} + export function ExpressionWrapper({ ExpressionRenderer: ExpressionRendererComponent, expression, @@ -50,23 +92,12 @@ export function ExpressionWrapper({ hasCompatibleActions, style, className, + errors, }: ExpressionWrapperProps) { return ( - {expression === null || expression === '' ? ( - - - - - - - - - - + {errors || expression === null || expression === '' ? ( + ) : (
    { setDimension: jest.fn(), removeDimension: jest.fn(), - getErrorMessages: jest.fn((_state, _frame) => undefined), + getErrorMessages: jest.fn((_state) => undefined), }; } @@ -88,7 +88,7 @@ export function createMockDatasource(id: string): DatasourceMock { uniqueLabels: jest.fn((_state) => ({})), renderDimensionTrigger: jest.fn(), renderDimensionEditor: jest.fn(), - getDropTypes: jest.fn(), + getDropProps: 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/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 9e54a4d630dc28..8769aceca3bfd0 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -72,7 +72,7 @@ export class EditorFrameService { * This is an asynchronous process and should only be triggered once for a saved object. * @param doc parsed Lens saved object */ - private async documentToExpression(doc: Document) { + private documentToExpression = async (doc: Document) => { const [resolvedDatasources, resolvedVisualizations] = await Promise.all([ collectAsyncDefinitions(this.datasources), collectAsyncDefinitions(this.visualizations), @@ -81,7 +81,7 @@ export class EditorFrameService { const { persistedStateToExpression } = await import('../async_services'); return await persistedStateToExpression(resolvedDatasources, resolvedVisualizations, doc); - } + }; public setup( core: CoreSetup, @@ -98,7 +98,7 @@ export class EditorFrameService { coreHttp: coreStart.http, timefilter: deps.data.query.timefilter.timefilter, expressionRenderer: deps.expressions.ReactExpressionRenderer, - documentToExpression: this.documentToExpression.bind(this), + documentToExpression: this.documentToExpression, indexPatternService: deps.data.indexPatterns, uiActions: deps.uiActions, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/types.ts b/x-pack/plugins/lens/public/editor_frame_service/types.ts index dc5a4aa0e234b1..6043e963438991 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/types.ts @@ -8,3 +8,8 @@ import { Datatable } from 'src/plugins/expressions'; export type TableInspectorAdapter = Record; + +export interface ErrorMessage { + shortMessage: string; + longMessage: string; +} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 8047807093eefe..e487e185a8c8fe 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -57,7 +57,15 @@ function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) { return fieldA.displayName.localeCompare(fieldB.displayName, undefined, { sensitivity: 'base' }); } -const supportedFieldTypes = new Set(['string', 'number', 'boolean', 'date', 'ip', 'document']); +const supportedFieldTypes = new Set([ + 'string', + 'number', + 'boolean', + 'date', + 'ip', + 'histogram', + 'document', +]); const fieldTypeNames: Record = { document: i18n.translate('xpack.lens.datatypes.record', { defaultMessage: 'record' }), @@ -66,6 +74,7 @@ const fieldTypeNames: Record = { boolean: i18n.translate('xpack.lens.datatypes.boolean', { defaultMessage: 'boolean' }), date: i18n.translate('xpack.lens.datatypes.date', { defaultMessage: 'date' }), ip: i18n.translate('xpack.lens.datatypes.ipAddress', { defaultMessage: 'IP' }), + histogram: i18n.translate('xpack.lens.datatypes.histogram', { defaultMessage: 'histogram' }), }; // Wrapper around esQuery.buildEsQuery, handling errors (e.g. because a query can't be parsed) by diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 6dffeb351d2601..8f5da64fcc9a81 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -59,6 +59,9 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri const [inputValue, setInputValue] = useState(value); const unflushedChanges = useRef(false); + // Save the initial value + const initialValue = useRef(value); + const onChangeDebounced = useMemo(() => { const callback = _.debounce((val: string) => { onChange(val); @@ -79,7 +82,7 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri const handleInputChange = (e: React.ChangeEvent) => { const val = String(e.target.value); setInputValue(val); - onChangeDebounced(val); + onChangeDebounced(val || initialValue.current); }; return ( @@ -96,6 +99,7 @@ const LabelInput = ({ value, onChange }: { value: string; onChange: (value: stri data-test-subj="indexPattern-label-edit" value={inputValue} onChange={handleInputChange} + placeholder={initialValue.current} /> ); 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 c26d35c4d9a5d1..5eaa798f459e33 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 @@ -8,7 +8,14 @@ import { ReactWrapper, ShallowWrapper } from 'enzyme'; import React, { ChangeEvent, MouseEvent } from 'react'; import { act } from 'react-dom/test-utils'; -import { EuiComboBox, EuiListGroupItemProps, EuiListGroup, EuiRange } from '@elastic/eui'; +import { + EuiComboBox, + EuiListGroupItemProps, + EuiListGroup, + EuiRange, + EuiSelect, + EuiButtonIcon, +} from '@elastic/eui'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternDimensionEditorComponent, @@ -24,8 +31,6 @@ import { OperationMetadata } from '../../types'; import { DateHistogramIndexPatternColumn } from '../operations/definitions/date_histogram'; import { getFieldByNameFactory } from '../pure_helpers'; import { TimeScaling } from './time_scaling'; -import { EuiSelect } from '@elastic/eui'; -import { EuiButtonIcon } from '@elastic/eui'; import { DimensionEditor } from './dimension_editor'; jest.mock('../loader'); @@ -742,6 +747,8 @@ describe('IndexPatternDimensionEditorPanel', () => { }); it('should leave error state when switching from incomplete state to fieldless operation', () => { + // @ts-expect-error + window['__react-beautiful-dnd-disable-dev-warnings'] = true; // issue with enzyme & react-beautiful-dnd throwing errors: https://github.com/atlassian/react-beautiful-dnd/issues/1593 wrapper = mount(); wrapper 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 b374be98748f0a..17f069b8831e7b 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 @@ -4,10 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternDimensionEditorProps } from './dimension_panel'; -import { onDrop, getDropTypes } from './droppable'; +import { onDrop, getDropProps } from './droppable'; import { DragContextState } from '../../drag_drop'; import { createMockedDragDropContext } from '../mocks'; import { IUiSettingsClient, SavedObjectsClientContract, HttpSetup, CoreSetup } from 'kibana/public'; @@ -92,7 +91,7 @@ const draggingField = { * - Dimension trigger: Not tested here * - Dimension editor component: First half of the tests * - * - getDropTypes: Returns drop types that are possible for the current dragging field or other dimension + * - getDropProps: Returns drop types that are possible for the current dragging field or other dimension * - onDrop: Correct application of drop logic */ describe('IndexPatternDimensionEditorPanel', () => { @@ -175,19 +174,23 @@ describe('IndexPatternDimensionEditorPanel', () => { }); const groupId = 'a'; - describe('getDropTypes', () => { + describe('getDropProps', () => { it('returns undefined if no drag is happening', () => { - expect(getDropTypes({ ...defaultProps, groupId, dragDropContext })).toBe(undefined); + expect(getDropProps({ ...defaultProps, groupId, dragDropContext })).toBe(undefined); }); it('returns undefined if the dragged item has no field', () => { expect( - getDropTypes({ + getDropProps({ ...defaultProps, groupId, dragDropContext: { ...dragDropContext, - dragging: { name: 'bar', id: 'bar', humanData: { label: 'Label' } }, + dragging: { + name: 'bar', + id: 'bar', + humanData: { label: 'Label' }, + }, }, }) ).toBe(undefined); @@ -195,7 +198,7 @@ describe('IndexPatternDimensionEditorPanel', () => { it('returns undefined if field is not supported by filterOperations', () => { expect( - getDropTypes({ + getDropProps({ ...defaultProps, groupId, dragDropContext: { @@ -214,7 +217,7 @@ describe('IndexPatternDimensionEditorPanel', () => { it('returns remove_add if the field is supported by filterOperations and the dropTarget is an existing column', () => { expect( - getDropTypes({ + getDropProps({ ...defaultProps, groupId, dragDropContext: { @@ -223,12 +226,12 @@ describe('IndexPatternDimensionEditorPanel', () => { }, filterOperations: (op: OperationMetadata) => op.dataType === 'number', }) - ).toBe('field_replace'); + ).toEqual({ dropType: 'field_replace', nextLabel: 'Intervals' }); }); it('returns undefined if the field belongs to another index pattern', () => { expect( - getDropTypes({ + getDropProps({ ...defaultProps, groupId, dragDropContext: { @@ -247,7 +250,7 @@ describe('IndexPatternDimensionEditorPanel', () => { it('returns undefined if the dragged field is already in use by this operation', () => { expect( - getDropTypes({ + getDropProps({ ...defaultProps, groupId, dragDropContext: { @@ -272,7 +275,7 @@ describe('IndexPatternDimensionEditorPanel', () => { it('returns move if the dragged column is compatible', () => { expect( - getDropTypes({ + getDropProps({ ...defaultProps, groupId, dragDropContext: { @@ -287,7 +290,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, columnId: 'col2', }) - ).toBe('move_compatible'); + ).toEqual({ dropType: 'move_compatible' }); }); it('returns undefined if the dragged column from different group uses the same field as the dropTarget', () => { @@ -315,7 +318,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }; expect( - getDropTypes({ + getDropProps({ ...defaultProps, groupId, dragDropContext: { @@ -354,7 +357,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }; expect( - getDropTypes({ + getDropProps({ ...defaultProps, groupId, dragDropContext: { @@ -370,7 +373,7 @@ describe('IndexPatternDimensionEditorPanel', () => { columnId: 'col2', filterOperations: (op: OperationMetadata) => op.isBucketed === false, }) - ).toEqual('replace_incompatible'); + ).toEqual({ dropType: 'replace_incompatible', nextLabel: 'Unique count' }); }); }); describe('onDrop', () => { 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 69c7e8c3c2ae61..be791b3c7f7cec 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 @@ -10,21 +10,29 @@ import { DatasourceDimensionDropHandlerProps, isDraggedOperation, DraggedOperation, + DropType, } from '../../types'; import { IndexPatternColumn } from '../indexpattern'; -import { insertOrReplaceColumn, deleteColumn, getOperationTypesForField } from '../operations'; +import { + insertOrReplaceColumn, + deleteColumn, + getOperationTypesForField, + getOperationDisplay, +} from '../operations'; import { mergeLayer } from '../state_helpers'; import { hasField, isDraggedField } from '../utils'; -import { IndexPatternPrivateState, IndexPatternField, DraggedField } from '../types'; +import { IndexPatternPrivateState, DraggedField } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; type DropHandlerProps = DatasourceDimensionDropHandlerProps & { droppedItem: T; }; -export function getDropTypes( +const operationLabels = getOperationDisplay(); + +export function getDropProps( props: DatasourceDimensionDropProps & { groupId: string } -) { +): { dropType: DropType; nextLabel?: string } | undefined { const { dragging } = props.dragDropContext; if (!dragging) { return; @@ -32,23 +40,27 @@ export function getDropTypes( const layerIndexPatternId = props.state.layers[props.layerId].indexPatternId; - function hasOperationForField(field: IndexPatternField) { - const operationsForNewField = getOperationTypesForField(field, props.filterOperations); - return !!operationsForNewField.length; - } - const currentColumn = props.state.layers[props.layerId].columns[props.columnId]; if (isDraggedField(dragging)) { - if ( - !!(layerIndexPatternId === dragging.indexPatternId && hasOperationForField(dragging.field)) - ) { + const operationsForNewField = getOperationTypesForField(dragging.field, props.filterOperations); + + if (!!(layerIndexPatternId === dragging.indexPatternId && operationsForNewField.length)) { + const highestPriorityOperationLabel = operationLabels[operationsForNewField[0]].displayName; if (!currentColumn) { - return 'field_add'; + return { dropType: 'field_add', nextLabel: highestPriorityOperationLabel }; } else if ( (hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name) || !hasField(currentColumn) ) { - return 'field_replace'; + const persistingOperationLabel = + currentColumn && + operationsForNewField.includes(currentColumn.operationType) && + operationLabels[currentColumn.operationType].displayName; + + return { + dropType: 'field_replace', + nextLabel: persistingOperationLabel || highestPriorityOperationLabel, + }; } } return; @@ -62,9 +74,9 @@ export function getDropTypes( // same group if (props.groupId === dragging.groupId) { if (currentColumn) { - return 'reorder'; + return { dropType: 'reorder' }; } - return 'duplicate_in_group'; + return { dropType: 'duplicate_in_group' }; } // compatible group @@ -80,20 +92,34 @@ export function getDropTypes( } if (props.filterOperations(op)) { if (currentColumn) { - return 'replace_compatible'; // in the future also 'swap_compatible' and 'duplicate_compatible' + return { dropType: 'replace_compatible' }; // in the future also 'swap_compatible' and 'duplicate_compatible' } else { - return 'move_compatible'; // in the future also 'duplicate_compatible' + return { dropType: 'move_compatible' }; // in the future also 'duplicate_compatible' } } // suggest const field = hasField(op) && props.state.indexPatterns[layerIndexPatternId].getFieldByName(op.sourceField); - if (field && hasOperationForField(field)) { + const operationsForNewField = field && getOperationTypesForField(field, props.filterOperations); + + if (operationsForNewField && operationsForNewField?.length) { + const highestPriorityOperationLabel = operationLabels[operationsForNewField[0]].displayName; + if (currentColumn) { - return 'replace_incompatible'; // in the future also 'swap_incompatible', 'duplicate_incompatible' + const persistingOperationLabel = + currentColumn && + operationsForNewField.includes(currentColumn.operationType) && + operationLabels[currentColumn.operationType].displayName; + return { + dropType: 'replace_incompatible', + nextLabel: persistingOperationLabel || highestPriorityOperationLabel, + }; // in the future also 'swap_incompatible', 'duplicate_incompatible' } else { - return 'move_incompatible'; // in the future also 'duplicate_incompatible' + return { + dropType: 'move_incompatible', + nextLabel: highestPriorityOperationLabel, + }; // in the future also 'duplicate_incompatible' } } } @@ -178,6 +204,12 @@ function onMoveDropToNonCompatibleGroup(props: DropHandlerProps {

    @@ -227,7 +226,7 @@ const MovingAveragePopup = () => {

      @@ -240,7 +239,7 @@ const MovingAveragePopup = () => {

      diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts index c0c3030cb598a6..59dbf74c11480c 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/utils.ts @@ -36,7 +36,7 @@ export function checkForDateHistogram(layer: IndexPatternLayer, name: string) { return [ i18n.translate('xpack.lens.indexPattern.calculations.dateHistogramErrorMessage', { defaultMessage: - '{name} requires a date histogram to work. Choose a different function or add a date histogram.', + '{name} requires a date histogram to work. Add a date histogram or select a different function.', values: { name, }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx index fa3a390fb199d5..4d4556a0ac4ade 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.tsx @@ -369,16 +369,14 @@ const AutoDateHistogramPopover = ({ data }: { data: DataPublicPluginStart }) => >

      {i18n.translate('xpack.lens.indexPattern.dateHistogram.autoBasicExplanation', { - defaultMessage: 'The auto date histogram splits a date field into buckets by interval.', + defaultMessage: 'The auto date histogram splits a data field into buckets by interval.', })}

      {UI_SETTINGS.HISTOGRAM_MAX_BARS}, targetBarSetting: {UI_SETTINGS.HISTOGRAM_BAR_TARGET}, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx index e11ee580deb9bf..e724a34be20e8f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/metrics.tsx @@ -32,6 +32,8 @@ const typeToFn: Record = { median: 'aggMedian', }; +const supportedTypes = ['number', 'histogram']; + function buildMetricOperation>({ type, displayName, @@ -61,7 +63,7 @@ function buildMetricOperation>({ timeScalingMode: optionalTimeScaling ? 'optional' : undefined, getPossibleOperationForField: ({ aggregationRestrictions, aggregatable, type: fieldType }) => { if ( - fieldType === 'number' && + supportedTypes.includes(fieldType) && aggregatable && (!aggregationRestrictions || aggregationRestrictions[type]) ) { @@ -77,7 +79,7 @@ function buildMetricOperation>({ return Boolean( newField && - newField.type === 'number' && + supportedTypes.includes(newField.type) && newField.aggregatable && (!newField.aggregationRestrictions || newField.aggregationRestrictions![type]) ); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx index 07bab16b7096f4..9ac91be5a17ec2 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx @@ -68,6 +68,52 @@ describe('percentile', () => { }; }); + describe('getPossibleOperationForField', () => { + it('should accept number', () => { + expect( + percentileOperation.getPossibleOperationForField({ + name: 'bytes', + displayName: 'bytes', + type: 'number', + esTypes: ['long'], + aggregatable: true, + }) + ).toEqual({ + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }); + }); + + it('should accept histogram', () => { + expect( + percentileOperation.getPossibleOperationForField({ + name: 'response_time', + displayName: 'response_time', + type: 'histogram', + esTypes: ['histogram'], + aggregatable: true, + }) + ).toEqual({ + dataType: 'number', + isBucketed: false, + scale: 'ratio', + }); + }); + + it('should reject keywords', () => { + expect( + percentileOperation.getPossibleOperationForField({ + name: 'origin', + displayName: 'origin', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + }) + ).toBeUndefined(); + }); + }); + describe('toEsAggsFn', () => { it('should reflect params correctly', () => { const percentileColumn = layer.columns.col2 as PercentileIndexPatternColumn; @@ -134,6 +180,34 @@ describe('percentile', () => { }); }); + describe('isTransferable', () => { + it('should transfer from number to histogram', () => { + const indexPattern = createMockedIndexPattern(); + indexPattern.getFieldByName = jest.fn().mockReturnValue({ + name: 'response_time', + displayName: 'response_time', + type: 'histogram', + esTypes: ['histogram'], + aggregatable: true, + }); + expect( + percentileOperation.isTransferable( + { + label: '', + sourceField: 'response_time', + isBucketed: false, + dataType: 'number', + operationType: 'percentile', + params: { + percentile: 95, + }, + }, + indexPattern + ) + ).toBeTruthy(); + }); + }); + describe('param editor', () => { it('should render current percentile', () => { const updateLayerSpy = jest.fn(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx index f236b2932b2d3f..e7654380bd85f1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.tsx @@ -42,6 +42,8 @@ function ofName(name: string, percentile: number) { const DEFAULT_PERCENTILE_VALUE = 95; +const supportedFieldTypes = ['number', 'histogram']; + export const percentileOperation: OperationDefinition = { type: 'percentile', displayName: i18n.translate('xpack.lens.indexPattern.percentile', { @@ -49,7 +51,7 @@ export const percentileOperation: OperationDefinition { - if (fieldType === 'number' && aggregatable && !aggregationRestrictions) { + if (supportedFieldTypes.includes(fieldType) && aggregatable && !aggregationRestrictions) { return { dataType: 'number', isBucketed: false, @@ -62,7 +64,7 @@ export const percentileOperation: OperationDefinition {

      {UI_SETTINGS.HISTOGRAM_MAX_BARS}, }} @@ -68,7 +66,7 @@ const GranularityHelpPopover = () => {

      {i18n.translate('xpack.lens.indexPattern.ranges.granularityPopoverAdvancedExplanation', { defaultMessage: - 'Intervals are incremented by 10, 5 or 2: for example an interval can be 100 or 0.2 .', + 'Intervals are incremented by 10, 5 or 2. For example, an interval can be 100 or 0.2 .', })}

      diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx index d8c4c5fd8ca89c..62c729aa2b3f19 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx @@ -33,6 +33,19 @@ import { RangePopover } from './advanced_editor'; import { DragDropBuckets } from '../shared_components'; import { getFieldByNameFactory } from '../../../pure_helpers'; +// mocking random id generator function +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + htmlIdGenerator: (fn: unknown) => { + let counter = 0; + return () => counter++; + }, + }; +}); + const dataPluginMockValue = dataPluginMock.createStartContract(); // need to overwrite the formatter field first dataPluginMockValue.fieldFormats.deserialize = jest.fn().mockImplementation(({ params }) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index 517cb941f2f670..3b0cb67cbce411 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -16,6 +16,7 @@ import { EuiPopover, EuiButtonEmpty, EuiText, + EuiIconTip, } from '@elastic/eui'; import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public'; @@ -316,9 +317,25 @@ export const termsOperation: OperationDefinition )} + {i18n.translate('xpack.lens.indexPattern.terms.orderBy', { + defaultMessage: 'Rank by', + })}{' '} + + + } display="columnCompressed" fullWidth > @@ -338,14 +355,30 @@ export const termsOperation: OperationDefinition + {i18n.translate('xpack.lens.indexPattern.terms.orderDirection', { + defaultMessage: 'Rank direction', + })}{' '} + + + } display="columnCompressed" fullWidth > @@ -378,7 +411,7 @@ export const termsOperation: OperationDefinition diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts index d4c9da188be612..19c37da5bf2a93 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts @@ -21,6 +21,7 @@ import { getInvalidFieldMessage } from './operations/definitions/helpers'; * produce 'number') */ export function normalizeOperationDataType(type: DataType) { + if (type === 'histogram') return 'number'; return type === 'document' ? 'number' : type; } diff --git a/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts index b09d757b371419..f010c0b8114b52 100644 --- a/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts +++ b/x-pack/plugins/lens/public/lens_ui_telemetry/factory.ts @@ -98,6 +98,12 @@ export class LensReportManager { this.write(); } catch (e) { // Silent error because events will be reported during the next timer + + // If posting stats is forbidden for the current user, stop attempting to send them, + // but keep them in storage to push in case the user logs in with sufficient permissions at some point. + if (e.response && e.response.status === 403) { + this.stop(); + } } } } diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts index 84abc38bf4106f..66e524435ebc8e 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.test.ts @@ -197,23 +197,7 @@ describe('metric_visualization', () => { describe('#getErrorMessages', () => { it('returns undefined if no error is raised', () => { - const datasource: DatasourcePublicAPI = { - ...createMockDatasource('l1').publicAPIMock, - getOperationForColumnId(_: string) { - return { - id: 'a', - dataType: 'number', - isBucketed: false, - label: 'shazm', - }; - }, - }; - const frame = { - ...mockFrame(), - datasourceLayers: { l1: datasource }, - }; - - const error = metricVisualization.getErrorMessages(exampleState(), frame); + const error = metricVisualization.getErrorMessages(exampleState()); expect(error).not.toBeDefined(); }); diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index b86ba71083440f..91516b7b7319b9 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -117,7 +117,7 @@ export const metricVisualization: Visualization = { return { ...prevState, accessor: undefined }; }, - getErrorMessages(state, frame) { + getErrorMessages(state) { // Is it possible to break it? return undefined; }, diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index 5ec97e90e57d91..e3bd54032a93cf 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -17,6 +17,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; import { Position } from '@elastic/charts'; +import { PaletteRegistry } from 'src/plugins/charts/public'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PieVisualizationState, SharedPieLayerState } from './types'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; @@ -250,11 +251,15 @@ const DecimalPlaceSlider = ({ ); }; -export function DimensionEditor(props: VisualizationDimensionEditorProps) { +export function DimensionEditor( + props: VisualizationDimensionEditorProps & { + paletteService: PaletteRegistry; + } +) { return ( <> { props.setState({ ...props.state, palette: newPalette }); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts index 52fd4daac63c5f..0cdeaa8c043d83 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts @@ -7,8 +7,6 @@ import { getPieVisualization } from './visualization'; import { PieVisualizationState } from './types'; -import { createMockDatasource, createMockFramePublicAPI } from '../editor_frame_service/mocks'; -import { DatasourcePublicAPI, FramePublicAPI } from '../types'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; jest.mock('../id_generator'); @@ -36,37 +34,11 @@ function exampleState(): PieVisualizationState { }; } -function mockFrame(): FramePublicAPI { - return { - ...createMockFramePublicAPI(), - addNewLayer: () => LAYER_ID, - datasourceLayers: { - [LAYER_ID]: createMockDatasource(LAYER_ID).publicAPIMock, - }, - }; -} - // Just a basic bootstrap here to kickstart the tests describe('pie_visualization', () => { describe('#getErrorMessages', () => { it('returns undefined if no error is raised', () => { - const datasource: DatasourcePublicAPI = { - ...createMockDatasource('l1').publicAPIMock, - getOperationForColumnId(_: string) { - return { - id: 'a', - dataType: 'number', - isBucketed: false, - label: 'shazm', - }; - }, - }; - const frame = { - ...mockFrame(), - datasourceLayers: { l1: datasource }, - }; - - const error = pieVisualization.getErrorMessages(exampleState(), frame); + const error = pieVisualization.getErrorMessages(exampleState()); expect(error).not.toBeDefined(); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 6408d7496d332d..683acc49859b68 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -227,7 +227,7 @@ export const getPieVisualization = ({ renderDimensionEditor(domElement, props) { render( - + , domElement ); @@ -274,7 +274,7 @@ export const getPieVisualization = ({ )); }, - getErrorMessages(state, frame) { + getErrorMessages(state) { // not possible to break it? return undefined; }, diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index cccc35acb3fca1..419354117eda2e 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -189,9 +189,9 @@ export interface Datasource { renderDimensionTrigger: (domElement: Element, props: DatasourceDimensionTriggerProps) => void; renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps) => void; renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => void; - getDropTypes: ( + getDropProps: ( props: DatasourceDimensionDropProps & { groupId: string } - ) => DropType | undefined; + ) => { dropType: DropType; nextLabel?: string } | undefined; onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; updateStateOnCloseDimension?: (props: { layerId: string; @@ -318,7 +318,8 @@ export type DatasourceDimensionDropHandlerProps = DatasourceDimensionDropProp dropType: DropType; }; -export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip'; +export type FieldOnlyDataType = 'document' | 'ip' | 'histogram'; +export type DataType = 'string' | 'number' | 'date' | 'boolean' | FieldOnlyDataType; // An operation represents a column in a table, not any information // about how the column was created such as whether it is a sum or average. @@ -358,7 +359,7 @@ export interface LensMultiTable { export interface VisualizationConfigProps { layerId: string; - frame: FramePublicAPI; + frame: Pick; state: T; } @@ -631,10 +632,7 @@ export interface Visualization { * The frame will call this function on all visualizations at few stages (pre-build/build error) in order * to provide more context to the error and show it to the user */ - getErrorMessages: ( - state: T, - frame: FramePublicAPI - ) => Array<{ shortMessage: string; longMessage: string }> | undefined; + getErrorMessages: (state: T) => Array<{ shortMessage: string; longMessage: string }> | undefined; /** * The frame calls this function to display warnings about visualization diff --git a/x-pack/plugins/lens/public/visualization_container.scss b/x-pack/plugins/lens/public/visualization_container.scss index a67aa50127c813..d40a0b48ab40eb 100644 --- a/x-pack/plugins/lens/public/visualization_container.scss +++ b/x-pack/plugins/lens/public/visualization_container.scss @@ -15,3 +15,11 @@ position: static; // Let the progress indicator position itself against the outer parent } } + +.lnsEmbeddedError { + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts index 27cc16ebf862bd..d2e87ece5b5ec8 100644 --- a/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts +++ b/x-pack/plugins/lens/public/xy_visualization/color_assignment.ts @@ -95,7 +95,7 @@ export function getColorAssignments( export function getAccessorColorConfig( colorAssignments: ColorAssignments, - frame: FramePublicAPI, + frame: Pick, layer: XYLayerConfig, paletteService: PaletteRegistry ): AccessorConfig[] { diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts index cdb7f452cf7cf2..c244fa7fdfc899 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -589,137 +589,119 @@ describe('xy_visualization', () => { describe('#getErrorMessages', () => { it("should not return an error when there's only one dimension (X or Y)", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + ], + }) ).not.toBeDefined(); }); it("should not return an error when there's only one dimension on multiple layers (same axis everywhere)", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + ], + }) ).not.toBeDefined(); }); it('should not return an error when mixing different valid configurations in multiple layers', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: ['a'], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: ['a'], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); }); it("should not return an error when there's only one splitAccessor dimension configured", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).not.toBeDefined(); }); it('should return an error when there are multiple layers, one axis configured for each layer (but different axis from each other)', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: ['a'], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: ['a'], + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', @@ -729,34 +711,31 @@ describe('xy_visualization', () => { }); it('should return an error with batched messages for the same error with multiple layers', () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - { - layerId: 'third', - seriesType: 'area', - xAccessor: undefined, - accessors: [], - splitAccessor: 'a', - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + { + layerId: 'third', + seriesType: 'area', + xAccessor: undefined, + accessors: [], + splitAccessor: 'a', + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', @@ -766,32 +745,29 @@ describe('xy_visualization', () => { }); it("should return an error when some layers are complete but other layers aren't", () => { expect( - xyVisualization.getErrorMessages( - { - ...exampleState(), - layers: [ - { - layerId: 'first', - seriesType: 'area', - xAccessor: 'a', - accessors: [], - }, - { - layerId: 'second', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - { - layerId: 'third', - seriesType: 'area', - xAccessor: 'a', - accessors: ['a'], - }, - ], - }, - createMockFramePublicAPI() - ) + xyVisualization.getErrorMessages({ + ...exampleState(), + layers: [ + { + layerId: 'first', + seriesType: 'area', + xAccessor: 'a', + accessors: [], + }, + { + layerId: 'second', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + { + layerId: 'third', + seriesType: 'area', + xAccessor: 'a', + accessors: ['a'], + }, + ], + }) ).toEqual([ { shortMessage: 'Missing Vertical axis.', diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index a4dc7a91822bd1..1ee4b2e050f3ec 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -340,7 +340,7 @@ export const getXyVisualization = ({ toExpression(state, layers, paletteService, attributes), toPreviewExpression: (state, layers) => toPreviewExpression(state, layers, paletteService), - getErrorMessages(state, frame) { + getErrorMessages(state) { // Data error handling below here const hasNoAccessors = ({ accessors }: XYLayerConfig) => accessors == null || accessors.length === 0; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index dd5fff3c49f4fd..ac08c55eeadbff 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -336,7 +336,7 @@ export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProp { setState(updateLayer(state, { ...layer, palette: newPalette }, index)); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index 8b121232162aad..772934160a0584 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -26,6 +26,7 @@ const columnSortOrder = { ip: 3, boolean: 4, number: 5, + histogram: 6, }; /** diff --git a/x-pack/plugins/lens/server/routes/field_stats.ts b/x-pack/plugins/lens/server/routes/field_stats.ts index e1681a74c2951c..7fd884755d86df 100644 --- a/x-pack/plugins/lens/server/routes/field_stats.ts +++ b/x-pack/plugins/lens/server/routes/field_stats.ts @@ -86,7 +86,11 @@ export async function initFieldsRoute(setup: CoreSetup) { return result; }; - if (field.type === 'number') { + if (field.type === 'histogram') { + return res.ok({ + body: await getNumberHistogram(search, field, false), + }); + } else if (field.type === 'number') { return res.ok({ body: await getNumberHistogram(search, field), }); @@ -120,21 +124,31 @@ export async function initFieldsRoute(setup: CoreSetup) { export async function getNumberHistogram( aggSearchWithBody: (body: unknown) => Promise, - field: IFieldType + field: IFieldType, + useTopHits = true ): Promise { const fieldRef = getFieldRef(field); - const searchBody = { + const baseAggs = { + min_value: { + min: { field: field.name }, + }, + max_value: { + max: { field: field.name }, + }, + sample_count: { value_count: { ...fieldRef } }, + }; + const searchWithoutHits = { + sample: { + sampler: { shard_size: SHARD_SIZE }, + aggs: { ...baseAggs }, + }, + }; + const searchWithHits = { sample: { sampler: { shard_size: SHARD_SIZE }, aggs: { - min_value: { - min: { field: field.name }, - }, - max_value: { - max: { field: field.name }, - }, - sample_count: { value_count: { ...fieldRef } }, + ...baseAggs, top_values: { terms: { ...fieldRef, size: 10 }, }, @@ -142,14 +156,18 @@ export async function getNumberHistogram( }, }; - const minMaxResult = (await aggSearchWithBody(searchBody)) as ESSearchResponse< - unknown, - { body: { aggs: typeof searchBody } } - >; + const minMaxResult = (await aggSearchWithBody( + useTopHits ? searchWithHits : searchWithoutHits + )) as + | ESSearchResponse + | ESSearchResponse; const minValue = minMaxResult.aggregations!.sample.min_value.value; const maxValue = minMaxResult.aggregations!.sample.max_value.value; - const terms = minMaxResult.aggregations!.sample.top_values; + const terms = + 'top_values' in minMaxResult.aggregations!.sample + ? minMaxResult.aggregations!.sample.top_values + : { buckets: [] }; const topValuesBuckets = { buckets: terms.buckets.map((bucket) => ({ count: bucket.doc_count, @@ -169,7 +187,12 @@ export async function getNumberHistogram( sampledValues: minMaxResult.aggregations!.sample.sample_count.value!, sampledDocuments: minMaxResult.aggregations!.sample.doc_count, topValues: topValuesBuckets, - histogram: { buckets: [] }, + histogram: useTopHits + ? { buckets: [] } + : { + // Insert a fake bucket for a single-value histogram + buckets: [{ count: minMaxResult.aggregations!.sample.doc_count, key: minValue }], + }, }; } diff --git a/x-pack/plugins/lens/server/routes/telemetry.ts b/x-pack/plugins/lens/server/routes/telemetry.ts index d4eec5beaba908..cb8cf4b15f8d90 100644 --- a/x-pack/plugins/lens/server/routes/telemetry.ts +++ b/x-pack/plugins/lens/server/routes/telemetry.ts @@ -9,6 +9,7 @@ import Boom from '@hapi/boom'; import { errors } from '@elastic/elasticsearch'; import { CoreSetup } from 'src/core/server'; import { schema } from '@kbn/config-schema'; +import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { BASE_API_URL } from '../../common'; import { PluginStartContract } from '../plugin'; @@ -73,6 +74,9 @@ export async function initLensUsageRoute(setup: CoreSetup) return res.ok({ body: {} }); } catch (e) { + if (SavedObjectsErrorHelpers.isForbiddenError(e)) { + return res.forbidden(); + } if (e instanceof errors.ResponseError && e.statusCode === 404) { return res.notFound(); } diff --git a/x-pack/plugins/lens/server/usage/collectors.ts b/x-pack/plugins/lens/server/usage/collectors.ts index 4c20f946b1a44a..2f7a72ba17ea0b 100644 --- a/x-pack/plugins/lens/server/usage/collectors.ts +++ b/x-pack/plugins/lens/server/usage/collectors.ts @@ -13,6 +13,19 @@ import { TaskManagerStartContract } from '../../../task_manager/server'; import { LensUsage, LensTelemetryState } from './types'; import { lensUsageSchema } from './schema'; +const emptyUsageCollection = { + saved_overall: {}, + saved_30_days: {}, + saved_90_days: {}, + saved_overall_total: 0, + saved_30_days_total: 0, + saved_90_days_total: 0, + events_30_days: {}, + events_90_days: {}, + suggestion_events_30_days: {}, + suggestion_events_90_days: {}, +}; + export function registerLensUsageCollector( usageCollection: UsageCollectionSetup, taskManager: Promise @@ -29,6 +42,7 @@ export function registerLensUsageCollector( const suggestions = getDataByDate(state.suggestionsByDate); return { + ...emptyUsageCollection, ...state.saved, events_30_days: events.last30, events_90_days: events.last90, @@ -36,19 +50,7 @@ export function registerLensUsageCollector( suggestion_events_90_days: suggestions.last90, }; } catch (err) { - return { - saved_overall_total: 0, - saved_30_days_total: 0, - saved_90_days_total: 0, - saved_overall: {}, - saved_30_days: {}, - saved_90_days: {}, - - events_30_days: {}, - events_90_days: {}, - suggestion_events_30_days: {}, - suggestion_events_90_days: {}, - }; + return emptyUsageCollection; } }, isReady: async () => { diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index 9db44bd8225ea2..bc69ab5352a4f5 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -296,140 +296,140 @@ exports[`UploadLicense should display a modal when license requires acknowledgem className="euiSpacer euiSpacer--l" /> - - -
      + + } + confirmButtonText={ + + } + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } + > + + + -
      -
      - Confirm License Upload -
      -
      -
      +
    -
    -
    - } - > - - } - confirmButtonText={ - - } - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } - > -
    -
    -
    - - + + + +
    - - } - onCancel={cancelStartBasicLicense} - onConfirm={() => startBasicLicense(licenseType, true)} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -
    - {firstLine} - -
      - {messages.map((message) => ( -
    • {message}
    • - ))} -
    -
    -
    -
    - + + } + onCancel={cancelStartBasicLicense} + onConfirm={() => startBasicLicense(licenseType, true)} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +
    + {firstLine} + +
      + {messages.map((message) => ( +
    • {message}
    • + ))} +
    +
    +
    +
    ); } render() { diff --git a/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx b/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx index 4f7d1ab4365b68..36af5c3b9c7adc 100644 --- a/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx +++ b/x-pack/plugins/license_management/public/application/sections/license_dashboard/start_trial/start_trial.tsx @@ -14,7 +14,6 @@ import { EuiFlexItem, EuiCard, EuiLink, - EuiOverlayMask, EuiText, EuiModal, EuiModalFooter, @@ -78,154 +77,152 @@ export class StartTrial extends Component { } return ( - - - - - - - - - -
    - -

    - + + + + + + + +

    + +

    + - - - ), - }} + values={{ + subscriptionFeaturesLinkText: ( + + + + ), + }} + /> +

    +
      +
    • + -

      -
        -
      • - -
      • -
      • - -
      • -
      • - -
      • -
      • - -
      • -
      -

      +

    • +
    • - - - ), - }} + id="xpack.licenseMgmt.licenseDashboard.startTrial.confirmModalDescription.alertingFeatureTitle" + defaultMessage="Alerting" + /> +
    • +
    • + -

      -

      +

    • +
    • - - - ), + jdbcStandard: 'JDBC', + odbcStandard: 'ODBC', + sqlDataBase: 'SQL', }} /> -

      - -
    -
    - - - - - - {shouldShowTelemetryOptIn(telemetry) && ( - + +

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

    +

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

    + +
    +
    +
    + + + + + {shouldShowTelemetryOptIn(telemetry) && ( + + )} + + + + + + + + + + + + + + + + + +
    ); } diff --git a/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js b/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js index 77efe30bbb71ea..4d639ec3123dff 100644 --- a/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js +++ b/x-pack/plugins/license_management/public/application/sections/upload_license/upload_license.js @@ -13,7 +13,6 @@ import { EuiForm, EuiSpacer, EuiConfirmModal, - EuiOverlayMask, EuiText, EuiTitle, EuiFlexGroup, @@ -62,41 +61,39 @@ export class UploadLicense extends React.PureComponent { return null; } return ( - - - } - onCancel={this.cancel} - onConfirm={() => this.send(true)} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -
    - {firstLine} - -
      - {messages.map((message) => ( -
    • {message}
    • - ))} -
    -
    -
    -
    -
    + + } + onCancel={this.cancel} + onConfirm={() => this.send(true)} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +
    + {firstLine} + +
      + {messages.map((message) => ( +
    • {message}
    • + ))} +
    +
    +
    +
    ); } errorMessage() { diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts index ba5891092fe124..6dcda5d1f8c24d 100644 --- a/x-pack/plugins/lists/common/shared_exports.ts +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -47,4 +47,4 @@ export { OsTypeArray, } from './schemas'; -export { ENDPOINT_LIST_ID } from './constants'; +export { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from './constants'; diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts index 6ac15fa990b6de..7fdf861543117d 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_lists.test.ts @@ -201,7 +201,7 @@ describe('useExceptionLists', () => { expect(spyOnfetchExceptionLists).toHaveBeenCalledWith({ filters: - '(exception-list.attributes.created_by:Moi* OR exception-list-agnostic.attributes.created_by:Moi*) AND (exception-list.attributes.name:Sample Endpoint* OR exception-list-agnostic.attributes.name:Sample Endpoint*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', + '(exception-list.attributes.created_by:Moi OR exception-list-agnostic.attributes.created_by:Moi) AND (exception-list.attributes.name.text:Sample Endpoint OR exception-list-agnostic.attributes.name.text:Sample Endpoint) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)', http: mockKibanaHttpService, namespaceTypes: 'single,agnostic', pagination: { page: 1, perPage: 20 }, diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index 0758b5babfc0ac..e37c03978c9f6d 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -128,6 +128,8 @@ export interface ExceptionListFilter { name?: string | null; list_id?: string | null; created_by?: string | null; + type?: string | null; + tags?: string | null; } export interface UseExceptionListsProps { diff --git a/x-pack/plugins/lists/public/exceptions/utils.test.ts b/x-pack/plugins/lists/public/exceptions/utils.test.ts index cb13b1aef97ea4..47279de0a84c8e 100644 --- a/x-pack/plugins/lists/public/exceptions/utils.test.ts +++ b/x-pack/plugins/lists/public/exceptions/utils.test.ts @@ -115,7 +115,7 @@ describe('Exceptions utils', () => { const filters = getGeneralFilters({ created_by: 'moi', name: 'Sample' }, ['exception-list']); expect(filters).toEqual( - '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample)' ); }); @@ -126,7 +126,7 @@ describe('Exceptions utils', () => { ]); expect(filters).toEqual( - '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample)' ); }); }); @@ -179,7 +179,7 @@ describe('Exceptions utils', () => { const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], false); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps*)' ); }); @@ -187,7 +187,7 @@ describe('Exceptions utils', () => { const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['single'], true); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample*) AND (exception-list.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps*)' ); }); }); @@ -213,7 +213,7 @@ describe('Exceptions utils', () => { const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], false); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi*) AND (exception-list-agnostic.attributes.name:Sample*) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' ); }); @@ -221,7 +221,7 @@ describe('Exceptions utils', () => { const filter = getFilters({ created_by: 'moi', name: 'Sample' }, ['agnostic'], true); expect(filter).toEqual( - '(exception-list-agnostic.attributes.created_by:moi*) AND (exception-list-agnostic.attributes.name:Sample*) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list-agnostic.attributes.created_by:moi) AND (exception-list-agnostic.attributes.name.text:Sample) AND (exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' ); }); }); @@ -251,7 +251,7 @@ describe('Exceptions utils', () => { ); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (not exception-list.attributes.list_id: endpoint_trusted_apps* AND not exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' ); }); @@ -263,7 +263,7 @@ describe('Exceptions utils', () => { ); expect(filter).toEqual( - '(exception-list.attributes.created_by:moi* OR exception-list-agnostic.attributes.created_by:moi*) AND (exception-list.attributes.name:Sample* OR exception-list-agnostic.attributes.name:Sample*) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' + '(exception-list.attributes.created_by:moi OR exception-list-agnostic.attributes.created_by:moi) AND (exception-list.attributes.name.text:Sample OR exception-list-agnostic.attributes.name.text:Sample) AND (exception-list.attributes.list_id: endpoint_trusted_apps* OR exception-list-agnostic.attributes.list_id: endpoint_trusted_apps*)' ); }); }); diff --git a/x-pack/plugins/lists/public/exceptions/utils.ts b/x-pack/plugins/lists/public/exceptions/utils.ts index 51dec8bb49007b..009d6e56dc022e 100644 --- a/x-pack/plugins/lists/public/exceptions/utils.ts +++ b/x-pack/plugins/lists/public/exceptions/utils.ts @@ -74,10 +74,11 @@ export const getGeneralFilters = ( return Object.keys(filters) .map((filterKey) => { const value = get(filterKey, filters); - if (value != null) { + if (value != null && value.trim() !== '') { const filtersByNamespace = namespaceTypes .map((namespace) => { - return `${namespace}.attributes.${filterKey}:${value}*`; + const fieldToSearch = filterKey === 'name' ? 'name.text' : filterKey; + return `${namespace}.attributes.${fieldToSearch}:${value}`; }) .join(' OR '); return `(${filtersByNamespace})`; diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index d91910ad5ed28f..c9938897b50932 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -32,6 +32,7 @@ export { } from './exceptions/api'; export { ExceptionList, + ExceptionListFilter, ExceptionListIdentifiers, Pagination, UseExceptionListItemsSuccess, diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index 9766c0bcb98724..d380e821034e91 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -47,9 +47,19 @@ export const commonMapping: SavedObjectsType['mappings'] = { type: 'keyword', }, name: { + fields: { + text: { + type: 'text', + }, + }, type: 'keyword', }, tags: { + fields: { + text: { + type: 'text', + }, + }, type: 'keyword', }, tie_breaker_id: { diff --git a/x-pack/plugins/lists/server/saved_objects/migrations.test.ts b/x-pack/plugins/lists/server/saved_objects/migrations.test.ts index 143443b9320923..f71109b9bb85dc 100644 --- a/x-pack/plugins/lists/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/lists/server/saved_objects/migrations.test.ts @@ -6,61 +6,102 @@ */ import { SavedObjectUnsanitizedDoc } from 'kibana/server'; +import uuid from 'uuid'; -import { ENDPOINT_LIST_ID } from '../../common/constants'; +import { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../common/constants'; +import { ExceptionListSoSchema } from '../../common/schemas/saved_objects'; import { OldExceptionListSoSchema, migrations } from './migrations'; +const DEFAULT_EXCEPTION_LIST_SO: ExceptionListSoSchema = { + comments: undefined, + created_at: '2020-06-09T20:18:20.349Z', + created_by: 'user', + description: 'description', + entries: undefined, + immutable: false, + item_id: undefined, + list_id: 'some_list', + list_type: 'list', + meta: undefined, + name: 'name', + os_types: [], + tags: [], + tie_breaker_id: uuid.v4(), + type: 'endpoint', + updated_by: 'user', + version: undefined, +}; + +const DEFAULT_OLD_EXCEPTION_LIST_SO: OldExceptionListSoSchema = { + ...DEFAULT_EXCEPTION_LIST_SO, + _tags: [], +}; + +const createOldExceptionListSoSchemaSavedObject = ( + attributes: Partial +): SavedObjectUnsanitizedDoc => ({ + attributes: { ...DEFAULT_OLD_EXCEPTION_LIST_SO, ...attributes }, + id: 'abcd', + migrationVersion: {}, + references: [], + type: 'so-type', + updated_at: '2020-06-09T20:18:20.349Z', +}); + +const createExceptionListSoSchemaSavedObject = ( + attributes: Partial +): SavedObjectUnsanitizedDoc => ({ + attributes: { ...DEFAULT_EXCEPTION_LIST_SO, ...attributes }, + id: 'abcd', + migrationVersion: {}, + references: [], + type: 'so-type', + updated_at: '2020-06-09T20:18:20.349Z', +}); + describe('7.10.0 lists migrations', () => { const migration = migrations['7.10.0']; test('properly converts .text fields to .caseless', () => { - const doc = { - attributes: { - entries: [ - { - field: 'file.path.text', - operator: 'included', - type: 'match', - value: 'C:\\Windows\\explorer.exe', - }, - { - field: 'host.os.name', - operator: 'included', - type: 'match', - value: 'my-host', - }, - { - entries: [ - { - field: 'process.command_line.text', - operator: 'included', - type: 'match', - value: '/usr/bin/bash', - }, - { - field: 'process.parent.command_line.text', - operator: 'included', - type: 'match', - value: '/usr/bin/bash', - }, - ], - field: 'nested.field', - type: 'nested', - }, - ], - list_id: ENDPOINT_LIST_ID, - }, - id: 'abcd', - migrationVersion: {}, - references: [], - type: 'so-type', - updated_at: '2020-06-09T20:18:20.349Z', - }; - expect( - migration((doc as unknown) as SavedObjectUnsanitizedDoc) - ).toEqual({ - attributes: { + const doc = createOldExceptionListSoSchemaSavedObject({ + entries: [ + { + field: 'file.path.text', + operator: 'included', + type: 'match', + value: 'C:\\Windows\\explorer.exe', + }, + { + field: 'host.os.name', + operator: 'included', + type: 'match', + value: 'my-host', + }, + { + entries: [ + { + field: 'process.command_line.text', + operator: 'included', + type: 'match', + value: '/usr/bin/bash', + }, + { + field: 'process.parent.command_line.text', + operator: 'included', + type: 'match', + value: '/usr/bin/bash', + }, + ], + field: 'nested.field', + type: 'nested', + }, + ], + list_id: ENDPOINT_LIST_ID, + }); + + expect(migration(doc)).toEqual( + createOldExceptionListSoSchemaSavedObject({ entries: [ { field: 'file.path.caseless', @@ -94,40 +135,98 @@ describe('7.10.0 lists migrations', () => { }, ], list_id: ENDPOINT_LIST_ID, - }, - id: 'abcd', - migrationVersion: {}, - references: [], - type: 'so-type', - updated_at: '2020-06-09T20:18:20.349Z', - }); + }) + ); }); test('properly copies os tags to os_types', () => { - const doc = { - attributes: { - _tags: ['1234', 'os:windows'], - comments: [], - }, - id: 'abcd', - migrationVersion: {}, - references: [], - type: 'so-type', - updated_at: '2020-06-09T20:18:20.349Z', - }; - expect( - migration((doc as unknown) as SavedObjectUnsanitizedDoc) - ).toEqual({ - attributes: { + const doc = createOldExceptionListSoSchemaSavedObject({ + _tags: ['1234', 'os:windows'], + comments: [], + }); + + expect(migration(doc)).toEqual( + createOldExceptionListSoSchemaSavedObject({ _tags: ['1234', 'os:windows'], comments: [], os_types: ['windows'], - }, - id: 'abcd', - migrationVersion: {}, - references: [], - type: 'so-type', - updated_at: '2020-06-09T20:18:20.349Z', + }) + ); + }); +}); + +describe('7.12.0 lists migrations', () => { + const migration = migrations['7.12.0']; + + test('should not convert non trusted apps lists', () => { + const doc = createExceptionListSoSchemaSavedObject({ list_id: ENDPOINT_LIST_ID, tags: [] }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_LIST_ID, + tags: [], + tie_breaker_id: expect.anything(), + }) + ); + }); + + test('converts empty tags to contain list containing "policy:all" tag', () => { + const doc = createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: [], + }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:all'], + tie_breaker_id: expect.anything(), + }) + ); + }); + + test('preserves existing non policy related tags', () => { + const doc = createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['tag1', 'tag2'], + }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['tag1', 'tag2', 'policy:all'], + tie_breaker_id: expect.anything(), + }) + ); + }); + + test('preserves existing "policy:all" tag and does not add another one', () => { + const doc = createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:all', 'tag1', 'tag2'], + }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:all', 'tag1', 'tag2'], + tie_breaker_id: expect.anything(), + }) + ); + }); + + test('preserves existing policy reference tag and does not add "policy:all" tag', () => { + const doc = createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:056d2d4645421fb92e5cd39f33d70856', 'tag1', 'tag2'], }); + + expect(migration(doc)).toEqual( + createExceptionListSoSchemaSavedObject({ + list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, + tags: ['policy:056d2d4645421fb92e5cd39f33d70856', 'tag1', 'tag2'], + tie_breaker_id: expect.anything(), + }) + ); }); }); diff --git a/x-pack/plugins/lists/server/saved_objects/migrations.ts b/x-pack/plugins/lists/server/saved_objects/migrations.ts index 43faa7a5e8fb64..2fa19a6810a8ad 100644 --- a/x-pack/plugins/lists/server/saved_objects/migrations.ts +++ b/x-pack/plugins/lists/server/saved_objects/migrations.ts @@ -40,6 +40,9 @@ const reduceOsTypes = (acc: string[], tag: string): string[] => { return [...acc]; }; +const containsPolicyTags = (tags: string[]): boolean => + tags.some((tag) => tag.startsWith('policy:')); + export type OldExceptionListSoSchema = ExceptionListSoSchema & { _tags: string[]; }; @@ -64,4 +67,25 @@ export const migrations = { }, references: doc.references || [], }), + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + if (doc.attributes.list_id === ENDPOINT_TRUSTED_APPS_LIST_ID) { + return { + ...doc, + ...{ + attributes: { + ...doc.attributes, + tags: [ + ...(doc.attributes.tags || []), + ...(containsPolicyTags(doc.attributes.tags) ? [] : ['policy:all']), + ], + }, + }, + references: doc.references || [], + }; + } else { + return { ...doc, references: doc.references || [] }; + } + }, }; diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_editor/__snapshots__/confirm_delete_pipeline_modal.test.js.snap b/x-pack/plugins/logstash/public/application/components/pipeline_editor/__snapshots__/confirm_delete_pipeline_modal.test.js.snap index 8fc0ecacd4a3c1..31b8be8aab9ce1 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_editor/__snapshots__/confirm_delete_pipeline_modal.test.js.snap +++ b/x-pack/plugins/logstash/public/application/components/pipeline_editor/__snapshots__/confirm_delete_pipeline_modal.test.js.snap @@ -1,41 +1,39 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ConfirmDeletePipelineModal component renders as expected 1`] = ` - - - } - confirmButtonText={ - - } - defaultFocusedButton="cancel" - onCancel={[MockFunction]} - onConfirm={[MockFunction]} - title={ - + } + confirmButtonText={ + + } + defaultFocusedButton="cancel" + onCancel={[MockFunction]} + onConfirm={[MockFunction]} + title={ + - } - > -

    - You cannot recover a deleted pipeline. -

    -
    -
    + } + /> + } +> +

    + You cannot recover a deleted pipeline. +

    + `; diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_editor/confirm_delete_pipeline_modal.js b/x-pack/plugins/logstash/public/application/components/pipeline_editor/confirm_delete_pipeline_modal.js index 37ce05f42073af..d8cf85919bd425 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_editor/confirm_delete_pipeline_modal.js +++ b/x-pack/plugins/logstash/public/application/components/pipeline_editor/confirm_delete_pipeline_modal.js @@ -7,41 +7,39 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { EuiConfirmModal, EUI_MODAL_CANCEL_BUTTON, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { PIPELINE_EDITOR } from './constants'; export function ConfirmDeletePipelineModal({ id, cancelDeleteModal, confirmDeletePipeline }) { return ( - - - } - confirmButtonText={ - - } - defaultFocusedButton={EUI_MODAL_CANCEL_BUTTON} - onCancel={cancelDeleteModal} - onConfirm={confirmDeletePipeline} - title={ - - } - > -

    {PIPELINE_EDITOR.DELETE_PIPELINE_MODAL_MESSAGE}

    -
    -
    + + } + confirmButtonText={ + + } + defaultFocusedButton={EUI_MODAL_CANCEL_BUTTON} + onCancel={cancelDeleteModal} + onConfirm={confirmDeletePipeline} + title={ + + } + > +

    {PIPELINE_EDITOR.DELETE_PIPELINE_MODAL_MESSAGE}

    +
    ); } diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_list/__snapshots__/confirm_delete_modal.test.js.snap b/x-pack/plugins/logstash/public/application/components/pipeline_list/__snapshots__/confirm_delete_modal.test.js.snap index c58337612f2871..9eabf4120ef233 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_list/__snapshots__/confirm_delete_modal.test.js.snap +++ b/x-pack/plugins/logstash/public/application/components/pipeline_list/__snapshots__/confirm_delete_modal.test.js.snap @@ -1,93 +1,89 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ConfirmDeleteModal component confirms delete for multiple pipelines 1`] = ` - - - } - confirmButtonText={ - + } + confirmButtonText={ + - } - defaultFocusedButton="cancel" - onCancel={[MockFunction]} - onConfirm={[MockFunction]} - title={ - + } + defaultFocusedButton="cancel" + onCancel={[MockFunction]} + onConfirm={[MockFunction]} + title={ + - } - > -

    - -

    -
    -
    + } + /> + } +> +

    + +

    + `; exports[`ConfirmDeleteModal component confirms delete for single pipeline 1`] = ` - - - } - confirmButtonText={ - - } - defaultFocusedButton="cancel" - onCancel={[MockFunction]} - onConfirm={[MockFunction]} - title={ - + } + confirmButtonText={ + + } + defaultFocusedButton="cancel" + onCancel={[MockFunction]} + onConfirm={[MockFunction]} + title={ + - } - > -

    - -

    -
    -
    + } + /> + } +> +

    + +

    + `; diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_list/confirm_delete_modal.js b/x-pack/plugins/logstash/public/application/components/pipeline_list/confirm_delete_modal.js index c20db3d3fc5796..5dbefd2ae58e89 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_list/confirm_delete_modal.js +++ b/x-pack/plugins/logstash/public/application/components/pipeline_list/confirm_delete_modal.js @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EUI_MODAL_CANCEL_BUTTON, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CANCEL_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; export function ConfirmDeleteModal({ @@ -67,23 +67,21 @@ export function ConfirmDeleteModal({ }; return ( - - - } - confirmButtonText={confirmText.button} - defaultFocusedButton={EUI_MODAL_CANCEL_BUTTON} - onCancel={cancelDeletePipelines} - onConfirm={deleteSelectedPipelines} - title={confirmText.title} - > -

    {confirmText.message}

    -
    -
    + + } + confirmButtonText={confirmText.button} + defaultFocusedButton={EUI_MODAL_CANCEL_BUTTON} + onCancel={cancelDeletePipelines} + onConfirm={deleteSelectedPipelines} + title={confirmText.title} + > +

    {confirmText.message}

    +
    ); } diff --git a/x-pack/plugins/logstash/tsconfig.json b/x-pack/plugins/logstash/tsconfig.json new file mode 100644 index 00000000000000..6f21cfdb0b1919 --- /dev/null +++ b/x-pack/plugins/logstash/tsconfig.json @@ -0,0 +1,26 @@ + +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json"}, + { "path": "../../../src/plugins/management/tsconfig.json"}, + + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json"}, + { "path": "../monitoring/tsconfig.json"}, + { "path": "../security/tsconfig.json"}, + ] + } diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js index c0ad934c232e22..fafafa6b6a0711 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -277,6 +277,9 @@ describe('map_actions', () => { require('../selectors/map_selectors').getSearchSessionId = () => { return searchSessionId; }; + require('../selectors/map_selectors').getSearchSessionMapBuffer = () => { + return undefined; + }; require('../selectors/map_selectors').getMapSettings = () => { return { autoFitToDataBounds: false, diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 33c79c793974b4..9682306852ba92 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -22,6 +22,7 @@ import { getTimeFilters, getLayerList, getSearchSessionId, + getSearchSessionMapBuffer, } from '../selectors/map_selectors'; import { CLEAR_GOTO, @@ -229,12 +230,14 @@ export function setQuery({ filters = [], forceRefresh = false, searchSessionId, + searchSessionMapBuffer, }: { filters?: Filter[]; query?: Query; timeFilters?: TimeRange; forceRefresh?: boolean; searchSessionId?: string; + searchSessionMapBuffer?: MapExtent; }) { return async ( dispatch: ThunkDispatch, @@ -255,6 +258,7 @@ export function setQuery({ }, filters: filters ? filters : getFilters(getState()), searchSessionId, + searchSessionMapBuffer, }; const prevQueryContext = { @@ -262,6 +266,7 @@ export function setQuery({ query: getQuery(getState()), filters: getFilters(getState()), searchSessionId: getSearchSessionId(getState()), + searchSessionMapBuffer: getSearchSessionMapBuffer(getState()), }; if (_.isEqual(nextQueryContext, prevQueryContext)) { diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index 89c6d70a217c95..e3a21b596afe14 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -77,7 +77,7 @@ export interface ILayer { canShowTooltip(): boolean; syncLayerWithMB(mbMap: MbMap): void; getLayerTypeIconName(): string; - isDataLoaded(): boolean; + isInitialDataLoadComplete(): boolean; getIndexPatternIds(): string[]; getQueryableIndexPatternIds(): string[]; getType(): string | undefined; @@ -446,7 +446,7 @@ export class AbstractLayer implements ILayer { throw new Error('should implement Layer#getLayerTypeIconName'); } - isDataLoaded(): boolean { + isInitialDataLoadComplete(): boolean { const sourceDataRequest = this.getSourceDataRequest(); return sourceDataRequest ? sourceDataRequest.hasData() : false; } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 7e87d99fd4f93f..2373ed3ba2062f 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -174,7 +174,7 @@ export class VectorLayer extends AbstractLayer implements IVectorLayer { return this.getValidJoins().length > 0; } - isDataLoaded() { + isInitialDataLoadComplete() { const sourceDataRequest = this.getSourceDataRequest(); if (!sourceDataRequest || !sourceDataRequest.hasData()) { return false; diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.js b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.js index 71476be2f9c2c0..f2216f2afd2da4 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.js +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_tms_source.js @@ -101,7 +101,7 @@ export class EMSTMSSource extends AbstractTMSSource { return tmsService; } - throw new Error(getErrorInfo()); + throw new Error(getErrorInfo(emsTileLayerId)); } async getDisplayName() { diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index 25bd589cda658a..5ca370f7d54c8e 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -84,6 +84,8 @@ export class MBMap extends Component { private _checker?: ResizeChecker; private _isMounted: boolean = false; private _containerRef: HTMLDivElement | null = null; + private _prevDisableInteractive?: boolean; + private _navigationControl = new mapboxgl.NavigationControl({ showCompass: false }); state: State = { prevLayerList: undefined, @@ -181,7 +183,6 @@ export class MBMap extends Component { style: mbStyle, scrollZoom: this.props.scrollZoom, preserveDrawingBuffer: getPreserveDrawingBuffer(), - interactive: !this.props.settings.disableInteractive, maxZoom: this.props.settings.maxZoom, minZoom: this.props.settings.minZoom, }; @@ -197,9 +198,6 @@ export class MBMap extends Component { const mbMap = new mapboxgl.Map(options); mbMap.dragRotate.disable(); mbMap.touchZoomRotate.disableRotation(); - if (!this.props.settings.disableInteractive) { - mbMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-left'); - } const tooManyFeaturesImageSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA7DgAAOw4BzLahgwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAARLSURBVHic7ZnPbxRVAMe/7735sWO3293ZlUItJsivCxEE0oTYRgu1FqTQoFSwKTYx8SAH/wHjj4vRozGGi56sMcW2UfqTEuOhppE0KJc2GIuKQFDY7qzdtrudX88D3YTUdFuQN8+k87ltZt7uZz958/bNLAGwBWsYKltANmEA2QKyCQPIFpBNGEC2gGzCALIFZBMGkC0gmzCAbAHZhAFkC8gmDCBbQDZhANkCslnzARQZH6oDpNs0D5UDSUIInePcOpPLfdfnODNBuwQWIAWwNOABwHZN0x8npE6hNLJ4DPWRyFSf40wE5VOEQPBjcR0g3YlE4ybGmtK+/1NzJtOZA/xSYwZMs3nG962T2ez3It2AANaA/kSidYuivOQBs5WM1fUnk6f0u+GXJUqIuUtVXx00zRbRfkIDfBqL7a1WlIYbjvNtTTr99jXXHVpH6dMjK0R4cXq6c9rzxjcx9sKX8XitSEdhAToMI7VP10/97fsTh7PZrgWAN1lW72KE2vOm2b5chDTgtWQyn93x/bEEIetEOQIC14CxVOr1CkKefH929t0v8vn0vcdGEoljGxXl4C3PGz2YyXy+AHARDqtByAxoUdWKBKV70r4/vvTLA0CjZfX+5nkDGxirKzUTgkBIgNaysh3gnF627R+XO+dQJvP1ddcdrmSsbtA020pF+CAW21qrqmUiXIUEqGRsIwD0FQq/lzqv0bJ6rrvucBVjzwyb5ivLRTiiaW+8VV7eIEBVTAANiIIQd9RxZlc6t9Gyem647vn1jD07ZJonl4sQASoevqmgABzwwHnJzc69PGdZ3X+47sgGxuqHTPPE0ggeVtg5/QeEBMhxPg1Aa1DV2GrHPG9ZXy1G2D+wNALn9jyQEeHKAJgP+033Kgrdqij7AFwZtu3bqx3XWShMHtV1o1pRGo4YxiNd+fyEB2DKdX/4aG5u0hbwcylkBryTy/3scT6zW9Nq7ndso2Wdvea6Q1WUHuiPx1/WAXLBcWZXun94UMRcAoD/p+ddTFK6u8MwUvc7vsmyem+67oVqVT0wkEgcF+FYRNhW+L25uX6f84XThtHxIBudE5bVY/t++jFVrU/dvVSFICzAqG3PX/S8rihj2/61qK1AOUB7ksl2jdLUL7Z9rvgcQQRCFsEi5wqFmw26XnhCUQ63GcZmCly95Lrzpca0G0byk3j8tEnpU1c975tmyxoU5QcE8EAEAM5WVOzfoarHAeC2749dcpzxMwsLv07Ztg0AOzVNf03Ttu/S9T2PMlbjc25fdpyutmx2TLRbIAEA4M1otKo1EjmaoHQn4ZwBgA/kAVAK6MXXdzxv/ONcrq/HcbJBeAUWoEizqsaORaPbKglZrxMSZZyrM76f/ovzWx/m85PFWREUgQf4v7Hm/xcIA8gWkE0YQLaAbMIAsgVkEwaQLSCbMIBsAdmEAWQLyCYMIFtANmEA2QKyCQPIFpDNmg/wD3OFdEybUvJjAAAAAElFTkSuQmCC'; @@ -357,6 +355,28 @@ export class MBMap extends Component { return; } + if ( + this._prevDisableInteractive === undefined || + this._prevDisableInteractive !== this.props.settings.disableInteractive + ) { + this._prevDisableInteractive = this.props.settings.disableInteractive; + if (this.props.settings.disableInteractive) { + this.state.mbMap.boxZoom.disable(); + this.state.mbMap.doubleClickZoom.disable(); + this.state.mbMap.dragPan.disable(); + try { + this.state.mbMap.removeControl(this._navigationControl); + } catch (error) { + // ignore removeControl errors + } + } else { + this.state.mbMap.boxZoom.enable(); + this.state.mbMap.doubleClickZoom.enable(); + this.state.mbMap.dragPan.enable(); + this.state.mbMap.addControl(this._navigationControl, 'top-left'); + } + } + let zoomRangeChanged = false; if (this.props.settings.minZoom !== this.state.mbMap.getMinZoom()) { this.state.mbMap.setMinZoom(this.props.settings.minZoom); diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js index 89eef907b22593..9e5a6080c830d8 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/view.js @@ -8,7 +8,7 @@ import React from 'react'; import classNames from 'classnames'; -import { EuiIcon, EuiOverlayMask, EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; +import { EuiIcon, EuiButtonIcon, EuiConfirmModal } from '@elastic/eui'; import { TOCEntryActionsPopover } from './toc_entry_actions_popover'; import { i18n } from '@kbn/i18n'; @@ -100,20 +100,18 @@ export class TOCEntry extends React.Component { }; return ( - - -

    There are unsaved changes to your layer.

    -

    Are you sure you want to proceed?

    -
    -
    + +

    There are unsaved changes to your layer.

    +

    Are you sure you want to proceed?

    +
    ); } diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index b769ac489f565e..b7e50815fd1f71 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -29,6 +29,7 @@ import { } from '../../../../../src/plugins/data/public'; import { replaceLayerList, + setMapSettings, setQuery, setRefreshConfig, disableScrollZoom, @@ -43,6 +44,7 @@ import { } from '../reducers/non_serializable_instances'; import { getMapCenter, + getMapBuffer, getMapZoom, getHiddenLayerIds, getQueryableUniqueIndexPatternIds, @@ -60,6 +62,7 @@ import { getCoreI18n, getHttp, getChartsPaletteServiceGetColor, + getSearchService, } from '../kibana_services'; import { LayerDescriptor } from '../../common/descriptor_types'; import { MapContainer } from '../connected_components/map_container'; @@ -77,6 +80,14 @@ import { } from './types'; export { MapEmbeddableInput, MapEmbeddableOutput }; +function getIsRestore(searchSessionId?: string) { + if (!searchSessionId) { + return false; + } + const searchSessionOptions = getSearchService().session.getSearchOptions(searchSessionId); + return searchSessionOptions ? searchSessionOptions.isRestore : false; +} + export class MapEmbeddable extends Embeddable implements ReferenceOrValueEmbeddable { @@ -85,6 +96,7 @@ export class MapEmbeddable private _savedMap: SavedMap; private _renderTooltipContent?: RenderToolTipContent; private _subscription: Subscription; + private _prevIsRestore: boolean = false; private _prevTimeRange?: TimeRange; private _prevQuery?: Query; private _prevRefreshConfig?: RefreshInterval; @@ -140,11 +152,7 @@ export class MapEmbeddable store.dispatch(disableScrollZoom()); this._dispatchSetQuery({ - query: this.input.query, - timeRange: this.input.timeRange, - filters: this.input.filters, forceRefresh: false, - searchSessionId: this.input.searchSessionId, }); if (this.input.refreshConfig) { this._dispatchSetRefreshConfig(this.input.refreshConfig); @@ -219,11 +227,7 @@ export class MapEmbeddable this.input.searchSessionId !== this._prevSearchSessionId ) { this._dispatchSetQuery({ - query: this.input.query, - timeRange: this.input.timeRange, - filters: this.input.filters, forceRefresh: false, - searchSessionId: this.input.searchSessionId, }); } @@ -234,32 +238,37 @@ export class MapEmbeddable if (this.input.syncColors !== this._prevSyncColors) { this._dispatchSetChartsPaletteServiceGetColor(this.input.syncColors); } + + const isRestore = getIsRestore(this.input.searchSessionId); + if (isRestore !== this._prevIsRestore) { + this._prevIsRestore = isRestore; + this._savedMap.getStore().dispatch( + setMapSettings({ + disableInteractive: isRestore, + hideToolbarOverlay: isRestore, + }) + ); + } } - _dispatchSetQuery({ - query, - timeRange, - filters = [], - forceRefresh, - searchSessionId, - }: { - query?: Query; - timeRange?: TimeRange; - filters?: Filter[]; - forceRefresh: boolean; - searchSessionId?: string; - }) { - this._prevTimeRange = timeRange; - this._prevQuery = query; - this._prevFilters = filters; - this._prevSearchSessionId = searchSessionId; + _dispatchSetQuery({ forceRefresh }: { forceRefresh: boolean }) { + this._prevTimeRange = this.input.timeRange; + this._prevQuery = this.input.query; + this._prevFilters = this.input.filters; + this._prevSearchSessionId = this.input.searchSessionId; + const enabledFilters = this.input.filters + ? this.input.filters.filter((filter) => !filter.meta.disabled) + : []; this._savedMap.getStore().dispatch( setQuery({ - filters: filters.filter((filter) => !filter.meta.disabled), - query, - timeFilters: timeRange, + filters: enabledFilters, + query: this.input.query, + timeFilters: this.input.timeRange, forceRefresh, - searchSessionId, + searchSessionId: this.input.searchSessionId, + searchSessionMapBuffer: getIsRestore(this.input.searchSessionId) + ? this.input.mapBuffer + : undefined, }) ); } @@ -410,11 +419,7 @@ export class MapEmbeddable reload() { this._dispatchSetQuery({ - query: this.input.query, - timeRange: this.input.timeRange, - filters: this.input.filters, forceRefresh: true, - searchSessionId: this.input.searchSessionId, }); } @@ -435,6 +440,7 @@ export class MapEmbeddable lon: center.lon, zoom, }, + mapBuffer: getMapBuffer(this._savedMap.getStore().getState()), }); } diff --git a/x-pack/plugins/maps/public/embeddable/types.ts b/x-pack/plugins/maps/public/embeddable/types.ts index 67489802bc31d1..7cd4fa8e1253bd 100644 --- a/x-pack/plugins/maps/public/embeddable/types.ts +++ b/x-pack/plugins/maps/public/embeddable/types.ts @@ -12,7 +12,7 @@ import { SavedObjectEmbeddableInput, } from '../../../../../src/plugins/embeddable/public'; import { RefreshInterval, Query, Filter, TimeRange } from '../../../../../src/plugins/data/common'; -import { MapCenterAndZoom } from '../../common/descriptor_types'; +import { MapCenterAndZoom, MapExtent } from '../../common/descriptor_types'; import { MapSavedObjectAttributes } from '../../common/map_saved_object_type'; import { MapSettings } from '../reducers/map'; @@ -25,6 +25,7 @@ interface MapEmbeddableState { isLayerTOCOpen?: boolean; openTOCDetails?: string[]; mapCenter?: MapCenterAndZoom; + mapBuffer?: MapExtent; mapSettings?: Partial; hiddenLayers?: string[]; hideFilterActions?: boolean; diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index 6a51d4feeb9dfe..1cf37561609640 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -37,6 +37,7 @@ export type MapContext = { refreshTimerLastTriggeredAt?: string; drawState?: DrawState; searchSessionId?: string; + searchSessionMapBuffer?: MapExtent; }; export type MapSettings = { diff --git a/x-pack/plugins/maps/public/reducers/map.js b/x-pack/plugins/maps/public/reducers/map.js index fa7e1308bac4f4..9bf0df612bac40 100644 --- a/x-pack/plugins/maps/public/reducers/map.js +++ b/x-pack/plugins/maps/public/reducers/map.js @@ -241,7 +241,7 @@ export function map(state = DEFAULT_MAP_STATE, action) { }; return { ...state, mapState: { ...state.mapState, ...newMapState } }; case SET_QUERY: - const { query, timeFilters, filters, searchSessionId } = action; + const { query, timeFilters, filters, searchSessionId, searchSessionMapBuffer } = action; return { ...state, mapState: { @@ -250,6 +250,7 @@ export function map(state = DEFAULT_MAP_STATE, action) { timeFilters, filters, searchSessionId, + searchSessionMapBuffer, }, }; case SET_REFRESH_CONFIG: diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts index c2f5fc02c5df20..58268b6ea9d82c 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts @@ -26,10 +26,70 @@ jest.mock('../kibana_services', () => ({ })); import { DEFAULT_MAP_STORE_STATE } from '../reducers/store'; -import { getTimeFilters } from './map_selectors'; +import { areLayersLoaded, getDataFilters, getTimeFilters } from './map_selectors'; +import { LayerDescriptor } from '../../common/descriptor_types'; +import { ILayer } from '../classes/layers/layer'; +import { Filter } from '../../../../../src/plugins/data/public'; + +describe('getDataFilters', () => { + const mapExtent = { + maxLat: 1, + maxLon: 1, + minLat: 0, + minLon: 0, + }; + const mapBuffer = { + maxLat: 1.5, + maxLon: 1.5, + minLat: -0.5, + minLon: -0.5, + }; + const mapZoom = 4; + const timeFilters = { to: '2001-01-01', from: '2001-12-31' }; + const refreshTimerLastTriggeredAt = '2001-01-01T00:00:00'; + const query = undefined; + const filters: Filter[] = []; + const searchSessionId = '12345'; + const searchSessionMapBuffer = { + maxLat: 1.25, + maxLon: 1.25, + minLat: -0.25, + minLon: -0.25, + }; + + test('should set buffer as searchSessionMapBuffer when using searchSessionId', () => { + const dataFilters = getDataFilters.resultFunc( + mapExtent, + mapBuffer, + mapZoom, + timeFilters, + refreshTimerLastTriggeredAt, + query, + filters, + searchSessionId, + searchSessionMapBuffer + ); + expect(dataFilters.buffer).toEqual(searchSessionMapBuffer); + }); + + test('should fall back to screen buffer when using searchSessionId and searchSessionMapBuffer is not provided', () => { + const dataFilters = getDataFilters.resultFunc( + mapExtent, + mapBuffer, + mapZoom, + timeFilters, + refreshTimerLastTriggeredAt, + query, + filters, + searchSessionId, + undefined + ); + expect(dataFilters.buffer).toEqual(mapBuffer); + }); +}); describe('getTimeFilters', () => { - it('should return timeFilters when contained in state', () => { + test('should return timeFilters when contained in state', () => { const state = { ...DEFAULT_MAP_STORE_STATE, map: { @@ -46,7 +106,7 @@ describe('getTimeFilters', () => { expect(getTimeFilters(state)).toEqual({ to: '2001-01-01', from: '2001-12-31' }); }); - it('should return kibana time filters when not contained in state', () => { + test('should return kibana time filters when not contained in state', () => { const state = { ...DEFAULT_MAP_STORE_STATE, map: { @@ -60,3 +120,76 @@ describe('getTimeFilters', () => { expect(getTimeFilters(state)).toEqual({ to: 'now', from: 'now-15m' }); }); }); + +describe('areLayersLoaded', () => { + function createLayerMock({ + hasErrors = false, + isInitialDataLoadComplete = false, + isVisible = true, + showAtZoomLevel = true, + }: { + hasErrors?: boolean; + isInitialDataLoadComplete?: boolean; + isVisible?: boolean; + showAtZoomLevel?: boolean; + }) { + return ({ + hasErrors: () => { + return hasErrors; + }, + isInitialDataLoadComplete: () => { + return isInitialDataLoadComplete; + }, + isVisible: () => { + return isVisible; + }, + showAtZoomLevel: () => { + return showAtZoomLevel; + }, + } as unknown) as ILayer; + } + + test('layers waiting for map to load should not be counted loaded', () => { + const layerList: ILayer[] = []; + const waitingForMapReadyLayerList: LayerDescriptor[] = [({} as unknown) as LayerDescriptor]; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(false); + }); + + test('layer should not be counted as loaded if it has not loaded', () => { + const layerList = [createLayerMock({ isInitialDataLoadComplete: false })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(false); + }); + + test('layer should be counted as loaded if its not visible', () => { + const layerList = [createLayerMock({ isVisible: false, isInitialDataLoadComplete: false })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); + }); + + test('layer should be counted as loaded if its not shown at zoom level', () => { + const layerList = [ + createLayerMock({ showAtZoomLevel: false, isInitialDataLoadComplete: false }), + ]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); + }); + + test('layer should be counted as loaded if it has a loading error', () => { + const layerList = [createLayerMock({ hasErrors: true, isInitialDataLoadComplete: false })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); + }); + + test('layer should be counted as loaded if its loaded', () => { + const layerList = [createLayerMock({ isInitialDataLoadComplete: true })]; + const waitingForMapReadyLayerList: LayerDescriptor[] = []; + const zoom = 4; + expect(areLayersLoaded.resultFunc(layerList, waitingForMapReadyLayerList, zoom)).toBe(true); + }); +}); diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index f53f39ad2fc0cc..35c73a2bd2f1c7 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -183,6 +183,9 @@ export const getFilters = ({ map }: MapStoreState): Filter[] => map.mapState.fil export const getSearchSessionId = ({ map }: MapStoreState): string | undefined => map.mapState.searchSessionId; +export const getSearchSessionMapBuffer = ({ map }: MapStoreState): MapExtent | undefined => + map.mapState.searchSessionMapBuffer; + export const isUsingSearch = (state: MapStoreState): boolean => { const filters = getFilters(state).filter((filter) => !filter.meta.disabled); const queryString = _.get(getQuery(state), 'query', ''); @@ -235,6 +238,7 @@ export const getDataFilters = createSelector( getQuery, getFilters, getSearchSessionId, + getSearchSessionMapBuffer, ( mapExtent, mapBuffer, @@ -243,11 +247,12 @@ export const getDataFilters = createSelector( refreshTimerLastTriggeredAt, query, filters, - searchSessionId + searchSessionId, + searchSessionMapBuffer ) => { return { extent: mapExtent, - buffer: mapBuffer, + buffer: searchSessionId && searchSessionMapBuffer ? searchSessionMapBuffer : mapBuffer, zoom: mapZoom, timeFilters, refreshTimerLastTriggeredAt, @@ -428,7 +433,12 @@ export const areLayersLoaded = createSelector( for (let i = 0; i < layerList.length; i++) { const layer = layerList[i]; - if (layer.isVisible() && layer.showAtZoomLevel(zoom) && !layer.isDataLoaded()) { + if ( + layer.isVisible() && + layer.showAtZoomLevel(zoom) && + !layer.hasErrors() && + !layer.isInitialDataLoadComplete() + ) { return false; } } diff --git a/x-pack/plugins/ml/common/constants/aggregation_types.ts b/x-pack/plugins/ml/common/constants/aggregation_types.ts index 7278f1de8b9a74..e5e1543c555f36 100644 --- a/x-pack/plugins/ml/common/constants/aggregation_types.ts +++ b/x-pack/plugins/ml/common/constants/aggregation_types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { Aggregation, METRIC_AGG_TYPE } from '../types/fields'; + export enum ML_JOB_AGGREGATION { // count COUNT = 'count', @@ -84,3 +86,315 @@ export enum ES_AGGREGATION { PERCENTILES = 'percentiles', CARDINALITY = 'cardinality', } + +// aggregation object missing id, title and fields and has null for kibana and dsl aggregation names. +// this is used as the basis for the ML only aggregations +function getBasicMlOnlyAggregation(): Omit { + return { + kibanaName: null, + dslName: null, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + }; +} + +// list of aggregations only support by ML and which don't have an equivalent ES aggregation +// note, not all aggs have a field list. Some aggs cannot be used with a field. +export const mlOnlyAggregations: Aggregation[] = [ + { + id: ML_JOB_AGGREGATION.NON_ZERO_COUNT, + title: 'Non zero count', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT, + title: 'High non zero count', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT, + title: 'Low non zero count', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT, + title: 'High distinct count', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT, + title: 'Low distinct count', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.METRIC, + title: 'Metric', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.VARP, + title: 'varp', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_VARP, + title: 'High varp', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_VARP, + title: 'Low varp', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.NON_NULL_SUM, + title: 'Non null sum', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_NON_NULL_SUM, + title: 'High non null sum', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_NON_NULL_SUM, + title: 'Low non null sum', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.RARE, + title: 'Rare', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.FREQ_RARE, + title: 'Freq rare', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.INFO_CONTENT, + title: 'Info content', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.HIGH_INFO_CONTENT, + title: 'High info content', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LOW_INFO_CONTENT, + title: 'Low info content', + fields: [], + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.TIME_OF_DAY, + title: 'Time of day', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.TIME_OF_WEEK, + title: 'Time of week', + ...getBasicMlOnlyAggregation(), + }, + { + id: ML_JOB_AGGREGATION.LAT_LONG, + title: 'Lat long', + fields: [], + ...getBasicMlOnlyAggregation(), + }, +]; + +export const aggregations: Aggregation[] = [ + { + id: ML_JOB_AGGREGATION.COUNT, + title: 'Count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + }, + { + id: ML_JOB_AGGREGATION.HIGH_COUNT, + title: 'High count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + }, + { + id: ML_JOB_AGGREGATION.LOW_COUNT, + title: 'Low count', + kibanaName: KIBANA_AGGREGATION.COUNT, + dslName: ES_AGGREGATION.COUNT, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + }, + { + id: ML_JOB_AGGREGATION.MEAN, + title: 'Mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_MEAN, + title: 'High mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_MEAN, + title: 'Low mean', + kibanaName: KIBANA_AGGREGATION.AVG, + dslName: ES_AGGREGATION.AVG, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.AVG, + min: KIBANA_AGGREGATION.AVG, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.SUM, + title: 'Sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_SUM, + title: 'High sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_SUM, + title: 'Low sum', + kibanaName: KIBANA_AGGREGATION.SUM, + dslName: ES_AGGREGATION.SUM, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.SUM, + min: KIBANA_AGGREGATION.SUM, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MEDIAN, + title: 'Median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.HIGH_MEDIAN, + title: 'High median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.LOW_MEDIAN, + title: 'Low median', + kibanaName: KIBANA_AGGREGATION.MEDIAN, + dslName: ES_AGGREGATION.PERCENTILES, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MIN, + title: 'Min', + kibanaName: KIBANA_AGGREGATION.MIN, + dslName: ES_AGGREGATION.MIN, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MIN, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.MAX, + title: 'Max', + kibanaName: KIBANA_AGGREGATION.MAX, + dslName: ES_AGGREGATION.MAX, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MAX, + }, + fields: [], + }, + { + id: ML_JOB_AGGREGATION.DISTINCT_COUNT, + title: 'Distinct count', + kibanaName: KIBANA_AGGREGATION.CARDINALITY, + dslName: ES_AGGREGATION.CARDINALITY, + type: METRIC_AGG_TYPE, + mlModelPlotAgg: { + max: KIBANA_AGGREGATION.MAX, + min: KIBANA_AGGREGATION.MIN, + }, + fields: [], + }, +]; diff --git a/x-pack/plugins/ml/common/types/alerts.ts b/x-pack/plugins/ml/common/types/alerts.ts index d19385a175efd9..7e6e9d89c5a651 100644 --- a/x-pack/plugins/ml/common/types/alerts.ts +++ b/x-pack/plugins/ml/common/types/alerts.ts @@ -15,7 +15,7 @@ export type TopHitsResultsKeys = 'top_record_hits' | 'top_bucket_hits' | 'top_in export interface AlertExecutionResult { count: number; key: number; - key_as_string: string; + alertInstanceKey: string; isInterim: boolean; jobIds: string[]; timestamp: number; @@ -47,10 +47,13 @@ interface BaseAnomalyAlertDoc { export interface RecordAnomalyAlertDoc extends BaseAnomalyAlertDoc { result_type: typeof ANOMALY_RESULT_TYPE.RECORD; function: string; - field_name: string; - by_field_value: string | number; - over_field_value: string | number; - partition_field_value: string | number; + field_name?: string; + by_field_name?: string; + by_field_value?: string | number; + over_field_name?: string; + over_field_value?: string | number; + partition_field_name?: string; + partition_field_value?: string | number; } export interface BucketAnomalyAlertDoc extends BaseAnomalyAlertDoc { @@ -89,4 +92,5 @@ export type MlAnomalyDetectionAlertParams = { }; severity: number; resultType: AnomalyResultType; + includeInterim: boolean; } & AlertTypeParams; diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts index 77466d27415366..06938485649fb9 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/datafeed.ts @@ -6,6 +6,7 @@ */ import { IndexPatternTitle } from '../kibana'; +import { RuntimeMappings } from '../fields'; import { JobId } from './job'; export type DatafeedId = string; @@ -21,7 +22,7 @@ export interface Datafeed { query: object; query_delay?: string; script_fields?: Record; - runtime_mappings?: Record; + runtime_mappings?: RuntimeMappings; scroll_size?: number; delayed_data_check_config?: object; indices_options?: IndicesOptions; diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts index bb6b331c10fc18..09f5c37ac9aeaf 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts @@ -49,6 +49,7 @@ export type MlSummaryJobs = MlSummaryJob[]; export interface MlJobWithTimeRange extends CombinedJobWithStats { id: string; + isRunning?: boolean; isNotSingleMetricViewerJobMessage?: string; timeRange: { from: number; diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index cccf87f0a7950d..61a5013642cd7a 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -57,6 +57,8 @@ export const adminMlCapabilities = { canCreateDataFrameAnalytics: false, canDeleteDataFrameAnalytics: false, canStartStopDataFrameAnalytics: false, + // Alerts + canCreateMlAlerts: false, }; export type UserMlCapabilities = typeof userMlCapabilities; diff --git a/x-pack/plugins/ml/common/types/data_frame_analytics.ts b/x-pack/plugins/ml/common/types/data_frame_analytics.ts index cacc5acb9768f4..95d82932a1212e 100644 --- a/x-pack/plugins/ml/common/types/data_frame_analytics.ts +++ b/x-pack/plugins/ml/common/types/data_frame_analytics.ts @@ -34,9 +34,10 @@ interface Regression { } interface Classification { + class_assignment_objective?: string; dependent_variable: string; training_percent?: number; - num_top_classes?: string; + num_top_classes?: number; num_top_feature_importance_values?: number; prediction_field_name?: string; } diff --git a/x-pack/plugins/ml/common/types/feature_importance.ts b/x-pack/plugins/ml/common/types/feature_importance.ts index 2e45c3cd4d8c44..964ce8c3257838 100644 --- a/x-pack/plugins/ml/common/types/feature_importance.ts +++ b/x-pack/plugins/ml/common/types/feature_importance.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { isPopulatedObject } from '../util/object_utils'; + export type FeatureImportanceClassName = string | number | boolean; export interface ClassFeatureImportance { @@ -87,7 +89,7 @@ export function isClassificationFeatureImportanceBaseline( baselineData: any ): baselineData is ClassificationFeatureImportanceBaseline { return ( - typeof baselineData === 'object' && + isPopulatedObject(baselineData) && baselineData.hasOwnProperty('classes') && Array.isArray(baselineData.classes) ); @@ -96,5 +98,5 @@ export function isClassificationFeatureImportanceBaseline( export function isRegressionFeatureImportanceBaseline( baselineData: any ): baselineData is RegressionFeatureImportanceBaseline { - return typeof baselineData === 'object' && baselineData.hasOwnProperty('baseline'); + return isPopulatedObject(baselineData) && baselineData.hasOwnProperty('baseline'); } diff --git a/x-pack/plugins/ml/common/types/fields.ts b/x-pack/plugins/ml/common/types/fields.ts index f12ed5b23542ae..047852534965c2 100644 --- a/x-pack/plugins/ml/common/types/fields.ts +++ b/x-pack/plugins/ml/common/types/fields.ts @@ -27,6 +27,7 @@ export interface Field { aggregatable?: boolean; aggIds?: AggId[]; aggs?: Aggregation[]; + runtimeField?: RuntimeField; } export interface Aggregation { @@ -103,3 +104,20 @@ export interface ScriptAggCardinality { export interface AggCardinality { cardinality: FieldAggCardinality | ScriptAggCardinality; } + +export type RollupFields = Record]>; + +// Replace this with import once #88995 is merged +const RUNTIME_FIELD_TYPES = ['keyword', 'long', 'double', 'date', 'ip', 'boolean'] as const; +type RuntimeType = typeof RUNTIME_FIELD_TYPES[number]; + +export interface RuntimeField { + type: RuntimeType; + script?: + | string + | { + source: string; + }; +} + +export type RuntimeMappings = Record; diff --git a/x-pack/plugins/ml/common/util/anomaly_utils.ts b/x-pack/plugins/ml/common/util/anomaly_utils.ts index 028afee2524c93..68605f29c7be96 100644 --- a/x-pack/plugins/ml/common/util/anomaly_utils.ts +++ b/x-pack/plugins/ml/common/util/anomaly_utils.ts @@ -230,8 +230,6 @@ export function getEntityFieldName(record: AnomalyRecordDoc): string | undefined if (record.partition_field_name !== undefined) { return record.partition_field_name; } - - return undefined; } // Returns the value of the field to use as the entity value from the source record @@ -249,8 +247,6 @@ export function getEntityFieldValue(record: AnomalyRecordDoc): string | number | if (record.partition_field_value !== undefined) { return record.partition_field_value; } - - return undefined; } // Returns the list of partitioning entity fields for the source record as a list diff --git a/x-pack/plugins/ml/common/util/datafeed_utils.ts b/x-pack/plugins/ml/common/util/datafeed_utils.ts index fa1a940ba5492c..c0579ce947992a 100644 --- a/x-pack/plugins/ml/common/util/datafeed_utils.ts +++ b/x-pack/plugins/ml/common/util/datafeed_utils.ts @@ -20,7 +20,7 @@ export const getDatafeedAggregations = ( }; export const getAggregationBucketsName = (aggregations: any): string | undefined => { - if (typeof aggregations === 'object') { + if (aggregations !== null && typeof aggregations === 'object') { const keys = Object.keys(aggregations); return keys.length > 0 ? keys[0] : undefined; } diff --git a/x-pack/plugins/ml/common/util/fields_utils.ts b/x-pack/plugins/ml/common/util/fields_utils.ts new file mode 100644 index 00000000000000..98b0fcd6efd805 --- /dev/null +++ b/x-pack/plugins/ml/common/util/fields_utils.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + Field, + Aggregation, + NewJobCaps, + METRIC_AGG_TYPE, + RollupFields, + EVENT_RATE_FIELD_ID, +} from '../types/fields'; +import { ES_FIELD_TYPES } from '../../../../../src/plugins/data/common'; +import { ML_JOB_AGGREGATION } from '../constants/aggregation_types'; + +// cross reference fields and aggs. +// fields contain a list of aggs that are compatible, and vice versa. +export function combineFieldsAndAggs( + fields: Field[], + aggs: Aggregation[], + rollupFields: RollupFields +): NewJobCaps { + const keywordFields = getKeywordFields(fields); + const textFields = getTextFields(fields); + const numericalFields = getNumericalFields(fields); + const ipFields = getIpFields(fields); + const geoFields = getGeoFields(fields); + + const isRollup = Object.keys(rollupFields).length > 0; + const mix = mixFactory(isRollup, rollupFields); + + aggs.forEach((a) => { + if (a.type === METRIC_AGG_TYPE && a.fields !== undefined) { + switch (a.id) { + case ML_JOB_AGGREGATION.LAT_LONG: + geoFields.forEach((f) => mix(f, a)); + break; + case ML_JOB_AGGREGATION.INFO_CONTENT: + case ML_JOB_AGGREGATION.HIGH_INFO_CONTENT: + case ML_JOB_AGGREGATION.LOW_INFO_CONTENT: + textFields.forEach((f) => mix(f, a)); + case ML_JOB_AGGREGATION.DISTINCT_COUNT: + case ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT: + case ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT: + // distinct count (i.e. cardinality) takes keywords, ips + // as well as numerical fields + keywordFields.forEach((f) => mix(f, a)); + ipFields.forEach((f) => mix(f, a)); + // note, no break to fall through to add numerical fields. + default: + // all other aggs take numerical fields + numericalFields.forEach((f) => { + mix(f, a); + }); + break; + } + } + }); + + return { + aggs, + fields: isRollup ? filterFields(fields) : fields, + }; +} + +// remove fields that have no aggs associated to them, unless they are date fields +function filterFields(fields: Field[]): Field[] { + return fields.filter( + (f) => f.aggs && (f.aggs.length > 0 || (f.aggs.length === 0 && f.type === ES_FIELD_TYPES.DATE)) + ); +} + +// returns a mix function that is used to cross-reference aggs and fields. +// wrapped in a provider to allow filtering based on rollup job capabilities +function mixFactory(isRollup: boolean, rollupFields: RollupFields) { + return function mix(field: Field, agg: Aggregation): void { + if ( + isRollup === false || + (rollupFields[field.id] && rollupFields[field.id].find((f) => f.agg === agg.dslName)) + ) { + if (field.aggs !== undefined) { + field.aggs.push(agg); + } + if (agg.fields !== undefined) { + agg.fields.push(field); + } + } + }; +} + +function getKeywordFields(fields: Field[]): Field[] { + return fields.filter((f) => f.type === ES_FIELD_TYPES.KEYWORD); +} + +function getTextFields(fields: Field[]): Field[] { + return fields.filter((f) => f.type === ES_FIELD_TYPES.TEXT); +} + +function getIpFields(fields: Field[]): Field[] { + return fields.filter((f) => f.type === ES_FIELD_TYPES.IP); +} + +function getNumericalFields(fields: Field[]): Field[] { + return fields.filter( + (f) => + f.type === ES_FIELD_TYPES.LONG || + f.type === ES_FIELD_TYPES.UNSIGNED_LONG || + f.type === ES_FIELD_TYPES.INTEGER || + f.type === ES_FIELD_TYPES.SHORT || + f.type === ES_FIELD_TYPES.BYTE || + f.type === ES_FIELD_TYPES.DOUBLE || + f.type === ES_FIELD_TYPES.FLOAT || + f.type === ES_FIELD_TYPES.HALF_FLOAT || + f.type === ES_FIELD_TYPES.SCALED_FLOAT + ); +} + +function getGeoFields(fields: Field[]): Field[] { + return fields.filter( + (f) => f.type === ES_FIELD_TYPES.GEO_POINT || f.type === ES_FIELD_TYPES.GEO_SHAPE + ); +} + +/** + * Sort fields by name, keeping event rate at the beginning + */ +export function sortFields(fields: Field[]) { + if (fields.length === 0) { + return fields; + } + + let eventRate: Field | undefined; + if (fields[0].id === EVENT_RATE_FIELD_ID) { + [eventRate] = fields.splice(0, 1); + } + fields.sort((a, b) => a.name.localeCompare(b.name)); + if (eventRate !== undefined) { + fields.splice(0, 0, eventRate); + } + return fields; +} diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 711103b499ec90..ab56726e160f7c 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -28,6 +28,7 @@ import { getDatafeedAggregations, } from './datafeed_utils'; import { findAggField } from './validation_utils'; +import { isPopulatedObject } from './object_utils'; export interface ValidationResults { valid: boolean; @@ -51,17 +52,9 @@ export function calculateDatafeedFrequencyDefaultSeconds(bucketSpanSeconds: numb } export function hasRuntimeMappings(job: CombinedJob): boolean { - const hasDatafeed = - typeof job.datafeed_config === 'object' && Object.keys(job.datafeed_config).length > 0; + const hasDatafeed = isPopulatedObject(job.datafeed_config); if (hasDatafeed) { - const runtimeMappings = - typeof job.datafeed_config.runtime_mappings === 'object' - ? Object.keys(job.datafeed_config.runtime_mappings) - : undefined; - - if (Array.isArray(runtimeMappings) && runtimeMappings.length > 0) { - return true; - } + return isPopulatedObject(job.datafeed_config.runtime_mappings); } return false; } @@ -114,7 +107,11 @@ export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex // If the datafeed uses script fields, we can only plot the time series if // model plot is enabled. Without model plot it will be very difficult or impossible // to invert to a reverse search of the underlying metric data. - if (isSourceDataChartable === true && typeof job.datafeed_config?.script_fields === 'object') { + if ( + isSourceDataChartable === true && + job.datafeed_config?.script_fields !== null && + typeof job.datafeed_config?.script_fields === 'object' + ) { // Perform extra check to see if the detector is using a scripted field. const scriptFields = Object.keys(job.datafeed_config.script_fields); isSourceDataChartable = @@ -123,8 +120,7 @@ export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex scriptFields.indexOf(dtr.over_field_name!) === -1; } - const hasDatafeed = - typeof job.datafeed_config === 'object' && Object.keys(job.datafeed_config).length > 0; + const hasDatafeed = isPopulatedObject(job.datafeed_config); if (hasDatafeed) { // We cannot plot the source data for some specific aggregation configurations const aggs = getDatafeedAggregations(job.datafeed_config); diff --git a/x-pack/plugins/ml/common/util/object_utils.ts b/x-pack/plugins/ml/common/util/object_utils.ts new file mode 100644 index 00000000000000..4bbd0c1c2810fe --- /dev/null +++ b/x-pack/plugins/ml/common/util/object_utils.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const isPopulatedObject = >(arg: any): arg is T => { + return typeof arg === 'object' && arg !== null && Object.keys(arg).length > 0; +}; diff --git a/x-pack/plugins/ml/common/util/validation_utils.ts b/x-pack/plugins/ml/common/util/validation_utils.ts index 7f0208e726ab0b..66084f83ea87d1 100644 --- a/x-pack/plugins/ml/common/util/validation_utils.ts +++ b/x-pack/plugins/ml/common/util/validation_utils.ts @@ -45,7 +45,7 @@ export function findAggField( value = returnParent === true ? aggs : aggs[k]; return true; } - if (aggs.hasOwnProperty(k) && typeof aggs[k] === 'object') { + if (aggs.hasOwnProperty(k) && aggs[k] !== null && typeof aggs[k] === 'object') { value = findAggField(aggs[k], fieldName, returnParent); return value !== undefined; } diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 790c9a28b656c9..d13920b084183c 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -39,7 +39,6 @@ "dashboard", "savedObjects", "home", - "spaces", "maps" ], "extraPublicDirs": [ diff --git a/x-pack/plugins/ml/public/alerting/interim_results_control.tsx b/x-pack/plugins/ml/public/alerting/interim_results_control.tsx new file mode 100644 index 00000000000000..fa930d9a0ea0fb --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/interim_results_control.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface InterimResultsControlProps { + value: boolean; + onChange: (update: boolean) => void; +} + +export const InterimResultsControl: FC = React.memo( + ({ value, onChange }) => { + return ( + + + } + checked={value} + onChange={onChange.bind(null, !value)} + /> + + ); + } +); diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx index 969ed5af79107e..60bb7517406b8e 100644 --- a/x-pack/plugins/ml/public/alerting/job_selector.tsx +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -19,7 +19,7 @@ interface JobSelection { export interface JobSelectorControlProps { jobSelection?: JobSelection; - onSelectionChange: (jobSelection: JobSelection) => void; + onChange: (jobSelection: JobSelection) => void; adJobsApiService: MlApiServices['jobs']; /** * Validation is handled by alerting framework @@ -29,7 +29,7 @@ export interface JobSelectorControlProps { export const JobSelectorControl: FC = ({ jobSelection, - onSelectionChange, + onChange, adJobsApiService, errors, }) => { @@ -70,7 +70,7 @@ export const JobSelectorControl: FC = ({ } }, [adJobsApiService]); - const onChange: EuiComboBoxProps['onChange'] = useCallback( + const onSelectionChange: EuiComboBoxProps['onChange'] = useCallback( (selectedOptions) => { const selectedJobIds: JobId[] = []; const selectedGroupIds: string[] = []; @@ -81,7 +81,7 @@ export const JobSelectorControl: FC = ({ selectedGroupIds.push(label); } }); - onSelectionChange({ + onChange({ ...(selectedJobIds.length > 0 ? { jobIds: selectedJobIds } : {}), ...(selectedGroupIds.length > 0 ? { groupIds: selectedGroupIds } : {}), }); @@ -114,7 +114,7 @@ export const JobSelectorControl: FC = ({ selectedOptions={selectedOptions} options={options} - onChange={onChange} + onChange={onSelectionChange} fullWidth data-test-subj={'mlAnomalyAlertJobSelection'} isInvalid={!!errors?.length} diff --git a/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx new file mode 100644 index 00000000000000..ba573fe42f5f23 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/ml_alerting_flyout.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { JobId } from '../../common/types/anomaly_detection_jobs'; +import { useMlKibana } from '../application/contexts/kibana'; +import { ML_ALERT_TYPES } from '../../common/constants/alerts'; +import { PLUGIN_ID } from '../../common/constants/app'; + +interface MlAnomalyAlertFlyoutProps { + jobIds: JobId[]; + onSave?: () => void; + onCloseFlyout: () => void; +} + +/** + * Invoke alerting flyout from the ML plugin context. + * @param jobIds + * @param onCloseFlyout + * @constructor + */ +export const MlAnomalyAlertFlyout: FC = ({ + jobIds, + onCloseFlyout, + onSave, +}) => { + const { + services: { triggersActionsUi }, + } = useMlKibana(); + + const AddAlertFlyout = useMemo( + () => + triggersActionsUi && + triggersActionsUi.getAddAlertFlyout({ + consumer: PLUGIN_ID, + onClose: () => { + onCloseFlyout(); + }, + // Callback for successful save + reloadAlerts: async () => { + if (onSave) { + onSave(); + } + }, + canChangeTrigger: false, + alertTypeId: ML_ALERT_TYPES.ANOMALY_DETECTION, + metadata: {}, + initialValues: { + params: { + jobSelection: { + jobIds, + }, + }, + }, + }), + [triggersActionsUi] + ); + + return <>{AddAlertFlyout}; +}; + +interface JobListMlAnomalyAlertFlyoutProps { + setShowFunction: (callback: Function) => void; + unsetShowFunction: () => void; +} + +/** + * Component to wire the Alerting flyout with the Job list view. + * @param setShowFunction + * @param unsetShowFunction + * @constructor + */ +export const JobListMlAnomalyAlertFlyout: FC = ({ + setShowFunction, + unsetShowFunction, +}) => { + const [isVisible, setIsVisible] = useState(false); + const [jobIds, setJobIds] = useState(); + + const showFlyoutCallback = useCallback((jobIdsUpdate: JobId[]) => { + setJobIds(jobIdsUpdate); + setIsVisible(true); + }, []); + + useEffect(() => { + setShowFunction(showFlyoutCallback); + return () => { + unsetShowFunction(); + }; + }, []); + + return isVisible && jobIds ? ( + setIsVisible(false)} + onSave={() => { + setIsVisible(false); + }} + /> + ) : null; +}; diff --git a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx b/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx index 5991a603890d71..3dd023a6187dda 100644 --- a/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx +++ b/x-pack/plugins/ml/public/alerting/ml_anomaly_alert_trigger.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import React, { FC, useCallback, useEffect, useMemo } from 'react'; +import React, { FC, useCallback, useMemo } from 'react'; import { EuiSpacer, EuiForm } from '@elastic/eui'; +import useMount from 'react-use/lib/useMount'; import { JobSelectorControl } from './job_selector'; import { useMlKibana } from '../application/contexts/kibana'; import { jobsApiProvider } from '../application/services/ml_api_service/jobs'; @@ -18,6 +19,7 @@ import { PreviewAlertCondition } from './preview_alert_condition'; import { ANOMALY_THRESHOLD } from '../../common'; import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; import { ANOMALY_RESULT_TYPE } from '../../common/constants/anomalies'; +import { InterimResultsControl } from './interim_results_control'; interface MlAnomalyAlertTriggerProps { alertParams: MlAnomalyDetectionAlertParams; @@ -25,12 +27,14 @@ interface MlAnomalyAlertTriggerProps { key: T, value: MlAnomalyDetectionAlertParams[T] ) => void; + setAlertProperty: (prop: string, update: Partial) => void; errors: Record; } const MlAnomalyAlertTrigger: FC = ({ alertParams, setAlertParams, + setAlertProperty, errors, }) => { const { @@ -49,21 +53,26 @@ const MlAnomalyAlertTrigger: FC = ({ [] ); - useEffect(function setDefaults() { - if (alertParams.severity === undefined) { - onAlertParamChange('severity')(ANOMALY_THRESHOLD.CRITICAL); + useMount(function setDefaults() { + const { jobSelection, ...rest } = alertParams; + if (Object.keys(rest).length === 0) { + setAlertProperty('params', { + // Set defaults + severity: ANOMALY_THRESHOLD.CRITICAL, + resultType: ANOMALY_RESULT_TYPE.BUCKET, + includeInterim: true, + // Preserve job selection + jobSelection, + }); } - if (alertParams.resultType === undefined) { - onAlertParamChange('resultType')(ANOMALY_RESULT_TYPE.BUCKET); - } - }, []); + }); return ( = ({ onChange={useCallback(onAlertParamChange('severity'), [])} /> + + + diff --git a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts index 7f55eba9cbdc20..1d7bd06989bd94 100644 --- a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts +++ b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts @@ -7,14 +7,12 @@ import { i18n } from '@kbn/i18n'; import { lazy } from 'react'; -import { MlStartDependencies } from '../plugin'; import { ML_ALERT_TYPES } from '../../common/constants/alerts'; import { MlAnomalyDetectionAlertParams } from '../../common/types/alerts'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; -export function registerMlAlerts( - alertTypeRegistry: MlStartDependencies['triggersActionsUi']['alertTypeRegistry'] -) { - alertTypeRegistry.register({ +export function registerMlAlerts(triggersActionsUi: TriggersAndActionsUIPublicPluginSetup) { + triggersActionsUi.alertTypeRegistry.register({ id: ML_ALERT_TYPES.ANOMALY_DETECTION, description: i18n.translate('xpack.ml.alertTypes.anomalyDetection.description', { defaultMessage: 'Alert when anomaly detection jobs results match the condition.', diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 0199e13e93d8c0..3df67bc16ab058 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -81,6 +81,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { storage: localStorage, embeddable: deps.embeddable, maps: deps.maps, + triggersActionsUi: deps.triggersActionsUi, ...coreStart, }; diff --git a/x-pack/plugins/ml/public/application/components/annotations/delete_annotation_modal/index.tsx b/x-pack/plugins/ml/public/application/components/annotations/delete_annotation_modal/index.tsx index 8469d42c16c519..9999fad89d0e1c 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/delete_annotation_modal/index.tsx +++ b/x-pack/plugins/ml/public/application/components/annotations/delete_annotation_modal/index.tsx @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import React, { Fragment } from 'react'; -import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EUI_MODAL_CONFIRM_BUTTON, EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,33 +26,31 @@ export const DeleteAnnotationModal: React.FC = ({ return ( {isVisible === true && ( - - - } - onCancel={cancelAction} - onConfirm={deleteAction} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - className="eui-textBreakWord" - /> - + + } + onCancel={cancelAction} + onConfirm={deleteAction} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + className="eui-textBreakWord" + /> )} ); diff --git a/x-pack/plugins/ml/public/application/components/data_grid/common.ts b/x-pack/plugins/ml/public/application/components/data_grid/common.ts index f169c56205e08e..069c13df2470f7 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/common.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/common.ts @@ -27,7 +27,11 @@ import { import { DEFAULT_RESULTS_FIELD } from '../../../../common/constants/data_frame_analytics'; import { extractErrorMessage } from '../../../../common/util/errors'; -import { FeatureImportance, TopClasses } from '../../../../common/types/feature_importance'; +import { + FeatureImportance, + FeatureImportanceClassName, + TopClasses, +} from '../../../../common/types/feature_importance'; import { BASIC_NUMERICAL_TYPES, @@ -44,6 +48,9 @@ import { getNestedProperty } from '../../util/object_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; import { DataGridItem, IndexPagination, RenderCellValue } from './types'; +import type { RuntimeField } from '../../../../../../../src/plugins/data/common/index_patterns'; +import { RuntimeMappings } from '../../../../common/types/fields'; +import { isPopulatedObject } from '../../../../common/util/object_utils'; export const INIT_MAX_COLUMNS = 10; @@ -82,6 +89,37 @@ export const getFieldsFromKibanaIndexPattern = (indexPattern: IndexPattern): str return indexPatternFields; }; +/** + * Return a map of runtime_mappings for each of the index pattern field provided + * to provide in ES search queries + * @param indexPatternFields + * @param indexPattern + * @param clonedRuntimeMappings + */ +export const getRuntimeFieldsMapping = ( + indexPatternFields: string[] | undefined, + indexPattern: IndexPattern | undefined, + clonedRuntimeMappings?: RuntimeMappings +) => { + if (!Array.isArray(indexPatternFields) || indexPattern === undefined) return {}; + const ipRuntimeMappings = indexPattern.getComputedFields().runtimeFields; + let combinedRuntimeMappings: RuntimeMappings = {}; + + if (isPopulatedObject(ipRuntimeMappings)) { + indexPatternFields.forEach((ipField) => { + if (ipRuntimeMappings.hasOwnProperty(ipField)) { + combinedRuntimeMappings[ipField] = ipRuntimeMappings[ipField]; + } + }); + } + if (isPopulatedObject(clonedRuntimeMappings)) { + combinedRuntimeMappings = { ...combinedRuntimeMappings, ...clonedRuntimeMappings }; + } + return Object.keys(combinedRuntimeMappings).length > 0 + ? { runtime_mappings: combinedRuntimeMappings } + : {}; +}; + export interface FieldTypes { [key: string]: ES_FIELD_TYPES; } @@ -131,6 +169,45 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results }; export const NON_AGGREGATABLE = 'non-aggregatable'; + +export const getDataGridSchemaFromESFieldType = ( + fieldType: ES_FIELD_TYPES | undefined | RuntimeField['type'] +): string | undefined => { + // Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json'] + // To fall back to the default string schema it needs to be undefined. + let schema; + + switch (fieldType) { + case ES_FIELD_TYPES.GEO_POINT: + case ES_FIELD_TYPES.GEO_SHAPE: + schema = 'json'; + break; + case ES_FIELD_TYPES.BOOLEAN: + schema = 'boolean'; + break; + case ES_FIELD_TYPES.DATE: + case ES_FIELD_TYPES.DATE_NANOS: + schema = 'datetime'; + break; + case ES_FIELD_TYPES.BYTE: + case ES_FIELD_TYPES.DOUBLE: + case ES_FIELD_TYPES.FLOAT: + case ES_FIELD_TYPES.HALF_FLOAT: + case ES_FIELD_TYPES.INTEGER: + case ES_FIELD_TYPES.LONG: + case ES_FIELD_TYPES.SCALED_FLOAT: + case ES_FIELD_TYPES.SHORT: + schema = 'numeric'; + break; + // keep schema undefined for text based columns + case ES_FIELD_TYPES.KEYWORD: + case ES_FIELD_TYPES.TEXT: + break; + } + + return schema; +}; + export const getDataGridSchemaFromKibanaFieldType = ( field: IFieldType | undefined ): string | undefined => { @@ -168,8 +245,9 @@ const getClassName = (className: string, isClassTypeBoolean: boolean) => { return className; }; + /** - * Helper to transform feature importance flattened fields with arrays back to object structure + * Helper to transform feature importance fields with arrays back to primitive value * * @param row - EUI data grid data row * @param mlResultsField - Data frame analytics results field @@ -180,69 +258,44 @@ export const getFeatureImportance = ( mlResultsField: string, isClassTypeBoolean = false ): FeatureImportance[] => { - const featureNames: string[] | undefined = - row[`${mlResultsField}.feature_importance.feature_name`]; - const classNames: string[] | undefined = - row[`${mlResultsField}.feature_importance.classes.class_name`]; - const classImportance: number[] | undefined = - row[`${mlResultsField}.feature_importance.classes.importance`]; - - if (featureNames === undefined) { - return []; - } - - // return object structure for classification job - if (classNames !== undefined && classImportance !== undefined) { - const overallClassNames = classNames?.slice(0, classNames.length / featureNames.length); - - return featureNames.map((fName, index) => { - const offset = overallClassNames.length * index; - const featureClassImportance = classImportance.slice( - offset, - offset + overallClassNames.length - ); - return { - feature_name: fName, - classes: overallClassNames.map((fClassName, fIndex) => { + const featureImportance: Array<{ + feature_name: string[]; + classes?: Array<{ class_name: FeatureImportanceClassName[]; importance: number[] }>; + importance?: number | number[]; + }> = row[`${mlResultsField}.feature_importance`]; + if (featureImportance === undefined) return []; + + return featureImportance.map((fi) => ({ + feature_name: Array.isArray(fi.feature_name) ? fi.feature_name[0] : fi.feature_name, + classes: Array.isArray(fi.classes) + ? fi.classes.map((c) => { + const processedClass = getProcessedFields(c); return { - class_name: getClassName(fClassName, isClassTypeBoolean), - importance: featureClassImportance[fIndex], + importance: processedClass.importance, + class_name: getClassName(processedClass.class_name, isClassTypeBoolean), }; - }), - }; - }); - } - - // return object structure for regression job - const importance: number[] = row[`${mlResultsField}.feature_importance.importance`]; - return featureNames.map((fName, index) => ({ - feature_name: fName, - importance: importance[index], + }) + : fi.classes, + importance: Array.isArray(fi.importance) ? fi.importance[0] : fi.importance, })); }; /** - * Helper to transforms top classes flattened fields with arrays back to object structure + * Helper to transforms top classes fields with arrays back to original primitive value * * @param row - EUI data grid data row * @param mlResultsField - Data frame analytics results field * @returns nested object structure of feature importance values */ export const getTopClasses = (row: Record, mlResultsField: string): TopClasses => { - const classNames: string[] | undefined = row[`${mlResultsField}.top_classes.class_name`]; - const classProbabilities: number[] | undefined = - row[`${mlResultsField}.top_classes.class_probability`]; - const classScores: number[] | undefined = row[`${mlResultsField}.top_classes.class_score`]; - - if (classNames === undefined || classProbabilities === undefined || classScores === undefined) { - return []; - } - - return classNames.map((className, index) => ({ - class_name: className, - class_probability: classProbabilities[index], - class_score: classScores[index], - })); + const topClasses: Array<{ + class_name: FeatureImportanceClassName[]; + class_probability: number[]; + class_score: number[]; + }> = row[`${mlResultsField}.top_classes`]; + + if (topClasses === undefined) return []; + return topClasses.map((tc) => getProcessedFields(tc)) as TopClasses; }; export const useRenderCellValue = ( diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index da34e0f1bc9fb9..5dad9801eb644b 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -35,7 +35,7 @@ import { getTopClasses, } from './common'; import { UseIndexDataReturnType } from './types'; -import { DecisionPathPopover } from './feature_importance/decision_path_popover'; +import { DecisionPathPopover } from '../../data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_popover'; import { FeatureImportanceBaseline, FeatureImportance, diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index ccd2f3f56e45df..79a8d65f9905a2 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -7,8 +7,10 @@ export { getDataGridSchemasFromFieldTypes, + getDataGridSchemaFromESFieldType, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, + getRuntimeFieldsMapping, multiColumnSortFactory, showDataGridColumnChartErrorMessageToast, useRenderCellValue, diff --git a/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx b/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx index eda0509d417ca7..972ed06ba13859 100644 --- a/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx +++ b/x-pack/plugins/ml/public/application/components/delete_job_check_modal/delete_job_check_modal.tsx @@ -13,7 +13,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiModal, - EuiOverlayMask, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, @@ -230,69 +229,67 @@ export const DeleteJobCheckModal: FC = ({ }; return ( - - - {isLoading === true && ( - <> - - - - - - - - - )} - {isLoading === false && ( - <> - - - - - + + {isLoading === true && ( + <> + + + + + + + + + )} + {isLoading === false && ( + <> + + + + + - {modalContent} + {modalContent} - - - - {!hasUntagged && + + + + {!hasUntagged && + jobCheckRespSummary?.canTakeAnyAction && + jobCheckRespSummary?.canRemoveFromSpace && + jobCheckRespSummary?.canDelete && ( + + {shouldUnTagLabel} + + )} + + + - {shouldUnTagLabel} - - )} - - - - {buttonContent} - - - - - - )} - - + !jobCheckRespSummary?.canDelete + ? onUntagClick + : onClick + } + fill + > + {buttonContent} + + + + + + )} + ); }; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts index cac8f63b6e0496..8acec6a45a0c8c 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { JobSpacesList, ALL_SPACES_ID } from './job_spaces_list'; +export { JobSpacesList } from './job_spaces_list'; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx index 2aa7c6bb4a6e38..6e0715de12fb9f 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx @@ -5,64 +5,87 @@ * 2.0. */ -import React, { FC, useState, useEffect } from 'react'; +import React, { FC, useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import { JobSpacesFlyout } from '../job_spaces_selector'; -import { JobType } from '../../../../common/types/saved_objects'; -import { useSpacesContext } from '../../contexts/spaces'; -import { Space, SpaceAvatar } from '../../../../../spaces/public'; - -export const ALL_SPACES_ID = '*'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { ShareToSpaceFlyoutProps } from 'src/plugins/spaces_oss/public'; +import { + JobType, + ML_SAVED_OBJECT_TYPE, + SavedObjectResult, +} from '../../../../common/types/saved_objects'; +import type { SpacesPluginStart } from '../../../../../spaces/public'; +import { ml } from '../../services/ml_api_service'; +import { useToastNotificationService } from '../../services/toast_notification_service'; interface Props { + spacesApi: SpacesPluginStart; spaceIds: string[]; jobId: string; jobType: JobType; refresh(): void; } -function filterUnknownSpaces(ids: string[]) { - return ids.filter((id) => id !== '?'); -} +const ALL_SPACES_ID = '*'; +const objectNoun = i18n.translate('xpack.ml.management.jobsSpacesList.objectNoun', { + defaultMessage: 'job', +}); -export const JobSpacesList: FC = ({ spaceIds, jobId, jobType, refresh }) => { - const { allSpaces } = useSpacesContext(); +export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, refresh }) => { + const { displayErrorToast } = useToastNotificationService(); const [showFlyout, setShowFlyout] = useState(false); - const [spaces, setSpaces] = useState([]); - useEffect(() => { - const tempSpaces = spaceIds.includes(ALL_SPACES_ID) - ? [{ id: ALL_SPACES_ID, name: ALL_SPACES_ID, disabledFeatures: [], color: '#DDD' }] - : allSpaces.filter((s) => spaceIds.includes(s.id)); - setSpaces(tempSpaces); - }, [spaceIds, allSpaces]); + async function changeSpacesHandler(spacesToAdd: string[], spacesToRemove: string[]) { + if (spacesToAdd.length) { + const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], spacesToAdd); + handleApplySpaces(resp); + } + if (spacesToRemove.length && !spacesToAdd.includes(ALL_SPACES_ID)) { + const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], spacesToRemove); + handleApplySpaces(resp); + } + onClose(); + } function onClose() { setShowFlyout(false); refresh(); } + function handleApplySpaces(resp: SavedObjectResult) { + Object.entries(resp).forEach(([id, { success, error }]) => { + if (success === false) { + const title = i18n.translate('xpack.ml.management.jobsSpacesList.updateSpaces.error', { + defaultMessage: 'Error updating {id}', + values: { id }, + }); + displayErrorToast(error, title); + } + }); + } + + const { SpaceList, ShareToSpaceFlyout } = spacesApi.ui.components; + const shareToSpaceFlyoutProps: ShareToSpaceFlyoutProps = { + savedObjectTarget: { + type: ML_SAVED_OBJECT_TYPE, + id: jobId, + namespaces: spaceIds, + title: jobId, + noun: objectNoun, + }, + behaviorContext: 'outside-space', + changeSpacesHandler, + onClose, + }; + return ( <> setShowFlyout(true)} style={{ height: 'auto' }}> - - {spaces.map((space) => ( - - - - ))} - + - {showFlyout && ( - - )} + {showFlyout && } ); }; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx deleted file mode 100644 index 94ed9ad0d30748..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/cannot_edit_callout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer, EuiCallOut } from '@elastic/eui'; - -export const CannotEditCallout: FC<{ jobId: string }> = ({ jobId }) => ( - <> - - - - - -); diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx deleted file mode 100644 index 12304cd133d8ef..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/jobs_spaces_flyout.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC, useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { difference, xor } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutFooter, - EuiFlexGroup, - EuiFlexItem, - EuiButton, - EuiButtonEmpty, - EuiTitle, - EuiFlyoutBody, -} from '@elastic/eui'; - -import { JobType, SavedObjectResult } from '../../../../common/types/saved_objects'; -import { ml } from '../../services/ml_api_service'; -import { useToastNotificationService } from '../../services/toast_notification_service'; - -import { SpacesSelector } from './spaces_selectors'; - -interface Props { - jobId: string; - jobType: JobType; - spaceIds: string[]; - onClose: () => void; -} -export const JobSpacesFlyout: FC = ({ jobId, jobType, spaceIds, onClose }) => { - const { displayErrorToast } = useToastNotificationService(); - - const [selectedSpaceIds, setSelectedSpaceIds] = useState(spaceIds); - const [saving, setSaving] = useState(false); - const [savable, setSavable] = useState(false); - const [canEditSpaces, setCanEditSpaces] = useState(false); - - useEffect(() => { - const different = xor(selectedSpaceIds, spaceIds).length !== 0; - setSavable(different === true && selectedSpaceIds.length > 0); - }, [selectedSpaceIds.length]); - - async function applySpaces() { - if (savable) { - setSaving(true); - const addedSpaces = difference(selectedSpaceIds, spaceIds); - const removedSpaces = difference(spaceIds, selectedSpaceIds); - if (addedSpaces.length) { - const resp = await ml.savedObjects.assignJobToSpace(jobType, [jobId], addedSpaces); - handleApplySpaces(resp); - } - if (removedSpaces.length) { - const resp = await ml.savedObjects.removeJobFromSpace(jobType, [jobId], removedSpaces); - handleApplySpaces(resp); - } - onClose(); - } - } - - function handleApplySpaces(resp: SavedObjectResult) { - Object.entries(resp).forEach(([id, { success, error }]) => { - if (success === false) { - const title = i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.updateSpaces.error', - { - defaultMessage: 'Error updating {id}', - values: { id }, - } - ); - displayErrorToast(error, title); - } - }); - } - - return ( - <> - - - -

    - -

    -
    -
    - - - - - - - - - - - - - - - - - -
    - - ); -}; diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss deleted file mode 100644 index 75cdbd972455b0..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selector.scss +++ /dev/null @@ -1,3 +0,0 @@ -.mlCopyToSpace__spacesList { - margin-top: $euiSizeXS; -} diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx deleted file mode 100644 index 281ac5028995b2..00000000000000 --- a/x-pack/plugins/ml/public/application/components/job_spaces_selector/spaces_selectors.tsx +++ /dev/null @@ -1,223 +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 './spaces_selector.scss'; -import React, { FC, useState, useEffect, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiFormRow, - EuiSelectable, - EuiSelectableOption, - EuiIconTip, - EuiText, - EuiCheckableCard, - EuiFormFieldset, -} from '@elastic/eui'; - -import { SpaceAvatar } from '../../../../../spaces/public'; -import { useSpacesContext } from '../../contexts/spaces'; -import { ML_SAVED_OBJECT_TYPE } from '../../../../common/types/saved_objects'; -import { ALL_SPACES_ID } from '../job_spaces_list'; -import { CannotEditCallout } from './cannot_edit_callout'; - -type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; - -interface Props { - jobId: string; - spaceIds: string[]; - setSelectedSpaceIds: (ids: string[]) => void; - selectedSpaceIds: string[]; - canEditSpaces: boolean; - setCanEditSpaces: (canEditSpaces: boolean) => void; -} - -export const SpacesSelector: FC = ({ - jobId, - spaceIds, - setSelectedSpaceIds, - selectedSpaceIds, - canEditSpaces, - setCanEditSpaces, -}) => { - const { spacesManager, allSpaces } = useSpacesContext(); - - const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); - - useEffect(() => { - if (spacesManager !== null) { - const getPermissions = spacesManager.getShareSavedObjectPermissions(ML_SAVED_OBJECT_TYPE); - Promise.all([getPermissions]).then(([{ shareToAllSpaces }]) => { - setCanShareToAllSpaces(shareToAllSpaces); - setCanEditSpaces(shareToAllSpaces || spaceIds.includes(ALL_SPACES_ID) === false); - }); - } - }, []); - - function toggleShareOption(isAllSpaces: boolean) { - const updatedSpaceIds = isAllSpaces - ? [ALL_SPACES_ID, ...selectedSpaceIds] - : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); - setSelectedSpaceIds(updatedSpaceIds); - } - - function updateSelectedSpaces(selectedOptions: SpaceOption[]) { - const ids = selectedOptions.filter((opt) => opt.checked).map((opt) => opt['data-space-id']); - setSelectedSpaceIds(ids); - } - - const isGlobalControlChecked = useMemo(() => selectedSpaceIds.includes(ALL_SPACES_ID), [ - selectedSpaceIds, - ]); - - const options = useMemo( - () => - allSpaces.map((space) => { - return { - label: space.name, - prepend: , - checked: selectedSpaceIds.includes(space.id) ? 'on' : undefined, - disabled: canEditSpaces === false, - ['data-space-id']: space.id, - ['data-test-subj']: `mlSpaceSelectorRow_${space.id}`, - }; - }), - [allSpaces, selectedSpaceIds, canEditSpaces] - ); - - const shareToAllSpaces = useMemo( - () => ({ - id: 'shareToAllSpaces', - title: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.title', { - defaultMessage: 'All spaces', - }), - text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.text', { - defaultMessage: 'Make job available in all current and future spaces.', - }), - ...(!canShareToAllSpaces && { - tooltip: isGlobalControlChecked - ? i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotUncheckTooltip', - { defaultMessage: 'You need additional privileges to change this option.' } - ) - : i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToAllSpaces.cannotCheckTooltip', - { defaultMessage: 'You need additional privileges to use this option.' } - ), - }), - disabled: !canShareToAllSpaces, - }), - [isGlobalControlChecked, canShareToAllSpaces] - ); - - const shareToExplicitSpaces = useMemo( - () => ({ - id: 'shareToExplicitSpaces', - title: i18n.translate( - 'xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.title', - { - defaultMessage: 'Select spaces', - } - ), - text: i18n.translate('xpack.ml.management.spacesSelectorFlyout.shareToExplicitSpaces.text', { - defaultMessage: 'Make job available in selected spaces only.', - }), - disabled: !canShareToAllSpaces && isGlobalControlChecked, - }), - [canShareToAllSpaces, isGlobalControlChecked] - ); - - return ( - <> - {canEditSpaces === false && } - - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > - - } - fullWidth - > - updateSelectedSpaces(newOptions as SpaceOption[])} - listProps={{ - bordered: true, - rowHeight: 40, - className: 'mlCopyToSpace__spacesList', - 'data-test-subj': 'mlFormSpaceSelector', - }} - searchable - > - {(list, search) => { - return ( - <> - {search} - {list} - - ); - }} - - - - - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} - /> - - - ); -}; - -function createLabel({ - title, - text, - disabled, - tooltip, -}: { - title: string; - text: string; - disabled: boolean; - tooltip?: string; -}) { - return ( - <> - - - {title} - - {tooltip && ( - - - - )} - - - - {text} - - - ); -} diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx index 1d2bda90516b99..8fc4a0d636bce7 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/close_job_confirm/close_job_confirm.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { COMBINED_JOB_STATE } from '../model_snapshots_table'; @@ -25,56 +25,51 @@ export const CloseJobConfirm: FC = ({ forceCloseJob, }) => { return ( - - +

    + {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING && ( + )} - confirmButtonText={ - combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING - ? i18n.translate('xpack.ml.modelSnapshotTable.closeJobConfirm.stopAndClose.button', { - defaultMessage: 'Force stop and close', - }) - : i18n.translate('xpack.ml.modelSnapshotTable.closeJobConfirm.close.button', { - defaultMessage: 'Force close', - }) - } - defaultFocusedButton="confirm" - > -

    - {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_RUNNING && ( - - )} - {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_STOPPED && ( - - )} -
    + {combinedJobState === COMBINED_JOB_STATE.OPEN_AND_STOPPED && ( -

    -
    -
    + )} +
    + +

    + ); }; diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx index 833e70fc86f4c4..20c98255930b4d 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/edit_model_snapshot_flyout/edit_model_snapshot_flyout.tsx @@ -22,7 +22,6 @@ import { EuiFormRow, EuiSwitch, EuiConfirmModal, - EuiOverlayMask, EuiCallOut, } from '@elastic/eui'; @@ -190,23 +189,21 @@ export const EditModelSnapshotFlyout: FC = ({ snapshot, job, closeFlyout {deleteModalVisible && ( - - - + )} ); diff --git a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx index 1929cddaca6b56..6dd4e6c14589b2 100644 --- a/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/model_snapshots/revert_model_snapshot_flyout/revert_model_snapshot_flyout.tsx @@ -22,7 +22,6 @@ import { EuiFormRow, EuiSwitch, EuiConfirmModal, - EuiOverlayMask, EuiCallOut, EuiHorizontalRule, EuiSuperSelect, @@ -368,34 +367,32 @@ export const RevertModelSnapshotFlyout: FC = ({ {revertModalVisible && ( - - - - - + + + )} ); diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/__snapshots__/delete_rule_modal.test.js.snap b/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/__snapshots__/delete_rule_modal.test.js.snap index a132e6682ee250..3a11531f6c4bc1 100644 --- a/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/__snapshots__/delete_rule_modal.test.js.snap +++ b/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/__snapshots__/delete_rule_modal.test.js.snap @@ -42,34 +42,32 @@ exports[`DeleteRuleModal renders modal after clicking delete rule link 1`] = ` values={Object {}} /> - - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - - } - /> - + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + + } + /> `; diff --git a/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/delete_rule_modal.js b/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/delete_rule_modal.js index 809bb780c33239..6caa6592e96c1e 100644 --- a/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/delete_rule_modal.js +++ b/x-pack/plugins/ml/public/application/components/rule_editor/select_rule_action/delete_rule_modal.js @@ -12,7 +12,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { EuiConfirmModal, EuiLink, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EuiLink, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; export class DeleteRuleModal extends Component { @@ -43,32 +43,30 @@ export class DeleteRuleModal extends Component { if (this.state.isModalVisible) { modal = ( - - - } - onCancel={this.closeModal} - onConfirm={this.deleteRule} - buttonColor="danger" - cancelButtonText={ - - } - confirmButtonText={ - - } - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - /> - + + } + onCancel={this.closeModal} + onConfirm={this.deleteRule} + buttonColor="danger" + cancelButtonText={ + + } + confirmButtonText={ + + } + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + /> ); } diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts index c72b0eb5fd66e5..216b0d8d5e9920 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts @@ -6,6 +6,4 @@ */ export { useScatterplotFieldOptions } from './use_scatterplot_field_options'; -export { LEGEND_TYPES } from './scatterplot_matrix_vega_lite_spec'; -export { ScatterplotMatrix } from './scatterplot_matrix'; -export type { ScatterplotMatrixViewProps as ScatterplotMatrixProps } from './scatterplot_matrix_view'; +export { ScatterplotMatrix, ScatterplotMatrixProps } from './scatterplot_matrix'; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss similarity index 100% rename from x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss rename to x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx index 8a10fd5574ba59..a4f68c84ba81f1 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx @@ -5,15 +5,305 @@ * 2.0. */ -import React, { FC, Suspense } from 'react'; +import React, { useMemo, useEffect, useState, FC } from 'react'; -import type { ScatterplotMatrixViewProps } from './scatterplot_matrix_view'; -import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; +import { + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSwitch, +} from '@elastic/eui'; -const ScatterplotMatrixLazy = React.lazy(() => import('./scatterplot_matrix_view')); +import { i18n } from '@kbn/i18n'; -export const ScatterplotMatrix: FC = (props) => ( - }> - - -); +import type { SearchResponse7 } from '../../../../common/types/es_client'; +import type { ResultsSearchQuery } from '../../data_frame_analytics/common/analytics'; + +import { useMlApiContext } from '../../contexts/kibana'; + +import { getProcessedFields } from '../data_grid'; +import { useCurrentEuiTheme } from '../color_range_legend'; + +// Separate imports for lazy loadable VegaChart and related code +import { VegaChart } from '../vega_chart'; +import type { LegendType } from '../vega_chart/common'; +import { VegaChartLoading } from '../vega_chart/vega_chart_loading'; + +import { + getScatterplotMatrixVegaLiteSpec, + OUTLIER_SCORE_FIELD, +} from './scatterplot_matrix_vega_lite_spec'; + +import './scatterplot_matrix.scss'; + +const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; + +const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { + defaultMessage: 'On', +}); +const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { + defaultMessage: 'Off', +}); + +const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); + +export interface ScatterplotMatrixProps { + fields: string[]; + index: string; + resultsField?: string; + color?: string; + legendType?: LegendType; + searchQuery?: ResultsSearchQuery; +} + +export const ScatterplotMatrix: FC = ({ + fields: allFields, + index, + resultsField, + color, + legendType, + searchQuery, +}) => { + const { esSearch } = useMlApiContext(); + + // dynamicSize is optionally used for outlier charts where the scatterplot marks + // are sized according to outlier_score + const [dynamicSize, setDynamicSize] = useState(false); + + // used to give the use the option to customize the fields used for the matrix axes + const [fields, setFields] = useState([]); + + useEffect(() => { + const defaultFields = + allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS + ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) + : allFields; + setFields(defaultFields); + }, [allFields]); + + // the amount of documents to be fetched + const [fetchSize, setFetchSize] = useState(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); + // flag to add a random score to the ES query to fetch documents + const [randomizeQuery, setRandomizeQuery] = useState(false); + + const [isLoading, setIsLoading] = useState(false); + + // contains the fetched documents and columns to be passed on to the Vega spec. + const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); + + // formats the array of field names for EuiComboBox + const fieldOptions = useMemo( + () => + allFields.map((d) => ({ + label: d, + })), + [allFields] + ); + + const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { + setFields(newFields.map((d) => d.label)); + }; + + const fetchSizeOnChange = (e: React.ChangeEvent) => { + setFetchSize( + Math.min( + Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), + SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE + ) + ); + }; + + const randomizeQueryOnChange = () => { + setRandomizeQuery(!randomizeQuery); + }; + + const dynamicSizeOnChange = () => { + setDynamicSize(!dynamicSize); + }; + + const { euiTheme } = useCurrentEuiTheme(); + + useEffect(() => { + if (fields.length === 0) { + setSplom(undefined); + setIsLoading(false); + return; + } + + async function fetchSplom(options: { didCancel: boolean }) { + setIsLoading(true); + try { + const queryFields = [ + ...fields, + ...(color !== undefined ? [color] : []), + ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), + ]; + + const queryFallback = searchQuery !== undefined ? searchQuery : { match_all: {} }; + const query = randomizeQuery + ? { + function_score: { + query: queryFallback, + random_score: { seed: 10, field: '_seq_no' }, + }, + } + : queryFallback; + + const resp: SearchResponse7 = await esSearch({ + index, + body: { + fields: queryFields, + _source: false, + query, + from: 0, + size: fetchSize, + }, + }); + + if (!options.didCancel) { + const items = resp.hits.hits.map((d) => + getProcessedFields(d.fields, (key: string) => + key.startsWith(`${resultsField}.feature_importance`) + ) + ); + + setSplom({ columns: fields, items }); + setIsLoading(false); + } + } catch (e) { + // TODO error handling + setIsLoading(false); + } + } + + const options = { didCancel: false }; + fetchSplom(options); + return () => { + options.didCancel = true; + }; + // stringify the fields array and search, otherwise the comparator will trigger on new but identical instances. + }, [fetchSize, JSON.stringify({ fields, searchQuery }), index, randomizeQuery, resultsField]); + + const vegaSpec = useMemo(() => { + if (splom === undefined) { + return; + } + + const { items, columns } = splom; + + const values = + resultsField !== undefined + ? items + : items.map((d) => { + d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; + return d; + }); + + return getScatterplotMatrixVegaLiteSpec( + values, + columns, + euiTheme, + resultsField, + color, + legendType, + dynamicSize + ); + }, [resultsField, splom, color, legendType, dynamicSize]); + + return ( + <> + {splom === undefined || vegaSpec === undefined ? ( + + ) : ( +
    + + + + ({ + label: d, + }))} + onChange={fieldsOnChange} + isClearable={true} + data-test-subj="mlScatterplotMatrixFieldsComboBox" + /> + + + + + + + + + + + + + {resultsField !== undefined && legendType === undefined && ( + + + + + + )} + + + +
    + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts index 44fba189e856cf..c963b7509139b8 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts @@ -10,13 +10,14 @@ import { compile } from 'vega-lite/build-es5/vega-lite'; import euiThemeLight from '@elastic/eui/dist/eui_theme_light.json'; +import { LEGEND_TYPES } from '../vega_chart/common'; + import { getColorSpec, getScatterplotMatrixVegaLiteSpec, COLOR_OUTLIER, COLOR_RANGE_NOMINAL, DEFAULT_COLOR, - LEGEND_TYPES, } from './scatterplot_matrix_vega_lite_spec'; describe('getColorSpec()', () => { diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts index e476123ad0f2a2..f99aa7c5c3de86 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts @@ -15,11 +15,7 @@ import { euiPaletteColorBlind, euiPaletteNegative, euiPalettePositive } from '@e import { i18n } from '@kbn/i18n'; -export const LEGEND_TYPES = { - NOMINAL: 'nominal', - QUANTITATIVE: 'quantitative', -} as const; -export type LegendType = typeof LEGEND_TYPES[keyof typeof LEGEND_TYPES]; +import { LegendType, LEGEND_TYPES } from '../vega_chart/common'; export const OUTLIER_SCORE_FIELD = 'outlier_score'; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx deleted file mode 100644 index 7d32992ace84da..00000000000000 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx +++ /dev/null @@ -1,324 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo, useEffect, useState, FC } from 'react'; - -// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. -// @ts-ignore -import { compile } from 'vega-lite/build-es5/vega-lite'; -import { parse, View, Warn } from 'vega'; -import { Handler } from 'vega-tooltip'; - -import { - htmlIdGenerator, - EuiComboBox, - EuiComboBoxOptionOption, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiSelect, - EuiSwitch, -} from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; - -import type { SearchResponse7 } from '../../../../common/types/es_client'; -import type { ResultsSearchQuery } from '../../data_frame_analytics/common/analytics'; - -import { useMlApiContext } from '../../contexts/kibana'; - -import { getProcessedFields } from '../data_grid'; -import { useCurrentEuiTheme } from '../color_range_legend'; - -import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; - -import { - getScatterplotMatrixVegaLiteSpec, - LegendType, - OUTLIER_SCORE_FIELD, -} from './scatterplot_matrix_vega_lite_spec'; - -import './scatterplot_matrix_view.scss'; - -const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; - -const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { - defaultMessage: 'On', -}); -const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { - defaultMessage: 'Off', -}); - -const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); - -export interface ScatterplotMatrixViewProps { - fields: string[]; - index: string; - resultsField?: string; - color?: string; - legendType?: LegendType; - searchQuery?: ResultsSearchQuery; -} - -export const ScatterplotMatrixView: FC = ({ - fields: allFields, - index, - resultsField, - color, - legendType, - searchQuery, -}) => { - const { esSearch } = useMlApiContext(); - - // dynamicSize is optionally used for outlier charts where the scatterplot marks - // are sized according to outlier_score - const [dynamicSize, setDynamicSize] = useState(false); - - // used to give the use the option to customize the fields used for the matrix axes - const [fields, setFields] = useState([]); - - useEffect(() => { - const defaultFields = - allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS - ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) - : allFields; - setFields(defaultFields); - }, [allFields]); - - // the amount of documents to be fetched - const [fetchSize, setFetchSize] = useState(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); - // flag to add a random score to the ES query to fetch documents - const [randomizeQuery, setRandomizeQuery] = useState(false); - - const [isLoading, setIsLoading] = useState(false); - - // contains the fetched documents and columns to be passed on to the Vega spec. - const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); - - // formats the array of field names for EuiComboBox - const fieldOptions = useMemo( - () => - allFields.map((d) => ({ - label: d, - })), - [allFields] - ); - - const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { - setFields(newFields.map((d) => d.label)); - }; - - const fetchSizeOnChange = (e: React.ChangeEvent) => { - setFetchSize( - Math.min( - Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), - SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE - ) - ); - }; - - const randomizeQueryOnChange = () => { - setRandomizeQuery(!randomizeQuery); - }; - - const dynamicSizeOnChange = () => { - setDynamicSize(!dynamicSize); - }; - - const { euiTheme } = useCurrentEuiTheme(); - - useEffect(() => { - async function fetchSplom(options: { didCancel: boolean }) { - setIsLoading(true); - try { - const queryFields = [ - ...fields, - ...(color !== undefined ? [color] : []), - ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), - ]; - - const queryFallback = searchQuery !== undefined ? searchQuery : { match_all: {} }; - const query = randomizeQuery - ? { - function_score: { - query: queryFallback, - random_score: { seed: 10, field: '_seq_no' }, - }, - } - : queryFallback; - - const resp: SearchResponse7 = await esSearch({ - index, - body: { - fields: queryFields, - _source: false, - query, - from: 0, - size: fetchSize, - }, - }); - - if (!options.didCancel) { - const items = resp.hits.hits.map((d) => - getProcessedFields(d.fields, (key: string) => - key.startsWith(`${resultsField}.feature_importance`) - ) - ); - - setSplom({ columns: fields, items }); - setIsLoading(false); - } - } catch (e) { - // TODO error handling - setIsLoading(false); - } - } - - const options = { didCancel: false }; - fetchSplom(options); - return () => { - options.didCancel = true; - }; - // stringify the fields array and search, otherwise the comparator will trigger on new but identical instances. - }, [fetchSize, JSON.stringify({ fields, searchQuery }), index, randomizeQuery, resultsField]); - - const htmlId = useMemo(() => htmlIdGenerator()(), []); - - useEffect(() => { - if (splom === undefined) { - return; - } - - const { items, columns } = splom; - - const values = - resultsField !== undefined - ? items - : items.map((d) => { - d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; - return d; - }); - - const vegaSpec = getScatterplotMatrixVegaLiteSpec( - values, - columns, - euiTheme, - resultsField, - color, - legendType, - dynamicSize - ); - - const vgSpec = compile(vegaSpec).spec; - - const view = new View(parse(vgSpec)) - .logLevel(Warn) - .renderer('canvas') - .tooltip(new Handler().call) - .initialize(`#${htmlId}`); - - view.runAsync(); // evaluate and render the view - }, [resultsField, splom, color, legendType, dynamicSize]); - - return ( - <> - {splom === undefined ? ( - - ) : ( - <> - - - - ({ - label: d, - }))} - onChange={fieldsOnChange} - isClearable={true} - data-test-subj="mlScatterplotMatrixFieldsComboBox" - /> - - - - - - - - - - - - - {resultsField !== undefined && legendType === undefined && ( - - - - - - )} - - -
    - - )} - - ); -}; - -// required for dynamic import using React.lazy() -// eslint-disable-next-line import/no-default-export -export default ScatterplotMatrixView; diff --git a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js index a93264c852dd15..2b7c89db15e2e1 100644 --- a/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js +++ b/x-pack/plugins/ml/public/application/components/validate_job/validate_job_view.js @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiText, EuiFlexGroup, @@ -161,24 +160,19 @@ const LoadingSpinner = () => ( ); const Modal = ({ close, title, children }) => ( - - - - {title} - - - {children} - - - - - - - - + + + {title} + + + {children} + + + + + + + ); Modal.propType = { close: PropTypes.func.isRequired, diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/common.ts b/x-pack/plugins/ml/public/application/components/vega_chart/common.ts new file mode 100644 index 00000000000000..79254788ce7a69 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/common.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const LEGEND_TYPES = { + NOMINAL: 'nominal', + QUANTITATIVE: 'quantitative', +} as const; +export type LegendType = typeof LEGEND_TYPES[keyof typeof LEGEND_TYPES]; diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/index.ts b/x-pack/plugins/ml/public/application/components/vega_chart/index.ts new file mode 100644 index 00000000000000..f1d5c3ed4523bc --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Make sure to only export the component we can lazy load here. +// Code from other files in this directory should be imported directly from the file, +// otherwise we break the bundling approach using lazy loading. +export { VegaChart } from './vega_chart'; diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx new file mode 100644 index 00000000000000..ab175908d9d797 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, Suspense } from 'react'; + +import { VegaChartLoading } from './vega_chart_loading'; +import type { VegaChartViewProps } from './vega_chart_view'; + +const VegaChartView = React.lazy(() => import('./vega_chart_view')); + +export const VegaChart: FC = (props) => ( + }> + + +); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx similarity index 91% rename from x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx rename to x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx index cdb4d99b041d54..8a5c1575f94d65 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_loading.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; -export const ScatterplotMatrixLoading = () => { +export const VegaChartLoading = () => { return ( diff --git a/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx new file mode 100644 index 00000000000000..7774def574b696 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/vega_chart/vega_chart_view.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useEffect, FC } from 'react'; + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import type { TopLevelSpec } from 'vega-lite/build-es5/vega-lite'; + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import { compile } from 'vega-lite/build-es5/vega-lite'; +import { parse, View, Warn } from 'vega'; +import { Handler } from 'vega-tooltip'; + +import { htmlIdGenerator } from '@elastic/eui'; + +export interface VegaChartViewProps { + vegaSpec: TopLevelSpec; +} + +export const VegaChartView: FC = ({ vegaSpec }) => { + const htmlId = useMemo(() => htmlIdGenerator()(), []); + + useEffect(() => { + const vgSpec = compile(vegaSpec).spec; + + const view = new View(parse(vgSpec)) + .logLevel(Warn) + .renderer('canvas') + .tooltip(new Handler().call) + .initialize(`#${htmlId}`); + + view.runAsync(); // evaluate and render the view + }, [vegaSpec]); + + return
    ; +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default VegaChartView; 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 1dd30d5d99335d..99d4b77547d9d0 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 @@ -19,6 +19,7 @@ import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/p import type { EmbeddableStart } from '../../../../../../../src/plugins/embeddable/public'; import type { MapsStartApi } from '../../../../../maps/public'; import type { LensPublicStart } from '../../../../../lens/public'; +import { TriggersAndActionsUIPublicPluginStart } from '../../../../../triggers_actions_ui/public'; interface StartPlugins { data: DataPublicPluginStart; @@ -28,6 +29,7 @@ interface StartPlugins { embeddable: EmbeddableStart; maps?: MapsStartApi; lens?: LensPublicStart; + triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; } export type StartServices = CoreStart & StartPlugins & { diff --git a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts b/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts deleted file mode 100644 index dca7d0989d4de9..00000000000000 --- a/x-pack/plugins/ml/public/application/contexts/spaces/spaces_context.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { createContext, useContext } from 'react'; -import { HttpSetup } from 'src/core/public'; -import { SpacesManager, Space } from '../../../../../spaces/public'; - -export interface SpacesContextValue { - spacesManager: SpacesManager | null; - allSpaces: Space[]; - spacesEnabled: boolean; -} - -export const SpacesContext = createContext>({}); - -export function createSpacesContext(http: HttpSetup, spacesEnabled: boolean) { - return { - spacesManager: spacesEnabled ? new SpacesManager(http) : null, - allSpaces: [], - spacesEnabled, - } as SpacesContextValue; -} - -export function useSpacesContext() { - const context = useContext(SpacesContext); - - if (context.spacesManager === undefined) { - throw new Error('required attribute is undefined'); - } - - return context as SpacesContextValue; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 4f1799ed26f872..1c13177e44e7fc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -154,11 +154,21 @@ export interface ConfusionMatrix { other_predicted_class_doc_count: number; } +export interface RocCurveItem { + fpr: number; + threshold: number; + tpr: number; +} + export interface ClassificationEvaluateResponse { classification: { - multiclass_confusion_matrix: { + multiclass_confusion_matrix?: { confusion_matrix: ConfusionMatrix[]; }; + auc_roc?: { + curve?: RocCurveItem[]; + value: number; + }; }; } @@ -244,7 +254,8 @@ export const isClassificationEvaluateResponse = ( return ( keys.length === 1 && keys[0] === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && - arg?.classification?.multiclass_confusion_matrix !== undefined + (arg?.classification?.multiclass_confusion_matrix !== undefined || + arg?.classification?.auc_roc !== undefined) ); }; @@ -422,7 +433,8 @@ export enum REGRESSION_STATS { interface EvaluateMetrics { classification: { - multiclass_confusion_matrix: object; + multiclass_confusion_matrix?: object; + auc_roc?: { include_curve: boolean; class_name: string }; }; regression: { r_squared: object; @@ -442,6 +454,8 @@ interface LoadEvalDataConfig { ignoreDefaultQuery?: boolean; jobType: DataFrameAnalysisConfigType; requiresKeyword?: boolean; + rocCurveClassName?: string; + includeMulticlassConfusionMatrix?: boolean; } export const loadEvalData = async ({ @@ -454,6 +468,8 @@ export const loadEvalData = async ({ ignoreDefaultQuery, jobType, requiresKeyword, + rocCurveClassName, + includeMulticlassConfusionMatrix = true, }: LoadEvalDataConfig) => { const results: LoadEvaluateResult = { success: false, eval: null, error: null }; const defaultPredictionField = `${dependentVariable}_prediction`; @@ -469,7 +485,10 @@ export const loadEvalData = async ({ const metrics: EvaluateMetrics = { classification: { - multiclass_confusion_matrix: {}, + ...(includeMulticlassConfusionMatrix ? { multiclass_confusion_matrix: {} } : {}), + ...(rocCurveClassName !== undefined + ? { auc_roc: { include_curve: true, class_name: rocCurveClassName } } + : {}), }, regression: { r_squared: {}, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts index a8b95a415ea539..2113f9385c5ef5 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts @@ -9,7 +9,7 @@ import { ANALYSIS_CONFIG_TYPE } from './analytics'; import { AnalyticsJobType } from '../pages/analytics_management/hooks/use_create_analytics_form/state'; -import { LEGEND_TYPES } from '../../components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec'; +import { LEGEND_TYPES } from '../../components/vega_chart/common'; export const getScatterplotMatrixLegendType = (jobType: AnalyticsJobType | 'unknown') => { switch (jobType) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx index 469327323bee98..fc8798a3af0f57 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx @@ -131,20 +131,6 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ } if (isRegOrClassJob) { - if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) { - advancedFirstCol.push({ - title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.numTopClasses', { - defaultMessage: 'Top classes', - }), - description: - numTopClasses === -1 - ? i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.allClasses', { - defaultMessage: 'All classes', - }) - : getStringValue(numTopClasses), - }); - } - advancedFirstCol.push({ title: i18n.translate( 'xpack.ml.dataframe.analytics.create.configDetails.numTopFeatureImportanceValues', @@ -170,15 +156,23 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ } ); - advancedSecondCol.push({ - title: i18n.translate( - 'xpack.ml.dataframe.analytics.create.configDetails.predictionFieldName', - { - defaultMessage: 'Prediction field name', - } - ), - description: predictionFieldName ? predictionFieldName : `${dependentVariable}_prediction`, - }); + advancedSecondCol.push( + { + title: i18n.translate( + 'xpack.ml.dataframe.analytics.create.configDetails.predictionFieldName', + { + defaultMessage: 'Prediction field name', + } + ), + description: predictionFieldName ? predictionFieldName : `${dependentVariable}_prediction`, + }, + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.randomizedSeed', { + defaultMessage: 'Randomized seed', + }), + description: getStringValue(randomizeSeed), + } + ); hyperSecondCol.push( { @@ -205,20 +199,26 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ description: `${modelMemoryLimit}`, }); - hyperThirdCol.push( - { - title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.gamma', { - defaultMessage: 'Gamma', - }), - description: getStringValue(gamma), - }, - { - title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.randomizedSeed', { - defaultMessage: 'Randomized seed', - }), - description: getStringValue(randomizeSeed), - } - ); + hyperThirdCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.gamma', { + defaultMessage: 'Gamma', + }), + description: getStringValue(gamma), + }); + } + + if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) { + advancedThirdCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.numTopClasses', { + defaultMessage: 'Top classes', + }), + description: + numTopClasses === -1 + ? i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.allClasses', { + defaultMessage: 'All classes', + }) + : getStringValue(numTopClasses), + }); } if (maxNumThreads !== undefined) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx index 8e25fc961c7c28..71770dcf952d97 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx @@ -409,6 +409,34 @@ export const AdvancedStepForm: FC = ({ /> + + + + setFormState({ randomizeSeed: e.target.value === '' ? undefined : +e.target.value }) + } + isInvalid={randomizeSeed !== undefined && typeof randomizeSeed !== 'number'} + value={getNumberValue(randomizeSeed)} + step={1} + /> + + ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx index 03dfc09d97b0e0..704b2cc77a7f93 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx @@ -31,7 +31,6 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors lambda, maxOptimizationRoundsPerHyperparameter, maxTrees, - randomizeSeed, softTreeDepthLimit, softTreeDepthTolerance, } = state.form; @@ -45,15 +44,14 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.lambdaHelpText', { defaultMessage: - 'Regularization parameter to prevent overfitting on the training data set. Must be a non negative value.', + 'A multiplier of the leaf weights in loss calculations. Must be a nonnegative value.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.LAMBDA] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.LAMBDA]} > @@ -71,7 +69,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors defaultMessage: 'Max trees', })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.maxTreesText', { - defaultMessage: 'The maximum number of trees the forest is allowed to contain.', + defaultMessage: 'The maximum number of decision trees in the forest.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.MAX_TREES] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.MAX_TREES]} @@ -80,7 +78,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors aria-label={i18n.translate( 'xpack.ml.dataframe.analytics.create.maxTreesInputAriaLabel', { - defaultMessage: 'The maximum number of trees the forest is allowed to contain.', + defaultMessage: 'The maximum number of decision trees in the forest.', } )} data-test-subj="mlAnalyticsCreateJobFlyoutMaxTreesInput" @@ -102,15 +100,14 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.gammaText', { defaultMessage: - 'Multiplies a linear penalty associated with the size of individual trees in the forest. Must be non-negative value.', + 'A multiplier of the tree size in loss calcuations. Must be nonnegative value.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.GAMMA] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.GAMMA]} > @@ -135,7 +132,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors > @@ -186,36 +183,6 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors /> - - - - setFormState({ randomizeSeed: e.target.value === '' ? undefined : +e.target.value }) - } - isInvalid={randomizeSeed !== undefined && typeof randomizeSeed !== 'number'} - value={getNumberValue(randomizeSeed)} - step={1} - /> - - = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.alphaText', { defaultMessage: - 'Multiplies a term based on tree depth in the regularized loss. Higher values result in shallower trees and faster training times. Must be greater than or equal to 0. ', + 'A multiplier of the tree depth in loss calculations. Must be greater than or equal to 0.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.ALPHA] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.ALPHA]} > @@ -249,7 +216,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.downsampleFactorText', { defaultMessage: - 'Controls the fraction of data that is used to compute the derivatives of the loss function for tree training. Must be between 0 and 1.', + 'The fraction of data used to compute derivatives of the loss function for tree training. Must be between 0 and 1.', })} isInvalid={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.DOWNSAMPLE_FACTOR] !== undefined} error={advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.DOWNSAMPLE_FACTOR]} @@ -259,7 +226,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.downsampleFactorInputAriaLabel', { defaultMessage: - 'Controls the fraction of data that is used to compute the derivatives of the loss function for tree training', + 'The fraction of data used to compute derivatives of the loss function for tree training.', } )} data-test-subj="mlAnalyticsCreateJobWizardDownsampleFactorInput" @@ -282,7 +249,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.etaGrowthRatePerTreeText', { defaultMessage: - 'Specifies the rate at which eta increases for each new tree that is added to the forest. Must be between 0.5 and 2.', + 'The rate at which eta increases for each new tree that is added to the forest. Must be between 0.5 and 2.', })} isInvalid={ advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.ETA_GROWTH_RATE_PER_TREE] !== undefined @@ -294,7 +261,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.etaGrowthRatePerTreeInputAriaLabel', { defaultMessage: - 'Specifies the rate at which eta increases for each new tree that is added to the forest.', + 'The rate at which eta increases for each new tree that is added to the forest.', } )} data-test-subj="mlAnalyticsCreateJobWizardEtaGrowthRatePerTreeInput" @@ -322,7 +289,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.maxOptimizationRoundsPerHyperparameterText', { defaultMessage: - 'Multiplier responsible for determining the maximum number of hyperparameter optimization steps in the Bayesian optimization procedure.', + 'The maximum number of optimization rounds for each undefined hyperparameter.', } )} isInvalid={ @@ -339,7 +306,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.maxOptimizationRoundsPerHyperparameterInputAriaLabel', { defaultMessage: - 'Multiplier responsible for determining the maximum number of hyperparameter optimization steps in the Bayesian optimization procedure. Must be an integer between 0 and 20.', + 'The maximum number of optimization rounds for each undefined hyperparameter. Must be an integer between 0 and 20.', } )} data-test-subj="mlAnalyticsCreateJobWizardMaxOptimizationRoundsPerHyperparameterInput" @@ -363,7 +330,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors })} helpText={i18n.translate('xpack.ml.dataframe.analytics.create.softTreeDepthLimitText', { defaultMessage: - 'Tree depth limit that increases regularized loss when exceeded. Must be greater than or equal to 0. ', + 'Decision trees that exceed this depth are penalized in loss calculations. Must be greater than or equal to 0. ', })} isInvalid={ advancedParamErrors[ANALYSIS_ADVANCED_FIELDS.SOFT_TREE_DEPTH_LIMIT] !== undefined @@ -374,7 +341,8 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors aria-label={i18n.translate( 'xpack.ml.dataframe.analytics.create.softTreeDepthLimitInputAriaLabel', { - defaultMessage: 'Tree depth limit that increases regularized loss when exceeded', + defaultMessage: + 'Decision trees that exceed this depth are penalized in loss calculations.', } )} data-test-subj="mlAnalyticsCreateJobWizardSoftTreeDepthLimitInput" @@ -398,7 +366,7 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors 'xpack.ml.dataframe.analytics.create.softTreeDepthToleranceText', { defaultMessage: - 'Controls how quickly the regularized loss increases when the tree depth exceeds soft_tree_depth_limit. Must be greater than or equal to 0.01. ', + 'Controls how quickly the loss increases when tree depths exceed soft limits. The smaller the value, the faster the loss increases. Must be greater than or equal to 0.01. ', } )} isInvalid={ @@ -410,7 +378,8 @@ export const HyperParameters: FC = ({ actions, state, advancedParamErrors aria-label={i18n.translate( 'xpack.ml.dataframe.analytics.create.softTreeDepthToleranceInputAriaLabel', { - defaultMessage: 'Tree depth limit that increases regularized loss when exceeded', + defaultMessage: + 'Decision trees that exceed this depth are penalized in loss calculations.', } )} data-test-subj="mlAnalyticsCreateJobWizardSoftTreeDepthToleranceInput" diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_details.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_details.tsx index f92d391ecd4a95..ef88c363e3e279 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_details.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_details.tsx @@ -69,9 +69,16 @@ export const ConfigurationStepDetails: FC = ({ setCurrentStep, state }) = }), description: includes.length > MAX_INCLUDES_LENGTH - ? `${includes.slice(0, MAX_INCLUDES_LENGTH).join(', ')} ... (and ${ - includes.length - MAX_INCLUDES_LENGTH - } more)` + ? i18n.translate( + 'xpack.ml.dataframe.analytics.create.configDetails.includedFieldsAndMoreDescription', + { + defaultMessage: '{includedFields} ... (and {extraCount} more)', + values: { + extraCount: includes.length - MAX_INCLUDES_LENGTH, + includedFields: includes.slice(0, MAX_INCLUDES_LENGTH).join(', '), + }, + } + ) : includes.join(', '), }, ]; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index 6ad874d3abd6c9..0432094c30c500 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -8,6 +8,7 @@ import React, { FC, Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { EuiBadge, + EuiCallOut, EuiComboBox, EuiComboBoxOptionOption, EuiFormRow, @@ -19,6 +20,7 @@ import { import { i18n } from '@kbn/i18n'; import { debounce } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; import { useMlContext } from '../../../../../contexts/ml'; @@ -62,6 +64,8 @@ const requiredFieldsErrorText = i18n.translate( } ); +const maxRuntimeFieldsDisplayCount = 5; + export const ConfigurationStepForm: FC = ({ actions, state, @@ -314,6 +318,15 @@ export const ConfigurationStepForm: FC = ({ }; }, [jobType, dependentVariable, trainingPercent, JSON.stringify(includes), jobConfigQueryString]); + const unsupportedRuntimeFields = useMemo( + () => + currentIndexPattern.fields + .getAll() + .filter((f) => f.runtimeField) + .map((f) => `'${f.displayName}'`), + [currentIndexPattern.fields] + ); + return ( @@ -445,6 +458,36 @@ export const ConfigurationStepForm: FC = ({ > + {Array.isArray(unsupportedRuntimeFields) && unsupportedRuntimeFields.length > 0 && ( + <> + + 0 ? ( + + ) : ( + '' + ), + unsupportedRuntimeFields: unsupportedRuntimeFields + .slice(0, maxRuntimeFieldsDisplayCount) + .join(', '), + }} + /> + + + + )} + { - const indexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern); + const indexPatternFields = useMemo(() => getFieldsFromKibanaIndexPattern(indexPattern), [ + indexPattern, + ]); // EuiDataGrid State const columns: EuiDataGridColumn[] = [ @@ -75,7 +78,6 @@ export const useIndexData = ( s[column.id] = { order: column.direction }; return s; }, {} as EsSorting); - const esSearchRequest = { index: indexPattern.title, body: { @@ -86,6 +88,7 @@ export const useIndexData = ( fields: ['*'], _source: false, ...(Object.keys(sort).length > 0 ? { sort } : {}), + ...getRuntimeFieldsMapping(indexPatternFields, indexPattern), }, }; @@ -105,7 +108,7 @@ export const useIndexData = ( useEffect(() => { getIndexData(); // custom comparison - }, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]); + }, [indexPattern.title, indexPatternFields, JSON.stringify([query, pagination, sortingColumns])]); const dataLoader = useMemo(() => new DataLoader(indexPattern, toastNotifications), [ indexPattern, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss index d1c507c5241d5f..73ced778821cfa 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/_classification_exploration.scss @@ -1,3 +1,6 @@ +/* Fixed width so we can align it with the padding of the AUC ROC chart. */ +$labelColumnWidth: 80px; + /* Workaround for EuiDataGrid within a Flex Layout, this tricks browsers treating the width as a px value instead of % @@ -6,7 +9,7 @@ width: 100%; } -.mlDataFrameAnalyticsClassification__confusionMatrix { +.mlDataFrameAnalyticsClassification__evaluateSectionContent { padding: 0 5%; } @@ -14,7 +17,7 @@ The following two classes are a workaround to avoid having EuiDataGrid in a flex layout and just uses a legacy approach for a two column layout so we don't break IE11. */ -.mlDataFrameAnalyticsClassification__confusionMatrix:after { +.mlDataFrameAnalyticsClassification__evaluateSectionContent:after { content: ''; display: table; clear: both; @@ -22,7 +25,7 @@ .mlDataFrameAnalyticsClassification__actualLabel { float: left; - width: 8%; + width: $labelColumnWidth; padding-top: $euiSize * 4; } @@ -32,7 +35,7 @@ .mlDataFrameAnalyticsClassification__dataGridMinWidth { float: left; min-width: 480px; - width: 92%; + width: calc(100% - #{$labelColumnWidth}); .euiDataGridRowCell--boolean { text-transform: none; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx index b7dec4e5a435ee..20866bf43a2f46 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/evaluate_panel.tsx @@ -21,26 +21,20 @@ import { EuiTitle, } from '@elastic/eui'; import { useMlKibana } from '../../../../../contexts/kibana'; + +// Separate imports for lazy loadable VegaChart and related code +import { VegaChart } from '../../../../../components/vega_chart'; +import { VegaChartLoading } from '../../../../../components/vega_chart/vega_chart_loading'; + import { ErrorCallout } from '../error_callout'; -import { - getDependentVar, - getPredictionFieldName, - loadEvalData, - loadDocsCount, - DataFrameAnalyticsConfig, -} from '../../../../common'; -import { isKeywordAndTextType } from '../../../../common/fields'; +import { getDependentVar, DataFrameAnalyticsConfig } from '../../../../common'; import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common'; -import { - isResultsSearchBoolQuery, - isClassificationEvaluateResponse, - ConfusionMatrix, - ResultsSearchQuery, - ANALYSIS_CONFIG_TYPE, -} from '../../../../common/analytics'; +import { ResultsSearchQuery } from '../../../../common/analytics'; import { ExpandableSection, HEADER_ITEMS_LOADING } from '../expandable_section'; +import { getRocCurveChartVegaLiteSpec } from './get_roc_curve_chart_vega_lite_spec'; + import { getColumnData, ACTUAL_CLASS_ID, @@ -48,6 +42,10 @@ import { getTrailingControlColumns, } from './column_data'; +import { isTrainingFilter } from './is_training_filter'; +import { useRocCurve } from './use_roc_curve'; +import { useConfusionMatrix } from './use_confusion_matrix'; + export interface EvaluatePanelProps { jobConfig: DataFrameAnalyticsConfig; jobStatus?: DataFrameTaskStateType; @@ -81,7 +79,7 @@ const trainingDatasetHelpText = i18n.translate( } ); -function getHelpText(dataSubsetTitle: string) { +function getHelpText(dataSubsetTitle: string): string { let helpText = entireDatasetHelpText; if (dataSubsetTitle === SUBSET_TITLE.TESTING) { helpText = testingDatasetHelpText; @@ -95,77 +93,36 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se const { services: { docLinks }, } = useMlKibana(); - const [isLoading, setIsLoading] = useState(false); - const [confusionMatrixData, setConfusionMatrixData] = useState([]); + const [columns, setColumns] = useState([]); const [columnsData, setColumnsData] = useState([]); const [showFullColumns, setShowFullColumns] = useState(false); const [popoverContents, setPopoverContents] = useState([]); - const [docsCount, setDocsCount] = useState(null); - const [error, setError] = useState(null); const [dataSubsetTitle, setDataSubsetTitle] = useState(SUBSET_TITLE.ENTIRE); // Column visibility - const [visibleColumns, setVisibleColumns] = useState(() => + const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }: { id: string }) => id) ); - const index = jobConfig.dest.index; - const dependentVariable = getDependentVar(jobConfig.analysis); - const predictionFieldName = getPredictionFieldName(jobConfig.analysis); - // default is 'ml' const resultsField = jobConfig.dest.results_field; - let requiresKeyword = false; + const isTraining = isTrainingFilter(searchQuery, resultsField); - const loadData = async ({ isTraining }: { isTraining: boolean | undefined }) => { - setIsLoading(true); - - try { - requiresKeyword = isKeywordAndTextType(dependentVariable); - } catch (e) { - // Additional error handling due to missing field type is handled by loadEvalData - console.error('Unable to load new field types', error); // eslint-disable-line no-console - } - - const evalData = await loadEvalData({ - isTraining, - index, - dependentVariable, - resultsField, - predictionFieldName, - searchQuery, - jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, - requiresKeyword, - }); - - const docsCountResp = await loadDocsCount({ - isTraining, - searchQuery, - resultsField, - destIndex: jobConfig.dest.index, - }); - - if ( - evalData.success === true && - evalData.eval && - isClassificationEvaluateResponse(evalData.eval) - ) { - const confusionMatrix = - evalData.eval?.classification?.multiclass_confusion_matrix?.confusion_matrix; - setError(null); - setConfusionMatrixData(confusionMatrix || []); - setIsLoading(false); - } else { - setIsLoading(false); - setConfusionMatrixData([]); - setError(evalData.error); - } + const { + confusionMatrixData, + docsCount, + error: errorConfusionMatrix, + isLoading: isLoadingConfusionMatrix, + } = useConfusionMatrix(jobConfig, searchQuery); - if (docsCountResp.success === true) { - setDocsCount(docsCountResp.docsCount); + useEffect(() => { + if (isTraining === undefined) { + setDataSubsetTitle(SUBSET_TITLE.ENTIRE); } else { - setDocsCount(null); + setDataSubsetTitle( + isTraining && isTraining === true ? SUBSET_TITLE.TRAINING : SUBSET_TITLE.TESTING + ); } - }; + }, [isTraining]); useEffect(() => { if (confusionMatrixData.length > 0) { @@ -198,48 +155,12 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } }, [confusionMatrixData]); - useEffect(() => { - let isTraining: boolean | undefined; - const query = - isResultsSearchBoolQuery(searchQuery) && (searchQuery.bool.should || searchQuery.bool.filter); - - if (query !== undefined && query !== false) { - for (let i = 0; i < query.length; i++) { - const clause = query[i]; - - if (clause.match && clause.match[`${resultsField}.is_training`] !== undefined) { - isTraining = clause.match[`${resultsField}.is_training`]; - break; - } else if ( - clause.bool && - (clause.bool.should !== undefined || clause.bool.filter !== undefined) - ) { - const innerQuery = clause.bool.should || clause.bool.filter; - if (innerQuery !== undefined) { - for (let j = 0; j < innerQuery.length; j++) { - const innerClause = innerQuery[j]; - if ( - innerClause.match && - innerClause.match[`${resultsField}.is_training`] !== undefined - ) { - isTraining = innerClause.match[`${resultsField}.is_training`]; - break; - } - } - } - } - } - } - if (isTraining === undefined) { - setDataSubsetTitle(SUBSET_TITLE.ENTIRE); - } else { - setDataSubsetTitle( - isTraining && isTraining === true ? SUBSET_TITLE.TRAINING : SUBSET_TITLE.TESTING - ); - } - - loadData({ isTraining }); - }, [JSON.stringify(searchQuery)]); + const { + rocCurveData, + classificationClasses, + error: errorRocCurve, + isLoading: isLoadingRocCurve, + } = useRocCurve(jobConfig, searchQuery, visibleColumns); const renderCellValue = ({ rowIndex, @@ -312,7 +233,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } headerItems={ - !isLoading + !isLoadingConfusionMatrix ? [ ...(jobStatus !== undefined ? [ @@ -348,94 +269,149 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, se } contentPadding={true} content={ - !isLoading ? ( - <> - {error !== null && } - {error === null && ( - <> - - - {getHelpText(dataSubsetTitle)} - - - - - - {/* BEGIN TABLE ELEMENTS */} - -
    -
    - - - -
    -
    - {columns.length > 0 && columnsData.length > 0 && ( - <> -
    - - - -
    - - + {!isLoadingConfusionMatrix ? ( + <> + {errorConfusionMatrix !== null && } + {errorConfusionMatrix === null && ( + <> + + + {getHelpText(dataSubsetTitle)} + + + + + + {/* BEGIN TABLE ELEMENTS */} + +
    +
    + + - - )} + +
    +
    + {columns.length > 0 && columnsData.length > 0 && ( + <> +
    + + + +
    + + + + )} +
    -
    - - )} - {/* END TABLE ELEMENTS */} - - ) : null + {/* END TABLE ELEMENTS */} + + )} + + ) : null} + {/* AUC ROC Chart */} + + + + + + + + + + + + {Array.isArray(errorRocCurve) && ( + + {errorRocCurve.map((e) => ( + <> + {e} +
    + + ))} + + } + /> + )} + {!isLoadingRocCurve && errorRocCurve === null && rocCurveData.length > 0 && ( +
    + +
    + )} + {isLoadingRocCurve && } + } /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx new file mode 100644 index 00000000000000..b9e9c5720e5aa9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/get_roc_curve_chart_vega_lite_spec.tsx @@ -0,0 +1,131 @@ +/* + * 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. + */ + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import type { TopLevelSpec } from 'vega-lite/build-es5/vega-lite'; + +import { euiPaletteColorBlind, euiPaletteGray } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { LEGEND_TYPES } from '../../../../../components/vega_chart/common'; + +import { RocCurveItem } from '../../../../common/analytics'; + +const GRAY = euiPaletteGray(1)[0]; +const BASELINE = 'baseline'; +const SIZE = 300; + +// returns a custom color range that includes gray for the baseline +function getColorRangeNominal(classificationClasses: string[]) { + const legendItems = [...classificationClasses, BASELINE].sort(); + const baselineIndex = legendItems.indexOf(BASELINE); + + const colorRangeNominal = euiPaletteColorBlind({ rotations: 2 }).slice( + 0, + classificationClasses.length + ); + + colorRangeNominal.splice(baselineIndex, 0, GRAY); + + return colorRangeNominal; +} + +export interface RocCurveDataRow extends RocCurveItem { + class_name: string; +} + +export const getRocCurveChartVegaLiteSpec = ( + classificationClasses: string[], + data: RocCurveDataRow[], + legendTitle: string +): TopLevelSpec => { + // we append two rows which make up the data for the diagonal baseline + data.push({ tpr: 0, fpr: 0, threshold: 1, class_name: BASELINE }); + data.push({ tpr: 1, fpr: 1, threshold: 1, class_name: BASELINE }); + + const colorRangeNominal = getColorRangeNominal(classificationClasses); + + return { + $schema: 'https://vega.github.io/schema/vega-lite/v4.8.1.json', + // Left padding of 45px to align the left axis of the chart with the confusion matrix above. + padding: { left: 45, top: 0, right: 0, bottom: 0 }, + config: { + legend: { + orient: 'right', + }, + view: { + continuousHeight: SIZE, + continuousWidth: SIZE, + }, + }, + data: { + name: 'roc-curve-data', + }, + datasets: { + 'roc-curve-data': data, + }, + encoding: { + color: { + field: 'class_name', + type: LEGEND_TYPES.NOMINAL, + scale: { + range: colorRangeNominal, + }, + legend: { + title: legendTitle, + }, + }, + size: { + value: 2, + }, + strokeDash: { + condition: { + test: `(datum.class_name === '${BASELINE}')`, + value: [5, 5], + }, + value: [0], + }, + x: { + field: 'fpr', + sort: null, + title: i18n.translate('xpack.ml.dataframe.analytics.rocChartSpec.xAxisTitle', { + defaultMessage: 'False Positive Rate (FPR)', + }), + type: 'quantitative', + axis: { + tickColor: GRAY, + labelColor: GRAY, + domainColor: GRAY, + titleColor: GRAY, + }, + }, + y: { + field: 'tpr', + title: i18n.translate('xpack.ml.dataframe.analytics.rocChartSpec.yAxisTitle', { + defaultMessage: 'True Positive Rate (TPR) (a.k.a Recall)', + }), + type: 'quantitative', + axis: { + tickColor: GRAY, + labelColor: GRAY, + domainColor: GRAY, + titleColor: GRAY, + }, + }, + tooltip: [ + { type: LEGEND_TYPES.NOMINAL, field: 'class_name' }, + { type: LEGEND_TYPES.QUANTITATIVE, field: 'fpr' }, + { type: LEGEND_TYPES.QUANTITATIVE, field: 'tpr' }, + ], + }, + height: SIZE, + width: SIZE, + mark: 'line', + }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.ts new file mode 100644 index 00000000000000..21203f85bbe849 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/is_training_filter.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 { isResultsSearchBoolQuery, ResultsSearchQuery } from '../../../../common/analytics'; + +export type IsTraining = boolean | undefined; + +export function isTrainingFilter( + searchQuery: ResultsSearchQuery, + resultsField: string +): IsTraining { + let isTraining: IsTraining; + const query = + isResultsSearchBoolQuery(searchQuery) && (searchQuery.bool.should || searchQuery.bool.filter); + + if (query !== undefined && query !== false) { + for (let i = 0; i < query.length; i++) { + const clause = query[i]; + + if (clause.match && clause.match[`${resultsField}.is_training`] !== undefined) { + isTraining = clause.match[`${resultsField}.is_training`]; + break; + } else if ( + clause.bool && + (clause.bool.should !== undefined || clause.bool.filter !== undefined) + ) { + const innerQuery = clause.bool.should || clause.bool.filter; + if (innerQuery !== undefined) { + for (let j = 0; j < innerQuery.length; j++) { + const innerClause = innerQuery[j]; + if ( + innerClause.match && + innerClause.match[`${resultsField}.is_training`] !== undefined + ) { + isTraining = innerClause.match[`${resultsField}.is_training`]; + break; + } + } + } + } + } + } + + return isTraining; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts new file mode 100644 index 00000000000000..be44a8e36ed009 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_confusion_matrix.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; + +import { + isClassificationEvaluateResponse, + ConfusionMatrix, + ResultsSearchQuery, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common/analytics'; +import { isKeywordAndTextType } from '../../../../common/fields'; + +import { + getDependentVar, + getPredictionFieldName, + loadEvalData, + loadDocsCount, + DataFrameAnalyticsConfig, +} from '../../../../common'; + +import { isTrainingFilter } from './is_training_filter'; + +export const useConfusionMatrix = ( + jobConfig: DataFrameAnalyticsConfig, + searchQuery: ResultsSearchQuery +) => { + const [confusionMatrixData, setConfusionMatrixData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [docsCount, setDocsCount] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadConfusionMatrixData() { + setIsLoading(true); + + let requiresKeyword = false; + const dependentVariable = getDependentVar(jobConfig.analysis); + const resultsField = jobConfig.dest.results_field; + const isTraining = isTrainingFilter(searchQuery, resultsField); + + try { + requiresKeyword = isKeywordAndTextType(dependentVariable); + } catch (e) { + // Additional error handling due to missing field type is handled by loadEvalData + console.error('Unable to load new field types', e); // eslint-disable-line no-console + } + + const evalData = await loadEvalData({ + isTraining, + index: jobConfig.dest.index, + dependentVariable, + resultsField, + predictionFieldName: getPredictionFieldName(jobConfig.analysis), + searchQuery, + jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + requiresKeyword, + }); + + const docsCountResp = await loadDocsCount({ + isTraining, + searchQuery, + resultsField, + destIndex: jobConfig.dest.index, + }); + + if ( + evalData.success === true && + evalData.eval && + isClassificationEvaluateResponse(evalData.eval) + ) { + const confusionMatrix = + evalData.eval?.classification?.multiclass_confusion_matrix?.confusion_matrix; + setError(null); + setConfusionMatrixData(confusionMatrix || []); + setIsLoading(false); + } else { + setIsLoading(false); + setConfusionMatrixData([]); + setError(evalData.error); + } + + if (docsCountResp.success === true) { + setDocsCount(docsCountResp.docsCount); + } else { + setDocsCount(null); + } + } + + loadConfusionMatrixData(); + }, [JSON.stringify([jobConfig, searchQuery])]); + + return { confusionMatrixData, docsCount, error, isLoading }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts new file mode 100644 index 00000000000000..8cdb6f86ebddab --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_roc_curve.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useEffect } from 'react'; + +import { + isClassificationEvaluateResponse, + ResultsSearchQuery, + RocCurveItem, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common/analytics'; +import { isKeywordAndTextType } from '../../../../common/fields'; + +import { + getDependentVar, + getPredictionFieldName, + loadEvalData, + DataFrameAnalyticsConfig, +} from '../../../../common'; + +import { ACTUAL_CLASS_ID, OTHER_CLASS_ID } from './column_data'; + +import { isTrainingFilter } from './is_training_filter'; + +interface RocCurveDataRow extends RocCurveItem { + class_name: string; +} + +export const useRocCurve = ( + jobConfig: DataFrameAnalyticsConfig, + searchQuery: ResultsSearchQuery, + visibleColumns: string[] +) => { + const classificationClasses = visibleColumns.filter( + (d) => d !== ACTUAL_CLASS_ID && d !== OTHER_CLASS_ID + ); + + const [rocCurveData, setRocCurveData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadRocCurveData() { + setIsLoading(true); + + const dependentVariable = getDependentVar(jobConfig.analysis); + const resultsField = jobConfig.dest.results_field; + + const newRocCurveData: RocCurveDataRow[] = []; + + let requiresKeyword = false; + const errors: string[] = []; + + try { + requiresKeyword = isKeywordAndTextType(dependentVariable); + } catch (e) { + // Additional error handling due to missing field type is handled by loadEvalData + console.error('Unable to load new field types', e); // eslint-disable-line no-console + } + + for (let i = 0; i < classificationClasses.length; i++) { + const rocCurveClassName = classificationClasses[i]; + const evalData = await loadEvalData({ + isTraining: isTrainingFilter(searchQuery, resultsField), + index: jobConfig.dest.index, + dependentVariable, + resultsField, + predictionFieldName: getPredictionFieldName(jobConfig.analysis), + searchQuery, + jobType: ANALYSIS_CONFIG_TYPE.CLASSIFICATION, + requiresKeyword, + rocCurveClassName, + includeMulticlassConfusionMatrix: false, + }); + + if ( + evalData.success === true && + evalData.eval && + isClassificationEvaluateResponse(evalData.eval) + ) { + const auc = evalData.eval?.classification?.auc_roc?.value || 0; + const rocCurveDataForClass = (evalData.eval?.classification?.auc_roc?.curve || []).map( + (d) => ({ + class_name: `${rocCurveClassName} (AUC: ${Math.round(auc * 100000) / 100000})`, + ...d, + }) + ); + newRocCurveData.push(...rocCurveDataForClass); + } else if (evalData.error !== null) { + errors.push(evalData.error); + } + } + + setError(errors.length > 0 ? errors : null); + setRocCurveData(newRocCurveData); + setIsLoading(false); + } + + loadRocCurveData(); + }, [JSON.stringify([jobConfig, searchQuery, visibleColumns])]); + + return { rocCurveData, classificationClasses, error, isLoading }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx index d18e5b55794b52..81f5e535708092 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/error_callout/error_callout.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiCallOut } from '@elastic/eui'; interface Props { - error: string; + error: string | JSX.Element; } export const ErrorCallout: FC = ({ error }) => { @@ -26,7 +26,7 @@ export const ErrorCallout: FC = ({ error }) => { ); // Job was created but not started so the destination index has not been created - if (error.includes('index_not_found')) { + if (typeof error === 'string' && error.includes('index_not_found')) { errorCallout = ( = ({ error }) => {

    ); - } else if (error.includes('No documents found')) { + } else if (typeof error === 'string' && error.includes('No documents found')) { // Job was started but no results have been written yet errorCallout = ( = ({ error }) => {

    ); - } else if (error.includes('userProvidedQueryBuilder')) { + } else if (typeof error === 'string' && error.includes('userProvidedQueryBuilder')) { // query bar syntax is incorrect errorCallout = ( = ({ )} + {isLoadingJobConfig === true && jobConfig === undefined && } + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( + + )} + {isLoadingJobConfig === true && jobConfig !== undefined && totalFeatureImportance === undefined && } @@ -191,10 +196,7 @@ export const ExplorationPageWrapper: FC = ({ )} - {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( - - )} + {isLoadingJobConfig === true && jobConfig === undefined && } {isLoadingJobConfig === false && diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_chart.tsx similarity index 95% rename from x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_chart.tsx index a711d672975aae..5e508df7c6ae5e 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_chart.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_chart.tsx @@ -25,12 +25,12 @@ import { EuiIcon } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import euiVars from '@elastic/eui/dist/eui_theme_light.json'; -import { DecisionPathPlotData } from './use_classification_path_data'; -import { formatSingleValue } from '../../../formatters/format_value'; +import type { DecisionPathPlotData } from './use_classification_path_data'; +import { formatSingleValue } from '../../../../../formatters/format_value'; import { FeatureImportanceBaseline, isRegressionFeatureImportanceBaseline, -} from '../../../../../common/types/feature_importance'; +} from '../../../../../../../common/types/feature_importance'; const { euiColorFullShade, euiColorMediumShade } = euiVars; const axisColor = euiColorMediumShade; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_classification.tsx similarity index 97% rename from x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_classification.tsx index 48a0c0871f6865..d10755b32d7a75 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_classification.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_classification.tsx @@ -14,11 +14,11 @@ import { useDecisionPathData, getStringBasedClassName, } from './use_classification_path_data'; -import { +import type { FeatureImportance, FeatureImportanceBaseline, TopClasses, -} from '../../../../../common/types/feature_importance'; +} from '../../../../../../../common/types/feature_importance'; import { DecisionPathChart } from './decision_path_chart'; import { MissingDecisionPathCallout } from './missing_decision_path_callout'; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_json_viewer.tsx similarity index 86% rename from x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_json_viewer.tsx index 93b7bd6bd012fb..1110ef8171b96a 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_json_viewer.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_json_viewer.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { EuiCodeBlock } from '@elastic/eui'; -import { FeatureImportance } from '../../../../../common/types/feature_importance'; +import type { FeatureImportance } from '../../../../../../../common/types/feature_importance'; interface DecisionPathJSONViewerProps { featureImportance: FeatureImportance[]; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_popover.tsx similarity index 94% rename from x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_popover.tsx index 3aed0f56d5a76d..e1ad6a68639081 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_popover.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_popover.tsx @@ -16,11 +16,11 @@ import { isClassificationFeatureImportanceBaseline, isRegressionFeatureImportanceBaseline, TopClasses, -} from '../../../../../common/types/feature_importance'; -import { ANALYSIS_CONFIG_TYPE } from '../../../data_frame_analytics/common'; +} from '../../../../../../../common/types/feature_importance'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../common'; import { ClassificationDecisionPath } from './decision_path_classification'; -import { useMlKibana } from '../../../contexts/kibana'; -import { DataFrameAnalysisConfigType } from '../../../../../common/types/data_frame_analytics'; +import { useMlKibana } from '../../../../../contexts/kibana'; +import type { DataFrameAnalysisConfigType } from '../../../../../../../common/types/data_frame_analytics'; import { getStringBasedClassName } from './use_classification_path_data'; interface DecisionPathPopoverProps { diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_regression.tsx similarity index 97% rename from x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_regression.tsx index ccb7870fd79dc2..bb9cdd861788c3 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/decision_path_regression.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/decision_path_regression.tsx @@ -9,11 +9,11 @@ import React, { FC, useMemo } from 'react'; import { EuiCallOut } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import d3 from 'd3'; -import { +import type { FeatureImportance, FeatureImportanceBaseline, TopClasses, -} from '../../../../../common/types/feature_importance'; +} from '../../../../../../../common/types/feature_importance'; import { useDecisionPathData, isDecisionPathData } from './use_classification_path_data'; import { DecisionPathChart } from './decision_path_chart'; import { MissingDecisionPathCallout } from './missing_decision_path_callout'; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/missing_decision_path_callout.tsx similarity index 100% rename from x-pack/plugins/ml/public/application/components/data_grid/feature_importance/missing_decision_path_callout.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/missing_decision_path_callout.tsx diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/use_classification_path_data.test.tsx similarity index 98% rename from x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.test.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/use_classification_path_data.test.tsx index 18bc02ae638473..70c62294cae009 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/use_classification_path_data.test.tsx @@ -9,7 +9,7 @@ import { buildClassificationDecisionPathData, buildRegressionDecisionPathData, } from './use_classification_path_data'; -import { FeatureImportance } from '../../../../../common/types/feature_importance'; +import type { FeatureImportance } from '../../../../../../../common/types/feature_importance'; describe('buildClassificationDecisionPathData()', () => { test('should return correct prediction probability for binary classification', () => { diff --git a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/use_classification_path_data.tsx similarity index 98% rename from x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx rename to x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/use_classification_path_data.tsx index ccee43a8c971d1..5d61d8b3ef0c49 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/feature_importance/use_classification_path_data.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/feature_importance/use_classification_path_data.tsx @@ -14,8 +14,8 @@ import { isClassificationFeatureImportanceBaseline, isRegressionFeatureImportanceBaseline, TopClasses, -} from '../../../../../common/types/feature_importance'; -import { ExtendedFeatureImportance } from './decision_path_popover'; +} from '../../../../../../../common/types/feature_importance'; +import type { ExtendedFeatureImportance } from './decision_path_popover'; export type DecisionPathPlotData = Array<[string, number, number]>; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx index bc99330a444ae5..e2e1ec852d1a95 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx @@ -9,7 +9,6 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiConfirmModal, - EuiOverlayMask, EuiSwitch, EuiFlexGroup, EuiFlexItem, @@ -37,67 +36,62 @@ export const DeleteActionModal: FC = ({ const indexName = item.config.dest.index; return ( - - - - - {userCanDeleteIndex && ( - - )} - - - {userCanDeleteIndex && indexPatternExists && ( - - )} - - - - + + + + {userCanDeleteIndex && ( + + )} + + + {userCanDeleteIndex && indexPatternExists && ( + + )} + + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx index 2a19068ca6f4e1..d63e60e43e9094 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/start_action_modal.tsx @@ -7,7 +7,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiConfirmModal, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { StartAction } from './use_start_action'; @@ -15,37 +15,35 @@ export const StartActionModal: FC = ({ closeModal, item, startAndCl return ( <> {item !== undefined && ( - - +

    + {i18n.translate('xpack.ml.dataframe.analyticsList.startModalBody', { + defaultMessage: + 'A data frame analytics job increases search and indexing load in your cluster. If excessive load occurs, stop the job.', })} - onCancel={closeModal} - onConfirm={startAndCloseModal} - cancelButtonText={i18n.translate( - 'xpack.ml.dataframe.analyticsList.startModalCancelButton', - { - defaultMessage: 'Cancel', - } - )} - confirmButtonText={i18n.translate( - 'xpack.ml.dataframe.analyticsList.startModalStartButton', - { - defaultMessage: 'Start', - } - )} - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - buttonColor="primary" - > -

    - {i18n.translate('xpack.ml.dataframe.analyticsList.startModalBody', { - defaultMessage: - 'A data frame analytics job increases search and indexing load in your cluster. If excessive load occurs, stop the job.', - })} -

    -
    -
    +

    + )} ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_action_modal.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_action_modal.tsx index a10c0c59abd973..8ee7350245be43 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_action_modal.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_stop/stop_action_modal.tsx @@ -8,7 +8,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { StopAction } from './use_stop_action'; @@ -16,37 +16,35 @@ export const StopActionModal: FC = ({ closeModal, item, forceStopAnd return ( <> {item !== undefined && ( - - -

    - -

    -
    -
    + +

    + +

    +
    )} ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index dc5b494d0e1812..8423e569a99f24 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -29,6 +29,7 @@ import { import { getAnalyticsFactory } from '../../services/analytics_service'; import { getTaskStateBadge, getJobTypeBadge, useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; +import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; import { SourceSelection } from '../source_selection'; @@ -84,7 +85,7 @@ function getItemIdToExpandedRowMap( interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; - spacesEnabled?: boolean; + spacesApi?: SpacesPluginStart; blockRefresh?: boolean; pageState: ListingPageUrlState; updatePageState: (update: Partial) => void; @@ -92,7 +93,7 @@ interface Props { export const DataFrameAnalyticsList: FC = ({ isManagementTable = false, isMlEnabledInSpace = true, - spacesEnabled = false, + spacesApi, blockRefresh = false, pageState, updatePageState, @@ -178,7 +179,7 @@ export const DataFrameAnalyticsList: FC = ({ setExpandedRowItemIds, isManagementTable, isMlEnabledInSpace, - spacesEnabled, + spacesApi, refresh ); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx index 7a0f00fd377bfc..cb0e2b0092c557 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/use_columns.tsx @@ -33,6 +33,7 @@ import { import { useActions } from './use_actions'; import { useMlLink } from '../../../../../contexts/kibana'; import { ML_PAGES } from '../../../../../../../common/constants/ml_url_generator'; +import type { SpacesPluginStart } from '../../../../../../../../spaces/public'; import { JobSpacesList } from '../../../../../components/job_spaces_list'; enum TASK_STATE_COLOR { @@ -150,7 +151,7 @@ export const useColumns = ( setExpandedRowItemIds: React.Dispatch>, isManagementTable: boolean = false, isMlEnabledInSpace: boolean = true, - spacesEnabled: boolean = true, + spacesApi?: SpacesPluginStart, refresh: () => void = () => {} ) => { const { actions, modals } = useActions(isManagementTable); @@ -281,7 +282,7 @@ export const useColumns = ( ]; if (isManagementTable === true) { - if (spacesEnabled === true) { + if (spacesApi) { // insert before last column columns.splice(columns.length - 1, 0, { name: i18n.translate('xpack.ml.jobsList.analyticsSpacesLabel', { @@ -290,6 +291,7 @@ export const useColumns = ( render: (item: DataFrameAnalyticsListRow) => Array.isArray(item.spaceIds) ? ( = ({ models, onClose .map((model) => model.model_id); return ( - - - - + + + + + + + + + {modelsWithPipelines.length > 0 && ( + - - - - - {modelsWithPipelines.length > 0 && ( - - - - )} - +
    + )} + - - - - + + + + - - - - - - + + + + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx index c9f78e9b0dab1a..40f97690d7790b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx @@ -9,13 +9,7 @@ import React, { FC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiModal, - EuiModalBody, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, -} from '@elastic/eui'; +import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { SavedObjectFinderUi } from '../../../../../../../../../../src/plugins/saved_objects/public'; import { useMlKibana, useNavigateToPath } from '../../../../../contexts/kibana'; @@ -41,66 +35,62 @@ export const SourceSelection: FC = ({ onClose }) => { }; return ( - <> - - - - - {' '} - /{' '} - - - - - + + + {' '} + /{' '} + + + + + 'search', + name: i18n.translate( + 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.search', { - defaultMessage: 'No matching indices or saved searches found.', + defaultMessage: 'Saved search', } - )} - savedObjectMetaData={[ + ), + }, + { + type: 'index-pattern', + getIconForSavedObject: () => 'indexPatternApp', + name: i18n.translate( + 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.indexPattern', { - type: 'search', - getIconForSavedObject: () => 'search', - name: i18n.translate( - 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.search', - { - defaultMessage: 'Saved search', - } - ), - }, - { - type: 'index-pattern', - getIconForSavedObject: () => 'indexPatternApp', - name: i18n.translate( - 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.indexPattern', - { - defaultMessage: 'Index pattern', - } - ), - }, - ]} - fixedPageSize={fixedPageSize} - uiSettings={uiSettings} - savedObjects={savedObjects} - /> - - - - + defaultMessage: 'Index pattern', + } + ), + }, + ]} + fixedPageSize={fixedPageSize} + uiSettings={uiSettings} + savedObjects={savedObjects} + /> + + ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 131da93a2328a0..40e13ea0e6867e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -16,6 +16,7 @@ import { DataFrameAnalyticsId, DataFrameAnalysisConfigType, } from '../../../../../../../common/types/data_frame_analytics'; +import { isClassificationAnalysis } from '../../../../../../../common/util/analytics_utils'; import { ANALYSIS_CONFIG_TYPE } from '../../../../../../../common/constants/data_frame_analytics'; export enum DEFAULT_MODEL_MEMORY_LIMIT { regression = '100mb', @@ -50,6 +51,7 @@ export interface State { alpha: undefined | number; computeFeatureInfluence: string; createIndexPattern: boolean; + classAssignmentObjective: undefined | string; dependentVariable: DependentVariable; description: string; destinationIndex: EsIndexName; @@ -126,6 +128,7 @@ export const getInitialState = (): State => ({ alpha: undefined, computeFeatureInfluence: 'true', createIndexPattern: true, + classAssignmentObjective: undefined, dependentVariable: '', description: '', destinationIndex: '', @@ -278,13 +281,14 @@ export const getJobConfigFromFormState = ( }; } - if ( - formState.jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && - jobConfig?.analysis?.classification !== undefined && - formState.numTopClasses !== undefined - ) { - // @ts-ignore - jobConfig.analysis.classification.num_top_classes = formState.numTopClasses; + if (jobConfig?.analysis !== undefined && isClassificationAnalysis(jobConfig?.analysis)) { + if (formState.numTopClasses !== undefined) { + jobConfig.analysis.classification.num_top_classes = formState.numTopClasses; + } + if (formState.classAssignmentObjective !== undefined) { + jobConfig.analysis.classification.class_assignment_objective = + formState.classAssignmentObjective; + } } if (formState.jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION) { diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js index e22cca2746f99d..0aadf9e17f30db 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js @@ -582,6 +582,7 @@ export class ImportView extends Component { = ({ + fieldStats, index, indexPatternId, timeFieldName, @@ -55,7 +58,7 @@ export const ResultsLinks: FC = ({ const { services: { - application: { getUrlForApp }, + application: { getUrlForApp, capabilities }, share: { urlGenerators: { getUrlGenerator }, }, @@ -66,6 +69,11 @@ export const ResultsLinks: FC = ({ let unmounted = false; const getDiscoverUrl = async (): Promise => { + const isDiscoverAvailable = capabilities.discover?.show ?? false; + if (!isDiscoverAvailable) { + return; + } + const state: DiscoverUrlGeneratorState = { indexPatternId, }; @@ -133,7 +141,7 @@ export const ResultsLinks: FC = ({ return () => { unmounted = true; }; - }, [indexPatternId, getUrlGenerator]); + }, [indexPatternId, getUrlGenerator, JSON.stringify(globalState)]); useEffect(() => { setShowCreateJobLink(checkPermission('canCreateJob') && mlNodesAvailable()); @@ -150,6 +158,22 @@ export const ResultsLinks: FC = ({ setGlobalState(_globalState); }, [duration]); + useEffect(() => { + // Update the global time range from known timeFieldName if stats is available + if ( + fieldStats && + typeof fieldStats === 'object' && + timeFieldName !== undefined && + fieldStats.hasOwnProperty(timeFieldName) && + fieldStats[timeFieldName].earliest !== undefined && + fieldStats[timeFieldName].latest !== undefined + ) { + setGlobalState({ + time: { from: fieldStats[timeFieldName].earliest!, to: fieldStats[timeFieldName].latest! }, + }); + } + }, [timeFieldName, fieldStats]); + async function updateTimeValues(recheck = true) { if (timeFieldName !== undefined) { const { from, to } = await getFullTimeRange(index, timeFieldName); 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 850367fc1a65a0..ca393c2d8ce725 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 @@ -9,7 +9,7 @@ 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, EuiTitle, EuiFlexGroup } from '@elastic/eui'; import { LinkCard } from '../../../../components/link_card'; import { DataRecognizer } from '../../../../components/data_recognizer'; import { ML_PAGES } from '../../../../../../common/constants/ml_url_generator'; @@ -35,6 +35,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer const [discoverLink, setDiscoverLink] = useState(''); const { services: { + application: { capabilities }, share: { urlGenerators: { getUrlGenerator }, }, @@ -66,6 +67,11 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer const indexPatternId = indexPattern.id; const getDiscoverUrl = async (): Promise => { + const isDiscoverAvailable = capabilities.discover?.show ?? false; + if (!isDiscoverAvailable) { + return; + } + const state: DiscoverUrlGeneratorState = { indexPatternId, }; @@ -110,7 +116,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer

    @@ -118,14 +124,6 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer {showCreateAnomalyDetectionJob && ( <> - -

    - -

    -
    = ({ indexPattern, searchString, searchQuer )} {mlAvailable && indexPattern.id !== undefined && createDataFrameAnalyticsLink && ( <> - -

    - -

    -
    = ({ indexPattern, searchString, searchQuer description={i18n.translate( 'xpack.ml.datavisualizer.actionsPanel.dataframeTypesDescription', { - defaultMessage: 'Create outlier detection, regression, or classification analytics', + defaultMessage: + 'Create outlier detection, regression, or classification analytics.', } )} title={ } data-test-subj="mlDataVisualizerCreateDataFrameAnalyticsCard" @@ -203,7 +186,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer

    @@ -214,7 +197,7 @@ export const ActionsPanel: FC = ({ indexPattern, searchString, searchQuer description={i18n.translate( 'xpack.ml.datavisualizer.actionsPanel.viewIndexInDiscoverDescription', { - defaultMessage: 'Explore index in Discover', + defaultMessage: 'Explore the documents in your index.', } )} title={ diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx index 3401c72a3b8549..2330eafd87825e 100644 --- a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -14,7 +14,6 @@ import { EuiModal, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiButtonEmpty, EuiButton, @@ -215,103 +214,99 @@ export const AddToDashboardControl: FC = ({ const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected); return ( - - - - + + + + + + + + - - - - - } - > - { - const newSelection = { - ...selectedSwimlanes, - [optionId]: !selectedSwimlanes[optionId as SwimlaneType], - }; - setSelectedSwimlanes(newSelection); - }} - data-test-subj="mlAddToDashboardSwimlaneTypeSelector" - /> - + } + > + { + const newSelection = { + ...selectedSwimlanes, + [optionId]: !selectedSwimlanes[optionId as SwimlaneType], + }; + setSelectedSwimlanes(newSelection); + }} + data-test-subj="mlAddToDashboardSwimlaneTypeSelector" + /> + - + - - } - data-test-subj="mlDashboardSelectionContainer" - > - - - - - - - - { - onClose(async () => { - const selectedDashboardId = selectedItems[0].id; - await addSwimlaneToDashboardCallback(); - await navigateToUrl( - await dashboardService.getDashboardEditUrl(selectedDashboardId) - ); - }); - }} - data-test-subj="mlAddAndEditDashboardButton" - > - - - + - - - - + } + data-test-subj="mlDashboardSelectionContainer" + > + + + + + + + + { + onClose(async () => { + const selectedDashboardId = selectedItems[0].id; + await addSwimlaneToDashboardCallback(); + await navigateToUrl(await dashboardService.getDashboardEditUrl(selectedDashboardId)); + }); + }} + data-test-subj="mlAddAndEditDashboardButton" + > + + + + + + + ); }; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap index dc7e567380fdf9..388e2f590edf28 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/__snapshots__/explorer_no_results_found.test.js.snap @@ -14,14 +14,6 @@ exports[`ExplorerNoInfluencersFound snapshot 1`] = ` } iconType="iInCircle" - title={ -

    - -

    - } + title={

    } /> `; diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js index 6e058a8fc8c610..799437e1799f00 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js @@ -14,26 +14,48 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiEmptyPrompt } from '@elastic/eui'; -export const ExplorerNoResultsFound = () => ( - - -

    - } - body={ - -

    - -

    -
    - } - /> -); +export const ExplorerNoResultsFound = ({ hasResults, selectedJobsRunning }) => { + const resultsHaveNoAnomalies = hasResults === true; + const noResults = hasResults === false; + return ( + + {resultsHaveNoAnomalies && ( + + )} + {noResults && ( + + )} + + } + body={ + + {selectedJobsRunning && noResults && ( +

    + +

    + )} + {!selectedJobsRunning && ( +

    + +

    + )} +
    + } + /> + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx b/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx index fe77fdf235b58d..65935050ee218a 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx +++ b/x-pack/plugins/ml/public/application/explorer/components/no_overall_data.tsx @@ -12,7 +12,7 @@ export const NoOverallData: FC = () => { return ( ); }; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 9f77260ab3320f..abf8197f51634d 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -142,6 +142,7 @@ export class Explorer extends React.Component { setSelectedCells: PropTypes.func.isRequired, severity: PropTypes.number.isRequired, showCharts: PropTypes.bool.isRequired, + selectedJobsRunning: PropTypes.bool.isRequired, }; state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; @@ -223,7 +224,7 @@ export class Explorer extends React.Component { updateLanguage = (language) => this.setState({ language }); render() { - const { showCharts, severity, stoppedPartitions } = this.props; + const { showCharts, severity, stoppedPartitions, selectedJobsRunning } = this.props; const { annotations, @@ -248,6 +249,9 @@ export class Explorer extends React.Component { const noJobsFound = selectedJobs === null || selectedJobs.length === 0; const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; + const hasResultsWithAnomalies = + (hasResults && overallSwimlaneData.points.some((v) => v.value > 0)) || + tableData.anomalies?.length > 0; if (noJobsFound && !loading) { return ( @@ -257,10 +261,13 @@ export class Explorer extends React.Component { ); } - if (noJobsFound && hasResults === false && !loading) { + if (hasResultsWithAnomalies === false && !loading) { return ( - + ); } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js deleted file mode 100644 index 49dc06888161f6..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_flyout.js +++ /dev/null @@ -1,182 +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 PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import { - EuiButton, - EuiButtonEmpty, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiTitle, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; - -import { loadFullJob } from '../utils'; -import { mlCreateWatchService } from './create_watch_service'; -import { CreateWatch } from './create_watch_view'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; - -function getSuccessToast(id, url) { - return { - title: i18n.translate( - 'xpack.ml.jobsList.createWatchFlyout.watchCreatedSuccessfullyNotificationMessage', - { - defaultMessage: 'Watch {id} created successfully', - values: { id }, - } - ), - text: ( - - - - - {i18n.translate('xpack.ml.jobsList.createWatchFlyout.editWatchButtonLabel', { - defaultMessage: 'Edit watch', - })} - - - - - ), - }; -} - -export class CreateWatchFlyoutUI extends Component { - constructor(props) { - super(props); - - this.state = { - jobId: null, - bucketSpan: null, - }; - } - - componentDidMount() { - if (typeof this.props.setShowFunction === 'function') { - this.props.setShowFunction(this.showFlyout); - } - } - - componentWillUnmount() { - if (typeof this.props.unsetShowFunction === 'function') { - this.props.unsetShowFunction(); - } - } - - closeFlyout = (watchCreated = false) => { - this.setState({ isFlyoutVisible: false }, () => { - if (typeof this.props.flyoutHidden === 'function') { - this.props.flyoutHidden(watchCreated); - } - }); - }; - - showFlyout = (jobId) => { - loadFullJob(jobId) - .then((job) => { - const bucketSpan = job.analysis_config.bucket_span; - mlCreateWatchService.config.includeInfluencers = job.analysis_config.influencers.length > 0; - - this.setState({ - job, - jobId, - bucketSpan, - isFlyoutVisible: true, - }); - }) - .catch((error) => { - console.error(error); - }); - }; - - save = () => { - const { toasts } = this.props.kibana.services.notifications; - mlCreateWatchService - .createNewWatch(this.state.jobId) - .then((resp) => { - toasts.addSuccess(getSuccessToast(resp.id, resp.url)); - this.closeFlyout(true); - }) - .catch((error) => { - toasts.addDanger( - i18n.translate( - 'xpack.ml.jobsList.createWatchFlyout.watchNotSavedErrorNotificationMessage', - { - defaultMessage: 'Could not save watch', - } - ) - ); - console.error(error); - }); - }; - - render() { - const { jobId, bucketSpan } = this.state; - - let flyout; - - if (this.state.isFlyoutVisible) { - flyout = ( - - - -

    - -

    -
    -
    - - - - - - - - - - - - - - - - - -
    - ); - } - return
    {flyout}
    ; - } -} -CreateWatchFlyoutUI.propTypes = { - setShowFunction: PropTypes.func.isRequired, - unsetShowFunction: PropTypes.func.isRequired, - flyoutHidden: PropTypes.func, -}; - -export const CreateWatchFlyout = withKibana(CreateWatchFlyoutUI); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js deleted file mode 100644 index cd81355b3f97e9..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_service.js +++ /dev/null @@ -1,199 +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 { template } from 'lodash'; -import { http } from '../../../../services/http_service'; - -import emailBody from './email.html'; -import emailInfluencersBody from './email_influencers.html'; -import { DEFAULT_WATCH_SEVERITY } from './select_severity'; -import { watch } from './watch.js'; -import { i18n } from '@kbn/i18n'; -import { getBasePath, getApplication } from '../../../../util/dependency_cache'; - -const compiledEmailBody = template(emailBody); -const compiledEmailInfluencersBody = template(emailInfluencersBody); - -const emailSection = { - send_email: { - throttle_period_in_millis: 900000, // 15m - email: { - profile: 'standard', - to: [], - subject: i18n.translate('xpack.ml.newJob.simple.watcher.email.mlWatcherAlertSubjectTitle', { - defaultMessage: 'ML Watcher Alert', - }), - body: { - html: '', - }, - }, - }, -}; - -// generate a random number between min and max -function randomNumber(min, max) { - return Math.floor(Math.random() * (max - min + 1) + min); -} - -function saveWatch(watchModel) { - const path = `/api/watcher/watch/${watchModel.id}`; - - return http({ - path, - method: 'PUT', - body: JSON.stringify(watchModel.upstreamJSON), - }); -} - -class CreateWatchService { - constructor() { - this.config = {}; - - this.STATUS = { - SAVE_FAILED: -1, - SAVING: 0, - SAVED: 1, - }; - - this.status = { - realtimeJob: null, - watch: null, - }; - } - - reset() { - this.status.realtimeJob = null; - this.status.watch = null; - - this.config.id = ''; - this.config.includeEmail = false; - this.config.email = ''; - this.config.interval = '20m'; - this.config.watcherEditURL = ''; - this.config.includeInfluencers = false; - - // Current implementation means that default needs to match that of the select severity control. - const { display, val } = DEFAULT_WATCH_SEVERITY; - this.config.threshold = { display, val }; - } - - createNewWatch = function (jobId) { - return new Promise((resolve, reject) => { - this.status.watch = this.STATUS.SAVING; - if (jobId !== undefined) { - const id = `ml-${jobId}`; - this.config.id = id; - - // set specific properties of the the watch - watch.input.search.request.body.query.bool.filter[0].term.job_id = jobId; - watch.input.search.request.body.query.bool.filter[1].range.timestamp.gte = `now-${this.config.interval}`; - watch.input.search.request.body.aggs.bucket_results.filter.range.anomaly_score.gte = this.config.threshold.val; - - if (this.config.includeEmail && this.config.email !== '') { - const { getUrlForApp } = getApplication(); - const emails = this.config.email.split(','); - emailSection.send_email.email.to = emails; - - // create the html by adding the variables to the compiled email body. - emailSection.send_email.email.body.html = compiledEmailBody({ - serverAddress: getUrlForApp('ml', { absolute: true }), - influencersSection: - this.config.includeInfluencers === true - ? compiledEmailInfluencersBody({ - topInfluencersLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.topInfluencersLabel', - { - defaultMessage: 'Top influencers:', - } - ), - }) - : '', - elasticStackMachineLearningAlertLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.elasticStackMachineLearningAlertLabel', - { - defaultMessage: 'Elastic Stack Machine Learning Alert', - } - ), - jobLabel: i18n.translate('xpack.ml.newJob.simple.watcher.email.jobLabel', { - defaultMessage: 'Job', - }), - timeLabel: i18n.translate('xpack.ml.newJob.simple.watcher.email.timeLabel', { - defaultMessage: 'Time', - }), - anomalyScoreLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.anomalyScoreLabel', - { - defaultMessage: 'Anomaly score', - } - ), - openInAnomalyExplorerLinkText: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.openInAnomalyExplorerLinkText', - { - defaultMessage: 'Click here to open in Anomaly Explorer.', - } - ), - topRecordsLabel: i18n.translate( - 'xpack.ml.newJob.simple.watcher.email.topRecordsLabel', - { defaultMessage: 'Top records:' } - ), - }); - - // add email section to watch - watch.actions.send_email = emailSection.send_email; - } - - // set the trigger interval to be a random number between 60 and 120 seconds - // this is to avoid all watches firing at once if the server restarts - // and the watches synchronize - const triggerInterval = randomNumber(60, 120); - watch.trigger.schedule.interval = `${triggerInterval}s`; - - const watchModel = { - id, - upstreamJSON: { - id, - type: 'json', - isNew: false, // Set to false, as we want to allow watches to be overwritten. - isActive: true, - watch, - }, - }; - - const basePath = getBasePath(); - if (id !== '') { - saveWatch(watchModel) - .then(() => { - this.status.watch = this.STATUS.SAVED; - this.config.watcherEditURL = `${basePath.get()}/app/management/insightsAndAlerting/watcher/watches/watch/${id}/edit?_g=()`; - resolve({ - id, - url: this.config.watcherEditURL, - }); - }) - .catch((resp) => { - this.status.watch = this.STATUS.SAVE_FAILED; - reject(resp); - }); - } - } else { - this.status.watch = this.STATUS.SAVE_FAILED; - reject(); - } - }); - }; - - loadWatch(jobId) { - const id = `ml-${jobId}`; - const path = `/api/watcher/watch/${id}`; - return http({ - path, - method: 'GET', - }); - } -} - -export const mlCreateWatchService = new CreateWatchService(); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js deleted file mode 100644 index 2997d56b68f06f..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/create_watch_view.js +++ /dev/null @@ -1,215 +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 PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import { EuiCheckbox, EuiFieldText, EuiCallOut } from '@elastic/eui'; - -import { has } from 'lodash'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { parseInterval } from '../../../../../../common/util/parse_interval'; -import { ml } from '../../../../services/ml_api_service'; -import { SelectSeverity } from './select_severity'; -import { mlCreateWatchService } from './create_watch_service'; -const STATUS = mlCreateWatchService.STATUS; - -export class CreateWatch extends Component { - static propTypes = { - jobId: PropTypes.string.isRequired, - bucketSpan: PropTypes.string.isRequired, - }; - - constructor(props) { - super(props); - mlCreateWatchService.reset(); - this.config = mlCreateWatchService.config; - - this.state = { - jobId: this.props.jobId, - bucketSpan: this.props.bucketSpan, - interval: this.config.interval, - threshold: this.config.threshold, - includeEmail: this.config.emailIncluded, - email: this.config.email, - emailEnabled: false, - status: null, - watchAlreadyExists: false, - }; - } - - componentDidMount() { - // make the interval 2 times the bucket span - if (this.state.bucketSpan) { - const intervalObject = parseInterval(this.state.bucketSpan); - let bs = intervalObject.asMinutes() * 2; - if (bs < 1) { - bs = 1; - } - - const interval = `${bs}m`; - this.setState({ interval }, () => { - this.config.interval = interval; - }); - } - - // load elasticsearch settings to see if email has been configured - ml.getNotificationSettings().then((resp) => { - if (has(resp, 'defaults.xpack.notification.email')) { - this.setState({ emailEnabled: true }); - } - }); - - mlCreateWatchService - .loadWatch(this.state.jobId) - .then(() => { - this.setState({ watchAlreadyExists: true }); - }) - .catch(() => { - this.setState({ watchAlreadyExists: false }); - }); - } - - onThresholdChange = (threshold) => { - this.setState({ threshold }, () => { - this.config.threshold = threshold; - }); - }; - - onIntervalChange = (e) => { - const interval = e.target.value; - this.setState({ interval }, () => { - this.config.interval = interval; - }); - }; - - onIncludeEmailChanged = (e) => { - const includeEmail = e.target.checked; - this.setState({ includeEmail }, () => { - this.config.includeEmail = includeEmail; - }); - }; - - onEmailChange = (e) => { - const email = e.target.value; - this.setState({ email }, () => { - this.config.email = email; - }); - }; - - render() { - const { status } = this.state; - - if (status === null || status === STATUS.SAVING || status === STATUS.SAVE_FAILED) { - return ( -
    -
    -
    -
    - -
    - - ), - }} - /> -
    - -
    -
    - -
    -
    - -
    -
    -
    - {this.state.emailEnabled && ( -
    - - } - checked={this.state.includeEmail} - onChange={this.onIncludeEmailChanged} - /> - {this.state.includeEmail && ( -
    - -
    - )} -
    - )} - {this.state.watchAlreadyExists && ( - - } - /> - )} -
    - ); - } else if (status === STATUS.SAVED) { - return ( -
    - -
    - ); - } else { - return
    ; - } - } -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html deleted file mode 100644 index 713a68ba0c0365..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - <%= elasticStackMachineLearningAlertLabel %> - -
    -
    - - - <%= jobLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0._source.job_id}} -
    - - - <%= timeLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.timestamp_iso8601.0}} -
    - - - <%= anomalyScoreLabel %> - : {{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.score.0}} -
    -
    - - - <%= openInAnomalyExplorerLinkText %> - -
    -
    - - <%= influencersSection %> - - - <%= topRecordsLabel %> - -
    - {{#ctx.payload.aggregations.record_results.top_record_hits.hits.hits}} - {{_source.function}}({{_source.field_name}}) {{_source.by_field_value}} {{_source.over_field_value}} {{_source.partition_field_value}} [{{fields.score.0}}] -
    - {{/ctx.payload.aggregations.record_results.top_record_hits.hits.hits}} - - - diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html deleted file mode 100644 index ab22ef672e97bf..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/email_influencers.html +++ /dev/null @@ -1,9 +0,0 @@ - - <%= topInfluencersLabel %> - -
    - {{#ctx.payload.aggregations.influencer_results.top_influencer_hits.hits.hits}} - {{_source.influencer_field_name}} = {{_source.influencer_field_value}} [{{fields.score.0}}] -
    - {{/ctx.payload.aggregations.influencer_results.top_influencer_hits.hits.hits}} -
    diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx deleted file mode 100644 index 347e25816672b4..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/select_severity.tsx +++ /dev/null @@ -1,134 +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. - */ - -/* - * React component for rendering a select element with threshold levels. - * This is basically a copy of SelectSeverity in public/application/components/controls/select_severity - * but which stores its state internally rather than in the appState - */ -import React, { Fragment, FC, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; - -import { getSeverityColor } from '../../../../../../common/util/anomaly_utils'; - -const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { - defaultMessage: 'warning', -}); -const minorLabel = i18n.translate('xpack.ml.controls.selectSeverity.minorLabel', { - defaultMessage: 'minor', -}); -const majorLabel = i18n.translate('xpack.ml.controls.selectSeverity.majorLabel', { - defaultMessage: 'major', -}); -const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalLabel', { - defaultMessage: 'critical', -}); - -const optionsMap = { - [warningLabel]: 0, - [minorLabel]: 25, - [majorLabel]: 50, - [criticalLabel]: 75, -}; - -interface TableSeverity { - val: number; - display: string; - color: string; -} - -export const SEVERITY_OPTIONS: TableSeverity[] = [ - { - val: 0, - display: warningLabel, - color: getSeverityColor(0), - }, - { - val: 25, - display: minorLabel, - color: getSeverityColor(25), - }, - { - val: 50, - display: majorLabel, - color: getSeverityColor(50), - }, - { - val: 75, - display: criticalLabel, - color: getSeverityColor(75), - }, -]; - -function optionValueToThreshold(value: number) { - // Get corresponding threshold object with required display and val properties from the specified value. - let threshold = SEVERITY_OPTIONS.find((opt) => opt.val === value); - - // Default to warning if supplied value doesn't map to one of the options. - if (threshold === undefined) { - threshold = SEVERITY_OPTIONS[0]; - } - - return threshold; -} - -export const DEFAULT_WATCH_SEVERITY = SEVERITY_OPTIONS[3]; - -const getSeverityOptions = () => - SEVERITY_OPTIONS.map(({ color, display, val }) => ({ - value: display, - inputDisplay: ( - - - {display} - - - ), - dropdownDisplay: ( - - - {display} - - - -

    - -

    -
    -
    - ), - })); - -interface Props { - onChange: (sev: TableSeverity) => void; -} - -export const SelectSeverity: FC = ({ onChange }) => { - const [severity, setSeverity] = useState(DEFAULT_WATCH_SEVERITY); - - const onSeverityChange = (valueDisplay: string) => { - const option = optionValueToThreshold(optionsMap[valueDisplay]); - setSeverity(option); - onChange(option); - }; - - return ( - - ); -}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js deleted file mode 100644 index 2fcde2184bf062..00000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/create_watch_flyout/watch.js +++ /dev/null @@ -1,232 +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 { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; - -export const watch = { - trigger: { - schedule: { - interval: '60s', - }, - }, - input: { - search: { - request: { - search_type: 'query_then_fetch', - indices: [ML_RESULTS_INDEX_PATTERN], - body: { - size: 0, - query: { - bool: { - filter: [ - { - term: { - job_id: null, - }, - }, - { - range: { - timestamp: { - gte: null, - }, - }, - }, - { - terms: { - result_type: ['bucket', 'record', 'influencer'], - }, - }, - ], - }, - }, - aggs: { - bucket_results: { - filter: { - range: { - anomaly_score: { - gte: null, - }, - }, - }, - aggs: { - top_bucket_hits: { - top_hits: { - sort: [ - { - anomaly_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'job_id', - 'result_type', - 'timestamp', - 'anomaly_score', - 'is_interim', - ], - }, - size: 1, - script_fields: { - start: { - script: { - lang: 'painless', - source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()-((doc["bucket_span"].value * 1000) - * params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`, - params: { - padding: 10, - }, - }, - }, - end: { - script: { - lang: 'painless', - source: `LocalDateTime.ofEpochSecond((doc["timestamp"].value.getMillis()+((doc["bucket_span"].value * 1000) - * params.padding)) / 1000, 0, ZoneOffset.UTC).toString()+\":00.000Z\"`, - params: { - padding: 10, - }, - }, - }, - timestamp_epoch: { - script: { - lang: 'painless', - source: 'doc["timestamp"].value.getMillis()/1000', - }, - }, - timestamp_iso8601: { - script: { - lang: 'painless', - source: 'doc["timestamp"].value', - }, - }, - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["anomaly_score"].value)', - }, - }, - }, - }, - }, - }, - }, - influencer_results: { - filter: { - range: { - influencer_score: { - gte: 3, - }, - }, - }, - aggs: { - top_influencer_hits: { - top_hits: { - sort: [ - { - influencer_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'result_type', - 'timestamp', - 'influencer_field_name', - 'influencer_field_value', - 'influencer_score', - 'isInterim', - ], - }, - size: 3, - script_fields: { - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["influencer_score"].value)', - }, - }, - }, - }, - }, - }, - }, - record_results: { - filter: { - range: { - record_score: { - gte: 3, - }, - }, - }, - aggs: { - top_record_hits: { - top_hits: { - sort: [ - { - record_score: { - order: 'desc', - }, - }, - ], - _source: { - includes: [ - 'result_type', - 'timestamp', - 'record_score', - 'is_interim', - 'function', - 'field_name', - 'by_field_value', - 'over_field_value', - 'partition_field_value', - ], - }, - size: 3, - script_fields: { - score: { - script: { - lang: 'painless', - source: 'Math.round(doc["record_score"].value)', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - condition: { - compare: { - 'ctx.payload.aggregations.bucket_results.doc_count': { - gt: 0, - }, - }, - }, - actions: { - log: { - logging: { - level: 'info', - text: '', // this gets populated below. - }, - }, - }, -}; - -// Add logging text. Broken over a few lines due to its length. -let txt = - 'Alert for job [{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0._source.job_id}}] at '; -txt += - '[{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.timestamp_iso8601.0}}] score '; -txt += '[{{ctx.payload.aggregations.bucket_results.top_bucket_hits.hits.hits.0.fields.score.0}}]'; -watch.actions.log.logging.text = txt; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx index 3bec404276ca26..a67863ea5f803b 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/delete_job_modal/delete_job_modal.tsx @@ -10,7 +10,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiSpacer, EuiModal, - EuiOverlayMask, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, @@ -77,74 +76,72 @@ export const DeleteJobModal: FC = ({ setShowFunction, unsetShowFunction, if (canDelete) { return ( - - - - - - - - -

    - {deleting === true ? ( -

    - - -
    - -
    + + + + + + + +

    + {deleting === true ? ( +

    + + +
    +
    - ) : ( - - - - )} -

    - - <> - - - - - + + )} +

    + + <> + + + + + - - - - - - - + + + +
    + + ); } else { return ( diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js index 8769c2c3cca20e..b23bbedb7413a2 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js @@ -21,7 +21,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiTabbedContent, - EuiOverlayMask, EuiConfirmModal, } from '@elastic/eui'; @@ -443,38 +442,36 @@ export class EditJobFlyoutUI extends Component { if (this.state.isConfirmationModalVisible) { confirmationModal = ( - - - } - onCancel={() => this.closeFlyout(true)} - onConfirm={() => this.save()} - cancelButtonText={ - - } - confirmButtonText={ - - } - defaultFocusedButton="confirm" - > -

    - -

    -
    -
    + + } + onCancel={() => this.closeFlyout(true)} + onConfirm={() => this.save()} + cancelButtonText={ + + } + confirmButtonText={ + + } + defaultFocusedButton="confirm" + > +

    + +

    +
    ); } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/custom_urls.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/custom_urls.tsx index a1bac4b6a35979..da4c9b0b0cc004 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/custom_urls.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/tabs/custom_urls.tsx @@ -14,7 +14,6 @@ import { EuiFlexItem, EuiPanel, EuiSpacer, - EuiOverlayMask, EuiModal, EuiModalBody, EuiModalHeader, @@ -282,30 +281,28 @@ class CustomUrlsUI extends Component { ) : ( - - - - - - - + + + + + + - {editor} + {editor} - - {testButton} - {addButton} - - - + + {testButton} + {addButton} + + ); } diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js index 8f955e771327e6..471295938acde3 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/management.js @@ -17,7 +17,8 @@ export function actionsMenuContent( showEditJobFlyout, showDeleteJobModal, showStartDatafeedModal, - refreshJobs + refreshJobs, + showCreateAlertFlyout ) { const canCreateJob = checkPermission('canCreateJob') && mlNodesAvailable(); const canUpdateJob = checkPermission('canUpdateJob'); @@ -25,6 +26,7 @@ export function actionsMenuContent( const canUpdateDatafeed = checkPermission('canUpdateDatafeed'); const canStartStopDatafeed = checkPermission('canStartStopDatafeed') && mlNodesAvailable(); const canCloseJob = checkPermission('canCloseJob') && mlNodesAvailable(); + const canCreateMlAlerts = checkPermission('canCreateMlAlerts'); return [ { @@ -59,6 +61,22 @@ export function actionsMenuContent( }, 'data-test-subj': 'mlActionButtonStopDatafeed', }, + { + name: i18n.translate('xpack.ml.jobsList.managementActions.createAlertLabel', { + defaultMessage: 'Create alert', + }), + description: i18n.translate('xpack.ml.jobsList.managementActions.createAlertLabel', { + defaultMessage: 'Create alert', + }), + icon: 'bell', + enabled: (item) => item.deleting !== true, + available: () => canCreateMlAlerts, + onClick: (item) => { + showCreateAlertFlyout([item.id]); + closeMenu(true); + }, + 'data-test-subj': 'mlActionButtonCreateAlert', + }, { name: i18n.translate('xpack.ml.jobsList.managementActions.closeJobLabel', { defaultMessage: 'Close job', diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index 59908293d89294..4674342990df4a 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -96,7 +96,7 @@ export class JobsList extends Component { } render() { - const { loading, isManagementTable, spacesEnabled } = this.props; + const { loading, isManagementTable, spacesApi } = this.props; const selectionControls = { selectable: (job) => job.deleting !== true, selectableMessage: (selectable, rowItem) => @@ -116,8 +116,8 @@ export class JobsList extends Component { onSelectionChange: this.props.selectJobChange, }; // Adding 'width' props to columns for use in the Kibana management jobs list table - // The version of the table used in ML > Job Managment depends on many EUI class overrides that set the width explicitly. - // The ML > Job Managment table won't change as the overwritten class styles take precedence, though these values may need to + // The version of the table used in ML > Job Management depends on many EUI class overrides that set the width explicitly. + // The ML > Job Management table won't change as the overwritten class styles take precedence, though these values may need to // be updated if we move to always using props for width. const columns = [ { @@ -243,7 +243,7 @@ export class JobsList extends Component { ]; if (isManagementTable === true) { - if (spacesEnabled === true) { + if (spacesApi) { // insert before last column columns.splice(columns.length - 1, 0, { name: i18n.translate('xpack.ml.jobsList.spacesLabel', { @@ -251,6 +251,7 @@ export class JobsList extends Component { }), render: (item) => ( {}; this.showDeleteJobModal = () => {}; this.showStartDatafeedModal = () => {}; - this.showCreateWatchFlyout = () => {}; + this.showCreateAlertFlyout = () => {}; // work around to keep track of whether the component is mounted // used to block timeouts for results polling // which can run after unmounting @@ -206,14 +205,14 @@ export class JobsListView extends Component { this.showStartDatafeedModal = () => {}; }; - setShowCreateWatchFlyoutFunction = (func) => { - this.showCreateWatchFlyout = func; + setShowCreateAlertFlyoutFunction = (func) => { + this.showCreateAlertFlyout = func; }; - unsetShowCreateWatchFlyoutFunction = () => { - this.showCreateWatchFlyout = () => {}; + unsetShowCreateAlertFlyoutFunction = () => { + this.showCreateAlertFlyout = () => {}; }; - getShowCreateWatchFlyoutFunction = () => { - return this.showCreateWatchFlyout; + getShowCreateAlertFlyoutFunction = () => { + return this.showCreateAlertFlyout; }; selectJobChange = (selectedJobs) => { @@ -269,10 +268,10 @@ export class JobsListView extends Component { const expandedJobsIds = Object.keys(this.state.itemIdToExpandedRowMap); try { - let spaces = {}; - if (this.props.spacesEnabled && this.props.isManagementTable) { + let jobsSpaces = {}; + if (this.props.spacesApi && this.props.isManagementTable) { const allSpaces = await ml.savedObjects.jobsSpaces(); - spaces = allSpaces['anomaly-detector']; + jobsSpaces = allSpaces['anomaly-detector']; } let jobsAwaitingNodeCount = 0; @@ -285,11 +284,11 @@ export class JobsListView extends Component { } job.latestTimestampSortValue = job.latestTimestampMs || 0; job.spaceIds = - this.props.spacesEnabled && + this.props.spacesApi && this.props.isManagementTable && - spaces && - spaces[job.id] !== undefined - ? spaces[job.id] + jobsSpaces && + jobsSpaces[job.id] !== undefined + ? jobsSpaces[job.id] : []; if (job.awaitingNodeAssignment === true) { @@ -410,7 +409,7 @@ export class JobsListView extends Component { loading={loading} isManagementTable={true} isMlEnabledInSpace={this.props.isMlEnabledInSpace} - spacesEnabled={this.props.spacesEnabled} + spacesApi={this.props.spacesApi} jobsViewState={this.props.jobsViewState} onJobsViewStateUpdate={this.props.onJobsViewStateUpdate} refreshJobs={() => this.refreshJobSummaryList(true)} @@ -478,6 +477,7 @@ export class JobsListView extends Component { allJobIds={jobIds} showStartDatafeedModal={this.showStartDatafeedModal} showDeleteJobModal={this.showDeleteJobModal} + showCreateAlertFlyout={this.showCreateAlertFlyout} refreshJobs={() => this.refreshJobSummaryList(true)} /> this.refreshJobSummaryList(true)} /> -
    diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js index 5760fbeb38642d..e1314eb7188362 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/multi_job_actions/actions_menu.js @@ -27,6 +27,7 @@ class MultiJobActionsMenuUI extends Component { this.canDeleteJob = checkPermission('canDeleteJob'); this.canStartStopDatafeed = checkPermission('canStartStopDatafeed') && mlNodesAvailable(); this.canCloseJob = checkPermission('canCloseJob') && mlNodesAvailable(); + this.canCreateMlAlerts = checkPermission('canCreateMlAlerts'); } onButtonClick = () => { @@ -144,6 +145,26 @@ class MultiJobActionsMenuUI extends Component { ); } + if (this.canCreateMlAlerts) { + items.push( + { + this.props.showCreateAlertFlyout(this.props.jobs.map(({ id }) => id)); + this.closePopover(); + }} + data-test-subj="mlADJobListMultiSelectCreateAlertActionButton" + > + + + ); + } + return ( )} @@ -67,4 +68,5 @@ MultiJobActions.propTypes = { showStartDatafeedModal: PropTypes.func.isRequired, showDeleteJobModal: PropTypes.func.isRequired, refreshJobs: PropTypes.func.isRequired, + showCreateAlertFlyout: PropTypes.func.isRequired, }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js index 3ac6455bd745fc..361e8956c714e3 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/start_datafeed_modal/start_datafeed_modal.js @@ -16,7 +16,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiHorizontalRule, EuiCheckbox, } from '@elastic/eui'; @@ -39,8 +38,8 @@ export class StartDatafeedModal extends Component { isModalVisible: false, startTime: now, endTime: now, - createWatch: false, - allowCreateWatch: false, + createAlert: false, + allowCreateAlert: false, initialSpecifiedStartTime: now, now, timeRangeValid: true, @@ -48,7 +47,7 @@ export class StartDatafeedModal extends Component { this.initialSpecifiedStartTime = now; this.refreshJobs = this.props.refreshJobs; - this.getShowCreateWatchFlyoutFunction = this.props.getShowCreateWatchFlyoutFunction; + this.getShowCreateAlertFlyoutFunction = this.props.getShowCreateAlertFlyoutFunction; } componentDidMount() { @@ -71,8 +70,8 @@ export class StartDatafeedModal extends Component { this.setState({ endTime: time }); }; - setCreateWatch = (e) => { - this.setState({ createWatch: e.target.checked }); + setCreateAlert = (e) => { + this.setState({ createAlert: e.target.checked }); }; closeModal = () => { @@ -83,21 +82,21 @@ export class StartDatafeedModal extends Component { this.setState({ timeRangeValid }); }; - showModal = (jobs, showCreateWatchFlyout) => { + showModal = (jobs, showCreateAlertFlyout) => { const startTime = undefined; const now = moment(); const endTime = now; const initialSpecifiedStartTime = getLowestLatestTime(jobs); - const allowCreateWatch = jobs.length === 1; + const allowCreateAlert = jobs.length > 0; this.setState({ jobs, isModalVisible: true, startTime, endTime, initialSpecifiedStartTime, - showCreateWatchFlyout, - allowCreateWatch, - createWatch: false, + showCreateAlertFlyout, + allowCreateAlert, + createAlert: false, now, }); }; @@ -112,9 +111,8 @@ export class StartDatafeedModal extends Component { : this.state.endTime; forceStartDatafeeds(jobs, start, end, () => { - if (this.state.createWatch && jobs.length === 1) { - const jobId = jobs[0].id; - this.getShowCreateWatchFlyoutFunction()(jobId); + if (this.state.createAlert && jobs.length > 0) { + this.getShowCreateAlertFlyoutFunction()(jobs.map((job) => job.id)); } this.refreshJobs(); }); @@ -127,7 +125,7 @@ export class StartDatafeedModal extends Component { initialSpecifiedStartTime, startTime, endTime, - createWatch, + createAlert, now, timeRangeValid, } = this.state; @@ -139,78 +137,76 @@ export class StartDatafeedModal extends Component { if (this.state.isModalVisible) { modal = ( - - - - - - - - - - + + + - {this.state.endTime === undefined && ( -
    - - - } - checked={createWatch} - onChange={this.setCreateWatch} - /> -
    - )} -
    - - - - - - - - + + + + + {this.state.endTime === undefined && ( +
    + + + } + checked={createAlert} + onChange={this.setCreateAlert} /> - - - - +
    + )} +
    + + + + + + + + + + +
    ); } return
    {modal}
    ; 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 98d8b5eaf912a7..5b8fa5c672c6e0 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 @@ -19,6 +19,7 @@ import { stringMatch } from '../../../util/string_utils'; import { JOB_STATE, DATAFEED_STATE } from '../../../../../common/constants/states'; import { parseInterval } from '../../../../../common/util/parse_interval'; import { mlCalendarService } from '../../../services/calendar_service'; +import { isPopulatedObject } from '../../../../../common/util/object_utils'; export function loadFullJob(jobId) { return new Promise((resolve, reject) => { @@ -379,7 +380,7 @@ export function checkForAutoStartDatafeed() { mlJobService.tempJobCloningObjects.datafeed = undefined; mlJobService.tempJobCloningObjects.createdBy = undefined; - const hasDatafeed = typeof datafeed === 'object' && Object.keys(datafeed).length > 0; + const hasDatafeed = isPopulatedObject(datafeed); const datafeedId = hasDatafeed ? datafeed.datafeed_id : ''; return { id: job.job_id, diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts index 1c56be94d5891e..a36e52f4e863b3 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/chart_loader.ts @@ -8,12 +8,17 @@ import memoizeOne from 'memoize-one'; import { isEqual } from 'lodash'; import { IndexPatternTitle } from '../../../../../../common/types/kibana'; -import { Field, SplitField, AggFieldPair } from '../../../../../../common/types/fields'; +import { + Field, + SplitField, + AggFieldPair, + RuntimeMappings, +} from '../../../../../../common/types/fields'; import { ml } from '../../../../services/ml_api_service'; import { mlResultsService } from '../../../../services/results_service'; import { getCategoryFields as getCategoryFieldsOrig } from './searches'; import { aggFieldPairsCanBeCharted } from '../job_creator/util/general'; -import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; +import { IndexPattern } from '../../../../../../../../../src/plugins/data/common'; type DetectorIndex = number; export interface LineChartPoint { @@ -50,7 +55,8 @@ export class ChartLoader { aggFieldPairs: AggFieldPair[], splitField: SplitField, splitFieldValue: SplitFieldValue, - intervalMs: number + intervalMs: number, + runtimeMappings: RuntimeMappings | null ): Promise { if (this._timeFieldName !== '') { if (aggFieldPairsCanBeCharted(aggFieldPairs) === false) { @@ -70,7 +76,8 @@ export class ChartLoader { this._query, aggFieldPairNames, splitFieldName, - splitFieldValue + splitFieldValue, + runtimeMappings ?? undefined ); return resp.results; @@ -83,7 +90,8 @@ export class ChartLoader { end: number, aggFieldPairs: AggFieldPair[], splitField: SplitField, - intervalMs: number + intervalMs: number, + runtimeMappings: RuntimeMappings | null ): Promise { if (this._timeFieldName !== '') { if (aggFieldPairsCanBeCharted(aggFieldPairs) === false) { @@ -102,7 +110,8 @@ export class ChartLoader { intervalMs, this._query, aggFieldPairNames, - splitFieldName + splitFieldName, + runtimeMappings ?? undefined ); return resp.results; @@ -136,12 +145,16 @@ export class ChartLoader { return []; } - async loadFieldExampleValues(field: Field): Promise { + async loadFieldExampleValues( + field: Field, + runtimeMappings: RuntimeMappings | null + ): Promise { const { results } = await getCategoryFields( this._indexPatternTitle, field.name, 10, - this._query + this._query, + runtimeMappings ?? undefined ); return results; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts index 1e7ee9ca45bf15..54917c4884f22a 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/chart_loader/searches.ts @@ -8,6 +8,7 @@ import { get } from 'lodash'; import { ml } from '../../../../services/ml_api_service'; +import { RuntimeMappings } from '../../../../../../common/types/fields'; interface CategoryResults { success: boolean; @@ -18,7 +19,8 @@ export function getCategoryFields( indexPatternName: string, fieldName: string, size: number, - query: any + query: any, + runtimeMappings?: RuntimeMappings ): Promise { return new Promise((resolve, reject) => { ml.esSearch({ @@ -34,6 +36,7 @@ export function getCategoryFields( }, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }) .then((resp: any) => { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index 913832e2fb8a39..ec5cb59964ffd5 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -6,10 +6,15 @@ */ import { BehaviorSubject } from 'rxjs'; +import { cloneDeep } from 'lodash'; import { SavedSearchSavedObject } from '../../../../../../common/types/kibana'; import { UrlConfig } from '../../../../../../common/types/custom_urls'; import { IndexPatternTitle } from '../../../../../../common/types/kibana'; -import { ML_JOB_AGGREGATION } from '../../../../../../common/constants/aggregation_types'; +import { + ML_JOB_AGGREGATION, + aggregations, + mlOnlyAggregations, +} from '../../../../../../common/constants/aggregation_types'; import { ES_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/public'; import { Job, @@ -20,7 +25,8 @@ import { BucketSpan, CustomSettings, } from '../../../../../../common/types/anomaly_detection_jobs'; -import { Aggregation, Field } from '../../../../../../common/types/fields'; +import { Aggregation, Field, RuntimeMappings } from '../../../../../../common/types/fields'; +import { combineFieldsAndAggs } from '../../../../../../common/util/fields_utils'; import { createEmptyJob, createEmptyDatafeed } from './util/default_configs'; import { mlJobService } from '../../../../services/job_service'; import { JobRunner, ProgressSubscriber } from '../job_runner'; @@ -30,6 +36,7 @@ import { SHARED_RESULTS_INDEX_NAME, } from '../../../../../../common/constants/new_job'; import { collectAggs } from './util/general'; +import { filterRuntimeMappings } from './util/filter_runtime_mappings'; import { parseInterval } from '../../../../../../common/util/parse_interval'; import { Calendar } from '../../../../../../common/types/calendars'; import { mlCalendarService } from '../../../../services/calendar_service'; @@ -57,7 +64,9 @@ export class JobCreator { protected _aggs: Aggregation[] = []; protected _fields: Field[] = []; protected _scriptFields: Field[] = []; - protected _runtimeMappings: Field[] = []; + protected _runtimeFields: Field[] = []; + protected _runtimeMappings: RuntimeMappings | null = null; + protected _filterRuntimeMappingsOnSave: boolean = true; protected _aggregationFields: Field[] = []; protected _sparseData: boolean = false; private _stopAllRefreshPolls: { @@ -86,6 +95,8 @@ export class JobCreator { this._job_config.data_description.time_field = indexPattern.timeFieldName; } + this._extractRuntimeMappings(); + this._datafeed_config.query = query; } @@ -489,16 +500,20 @@ export class JobCreator { return this._scriptFields; } - public get runtimeMappings(): Field[] { + public get runtimeMappings(): RuntimeMappings | null { return this._runtimeMappings; } + public get runtimeFields(): Field[] { + return this._runtimeFields; + } + public get aggregationFields(): Field[] { return this._aggregationFields; } public get additionalFields(): Field[] { - return [...this._scriptFields, ...this._runtimeMappings, ...this._aggregationFields]; + return [...this._scriptFields, ...this._runtimeFields, ...this._aggregationFields]; } public get subscribers(): ProgressSubscriber[] { @@ -533,7 +548,8 @@ export class JobCreator { public async createDatafeed(): Promise { try { - return await mlJobService.saveNewDatafeed(this._datafeed_config, this._job_config.job_id); + const tempDatafeed = this._getDatafeedWithFilteredRuntimeMappings(); + return await mlJobService.saveNewDatafeed(tempDatafeed, this._job_config.job_id); } catch (error) { throw error; } @@ -546,6 +562,23 @@ export class JobCreator { return jobRunner; } + private _getDatafeedWithFilteredRuntimeMappings(): Datafeed { + if (this._filterRuntimeMappingsOnSave === false) { + return this._datafeed_config; + } + + const { runtime_mappings: filteredRuntimeMappings } = filterRuntimeMappings( + this._job_config, + this._datafeed_config + ); + + return { + ...this._datafeed_config, + runtime_mappings: + Object.keys(filteredRuntimeMappings).length > 0 ? filteredRuntimeMappings : undefined, + }; + } + public subscribeToProgress(func: ProgressSubscriber) { this._subscribers.push(func); } @@ -632,6 +665,14 @@ export class JobCreator { return JSON.stringify(this._datafeed_config, null, 2); } + public set filterRuntimeMappingsOnSave(filter: boolean) { + this._filterRuntimeMappingsOnSave = filter; + } + + public get filterRuntimeMappingsOnSave(): boolean { + return this._filterRuntimeMappingsOnSave; + } + protected _initPerPartitionCategorization() { if (this._job_config.analysis_config.per_partition_categorization === undefined) { this._job_config.analysis_config.per_partition_categorization = {}; @@ -662,6 +703,52 @@ export class JobCreator { this._job_config.analysis_config.per_partition_categorization!.stop_on_warn = enabled; } + private _extractRuntimeMappings() { + const runtimeFieldMap = this._indexPattern.toSpec().runtimeFieldMap; + if (runtimeFieldMap !== undefined) { + if (this._datafeed_config.runtime_mappings === undefined) { + this._datafeed_config.runtime_mappings = {}; + } + Object.entries(runtimeFieldMap).forEach(([key, val]) => { + this._datafeed_config.runtime_mappings![key] = val; + }); + } + this._populateRuntimeFields(); + } + + private _populateRuntimeFields() { + this._runtimeFields = []; + this._runtimeMappings = this._datafeed_config.runtime_mappings ?? null; + if (this._runtimeMappings !== null) { + const tempRuntimeFields = Object.entries(this._runtimeMappings).map( + ([id, runtimeField]) => + ({ + id, + name: id, + type: runtimeField.type, + aggregatable: true, + aggs: [], + runtimeField, + } as Field) + ); + + const aggs = cloneDeep([...aggregations, ...mlOnlyAggregations]); + this._runtimeFields = combineFieldsAndAggs(tempRuntimeFields, aggs, {}).fields; + } + } + + private _populateScriptFields() { + this._scriptFields = []; + if (this._datafeed_config.script_fields !== undefined) { + this._scriptFields = Object.keys(this._datafeed_config.script_fields).map((f) => ({ + id: f, + name: f, + type: ES_FIELD_TYPES.KEYWORD, + aggregatable: true, + })); + } + } + protected _overrideConfigs(job: Job, datafeed: Datafeed) { this._job_config = job; this._datafeed_config = datafeed; @@ -683,25 +770,8 @@ export class JobCreator { this.useDedicatedIndex = true; } - this._scriptFields = []; - if (this._datafeed_config.script_fields !== undefined) { - this._scriptFields = Object.keys(this._datafeed_config.script_fields).map((f) => ({ - id: f, - name: f, - type: ES_FIELD_TYPES.KEYWORD, - aggregatable: true, - })); - } - - this._runtimeMappings = []; - if (this._datafeed_config.runtime_mappings !== undefined) { - this._runtimeMappings = Object.keys(this._datafeed_config.runtime_mappings).map((f) => ({ - id: f, - name: f, - type: ES_FIELD_TYPES.KEYWORD, - aggregatable: true, - })); - } + this._populateScriptFields(); + this._populateRuntimeFields(); this._aggregationFields = []; const aggs = getDatafeedAggregations(this._datafeed_config); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.test.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.test.ts new file mode 100644 index 00000000000000..43e7d4e45b6e0c --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.test.ts @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Job, Datafeed } from '../../../../../../../common/types/anomaly_detection_jobs'; +import { filterRuntimeMappings } from './filter_runtime_mappings'; + +function getJob(): Job { + return { + job_id: 'test', + description: '', + groups: [], + analysis_config: { + bucket_span: '15m', + detectors: [ + { + function: 'mean', + field_name: 'responsetime', + }, + ], + influencers: [], + }, + data_description: { + time_field: '@timestamp', + }, + analysis_limits: { + model_memory_limit: '11MB', + }, + model_plot_config: { + enabled: false, + annotations_enabled: false, + }, + }; +} + +function getDatafeed(): Datafeed { + return { + datafeed_id: 'datafeed-test', + job_id: 'dds', + indices: ['farequote-*'], + query: { + bool: { + must: [ + { + match_all: {}, + }, + ], + }, + }, + runtime_mappings: { + responsetime_big: { + type: 'double', + script: { + source: "emit(doc['responsetime'].value * 100.0)", + }, + }, + airline_lower: { + type: 'keyword', + script: { + source: "emit(doc['airline'].value.toLowerCase())", + }, + }, + }, + }; +} + +function getAggs() { + return { + buckets: { + date_histogram: { + field: '@timestamp', + fixed_interval: '90000ms', + }, + aggregations: { + responsetime: { + avg: { + field: 'responsetime_big', + }, + }, + '@timestamp': { + max: { + field: '@timestamp', + }, + }, + }, + }, + }; +} + +describe('filter_runtime_mappings', () => { + describe('filterRuntimeMappings()', () => { + let job: Job; + let datafeed: Datafeed; + beforeEach(() => { + job = getJob(); + datafeed = getDatafeed(); + }); + + test('returns no runtime mappings, no mappings in aggs', () => { + const resp = filterRuntimeMappings(job, datafeed); + expect(Object.keys(resp.runtime_mappings).length).toEqual(0); + + expect(Object.keys(resp.discarded_mappings).length).toEqual(2); + expect(resp.discarded_mappings.responsetime_big).not.toEqual(undefined); + expect(resp.discarded_mappings.airline_lower).not.toEqual(undefined); + }); + + test('returns no runtime mappings, no runtime mappings in datafeed', () => { + datafeed.runtime_mappings = undefined; + const resp = filterRuntimeMappings(job, datafeed); + expect(Object.keys(resp.runtime_mappings).length).toEqual(0); + expect(resp.runtime_mappings.responsetime_big).toEqual(undefined); + + expect(Object.keys(resp.discarded_mappings).length).toEqual(0); + expect(resp.discarded_mappings.airline_lower).toEqual(undefined); + }); + + test('return one runtime mapping and one unused mapping, mappings in aggs', () => { + datafeed.aggregations = getAggs(); + const resp = filterRuntimeMappings(job, datafeed); + expect(Object.keys(resp.runtime_mappings).length).toEqual(1); + expect(resp.runtime_mappings.responsetime_big).not.toEqual(undefined); + + expect(Object.keys(resp.discarded_mappings).length).toEqual(1); + expect(resp.discarded_mappings.airline_lower).not.toEqual(undefined); + }); + + test('return no runtime mappings, no mappings in aggs', () => { + datafeed.aggregations = getAggs(); + datafeed.aggregations!.buckets!.aggregations!.responsetime!.avg!.field! = 'responsetime'; + + const resp = filterRuntimeMappings(job, datafeed); + expect(Object.keys(resp.runtime_mappings).length).toEqual(0); + + expect(Object.keys(resp.discarded_mappings).length).toEqual(2); + expect(resp.discarded_mappings.responsetime_big).not.toEqual(undefined); + expect(resp.discarded_mappings.airline_lower).not.toEqual(undefined); + }); + + test('return one runtime mapping and one unused mapping, no mappings in aggs', () => { + // set the detector field to be a runtime mapping + job.analysis_config.detectors[0].field_name = 'responsetime_big'; + const resp = filterRuntimeMappings(job, datafeed); + expect(Object.keys(resp.runtime_mappings).length).toEqual(1); + expect(resp.runtime_mappings.responsetime_big).not.toEqual(undefined); + + expect(Object.keys(resp.discarded_mappings).length).toEqual(1); + expect(resp.discarded_mappings.airline_lower).not.toEqual(undefined); + }); + + test('return two runtime mappings, no mappings in aggs', () => { + // set the detector field to be a runtime mapping + job.analysis_config.detectors[0].field_name = 'responsetime_big'; + // set the detector by field to be a runtime mapping + job.analysis_config.detectors[0].by_field_name = 'airline_lower'; + const resp = filterRuntimeMappings(job, datafeed); + expect(Object.keys(resp.runtime_mappings).length).toEqual(2); + expect(resp.runtime_mappings.responsetime_big).not.toEqual(undefined); + expect(resp.runtime_mappings.airline_lower).not.toEqual(undefined); + + expect(Object.keys(resp.discarded_mappings).length).toEqual(0); + }); + + test('return two runtime mappings, no mappings in aggs, categorization job', () => { + job.analysis_config.detectors[0].function = 'count'; + // set the detector field to be a runtime mapping + job.analysis_config.detectors[0].field_name = undefined; + // set the detector by field to be a runtime mapping + job.analysis_config.detectors[0].by_field_name = 'mlcategory'; + job.analysis_config.categorization_field_name = 'airline_lower'; + + const resp = filterRuntimeMappings(job, datafeed); + expect(Object.keys(resp.runtime_mappings).length).toEqual(1); + expect(resp.runtime_mappings.airline_lower).not.toEqual(undefined); + + expect(Object.keys(resp.discarded_mappings).length).toEqual(1); + expect(resp.discarded_mappings.responsetime_big).not.toEqual(undefined); + }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts new file mode 100644 index 00000000000000..5319cd3c3aabc9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/filter_runtime_mappings.ts @@ -0,0 +1,104 @@ +/* + * 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. + */ +/* + * 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 type { RuntimeMappings } from '../../../../../../../common/types/fields'; +import type { Datafeed, Job } from '../../../../../../../common/types/anomaly_detection_jobs'; + +interface Response { + runtime_mappings: RuntimeMappings; + discarded_mappings: RuntimeMappings; +} + +export function filterRuntimeMappings(job: Job, datafeed: Datafeed): Response { + if (datafeed.runtime_mappings === undefined) { + return { + runtime_mappings: {}, + discarded_mappings: {}, + }; + } + + const usedFields = findFieldsInJob(job, datafeed); + + const { runtimeMappings, discardedMappings } = createMappings( + datafeed.runtime_mappings, + usedFields + ); + + return { runtime_mappings: runtimeMappings, discarded_mappings: discardedMappings }; +} + +function findFieldsInJob(job: Job, datafeed: Datafeed) { + const usedFields = new Set(); + job.analysis_config.detectors.forEach((d) => { + if (d.field_name !== undefined) { + usedFields.add(d.field_name); + } + if (d.by_field_name !== undefined) { + usedFields.add(d.by_field_name); + } + if (d.over_field_name !== undefined) { + usedFields.add(d.over_field_name); + } + if (d.partition_field_name !== undefined) { + usedFields.add(d.partition_field_name); + } + }); + + if (job.analysis_config.categorization_field_name !== undefined) { + usedFields.add(job.analysis_config.categorization_field_name); + } + + if (job.analysis_config.summary_count_field_name !== undefined) { + usedFields.add(job.analysis_config.summary_count_field_name); + } + + if (job.analysis_config.influencers !== undefined) { + job.analysis_config.influencers.forEach((i) => usedFields.add(i)); + } + + const aggs = datafeed.aggregations ?? datafeed.aggs; + if (aggs !== undefined) { + findFieldsInAgg(aggs).forEach((f) => usedFields.add(f)); + } + + return [...usedFields]; +} + +function findFieldsInAgg(obj: Record) { + const fields: string[] = []; + Object.entries(obj).forEach(([key, val]) => { + if (typeof val === 'object' && val !== null) { + fields.push(...findFieldsInAgg(val)); + } else if (typeof val === 'string' && key === 'field') { + fields.push(val); + } + }); + return fields; +} + +function createMappings(rm: RuntimeMappings, usedFieldNames: string[]) { + return { + runtimeMappings: usedFieldNames.reduce((acc, cur) => { + if (rm[cur] !== undefined) { + acc[cur] = rm[cur]; + } + return acc; + }, {} as RuntimeMappings), + discardedMappings: Object.keys(rm).reduce((acc, cur) => { + if (usedFieldNames.includes(cur) === false && rm[cur] !== undefined) { + acc[cur] = rm[cur]; + } + return acc; + }, {} as RuntimeMappings), + }; +} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/model_memory_estimator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/model_memory_estimator.ts index 289bfb54b28551..1f0acfcbec5c86 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/model_memory_estimator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/model_memory_estimator.ts @@ -24,7 +24,11 @@ import { useEffect, useMemo } from 'react'; import { DEFAULT_MODEL_MEMORY_LIMIT } from '../../../../../../../common/constants/new_job'; import { ml } from '../../../../../services/ml_api_service'; import { JobValidator, VALIDATION_DELAY_MS } from '../../job_validator/job_validator'; -import { MLHttpFetchError, MLResponseError } from '../../../../../../../common/util/errors'; +import { + MLHttpFetchError, + MLResponseError, + extractErrorMessage, +} from '../../../../../../../common/util/errors'; import { useMlKibana } from '../../../../../contexts/kibana'; import { JobCreator } from '../job_creator'; @@ -121,8 +125,7 @@ export const useModelMemoryEstimator = ( title: i18n.translate('xpack.ml.newJob.wizard.estimateModelMemoryError', { defaultMessage: 'Model memory limit could not be calculated', }), - text: - error.body.attributes?.body.error.caused_by?.reason || error.body.message || undefined, + text: extractErrorMessage(error), }); }) ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts index b240d6f230b897..06d489ee5a4378 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/categorization_examples_loader.ts @@ -50,7 +50,8 @@ export class CategorizationExamplesLoader { this._timeFieldName, this._jobCreator.start, this._jobCreator.end, - analyzer + analyzer, + this._jobCreator.runtimeMappings ?? undefined ); return resp; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts index 86f7e494870bb3..c4365bd656f9ef 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/results_loader.ts @@ -255,7 +255,8 @@ export class ResultsLoader { if (isMultiMetricJobCreator(this._jobCreator)) { if (this._jobCreator.splitField !== null) { const fieldValues = await this._chartLoader.loadFieldExampleValues( - this._jobCreator.splitField + this._jobCreator.splitField, + this._jobCreator.runtimeMappings ); if (fieldValues.length > 0) { this._detectorSplitFieldFilters = { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/datafeed_preview_flyout/datafeed_preview.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/datafeed_preview_flyout/datafeed_preview.tsx index 6afc1122fcdab1..916a25271c63b8 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/datafeed_preview_flyout/datafeed_preview.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/common/datafeed_preview_flyout/datafeed_preview.tsx @@ -21,6 +21,7 @@ import { CombinedJob } from '../../../../../../../../common/types/anomaly_detect import { MLJobEditor } from '../../../../../jobs_list/components/ml_job_editor'; import { mlJobService } from '../../../../../../services/job_service'; import { ML_DATA_PREVIEW_COUNT } from '../../../../../../../../common/util/job_utils'; +import { isPopulatedObject } from '../../../../../../../../common/util/object_utils'; export const DatafeedPreview: FC<{ combinedJob: CombinedJob | null; @@ -64,7 +65,7 @@ export const DatafeedPreview: FC<{ const resp = await mlJobService.searchPreview(combinedJob); let data = resp.hits.hits; // the first item under aggregations can be any name - if (typeof resp.aggregations === 'object' && Object.keys(resp.aggregations).length > 0) { + if (isPopulatedObject(resp.aggregations)) { const accessor = Object.keys(resp.aggregations)[0]; data = resp.aggregations[accessor].buckets.slice(0, ML_DATA_PREVIEW_COUNT); } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/reset_query/reset_query.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/reset_query/reset_query.tsx index 02f53c77c088c0..e42ec414e9641f 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/reset_query/reset_query.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/datafeed_step/components/reset_query/reset_query.tsx @@ -8,13 +8,7 @@ import React, { FC, useContext, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiButtonEmpty, - EuiConfirmModal, - EuiOverlayMask, - EuiCodeBlock, - EuiSpacer, -} from '@elastic/eui'; +import { EuiButtonEmpty, EuiConfirmModal, EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; import { getDefaultDatafeedQuery } from '../../../../../utils/new_job_utils'; @@ -34,35 +28,33 @@ export const ResetQueryButton: FC = () => { return ( <> {confirmModalVisible && ( - - - + + - + - - {defaultQueryString} - - - + + {defaultQueryString} + + )} diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/modal_wrapper.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/modal_wrapper.tsx index 3f4a0f6ea6b3d2..aaed47cc7a02bb 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/modal_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/modal_wrapper.tsx @@ -9,7 +9,6 @@ import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, - EuiOverlayMask, EuiModal, EuiModalHeader, EuiModalHeaderTitle, @@ -28,44 +27,42 @@ interface Props { export const ModalWrapper: FC = ({ onCreateClick, closeModal, saveEnabled, children }) => { return ( - - - - - - - + + + + + + - {children} + {children} - - - - + + + + - - - - - - + + + + + ); }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts index 1e5487057bfb57..f0932b09af46b7 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/bucket_span_estimator/estimate_bucket_span.ts @@ -40,6 +40,7 @@ export function useEstimateBucketSpan() { query: mlContext.combinedQuery, splitField: undefined, timeField: mlContext.currentIndexPattern.timeFieldName, + runtimeMappings: jobCreator.runtimeMappings ?? undefined, }; if ( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx index 9497114d056bad..5bf4beacc1593c 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC, useContext, useEffect, useState, useMemo } from 'react'; import { JobCreatorContext } from '../../../job_creator_context'; import { MultiMetricJobCreator } from '../../../../../common/job_creator'; @@ -13,6 +13,7 @@ import { LineChartData } from '../../../../../common/chart_loader'; import { DropDownLabel, DropDownProps } from '../agg_select'; import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; import { AggFieldPair } from '../../../../../../../../../common/types/fields'; +import { sortFields } from '../../../../../../../../../common/util/fields_utils'; import { getChartSettings, defaultChartSettings } from '../../../charts/common/settings'; import { MetricSelector } from './metric_selector'; import { ChartGrid } from './chart_grid'; @@ -33,7 +34,10 @@ export const MultiMetricDetectors: FC = ({ setIsValid }) => { const jobCreator = jc as MultiMetricJobCreator; - const { fields } = newJobCapsService; + const fields = useMemo( + () => sortFields([...newJobCapsService.fields, ...jobCreator.runtimeFields]), + [] + ); const [selectedOptions, setSelectedOptions] = useState([]); const [aggFieldPairList, setAggFieldPairList] = useState( jobCreator.aggFieldPairs @@ -107,7 +111,7 @@ export const MultiMetricDetectors: FC = ({ setIsValid }) => { useEffect(() => { if (splitField !== null) { chartLoader - .loadFieldExampleValues(splitField) + .loadFieldExampleValues(splitField, jobCreator.runtimeMappings) .then(setFieldValues) .catch((error) => { getToastNotificationService().displayErrorToast(error); @@ -135,7 +139,8 @@ export const MultiMetricDetectors: FC = ({ setIsValid }) => { aggFieldPairList, jobCreator.splitField, fieldValues.length > 0 ? fieldValues[0] : null, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); setLineChartsData(resp); } catch (error) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx index fc94d2b0960124..11f2f60e17d3d4 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/multi_metric_view/metric_selection_summary.tsx @@ -41,7 +41,10 @@ export const MultiMetricDetectorsSummary: FC = () => { (async () => { if (jobCreator.splitField !== null) { try { - const tempFieldValues = await chartLoader.loadFieldExampleValues(jobCreator.splitField); + const tempFieldValues = await chartLoader.loadFieldExampleValues( + jobCreator.splitField, + jobCreator.runtimeMappings + ); setFieldValues(tempFieldValues); } catch (error) { getToastNotificationService().displayErrorToast(error); @@ -72,7 +75,8 @@ export const MultiMetricDetectorsSummary: FC = () => { jobCreator.aggFieldPairs, jobCreator.splitField, fieldValues.length > 0 ? fieldValues[0] : null, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); setLineChartsData(resp); } catch (error) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx index c1e291567ddc7a..aba2acfa41a859 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { Fragment, FC, useContext, useEffect, useState, useReducer } from 'react'; +import React, { Fragment, FC, useContext, useEffect, useState, useReducer, useMemo } from 'react'; import { EuiHorizontalRule } from '@elastic/eui'; import { JobCreatorContext } from '../../../job_creator_context'; @@ -14,6 +14,7 @@ import { LineChartData } from '../../../../../common/chart_loader'; import { DropDownLabel, DropDownProps } from '../agg_select'; import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; import { Field, AggFieldPair } from '../../../../../../../../../common/types/fields'; +import { sortFields } from '../../../../../../../../../common/util/fields_utils'; import { getChartSettings, defaultChartSettings } from '../../../charts/common/settings'; import { MetricSelector } from './metric_selector'; import { SplitFieldSelector } from '../split_field'; @@ -36,7 +37,10 @@ export const PopulationDetectors: FC = ({ setIsValid }) => { } = useContext(JobCreatorContext); const jobCreator = jc as PopulationJobCreator; - const { fields } = newJobCapsService; + const fields = useMemo( + () => sortFields([...newJobCapsService.fields, ...jobCreator.runtimeFields]), + [] + ); const [selectedOptions, setSelectedOptions] = useState([]); const [aggFieldPairList, setAggFieldPairList] = useState( jobCreator.aggFieldPairs @@ -155,7 +159,8 @@ export const PopulationDetectors: FC = ({ setIsValid }) => { jobCreator.end, aggFieldPairList, jobCreator.splitField, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); setLineChartsData(resp); @@ -175,7 +180,7 @@ export const PopulationDetectors: FC = ({ setIsValid }) => { (async (index: number, field: Field) => { return { index, - fields: await chartLoader.loadFieldExampleValues(field), + fields: await chartLoader.loadFieldExampleValues(field, jobCreator.runtimeMappings), }; })(i, af.by.field) ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx index 0057b50e6de57c..c6150108911017 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/population_view/metric_selection_summary.tsx @@ -77,7 +77,8 @@ export const PopulationDetectorsSummary: FC = () => { jobCreator.end, aggFieldPairList, jobCreator.splitField, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); setLineChartsData(resp); @@ -97,7 +98,7 @@ export const PopulationDetectorsSummary: FC = () => { (async (index: number, field: Field) => { return { index, - fields: await chartLoader.loadFieldExampleValues(field), + fields: await chartLoader.loadFieldExampleValues(field, jobCreator.runtimeMappings), }; })(i, af.by.field) ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx index 56a81d7351ec5a..f4a907dcc6a493 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import React, { Fragment, FC, useContext, useEffect, useState } from 'react'; +import React, { Fragment, FC, useContext, useEffect, useState, useMemo } from 'react'; import { JobCreatorContext } from '../../../job_creator_context'; import { SingleMetricJobCreator } from '../../../../../common/job_creator'; import { LineChartData } from '../../../../../common/chart_loader'; import { AggSelect, DropDownLabel, DropDownProps, createLabel } from '../agg_select'; import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; import { AggFieldPair } from '../../../../../../../../../common/types/fields'; +import { sortFields } from '../../../../../../../../../common/util/fields_utils'; import { AnomalyChart, CHART_TYPE } from '../../../charts/anomaly_chart'; import { getChartSettings } from '../../../charts/common/settings'; import { getToastNotificationService } from '../../../../../../../services/toast_notification_service'; @@ -32,7 +33,10 @@ export const SingleMetricDetectors: FC = ({ setIsValid }) => { } = useContext(JobCreatorContext); const jobCreator = jc as SingleMetricJobCreator; - const { fields } = newJobCapsService; + const fields = useMemo( + () => sortFields([...newJobCapsService.fields, ...jobCreator.runtimeFields]), + [] + ); const [selectedOptions, setSelectedOptions] = useState( jobCreator.aggFieldPair !== null ? [{ label: createLabel(jobCreator.aggFieldPair) }] : [] ); @@ -88,7 +92,8 @@ export const SingleMetricDetectors: FC = ({ setIsValid }) => { [aggFieldPair], null, null, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); if (resp[DTR_IDX] !== undefined) { setLineChartData(resp); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx index 66209c31427e74..4d8fc5ef760848 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/single_metric_view/metric_selection_summary.tsx @@ -58,7 +58,8 @@ export const SingleMetricDetectorsSummary: FC = () => { [jobCreator.aggFieldPair], null, null, - cs.intervalMs + cs.intervalMs, + jobCreator.runtimeMappings ); if (resp[DTR_IDX] !== undefined) { setLineChartData(resp); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/by_field.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/by_field.tsx index f34695c8c49986..01c538f7ceb011 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/by_field.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/by_field.tsx @@ -5,13 +5,16 @@ * 2.0. */ -import React, { FC, useContext, useEffect, useState } from 'react'; +import React, { FC, useContext, useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { SplitFieldSelect } from './split_field_select'; import { JobCreatorContext } from '../../../job_creator_context'; import { Field } from '../../../../../../../../../common/types/fields'; -import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + newJobCapsService, + filterCategoryFields, +} from '../../../../../../../services/new_job_capabilities_service'; import { MultiMetricJobCreator, PopulationJobCreator } from '../../../../../common/job_creator'; interface Props { @@ -22,7 +25,11 @@ export const ByFieldSelector: FC = ({ detectorIndex }) => { const { jobCreator: jc, jobCreatorUpdate, jobCreatorUpdated } = useContext(JobCreatorContext); const jobCreator = jc as PopulationJobCreator; - const { categoryFields: allCategoryFields } = newJobCapsService; + const runtimeCategoryFields = useMemo(() => filterCategoryFields(jobCreator.runtimeFields), []); + const allCategoryFields = useMemo( + () => [...newJobCapsService.categoryFields, ...runtimeCategoryFields], + [] + ); const [byField, setByField] = useState(jobCreator.getByField(detectorIndex)); const categoryFields = useFilteredCategoryFields( diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field.tsx index d20f186b1d88c6..7a99d4da131854 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/split_field/split_field.tsx @@ -5,11 +5,14 @@ * 2.0. */ -import React, { FC, useContext, useEffect, useState } from 'react'; +import React, { FC, useContext, useEffect, useState, useMemo } from 'react'; import { SplitFieldSelect } from './split_field_select'; import { JobCreatorContext } from '../../../job_creator_context'; -import { newJobCapsService } from '../../../../../../../services/new_job_capabilities_service'; +import { + newJobCapsService, + filterCategoryFields, +} from '../../../../../../../services/new_job_capabilities_service'; import { Description } from './description'; import { MultiMetricJobCreator, @@ -23,7 +26,11 @@ export const SplitFieldSelector: FC = () => { const jobCreator = jc as MultiMetricJobCreator | PopulationJobCreator; const canClearSelection = isMultiMetricJobCreator(jc); - const { categoryFields } = newJobCapsService; + const runtimeCategoryFields = useMemo(() => filterCategoryFields(jobCreator.runtimeFields), []); + const categoryFields = useMemo( + () => [...newJobCapsService.categoryFields, ...runtimeCategoryFields], + [] + ); const [splitField, setSplitField] = useState(jobCreator.splitField); useEffect(() => { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx index a39ffd171d1ca0..6cefc239905c78 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/summary_step/components/post_save_options/post_save_options.tsx @@ -13,38 +13,21 @@ import { JobRunner } from '../../../../../common/job_runner'; import { useMlKibana } from '../../../../../../../contexts/kibana'; import { extractErrorMessage } from '../../../../../../../../../common/util/errors'; -// @ts-ignore -import { CreateWatchFlyout } from '../../../../../../jobs_list/components/create_watch_flyout/index'; import { JobCreatorContext } from '../../../job_creator_context'; import { DATAFEED_STATE } from '../../../../../../../../../common/constants/states'; +import { MlAnomalyAlertFlyout } from '../../../../../../../../alerting/ml_alerting_flyout'; interface Props { jobRunner: JobRunner | null; } -type ShowFlyout = (jobId: string) => void; - export const PostSaveOptions: FC = ({ jobRunner }) => { const { services: { notifications }, } = useMlKibana(); const { jobCreator } = useContext(JobCreatorContext); const [datafeedState, setDatafeedState] = useState(DATAFEED_STATE.STOPPED); - const [watchFlyoutVisible, setWatchFlyoutVisible] = useState(false); - const [watchCreated, setWatchCreated] = useState(false); - - function setShowCreateWatchFlyoutFunction(showFlyout: ShowFlyout) { - showFlyout(jobCreator.jobId); - } - - function flyoutHidden(jobCreated: boolean) { - setWatchFlyoutVisible(false); - setWatchCreated(jobCreated); - } - - function unsetShowCreateWatchFlyoutFunction() { - setWatchFlyoutVisible(false); - } + const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); async function startJobInRealTime() { const { toasts } = notifications; @@ -93,28 +76,26 @@ export const PostSaveOptions: FC = ({ jobRunner }) => { /> + setWatchFlyoutVisible(true)} - data-test-subj="mlJobWizardButtonCreateWatch" + onClick={setAlertFlyoutVisible.bind(null, true)} + data-test-subj="mlJobWizardButtonCreateAlert" > - {datafeedState === DATAFEED_STATE.STARTED && watchFlyoutVisible && ( - )} diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index a322174e8a8c44..b61a28aff732a6 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -24,7 +24,6 @@ import { } from '@elastic/eui'; import { PLUGIN_ID } from '../../../../../../common/constants/app'; -import { createSpacesContext, SpacesContext } from '../../../../contexts/spaces'; import { ManagementAppMountParams } from '../../../../../../../../../src/plugins/management/public/'; import { checkGetManagementMlJobsResolver } from '../../../../capabilities/check_capabilities'; @@ -39,7 +38,7 @@ import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_vi import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; -import { SpacesPluginStart } from '../../../../../../../spaces/public'; +import type { SpacesPluginStart } from '../../../../../../../spaces/public'; import { JobSpacesSyncFlyout } from '../../../../components/job_spaces_sync'; import { getDefaultAnomalyDetectionJobsListState } from '../../../../jobs/jobs_list/jobs'; import { getMlGlobalServices } from '../../../../app'; @@ -68,7 +67,9 @@ function usePageState( return [pageState, updateState]; } -function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { +const EmptyFunctionComponent: React.FC = ({ children }) => <>{children}; + +function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | undefined): Tab[] { const [adPageState, updateAdPageState] = usePageState(getDefaultAnomalyDetectionJobsListState()); const [dfaPageState, updateDfaPageState] = usePageState(getDefaultDFAListState()); @@ -88,7 +89,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { onJobsViewStateUpdate={updateAdPageState} isManagementTable={true} isMlEnabledInSpace={isMlEnabledInSpace} - spacesEnabled={spacesEnabled} + spacesApi={spacesApi} /> ), @@ -105,7 +106,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesEnabled: boolean): Tab[] { @@ -121,28 +122,21 @@ export const JobsListPage: FC<{ coreStart: CoreStart; share: SharePluginStart; history: ManagementAppMountParams['history']; - spaces?: SpacesPluginStart; -}> = ({ coreStart, share, history, spaces }) => { - const spacesEnabled = spaces !== undefined; + spacesApi?: SpacesPluginStart; +}> = ({ coreStart, share, history, spacesApi }) => { + const spacesEnabled = spacesApi !== undefined; const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); - const tabs = useTabs(isMlEnabledInSpace, spacesEnabled); + const tabs = useTabs(isMlEnabledInSpace, spacesApi); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); const I18nContext = coreStart.i18n.Context; - const spacesContext = useMemo(() => createSpacesContext(coreStart.http, spacesEnabled), []); const check = async () => { try { const { mlFeatureEnabledInSpace } = await checkGetManagementMlJobsResolver(); setIsMlEnabledInSpace(mlFeatureEnabledInSpace); - spacesContext.spacesEnabled = spacesEnabled; - if (spacesEnabled && spacesContext.spacesManager !== null) { - spacesContext.allSpaces = (await spacesContext.spacesManager.getSpaces()).filter( - (space) => space.disabledFeatures.includes(PLUGIN_ID) === false - ); - } } catch (e) { setAccessDenied(true); } @@ -191,13 +185,15 @@ export const JobsListPage: FC<{ return ; } + const ContextWrapper = spacesApi?.ui.components.SpacesContext || EmptyFunctionComponent; + return ( - + - + diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index 4059207aafcc36..dde543ac6ac9cb 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -22,10 +22,10 @@ const renderApp = ( history: ManagementAppMountParams['history'], coreStart: CoreStart, share: SharePluginStart, - spaces?: SpacesPluginStart + spacesApi?: SpacesPluginStart ) => { ReactDOM.render( - React.createElement(JobsListPage, { coreStart, history, share, spaces }), + React.createElement(JobsListPage, { coreStart, history, share, spacesApi }), element ); return () => { diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 052be41ca1eb70..e65ca22effd768 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -87,6 +87,9 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange); + const selectedJobsRunning = jobsWithTimeRange.some( + (job) => jobIds.includes(job.id) && job.isRunning === true + ); const explorerAppState = useObservable(explorerService.appState$); const explorerState = useObservable(explorerService.state$); @@ -261,6 +264,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim severity: tableSeverity.val, stoppedPartitions, invalidTimeRangeError, + selectedJobsRunning, }} /> diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index befc1cff6e9fe5..8d0ecddaa97b8f 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -24,6 +24,7 @@ import { import { MlCapabilitiesResponse } from '../../../../common/types/capabilities'; import { Calendar, CalendarId, UpdateCalendar } from '../../../../common/types/calendars'; +import { RuntimeMappings } from '../../../../common/types/fields'; import { Job, JobStats, @@ -63,6 +64,7 @@ export interface BucketSpanEstimatorData { query: any; splitField: string | undefined; timeField: string | undefined; + runtimeMappings: RuntimeMappings | undefined; } export interface BucketSpanEstimatorResponse { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 400841587bf8c6..df72bd25c6bcd8 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -17,7 +17,7 @@ import type { Datafeed, } from '../../../../common/types/anomaly_detection_jobs'; import type { JobMessage } from '../../../../common/types/audit_message'; -import type { AggFieldNamePair } from '../../../../common/types/fields'; +import type { AggFieldNamePair, RuntimeMappings } from '../../../../common/types/fields'; import type { ExistingJobsAndGroups } from '../job_service'; import type { CategorizationAnalyzer, @@ -188,7 +188,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ query: any, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, - splitFieldValue: string | null + splitFieldValue: string | null, + runtimeMappings?: RuntimeMappings ) { const body = JSON.stringify({ indexPatternTitle, @@ -200,6 +201,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ aggFieldNamePairs, splitFieldName, splitFieldValue, + runtimeMappings, }); return httpService.http({ path: `${ML_BASE_PATH}/jobs/new_job_line_chart`, @@ -216,7 +218,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ intervalMs: number, query: any, aggFieldNamePairs: AggFieldNamePair[], - splitFieldName: string + splitFieldName: string, + runtimeMappings?: RuntimeMappings ) { const body = JSON.stringify({ indexPatternTitle, @@ -227,6 +230,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ query, aggFieldNamePairs, splitFieldName, + runtimeMappings, }); return httpService.http({ path: `${ML_BASE_PATH}/jobs/new_job_population_chart`, @@ -263,7 +267,8 @@ export const jobsApiProvider = (httpService: HttpService) => ({ timeField: string, start: number, end: number, - analyzer: CategorizationAnalyzer + analyzer: CategorizationAnalyzer, + runtimeMappings?: RuntimeMappings ) { const body = JSON.stringify({ indexPatternTitle, @@ -274,6 +279,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ start, end, analyzer, + runtimeMappings, }); return httpService.http<{ examples: CategoryFieldExample[]; diff --git a/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts b/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts index bd5dfbd6160f34..b9520df4e710f3 100644 --- a/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts +++ b/x-pack/plugins/ml/public/application/services/new_job_capabilities_service.ts @@ -86,7 +86,7 @@ class NewJobCapsService { } public get categoryFields(): Field[] { - return this._fields.filter((f) => categoryFieldTypes.includes(f.type)); + return filterCategoryFields(this._fields); } public async initializeFromIndexPattern( @@ -252,4 +252,8 @@ function processTextAndKeywordFields(fields: Field[]) { return { fieldsPreferringKeyword, fieldsPreferringText }; } +export function filterCategoryFields(fields: Field[]) { + return fields.filter((f) => categoryFieldTypes.includes(f.type)); +} + export const newJobCapsService = new NewJobCapsService(); diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index ec1d36a1ced4c2..a8ae42658f3689 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -24,6 +24,7 @@ import { findAggField } from '../../../../common/util/validation_utils'; import { getDatafeedAggregations } from '../../../../common/util/datafeed_utils'; import { aggregationTypeTransform } from '../../../../common/util/anomaly_utils'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; +import { isPopulatedObject } from '../../../../common/util/object_utils'; interface ResultResponse { success: boolean; @@ -175,7 +176,7 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { // when the field is an aggregation field, because the field doesn't actually exist in the indices // we need to pass all the sub aggs from the original datafeed config // so that we can access the aggregated field - if (typeof aggFields === 'object' && Object.keys(aggFields).length > 0) { + if (isPopulatedObject(aggFields)) { // first item under aggregations can be any name, not necessarily 'buckets' const accessor = Object.keys(aggFields)[0]; const tempAggs = { ...(aggFields[accessor].aggs ?? aggFields[accessor].aggregations) }; diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js index 7f3c8ce9a1a6e5..42d8b32691c205 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_calendar.js @@ -10,7 +10,7 @@ import { PropTypes } from 'prop-types'; import { i18n } from '@kbn/i18n'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiOverlayMask } from '@elastic/eui'; +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; import { NavigationMenu } from '../../../components/navigation_menu'; @@ -336,19 +336,13 @@ class NewCalendarUI extends Component { let modal = ''; if (isNewEventModalVisible) { - modal = ( - - - - ); + modal = ; } else if (isImportModalVisible) { modal = ( - - - + ); } diff --git a/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js b/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js index afd1433b7ae698..bba28ab481ea11 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/list/calendars_list.js @@ -10,7 +10,6 @@ import { PropTypes } from 'prop-types'; import { EuiConfirmModal, - EuiOverlayMask, EuiPage, EuiPageBody, EuiPageContent, @@ -111,37 +110,35 @@ export class CalendarsListUI extends Component { if (this.state.isDestroyModalVisible) { destroyModal = ( - - c.calendar_id).join(', '), - }} - /> - } - onCancel={this.closeDestroyModal} - onConfirm={this.deleteCalendars} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - /> - + c.calendar_id).join(', '), + }} + /> + } + onCancel={this.closeDestroyModal} + onConfirm={this.deleteCalendars} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="danger" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + /> ); } diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/__snapshots__/delete_filter_list_modal.test.js.snap b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/__snapshots__/delete_filter_list_modal.test.js.snap index 93ca044cb0c830..8cadb8270f680a 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/__snapshots__/delete_filter_list_modal.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/__snapshots__/delete_filter_list_modal.test.js.snap @@ -92,41 +92,39 @@ exports[`DeleteFilterListModal renders modal after clicking delete button 1`] = values={Object {}} /> - - - } - className="eui-textBreakWord" - confirmButtonText={ - - } - data-test-subj="mlFilterListDeleteConfirmation" - defaultFocusedButton="confirm" - onCancel={[Function]} - onConfirm={[Function]} - title={ - + } + className="eui-textBreakWord" + confirmButtonText={ + + } + data-test-subj="mlFilterListDeleteConfirmation" + defaultFocusedButton="confirm" + onCancel={[Function]} + onConfirm={[Function]} + title={ + - } - /> - + } + /> + } + /> `; diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js index bed0e7ca281e5b..20b716586b97d1 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js @@ -9,7 +9,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton, EuiConfirmModal, EuiOverlayMask, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; +import { EuiButton, EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { deleteFilterLists } from './delete_filter_lists'; @@ -67,29 +67,27 @@ export class DeleteFilterListModal extends Component { /> ); modal = ( - - - } - confirmButtonText={ - - } - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - data-test-subj={'mlFilterListDeleteConfirmation'} - /> - + + } + confirmButtonText={ + + } + buttonColor="danger" + defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + data-test-subj={'mlFilterListDeleteConfirmation'} + /> ); } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/modal.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/modal.js index 613bd51bc16c3c..3261846a5fdd5b 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/modal.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/modal.js @@ -19,7 +19,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, } from '@elastic/eui'; @@ -31,48 +30,42 @@ import { FormattedMessage } from '@kbn/i18n/react'; export function Modal(props) { return ( - - - - - - - + + + + + + - - {props.messages.map((message, i) => ( - - - - - ))} + + {props.messages.map((message, i) => ( + + + + + ))} - {props.forecasts.length > 0 && ( - - - - - )} - - + {props.forecasts.length > 0 && ( + + + + + )} + + - - - - - - - + + + + + + ); } diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 33e5183fa79493..06a0f7e17e1649 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -1000,7 +1000,6 @@ export class TimeSeriesExplorer extends React.Component { }} /> } - color="warning" iconType="help" size="s" /> diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index b4eb5a6d702b74..212d6fe13a6b4b 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -62,7 +62,7 @@ export interface MlStartDependencies { embeddable: EmbeddableStart; maps?: MapsStartApi; lens?: LensPublicStart; - triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; } export interface MlSetupDependencies { @@ -76,7 +76,7 @@ export interface MlSetupDependencies { kibanaVersion: string; share: SharePluginSetup; indexPatternManagement: IndexPatternManagementSetup; - triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup; } export type MlCoreSetup = CoreSetup; @@ -129,6 +129,10 @@ export class MlPlugin implements Plugin { this.urlGenerator = registerUrlGenerator(pluginsSetup.share, core); } + if (pluginsSetup.triggersActionsUi) { + registerMlAlerts(pluginsSetup.triggersActionsUi); + } + const licensing = pluginsSetup.licensing.license$.pipe(take(1)); licensing.subscribe(async (license) => { const [coreStart] = await core.getStartServices(); @@ -190,7 +194,7 @@ export class MlPlugin implements Plugin { http: core.http, i18n: core.i18n, }); - registerMlAlerts(deps.triggersActionsUi.alertTypeRegistry); + return { urlGenerator: this.urlGenerator, }; diff --git a/x-pack/plugins/ml/public/shared.ts b/x-pack/plugins/ml/public/shared.ts index a0107ce8e049c5..7fb27f889c4173 100644 --- a/x-pack/plugins/ml/public/shared.ts +++ b/x-pack/plugins/ml/public/shared.ts @@ -17,8 +17,8 @@ export * from '../common/types/audit_message'; export * from '../common/util/anomaly_utils'; export * from '../common/util/errors'; export * from '../common/util/validators'; +export * from '../common/util/date_utils'; export * from './application/formatters/metric_change_description'; export * from './application/components/data_grid'; export * from './application/data_frame_analytics/common'; -export * from '../common/util/date_utils'; diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts index 261fac7b620ba9..f029fa24f96078 100644 --- a/x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.test.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { resolveTimeInterval } from './alerting_service'; +import { resolveBucketSpanInSeconds } from './alerting_service'; describe('Alerting Service', () => { test('should resolve maximum bucket interval', () => { - expect(resolveTimeInterval(['15m', '1h', '6h', '90s'])).toBe('43200s'); + expect(resolveBucketSpanInSeconds(['15m', '1h', '6h', '90s'])).toBe(43200); }); }); diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts index 3b83e6d005077b..6e7cd77e450bcc 100644 --- a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts @@ -7,6 +7,9 @@ import Boom from '@hapi/boom'; import rison from 'rison-node'; +import { ElasticsearchClient } from 'kibana/server'; +import moment from 'moment'; +import { Duration } from 'moment/moment'; import { MlClient } from '../ml_client'; import { MlAnomalyDetectionAlertParams, @@ -25,6 +28,8 @@ import { import { parseInterval } from '../../../common/util/parse_interval'; import { AnomalyDetectionAlertContext } from './register_anomaly_detection_alert_type'; import { MlJobsResponse } from '../../../common/types/job_service'; +import { ANOMALY_SCORE_MATCH_GROUP_ID } from '../../../common/constants/alerts'; +import { getEntityFieldName, getEntityFieldValue } from '../../../common/util/anomaly_utils'; function isDefined(argument: T | undefined | null): argument is T { return argument !== undefined && argument !== null; @@ -34,22 +39,23 @@ function isDefined(argument: T | undefined | null): argument is T { * Resolves the longest bucket span from the list and multiply it by 2. * @param bucketSpans Collection of bucket spans */ -export function resolveTimeInterval(bucketSpans: string[]): string { - return `${ +export function resolveBucketSpanInSeconds(bucketSpans: string[]): number { + return ( Math.max( ...bucketSpans .map((b) => parseInterval(b)) .filter(isDefined) .map((v) => v.asSeconds()) ) * 2 - }s`; + ); } /** * Alerting related server-side methods * @param mlClient + * @param esClient */ -export function alertingServiceProvider(mlClient: MlClient) { +export function alertingServiceProvider(mlClient: MlClient, esClient: ElasticsearchClient) { const getAggResultsLabel = (resultType: AnomalyResultType) => { return { aggGroupLabel: `${resultType}_results` as PreviewResultsKeys, @@ -177,10 +183,14 @@ export function alertingServiceProvider(mlClient: MlClient) { 'is_interim', 'function', 'field_name', + 'by_field_name', 'by_field_value', + 'over_field_name', 'over_field_value', + 'partition_field_name', 'partition_field_value', 'job_id', + 'detector_index', ], }, size: 3, @@ -257,14 +267,31 @@ export function alertingServiceProvider(mlClient: MlClient) { }; }; + /** + * Provides unique key for the anomaly result. + */ + const getAlertInstanceKey = (source: any): string => { + let alertInstanceKey = `${source.job_id}_${source.timestamp}`; + if (source.result_type === ANOMALY_RESULT_TYPE.INFLUENCER) { + alertInstanceKey += `_${source.influencer_field_name}_${source.influencer_field_value}`; + } else if (source.result_type === ANOMALY_RESULT_TYPE.RECORD) { + const fieldName = getEntityFieldName(source); + const fieldValue = getEntityFieldValue(source); + alertInstanceKey += `_${source.detector_index}_${source.function}_${fieldName}_${fieldValue}`; + } + return alertInstanceKey; + }; + /** * Builds a request body - * @param params - * @param previewTimeInterval + * @param params - Alert params + * @param previewTimeInterval - Relative time interval to test the alert condition + * @param checkIntervalGap - Interval between alert executions */ const fetchAnomalies = async ( params: MlAnomalyDetectionAlertParams, - previewTimeInterval?: string + previewTimeInterval?: string, + checkIntervalGap?: Duration ): Promise => { const jobAndGroupIds = [ ...(params.jobSelection.jobIds ?? []), @@ -281,9 +308,14 @@ export function alertingServiceProvider(mlClient: MlClient) { return; } - const lookBackTimeInterval = resolveTimeInterval( - jobsResponse.map((v) => v.analysis_config.bucket_span) - ); + /** + * The check interval might be bigger than the 2x bucket span. + * We need to check the biggest time range to make sure anomalies are not missed. + */ + const lookBackTimeInterval = `${Math.max( + resolveBucketSpanInSeconds(jobsResponse.map((v) => v.analysis_config.bucket_span)), + checkIntervalGap ? checkIntervalGap.asSeconds() : 0 + )}s`; const jobIds = jobsResponse.map((v) => v.job_id); @@ -309,6 +341,13 @@ export function alertingServiceProvider(mlClient: MlClient) { result_type: Object.values(ANOMALY_RESULT_TYPE), }, }, + ...(params.includeInterim + ? [] + : [ + { + term: { is_interim: false }, + }, + ]), ], }, }, @@ -363,19 +402,22 @@ export function alertingServiceProvider(mlClient: MlClient) { const aggTypeResults = v[resultsLabel.aggGroupLabel]; const requestedAnomalies = aggTypeResults[resultsLabel.topHitsLabel].hits.hits; + const topAnomaly = requestedAnomalies[0]; + const alertInstanceKey = getAlertInstanceKey(topAnomaly._source); + return { count: aggTypeResults.doc_count, key: v.key, - key_as_string: v.key_as_string, + alertInstanceKey, jobIds: [...new Set(requestedAnomalies.map((h) => h._source.job_id))], isInterim: requestedAnomalies.some((h) => h._source.is_interim), - timestamp: requestedAnomalies[0]._source.timestamp, - timestampIso8601: requestedAnomalies[0].fields.timestamp_iso8601[0], - timestampEpoch: requestedAnomalies[0].fields.timestamp_epoch[0], - score: requestedAnomalies[0].fields.score[0], + timestamp: topAnomaly._source.timestamp, + timestampIso8601: topAnomaly.fields.timestamp_iso8601[0], + timestampEpoch: topAnomaly.fields.timestamp_epoch[0], + score: topAnomaly.fields.score[0], bucketRange: { - start: requestedAnomalies[0].fields.start[0], - end: requestedAnomalies[0].fields.end[0], + start: topAnomaly.fields.start[0], + end: topAnomaly.fields.end[0], }, topRecords: v.record_results.top_record_hits.hits.hits.map((h) => ({ ...h._source, @@ -472,13 +514,24 @@ export function alertingServiceProvider(mlClient: MlClient) { /** * Return the result of an alert condition execution. * - * @param params + * @param params - Alert params + * @param publicBaseUrl + * @param alertId - Alert ID + * @param startedAt + * @param previousStartedAt */ execute: async ( params: MlAnomalyDetectionAlertParams, - publicBaseUrl: string | undefined + publicBaseUrl: string | undefined, + alertId: string, + startedAt: Date, + previousStartedAt: Date | null ): Promise => { - const res = await fetchAnomalies(params); + const checkIntervalGap = previousStartedAt + ? moment.duration(moment(startedAt).diff(previousStartedAt)) + : undefined; + + const res = await fetchAnomalies(params, undefined, checkIntervalGap); if (!res) { throw new Error('No results found'); @@ -489,12 +542,65 @@ export function alertingServiceProvider(mlClient: MlClient) { const anomalyExplorerUrl = buildExplorerUrl(result, params.resultType as AnomalyResultType); - return { + const executionResult = { ...result, - name: result.key_as_string, + name: result.alertInstanceKey, anomalyExplorerUrl, kibanaBaseUrl: publicBaseUrl!, }; + + let kibanaEventLogCount = 0; + try { + // Check kibana-event-logs for presence of this alert instance + const kibanaLogResults = await esClient.count({ + index: '.kibana-event-log-*', + body: { + query: { + bool: { + must: [ + { + term: { + 'kibana.alerting.action_group_id': { + value: ANOMALY_SCORE_MATCH_GROUP_ID, + }, + }, + }, + { + term: { + 'kibana.alerting.instance_id': { + value: executionResult.name, + }, + }, + }, + { + nested: { + path: 'kibana.saved_objects', + query: { + term: { + 'kibana.saved_objects.id': { + value: alertId, + }, + }, + }, + }, + }, + ], + }, + }, + }, + }); + + kibanaEventLogCount = kibanaLogResults.body.count; + } catch (e) { + // eslint-disable-next-line no-console + console.log('Unable to check kibana event logs', e); + } + + if (kibanaEventLogCount > 0) { + return; + } + + return executionResult; }, /** * Checks how often the alert condition will fire an alert instance diff --git a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts index 6f8fa59aa231e7..30a92c02cefc3a 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts @@ -123,13 +123,19 @@ export function registerAnomalyDetectionAlertType({ }, producer: PLUGIN_ID, minimumLicenseRequired: MINIMUM_FULL_LICENSE, - async executor({ services, params }) { + async executor({ services, params, alertId, state, previousStartedAt, startedAt }) { const fakeRequest = {} as KibanaRequest; const { execute } = mlSharedServices.alertingServiceProvider( services.savedObjectsClient, fakeRequest ); - const executionResult = await execute(params, publicBaseUrl); + const executionResult = await execute( + params, + publicBaseUrl, + alertId, + startedAt, + previousStartedAt + ); if (executionResult) { const alertInstanceName = executionResult.name; diff --git a/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts b/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts index 5c9106d78595fc..371c5435f91de9 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts @@ -5,12 +5,14 @@ * 2.0. */ +import { Logger } from 'kibana/server'; import { AlertingPlugin } from '../../../../alerts/server'; import { registerAnomalyDetectionAlertType } from './register_anomaly_detection_alert_type'; import { SharedServices } from '../../shared_services'; export interface RegisterAlertParams { alerts: AlertingPlugin['setup']; + logger: Logger; mlSharedServices: SharedServices; publicBaseUrl: string | undefined; } diff --git a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts index 8bfa825baacd93..49a63d2796969e 100644 --- a/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts +++ b/x-pack/plugins/ml/server/lib/capabilities/check_capabilities.test.ts @@ -51,7 +51,7 @@ describe('check_capabilities', () => { ); const { capabilities } = await getCapabilities(); const count = Object.keys(capabilities).length; - expect(count).toBe(28); + expect(count).toBe(29); }); }); @@ -102,6 +102,7 @@ describe('check_capabilities', () => { expect(capabilities.canDeleteDataFrameAnalytics).toBe(false); expect(capabilities.canCreateDataFrameAnalytics).toBe(false); expect(capabilities.canStartStopDataFrameAnalytics).toBe(false); + expect(capabilities.canCreateMlAlerts).toBe(false); }); test('full capabilities', async () => { diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts index 24743d3bc08749..40a6bd1decd974 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts @@ -7,6 +7,7 @@ import { IScopedClusterClient } from 'kibana/server'; import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; +import { RuntimeMappings } from '../../../common/types/fields'; export interface BucketSpanEstimatorData { aggTypes: Array; @@ -19,6 +20,7 @@ export interface BucketSpanEstimatorData { query: any; splitField: string | undefined; timeField: string | undefined; + runtimeMappings: RuntimeMappings | undefined; } export function estimateBucketSpanFactory({ diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js index 9639a6e1e1317b..79f48645d52f23 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.js @@ -20,7 +20,7 @@ export function estimateBucketSpanFactory(client) { class BucketSpanEstimator { constructor( - { index, timeField, aggTypes, fields, duration, query, splitField }, + { index, timeField, aggTypes, fields, duration, query, splitField, runtimeMappings }, splitFieldValues, maxBuckets ) { @@ -38,6 +38,9 @@ export function estimateBucketSpanFactory(client) { minimumBucketSpanMS: 0, }; + this.runtimeMappings = + runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}; + // determine durations for bucket span estimation // taking into account the clusters' search.max_buckets settings // the polled_data_checker uses an aggregation interval of 1 minute @@ -85,7 +88,8 @@ export function estimateBucketSpanFactory(client) { this.fields[i], this.duration, this.query, - this.thresholds + this.thresholds, + this.runtimeMappings ), result: null, }); @@ -107,7 +111,8 @@ export function estimateBucketSpanFactory(client) { this.fields[i], this.duration, queryCopy, - this.thresholds + this.thresholds, + this.runtimeMappings ), result: null, }); @@ -241,7 +246,7 @@ export function estimateBucketSpanFactory(client) { } } - const getFieldCardinality = function (index, field) { + const getFieldCardinality = function (index, field, runtimeMappings) { return new Promise((resolve, reject) => { asCurrentUser .search({ @@ -255,6 +260,7 @@ export function estimateBucketSpanFactory(client) { }, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }) .then(({ body }) => { @@ -267,7 +273,7 @@ export function estimateBucketSpanFactory(client) { }); }; - const getRandomFieldValues = function (index, field, query) { + const getRandomFieldValues = function (index, field, query, runtimeMappings) { let fieldValues = []; return new Promise((resolve, reject) => { const NUM_PARTITIONS = 10; @@ -293,6 +299,7 @@ export function estimateBucketSpanFactory(client) { }, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }) .then(({ body }) => { @@ -379,7 +386,12 @@ export function estimateBucketSpanFactory(client) { // a partition has been selected, so we need to load some field values to use in the // bucket span tests. if (formConfig.splitField !== undefined) { - getRandomFieldValues(formConfig.index, formConfig.splitField, formConfig.query) + getRandomFieldValues( + formConfig.index, + formConfig.splitField, + formConfig.query, + formConfig.runtimeMappings + ) .then((splitFieldValues) => { runEstimator(splitFieldValues); }) diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts index 05a6ae85696d84..aa576d1f69915e 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.test.ts @@ -35,6 +35,7 @@ const formConfig: BucketSpanEstimatorData = { }, splitField: undefined, timeField: undefined, + runtimeMappings: undefined, }; describe('ML - BucketSpanEstimator', () => { diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js b/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js index 8564ddc72770dc..25c87c5c2acbf8 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/single_series_checker.js @@ -18,7 +18,7 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { const REF_DATA_INTERVAL = { name: '1h', ms: 3600000 }; class SingleSeriesChecker { - constructor(index, timeField, aggType, field, duration, query, thresholds) { + constructor(index, timeField, aggType, field, duration, query, thresholds, runtimeMappings) { this.index = index; this.timeField = timeField; this.aggType = aggType; @@ -31,7 +31,7 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { varDiff: 0, created: false, }; - + this.runtimeMappings = runtimeMappings; this.interval = null; } @@ -171,6 +171,7 @@ export function singleSeriesCheckerFactory({ asCurrentUser }) { }, }, }, + ...this.runtimeMappings, }; if (this.field !== null) { diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index 7a022c0c1805cd..2efc2f905d9bb9 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -116,7 +116,8 @@ const cardinalityCheckProvider = (client: IScopedClusterClient) => { timeFieldName, earliestMs, latestMs, - bucketSpan + bucketSpan, + datafeedConfig ); } diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts index 3c12aa8c75c039..56eddf9df2e04f 100644 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -298,13 +298,14 @@ export function fieldsServiceProvider({ asCurrentUser }: IScopedClusterClient) { timeFieldName: string, earliestMs: number, latestMs: number, - interval: string | undefined + interval: string | undefined, + datafeedConfig?: Datafeed ): Promise<{ [key: string]: number }> { if (!interval) { throw Boom.badRequest('Interval is required to retrieve max bucket cardinalities.'); } - const aggregatableFields = await getAggregatableFields(index, fieldNames); + const aggregatableFields = await getAggregatableFields(index, fieldNames, datafeedConfig); if (aggregatableFields.length === 0) { return {}; diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index 0af8f1e1ec1cab..dc2c04540ef21d 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -39,6 +39,7 @@ import { } from '../../../common/util/job_utils'; import { groupsProvider } from './groups'; import type { MlClient } from '../../lib/ml_client'; +import { isPopulatedObject } from '../../../common/util/object_utils'; interface Results { [id: string]: { @@ -172,8 +173,7 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { }); const jobs = fullJobsList.map((job) => { - const hasDatafeed = - typeof job.datafeed_config === 'object' && Object.keys(job.datafeed_config).length > 0; + const hasDatafeed = isPopulatedObject(job.datafeed_config); const dataCounts = job.data_counts; const errorMessage = getSingleMetricViewerJobErrorMessage(job); @@ -233,8 +233,7 @@ export function jobsProvider(client: IScopedClusterClient, mlClient: MlClient) { const jobs = fullJobsList.map((job) => { jobsMap[job.job_id] = job.groups || []; - const hasDatafeed = - typeof job.datafeed_config === 'object' && Object.keys(job.datafeed_config).length > 0; + const hasDatafeed = isPopulatedObject(job.datafeed_config); const timeRange: { to?: number; from?: number } = {}; const dataCounts = job.data_counts; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts index 41837dda29e3f0..63df425791e852 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/examples.ts @@ -14,6 +14,7 @@ import { CategorizationAnalyzer, CategoryFieldExample, } from '../../../../../common/types/categories'; +import { RuntimeMappings } from '../../../../../common/types/fields'; import { ValidationResults } from './validation_results'; const CHUNK_SIZE = 100; @@ -32,7 +33,8 @@ export function categorizationExamplesProvider({ timeField: string | undefined, start: number, end: number, - analyzer: CategorizationAnalyzer + analyzer: CategorizationAnalyzer, + runtimeMappings: RuntimeMappings | undefined ): Promise<{ examples: CategoryFieldExample[]; error?: any }> { if (timeField !== undefined) { const range = { @@ -65,6 +67,7 @@ export function categorizationExamplesProvider({ _source: false, query, sort: ['_doc'], + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }); @@ -165,7 +168,8 @@ export function categorizationExamplesProvider({ timeField: string | undefined, start: number, end: number, - analyzer: CategorizationAnalyzer + analyzer: CategorizationAnalyzer, + runtimeMappings: RuntimeMappings | undefined ) { const resp = await categorizationExamples( indexPatternTitle, @@ -175,7 +179,8 @@ export function categorizationExamplesProvider({ timeField, start, end, - analyzer + analyzer, + runtimeMappings ); const { examples } = resp; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts index 4b367c1430f50d..c83485211b4553 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/line_chart.ts @@ -7,7 +7,11 @@ import { get } from 'lodash'; import { IScopedClusterClient } from 'kibana/server'; -import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { + AggFieldNamePair, + EVENT_RATE_FIELD_ID, + RuntimeMappings, +} from '../../../../common/types/fields'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; type DtrIndex = number; @@ -34,7 +38,8 @@ export function newJobLineChartProvider({ asCurrentUser }: IScopedClusterClient) query: object, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, - splitFieldValue: string | null + splitFieldValue: string | null, + runtimeMappings: RuntimeMappings | undefined ) { const json: object = getSearchJsonFromConfig( indexPatternTitle, @@ -45,7 +50,8 @@ export function newJobLineChartProvider({ asCurrentUser }: IScopedClusterClient) query, aggFieldNamePairs, splitFieldName, - splitFieldValue + splitFieldValue, + runtimeMappings ); const { body } = await asCurrentUser.search(json); @@ -103,7 +109,8 @@ function getSearchJsonFromConfig( query: any, aggFieldNamePairs: AggFieldNamePair[], splitFieldName: string | null, - splitFieldValue: string | null + splitFieldValue: string | null, + runtimeMappings: RuntimeMappings | undefined ): object { const json = { index: indexPatternTitle, @@ -125,6 +132,7 @@ function getSearchJsonFromConfig( aggs: {}, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts index 469ae39296f129..10f6d94e764ac3 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/population_chart.ts @@ -7,7 +7,11 @@ import { get } from 'lodash'; import { IScopedClusterClient } from 'kibana/server'; -import { AggFieldNamePair, EVENT_RATE_FIELD_ID } from '../../../../common/types/fields'; +import { + AggFieldNamePair, + EVENT_RATE_FIELD_ID, + RuntimeMappings, +} from '../../../../common/types/fields'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; const OVER_FIELD_EXAMPLES_COUNT = 40; @@ -39,7 +43,8 @@ export function newJobPopulationChartProvider({ asCurrentUser }: IScopedClusterC intervalMs: number, query: object, aggFieldNamePairs: AggFieldNamePair[], - splitFieldName: string | null + splitFieldName: string | null, + runtimeMappings: RuntimeMappings | undefined ) { const json: object = getPopulationSearchJsonFromConfig( indexPatternTitle, @@ -49,7 +54,8 @@ export function newJobPopulationChartProvider({ asCurrentUser }: IScopedClusterC intervalMs, query, aggFieldNamePairs, - splitFieldName + splitFieldName, + runtimeMappings ); const { body } = await asCurrentUser.search(json); @@ -131,7 +137,8 @@ function getPopulationSearchJsonFromConfig( intervalMs: number, query: any, aggFieldNamePairs: AggFieldNamePair[], - splitFieldName: string | null + splitFieldName: string | null, + runtimeMappings: RuntimeMappings | undefined ): object { const json = { index: indexPatternTitle, @@ -153,6 +160,7 @@ function getPopulationSearchJsonFromConfig( aggs: {}, }, }, + ...(runtimeMappings !== undefined ? { runtime_mappings: runtimeMappings } : {}), }, }; @@ -237,5 +245,6 @@ function getPopulationSearchJsonFromConfig( } else { json.body.aggs.times.aggs = aggs; } + return json; } diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts deleted file mode 100644 index eb407357bcda33..00000000000000 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/aggregations.ts +++ /dev/null @@ -1,325 +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 { Aggregation, METRIC_AGG_TYPE } from '../../../../common/types/fields'; -import { - ML_JOB_AGGREGATION, - KIBANA_AGGREGATION, - ES_AGGREGATION, -} from '../../../../common/constants/aggregation_types'; - -// aggregation object missing id, title and fields and has null for kibana and dsl aggregation names. -// this is used as the basis for the ML only aggregations -function getBasicMlOnlyAggregation(): Omit { - return { - kibanaName: null, - dslName: null, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - }; -} - -// list of aggregations only support by ML and which don't have an equivalent ES aggregation -// note, not all aggs have a field list. Some aggs cannot be used with a field. -export const mlOnlyAggregations: Aggregation[] = [ - { - id: ML_JOB_AGGREGATION.NON_ZERO_COUNT, - title: 'Non zero count', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_NON_ZERO_COUNT, - title: 'High non zero count', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_NON_ZERO_COUNT, - title: 'Low non zero count', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT, - title: 'High distinct count', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT, - title: 'Low distinct count', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.METRIC, - title: 'Metric', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.VARP, - title: 'varp', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_VARP, - title: 'High varp', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_VARP, - title: 'Low varp', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.NON_NULL_SUM, - title: 'Non null sum', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_NON_NULL_SUM, - title: 'High non null sum', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_NON_NULL_SUM, - title: 'Low non null sum', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.RARE, - title: 'Rare', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.FREQ_RARE, - title: 'Freq rare', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.INFO_CONTENT, - title: 'Info content', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.HIGH_INFO_CONTENT, - title: 'High info content', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LOW_INFO_CONTENT, - title: 'Low info content', - fields: [], - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.TIME_OF_DAY, - title: 'Time of day', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.TIME_OF_WEEK, - title: 'Time of week', - ...getBasicMlOnlyAggregation(), - }, - { - id: ML_JOB_AGGREGATION.LAT_LONG, - title: 'Lat long', - fields: [], - ...getBasicMlOnlyAggregation(), - }, -]; - -export const aggregations: Aggregation[] = [ - { - id: ML_JOB_AGGREGATION.COUNT, - title: 'Count', - kibanaName: KIBANA_AGGREGATION.COUNT, - dslName: ES_AGGREGATION.COUNT, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - }, - { - id: ML_JOB_AGGREGATION.HIGH_COUNT, - title: 'High count', - kibanaName: KIBANA_AGGREGATION.COUNT, - dslName: ES_AGGREGATION.COUNT, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - }, - { - id: ML_JOB_AGGREGATION.LOW_COUNT, - title: 'Low count', - kibanaName: KIBANA_AGGREGATION.COUNT, - dslName: ES_AGGREGATION.COUNT, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - }, - { - id: ML_JOB_AGGREGATION.MEAN, - title: 'Mean', - kibanaName: KIBANA_AGGREGATION.AVG, - dslName: ES_AGGREGATION.AVG, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.AVG, - min: KIBANA_AGGREGATION.AVG, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.HIGH_MEAN, - title: 'High mean', - kibanaName: KIBANA_AGGREGATION.AVG, - dslName: ES_AGGREGATION.AVG, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.AVG, - min: KIBANA_AGGREGATION.AVG, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.LOW_MEAN, - title: 'Low mean', - kibanaName: KIBANA_AGGREGATION.AVG, - dslName: ES_AGGREGATION.AVG, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.AVG, - min: KIBANA_AGGREGATION.AVG, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.SUM, - title: 'Sum', - kibanaName: KIBANA_AGGREGATION.SUM, - dslName: ES_AGGREGATION.SUM, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.SUM, - min: KIBANA_AGGREGATION.SUM, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.HIGH_SUM, - title: 'High sum', - kibanaName: KIBANA_AGGREGATION.SUM, - dslName: ES_AGGREGATION.SUM, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.SUM, - min: KIBANA_AGGREGATION.SUM, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.LOW_SUM, - title: 'Low sum', - kibanaName: KIBANA_AGGREGATION.SUM, - dslName: ES_AGGREGATION.SUM, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.SUM, - min: KIBANA_AGGREGATION.SUM, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.MEDIAN, - title: 'Median', - kibanaName: KIBANA_AGGREGATION.MEDIAN, - dslName: ES_AGGREGATION.PERCENTILES, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.HIGH_MEDIAN, - title: 'High median', - kibanaName: KIBANA_AGGREGATION.MEDIAN, - dslName: ES_AGGREGATION.PERCENTILES, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.LOW_MEDIAN, - title: 'Low median', - kibanaName: KIBANA_AGGREGATION.MEDIAN, - dslName: ES_AGGREGATION.PERCENTILES, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.MIN, - title: 'Min', - kibanaName: KIBANA_AGGREGATION.MIN, - dslName: ES_AGGREGATION.MIN, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MIN, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.MAX, - title: 'Max', - kibanaName: KIBANA_AGGREGATION.MAX, - dslName: ES_AGGREGATION.MAX, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MAX, - }, - fields: [], - }, - { - id: ML_JOB_AGGREGATION.DISTINCT_COUNT, - title: 'Distinct count', - kibanaName: KIBANA_AGGREGATION.CARDINALITY, - dslName: ES_AGGREGATION.CARDINALITY, - type: METRIC_AGG_TYPE, - mlModelPlotAgg: { - max: KIBANA_AGGREGATION.MAX, - min: KIBANA_AGGREGATION.MIN, - }, - fields: [], - }, -]; diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts index 289c0118dce4e4..7ce54cd2f9c5e2 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/field_service.ts @@ -8,17 +8,11 @@ import { IScopedClusterClient } from 'kibana/server'; import { cloneDeep } from 'lodash'; import { SavedObjectsClientContract } from 'kibana/server'; -import { - Field, - Aggregation, - FieldId, - NewJobCaps, - METRIC_AGG_TYPE, -} from '../../../../common/types/fields'; -import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/server'; -import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { rollupServiceProvider, RollupJob, RollupFields } from './rollup'; -import { aggregations, mlOnlyAggregations } from './aggregations'; +import { Field, FieldId, NewJobCaps, RollupFields } from '../../../../common/types/fields'; +import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/common'; +import { combineFieldsAndAggs } from '../../../../common/util/fields_utils'; +import { rollupServiceProvider, RollupJob } from './rollup'; +import { aggregations, mlOnlyAggregations } from '../../../../common/constants/aggregation_types'; const supportedTypes: string[] = [ ES_FIELD_TYPES.DATE, @@ -133,85 +127,10 @@ class FieldsService { const aggs = cloneDeep([...aggregations, ...mlOnlyAggregations]); const fields: Field[] = await this.createFields(); - return await combineFieldsAndAggs(fields, aggs, rollupFields); + return combineFieldsAndAggs(fields, aggs, rollupFields); } } -// cross reference fields and aggs. -// fields contain a list of aggs that are compatible, and vice versa. -async function combineFieldsAndAggs( - fields: Field[], - aggs: Aggregation[], - rollupFields: RollupFields -): Promise { - const keywordFields = getKeywordFields(fields); - const textFields = getTextFields(fields); - const numericalFields = getNumericalFields(fields); - const ipFields = getIpFields(fields); - const geoFields = getGeoFields(fields); - - const isRollup = Object.keys(rollupFields).length > 0; - const mix = mixFactory(isRollup, rollupFields); - - aggs.forEach((a) => { - if (a.type === METRIC_AGG_TYPE && a.fields !== undefined) { - switch (a.id) { - case ML_JOB_AGGREGATION.LAT_LONG: - geoFields.forEach((f) => mix(f, a)); - break; - case ML_JOB_AGGREGATION.INFO_CONTENT: - case ML_JOB_AGGREGATION.HIGH_INFO_CONTENT: - case ML_JOB_AGGREGATION.LOW_INFO_CONTENT: - textFields.forEach((f) => mix(f, a)); - case ML_JOB_AGGREGATION.DISTINCT_COUNT: - case ML_JOB_AGGREGATION.HIGH_DISTINCT_COUNT: - case ML_JOB_AGGREGATION.LOW_DISTINCT_COUNT: - // distinct count (i.e. cardinality) takes keywords, ips - // as well as numerical fields - keywordFields.forEach((f) => mix(f, a)); - ipFields.forEach((f) => mix(f, a)); - // note, no break to fall through to add numerical fields. - default: - // all other aggs take numerical fields - numericalFields.forEach((f) => { - mix(f, a); - }); - break; - } - } - }); - - return { - aggs, - fields: isRollup ? filterFields(fields) : fields, - }; -} - -// remove fields that have no aggs associated to them, unless they are date fields -function filterFields(fields: Field[]): Field[] { - return fields.filter( - (f) => f.aggs && (f.aggs.length > 0 || (f.aggs.length === 0 && f.type === ES_FIELD_TYPES.DATE)) - ); -} - -// returns a mix function that is used to cross-reference aggs and fields. -// wrapped in a provider to allow filtering based on rollup job capabilities -function mixFactory(isRollup: boolean, rollupFields: RollupFields) { - return function mix(field: Field, agg: Aggregation): void { - if ( - isRollup === false || - (rollupFields[field.id] && rollupFields[field.id].find((f) => f.agg === agg.dslName)) - ) { - if (field.aggs !== undefined) { - field.aggs.push(agg); - } - if (agg.fields !== undefined) { - agg.fields.push(field); - } - } - }; -} - function combineAllRollupFields(rollupConfigs: RollupJob[]): RollupFields { const rollupFields: RollupFields = {}; rollupConfigs.forEach((conf) => { @@ -230,36 +149,3 @@ function combineAllRollupFields(rollupConfigs: RollupJob[]): RollupFields { }); return rollupFields; } - -function getKeywordFields(fields: Field[]): Field[] { - return fields.filter((f) => f.type === ES_FIELD_TYPES.KEYWORD); -} - -function getTextFields(fields: Field[]): Field[] { - return fields.filter((f) => f.type === ES_FIELD_TYPES.TEXT); -} - -function getIpFields(fields: Field[]): Field[] { - return fields.filter((f) => f.type === ES_FIELD_TYPES.IP); -} - -function getNumericalFields(fields: Field[]): Field[] { - return fields.filter( - (f) => - f.type === ES_FIELD_TYPES.LONG || - f.type === ES_FIELD_TYPES.UNSIGNED_LONG || - f.type === ES_FIELD_TYPES.INTEGER || - f.type === ES_FIELD_TYPES.SHORT || - f.type === ES_FIELD_TYPES.BYTE || - f.type === ES_FIELD_TYPES.DOUBLE || - f.type === ES_FIELD_TYPES.FLOAT || - f.type === ES_FIELD_TYPES.HALF_FLOAT || - f.type === ES_FIELD_TYPES.SCALED_FLOAT - ); -} - -function getGeoFields(fields: Field[]): Field[] { - return fields.filter( - (f) => f.type === ES_FIELD_TYPES.GEO_POINT || f.type === ES_FIELD_TYPES.GEO_SHAPE - ); -} diff --git a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts index e3f1b5939a9077..3b480bae2199eb 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job_caps/rollup.ts @@ -9,10 +9,7 @@ import { IScopedClusterClient } from 'kibana/server'; import { SavedObject } from 'kibana/server'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { SavedObjectsClientContract } from 'kibana/server'; -import { FieldId } from '../../../../common/types/fields'; -import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; - -export type RollupFields = Record]>; +import { RollupFields } from '../../../../common/types/fields'; export interface RollupJob { job_id: string; diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 10ed70d7f73969..24fac9184cc27c 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -211,6 +211,7 @@ export class MlServerPlugin if (plugins.alerts) { registerMlAlerts({ alerts: plugins.alerts, + logger: this.log, mlSharedServices: sharedServices, publicBaseUrl: coreSetup.http.basePath.publicBaseUrl, }); diff --git a/x-pack/plugins/ml/server/routes/alerting.ts b/x-pack/plugins/ml/server/routes/alerting.ts index b7a1be2434e8bc..a268a5200b35e7 100644 --- a/x-pack/plugins/ml/server/routes/alerting.ts +++ b/x-pack/plugins/ml/server/routes/alerting.ts @@ -17,6 +17,8 @@ export function alertingRoutes({ router, routeGuard }: RouteInitialization) { * @api {post} /api/ml/alerting/preview Preview alerting condition * @apiName PreviewAlert * @apiDescription Returns a preview of the alerting condition + * + * @apiSchema (body) mlAnomalyDetectionAlertPreviewRequest */ router.post( { @@ -28,9 +30,9 @@ export function alertingRoutes({ router, routeGuard }: RouteInitialization) { tags: ['access:ml:canGetJobs'], }, }, - routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response }) => { + routeGuard.fullLicenseAPIGuard(async ({ mlClient, request, response, client }) => { try { - const alertingService = alertingServiceProvider(mlClient); + const alertingService = alertingServiceProvider(mlClient, client.asInternalUser); const result = await alertingService.preview(request.body); diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index eb142542944ad2..1e028dfb20b4d9 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -534,6 +534,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { aggFieldNamePairs, splitFieldName, splitFieldValue, + runtimeMappings, } = request.body; const { newJobLineChart } = jobServiceProvider(client, mlClient); @@ -546,7 +547,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { query, aggFieldNamePairs, splitFieldName, - splitFieldValue + splitFieldValue, + runtimeMappings ); return response.ok({ @@ -588,6 +590,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { query, aggFieldNamePairs, splitFieldName, + runtimeMappings, } = request.body; const { newJobPopulationChart } = jobServiceProvider(client, mlClient); @@ -599,7 +602,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { intervalMs, query, aggFieldNamePairs, - splitFieldName + splitFieldName, + runtimeMappings ); return response.ok({ @@ -705,6 +709,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { start, end, analyzer, + runtimeMappings, } = request.body; const resp = await validateCategoryExamples( @@ -715,7 +720,8 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { timeField, start, end, - analyzer + analyzer, + runtimeMappings ); return response.ok({ diff --git a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts index 636185808f9a5b..9e13b7ed81a15c 100644 --- a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts @@ -27,6 +27,7 @@ export const mlAnomalyDetectionAlertParams = schema.object({ ), severity: schema.number(), resultType: schema.string(), + includeInterim: schema.boolean({ defaultValue: true }), }); export const mlAnomalyDetectionAlertPreviewRequest = schema.object({ diff --git a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts index afd56c0067e4de..65955fbc47a372 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts @@ -16,6 +16,7 @@ export const categorizationFieldExamplesSchema = { start: schema.number(), end: schema.number(), analyzer: schema.any(), + runtimeMappings: schema.maybe(schema.any()), }; export const chartSchema = { @@ -28,6 +29,7 @@ export const chartSchema = { aggFieldNamePairs: schema.arrayOf(schema.any()), splitFieldName: schema.maybe(schema.nullable(schema.string())), splitFieldValue: schema.maybe(schema.nullable(schema.string())), + runtimeMappings: schema.maybe(schema.any()), }; export const datafeedIdsSchema = schema.object({ diff --git a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts index feee5a49ed2cad..8c054d54e0589a 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_validation_schema.ts @@ -18,6 +18,7 @@ export const estimateBucketSpanSchema = schema.object({ query: schema.any(), splitField: schema.maybe(schema.string()), timeField: schema.maybe(schema.string()), + runtimeMappings: schema.maybe(schema.any()), }); export const modelMemoryLimitSchema = schema.object({ diff --git a/x-pack/plugins/ml/server/shared_services/providers/alerting_service.ts b/x-pack/plugins/ml/server/shared_services/providers/alerting_service.ts index 318dac200a8772..cbe22478e12d6c 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/alerting_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/alerting_service.ts @@ -20,7 +20,9 @@ export function getAlertingServiceProvider(getGuards: GetGuards) { return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(({ mlClient }) => alertingServiceProvider(mlClient).preview(...args)); + .ok(({ mlClient, scopedClient }) => + alertingServiceProvider(mlClient, scopedClient.asInternalUser).preview(...args) + ); }, execute: async ( ...args: Parameters @@ -28,7 +30,9 @@ export function getAlertingServiceProvider(getGuards: GetGuards) { return await getGuards(request, savedObjectsClient) .isFullLicense() .hasMlCapabilities(['canGetJobs']) - .ok(({ mlClient }) => alertingServiceProvider(mlClient).execute(...args)); + .ok(({ mlClient, scopedClient }) => + alertingServiceProvider(mlClient, scopedClient.asInternalUser).execute(...args) + ); }, }; }, diff --git a/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js b/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js index 8f19fb6ab87be6..92a172f4ef3df9 100644 --- a/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js +++ b/x-pack/plugins/monitoring/public/lib/elasticsearch_settings/checkers/settings_checker.js @@ -44,7 +44,9 @@ export class SettingsChecker { async executeCheck() { try { - const { data } = await this.$http.get(this.getApi()); + const { data } = await this.$http.get(this.getApi(), { + headers: { 'kbn-system-request': 'true' }, + }); const { found, reason } = data; return { found, reason }; diff --git a/x-pack/plugins/monitoring/public/services/clusters.js b/x-pack/plugins/monitoring/public/services/clusters.js index 638b3a91b98744..71ae128072b7fa 100644 --- a/x-pack/plugins/monitoring/public/services/clusters.js +++ b/x-pack/plugins/monitoring/public/services/clusters.js @@ -38,14 +38,18 @@ export function monitoringClustersProvider($injector) { async function getClusters() { try { - const response = await $http.post(url, { - ccs, - timeRange: { - min: min.toISOString(), - max: max.toISOString(), + const response = await $http.post( + url, + { + ccs, + timeRange: { + min: min.toISOString(), + max: max.toISOString(), + }, + codePaths, }, - codePaths, - }); + { headers: { 'kbn-system-request': 'true' } } + ); return formatClusters(response.data); } catch (err) { const Private = $injector.get('Private'); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_1n1p.json b/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_1n1p.json new file mode 100644 index 00000000000000..71656e3468c098 --- /dev/null +++ b/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_1n1p.json @@ -0,0 +1,168 @@ +[ + { + "hits" : { + "hits": [ + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "ezNjX3cB1VO1nvgv72Wz", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 125, + "workers" : 1, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "stdin", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "elasticsearch", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "mutate", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "ruby", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "split", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "elasticsearch", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "ruby", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "ruby", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "mutate", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "aggregate", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "drop", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "meta" : { + "source" : { + "protocol" : "file" + } + } + }, + { + "config_name" : "mutate", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "elasticsearch", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "output" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "output" + } + ] + } + } + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T15:58:40.943-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "994e68cd-d607-40e6-a54c-02a51caa17e0" + ] + } + } + ] + } +} +] diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_1nmp.json b/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_1nmp.json new file mode 100644 index 00000000000000..f2e3b1e038476c --- /dev/null +++ b/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_1nmp.json @@ -0,0 +1,187 @@ +[ + { + "hits" : { + "hits" : [ + { + "_index" : ".monitoring-logstash-7-mb-2021.01.28", + "_type" : "_doc", + "_id" : "CiziSXcB1VO1nvgvRr-E", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 125, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "stdin", + "meta" : { + "source" : { + "protocol" : "x-pack-config-management" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "x-pack-config-management" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 12 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-01-28T11:45:01.937-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "47a70feb-3cb5-4618-8670-2c0bada61acd" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.01.12", + "_type" : "_doc", + "_id" : "DSziSXcB1VO1nvgvRr-Z", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 125, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "stdin", + "meta" : { + "source" : { + "protocol" : "x-pack-config-management" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "x-pack-config-management" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 1 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-01-28T11:45:01.955-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "5a65d966-0330-4bd7-82f2-ee81040c13cf" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.01.12", + "_type" : "_doc", + "_id" : "DCziSXcB1VO1nvgvRr-Z", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 125, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "stdin", + "meta" : { + "source" : { + "protocol" : "x-pack-config-management" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "x-pack-config-management" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 44 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-01-28T11:45:01.937-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "8d33fe25-a2c0-4c54-9ecf-d218cb8dbfe4" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.01.12", + "_type" : "_doc", + "_id" : "CyziSXcB1VO1nvgvRr-E", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 1251, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "stdin", + "meta" : { + "source" : { + "protocol" : "x-pack-config-management" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "x-pack-config-management" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 12 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-01-28T11:45:01.937-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "f4167a94-20a8-43e7-828e-4cf38d906187" + ] + } + } + ] + } + }] diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_mnmp.json b/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_mnmp.json new file mode 100644 index 00000000000000..65cb704529efd0 --- /dev/null +++ b/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_state_results_mnmp.json @@ -0,0 +1,926 @@ +[ + { + "hits" : { + "hits" : [ + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "ZzNmX3cB1VO1nvgv9Wlp", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 125, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "stdin", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "clone", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "pipeline", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 1 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:01:58.852-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "cf37c6fa-2f1a-41e2-9a89-36b420a8b9a5" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "ujNxX3cB1VO1nvgvv3em", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 100, + "workers" : 1, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "pipeline", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + } + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:13:45.947-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "bc6ef6f2-ecce-4328-96a2-002de41a144d" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "ODNtX3cB1VO1nvgvo3IK", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 1, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "pipeline", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 10 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.534-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "3193df5f-2a34-4fe3-816e-6b05999aa5ce" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "LDNtX3cB1VO1nvgvonIZ", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 6, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 6 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.322-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "602936f5-98a3-4f8c-9471-cf389a519f4b" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "LTNtX3cB1VO1nvgvonIZ", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 12, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 12 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.332-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "41258219-b129-4fad-a629-f244826281f8" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "LjNtX3cB1VO1nvgvonI3", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 7, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 7 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.335-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "18593052-c021-4158-860d-d8122981a0ac" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "LzNtX3cB1VO1nvgvonI3", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 10, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 10 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.337-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "8b300988-62cc-4bc6-9ee0-9194f3f78e27" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "MDNtX3cB1VO1nvgvonJL", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 8, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 8 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.339-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "802a5994-a03c-44b8-a650-47c0f71c2e48" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "MTNtX3cB1VO1nvgvonJL", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 4, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 4 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.468-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "6ab60531-fb6f-478c-9063-82f2b0af2bed" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "MjNtX3cB1VO1nvgvonJi", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 5, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 5 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.470-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "0ec4702d-b5e5-4c60-91e9-6fa6a836f0d1" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "MzNtX3cB1VO1nvgvonJi", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 2, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "pipeline", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 2 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.489-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "ddf882b7-be26-4a93-8144-0aeb35122651" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "NDNtX3cB1VO1nvgvonJ3", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 13, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 13 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.490-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "6070b400-5c10-4c5e-b5c5-a5bd9be6d321" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "NTNtX3cB1VO1nvgvonJ3", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 3, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 3 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.512-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "e73bc63d-561a-4acd-a0c4-d5f70c4603df" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "NjNtX3cB1VO1nvgvonKM", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 11, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 11 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.513-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "4207025c-9b00-4bea-a36c-6fbf2d3c215e" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "NzNtX3cB1VO1nvgvonKM", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 9, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + }, + "workers" : 9 + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:09:16.533-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "72058ad1-68a1-45f6-a8e8-10621ffc7288" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "uDNxX3cB1VO1nvgvv3cb", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 125, + "workers" : 16, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "stdout", + "meta" : { + "source" : { + "protocol" : "file" + } + }, + "plugin_type" : "output" + } + ] + } + } + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:13:45.932-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "c6785d63-6e5f-42c2-839d-5edf139b7c19" + ] + } + }, + { + "_index" : ".monitoring-logstash-7-2021.02.01", + "_type" : "_doc", + "_id" : "uTNxX3cB1VO1nvgvv3cb", + "_score" : 0.0, + "_source" : { + "logstash_state" : { + "pipeline" : { + "batch_size" : 125, + "workers" : 8, + "representation" : { + "graph" : { + "vertices" : [ + { + "config_name" : "generator", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "input" + }, + { + "config_name" : "sleep", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "filter" + }, + { + "config_name" : "pipeline", + "meta" : { + "source" : { + "protocol" : "string" + } + }, + "plugin_type" : "output" + } + ] + } + } + } + }, + "type" : "logstash_state", + "timestamp" : "2021-02-01T16:13:45.945-0500" + }, + "fields" : { + "logstash_state.pipeline.ephemeral_id" : [ + "2fcd4161-e08f-4eea-818b-703ea3ec6389" + ] + } + } + ] + } +} +] diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_stats_results.json b/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_stats_results.json new file mode 100644 index 00000000000000..f9b54f62100dc7 --- /dev/null +++ b/x-pack/plugins/monitoring/server/telemetry_collection/__mocks__/fixtures/logstash_stats_results.json @@ -0,0 +1,323 @@ +[ + { + "hits" : { + "hits": [ + { + "_index": ".monitoring-logstash-7-2021.02.01", + "_type": "_doc", + "_id": "6DN5X3cB1VO1nvgvb4Ft", + "_score": null, + "_source": { + "logstash_stats": { + "logstash": { + "version": "7.10.0", + "snapshot": false, + "status": "green" + }, + "pipelines": [ + { + "id": "main", + "ephemeral_id": "cf37c6fa-2f1a-41e2-9a89-36b420a8b9a5", + "queue": { + "max_queue_size_in_bytes": 0, + "type": "memory" + } + } + ] + }, + "cluster_uuid": "1n1p", + "type": "logstash_stats" + }, + "fields": { + "logstash_stats.logstash.uuid": [ + "12a3b26f-9b8c-4d38-b4d0-54649c1dd244" + ] + }, + "sort": [ + 1612214529897 + ] + }, + { + "_index" : ".monitoring-logstash-7-mb-2021.01.28", + "_type" : "_doc", + "_id" : "rC2jSncB1VO1nvgvsKx6", + "_score" : null, + "_source" : { + "agent" : { + "type" : "metricbeat" + }, + "logstash_stats" : { + "logstash" : { + "version" : "7.8.0", + "snapshot" : false, + "status" : "green" + }, + "pipelines" : [ + { + "id" : "test2", + "ephemeral_id" : "47a70feb-3cb5-4618-8670-2c0bada61acd", + "queue" : { + "max_queue_size_in_bytes" : 0, + "type" : "memory" + } + }, + { + "id" : "main", + "ephemeral_id" : "5a65d966-0330-4bd7-82f2-ee81040c13cf", + "queue" : { + "max_queue_size_in_bytes" : 0, + "type" : "memory" + } + }, + { + "id" : "main2", + "ephemeral_id" : "8d33fe25-a2c0-4c54-9ecf-d218cb8dbfe4", + "queue" : { + "max_queue_size_in_bytes" : 0, + "type" : "memory" + } + }, + { + "id" : "main3", + "ephemeral_id" : "f4167a94-20a8-43e7-828e-4cf38d906187", + "queue" : { + "max_queue_size_in_bytes" : 1.19185342464E11, + "type" : "persisted" + } + } + ] + }, + "cluster_uuid" : "1nmp", + "type" : "logstash_stats" + }, + "fields" : { + "logstash_stats.logstash.uuid" : [ + "1232122-f2b6-4b6c-8cea-22661f9c4134" + ] + }, + "sort" : [ + 1611864976527 + ] + }, + { + "_index": ".monitoring-logstash-7-2021.02.01", + "_type": "_doc", + "_id": "JzNyX3cB1VO1nvgvDXi5", + "_score": null, + "_source": { + "logstash_stats": { + "logstash": { + "version": "7.9.2", + "snapshot": false, + "status": "green" + }, + "pipelines": [ + { + "id": "t2", + "ephemeral_id": "2fcd4161-e08f-4eea-818b-703ea3ec6389", + "queue": { + "max_queue_size_in_bytes": 0, + "type": "memory" + } + }, + { + "id": "aggregate-test", + "ephemeral_id": "c6785d63-6e5f-42c2-839d-5edf139b7c19", + "queue": { + "max_queue_size_in_bytes": 0, + "type": "memory" + } + }, + { + "id": "t1", + "ephemeral_id": "bc6ef6f2-ecce-4328-96a2-002de41a144d", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + } + ] + }, + "cluster_uuid": "mnmp", + "type": "logstash_stats" + }, + "fields": { + "logstash_stats.logstash.uuid": [ + "701ce2f6-4e6a-426b-a959-80e7cdd608aa" + ] + }, + "sort": [ + 1612214046021 + ] + }, + { + "_index": ".monitoring-logstash-7-2021.02.01", + "_type": "_doc", + "_id": "sTNuX3cB1VO1nvgvtXN6", + "_score": null, + "_source": { + "logstash_stats": { + "logstash": { + "version": "7.9.1", + "snapshot": false, + "status": "green" + }, + "pipelines": [ + { + "id": "t9", + "ephemeral_id": "72058ad1-68a1-45f6-a8e8-10621ffc7288", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t7", + "ephemeral_id": "18593052-c021-4158-860d-d8122981a0ac", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t11", + "ephemeral_id": "4207025c-9b00-4bea-a36c-6fbf2d3c215e", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t5", + "ephemeral_id": "0ec4702d-b5e5-4c60-91e9-6fa6a836f0d1", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t12", + "ephemeral_id": "41258219-b129-4fad-a629-f244826281f8", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t3", + "ephemeral_id": "e73bc63d-561a-4acd-a0c4-d5f70c4603df", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t2", + "ephemeral_id": "ddf882b7-be26-4a93-8144-0aeb35122651", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t6", + "ephemeral_id": "602936f5-98a3-4f8c-9471-cf389a519f4b", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t10", + "ephemeral_id": "8b300988-62cc-4bc6-9ee0-9194f3f78e27", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t4", + "ephemeral_id": "6ab60531-fb6f-478c-9063-82f2b0af2bed", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t8", + "ephemeral_id": "802a5994-a03c-44b8-a650-47c0f71c2e48", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t13", + "ephemeral_id": "6070b400-5c10-4c5e-b5c5-a5bd9be6d321", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + }, + { + "id": "t1", + "ephemeral_id": "3193df5f-2a34-4fe3-816e-6b05999aa5ce", + "queue": { + "max_queue_size_in_bytes": 1073741824, + "type": "persisted" + } + } + ] + }, + "cluster_uuid": "mnmp", + "type": "logstash_stats" + }, + "fields": { + "logstash_stats.logstash.uuid": [ + "255eab6a-e15c-4e21-8d81-a9474eadb3eb" + ] + }, + "sort": [ + 1612213826805 + ] + }, + { + "_index": ".monitoring-logstash-7-2021.02.01", + "_type": "_doc", + "_id": "FDNkX3cB1VO1nvgvZWaA", + "_score": null, + "_source": { + "logstash_stats": { + "logstash": { + "version": "7.10.0", + "snapshot": false, + "status": "green" + }, + "pipelines": [ + { + "id": "main", + "ephemeral_id": "994e68cd-d607-40e6-a54c-02a51caa17e0", + "queue": { + "max_queue_size_in_bytes": 0, + "type": "memory" + } + } + ] + }, + "cluster_uuid": "mnmp", + "type": "logstash_stats" + }, + "fields": { + "logstash_stats.logstash.uuid": [ + "beb45ee3-ad5f-4895-87f0-97625a6e5295" + ] + }, + "sort": [ + 1612213150986 + ] + } + ] + } + } +] + diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts index cee95869602649..33487ecafd8c52 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.test.ts @@ -9,7 +9,7 @@ import sinon from 'sinon'; import { getStackStats, getAllStats, handleAllStats } from './get_all_stats'; import { ESClusterStats } from './get_es_stats'; import { KibanaStats } from './get_kibana_stats'; -import { ClustersHighLevelStats } from './get_high_level_stats'; +import { LogstashStatsByClusterUuid } from './get_logstash_stats'; describe('get_all_stats', () => { const timestamp = Date.now(); @@ -145,13 +145,13 @@ describe('get_all_stats', () => { logstash: { count: 1, versions: [{ version: '2.3.4-beta2', count: 1 }], - os: { - platforms: [], - platformReleases: [], - distros: [], - distroReleases: [], + cluster_stats: { + collection_types: { + internal_collection: 1, + }, + pipelines: {}, + plugins: [], }, - cloud: undefined, }, }, }, @@ -188,7 +188,7 @@ describe('get_all_stats', () => { it('handles response', () => { const clusters = handleAllStats(esClusters as ESClusterStats[], { kibana: (kibanaStats as unknown) as KibanaStats, - logstash: (logstashStats as unknown) as ClustersHighLevelStats, + logstash: (logstashStats as unknown) as LogstashStatsByClusterUuid, beats: {}, }); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts index 67a21dc04fd558..60b107cb293426 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_all_stats.ts @@ -19,7 +19,7 @@ import { import { getElasticsearchStats, ESClusterStats } from './get_es_stats'; import { getKibanaStats, KibanaStats } from './get_kibana_stats'; import { getBeatsStats, BeatsStatsByClusterUuid } from './get_beats_stats'; -import { getHighLevelStats, ClustersHighLevelStats } from './get_high_level_stats'; +import { getLogstashStats, LogstashStatsByClusterUuid } from './get_logstash_stats'; /** * Get statistics for all products joined by Elasticsearch cluster. @@ -38,7 +38,7 @@ export async function getAllStats( const [esClusters, kibana, logstash, beats] = await Promise.all([ getElasticsearchStats(callCluster, clusterUuids, maxBucketSize), // cluster_stats, stack_stats.xpack, cluster_name/uuid, license, version getKibanaStats(callCluster, clusterUuids, start, end, maxBucketSize), // stack_stats.kibana - getHighLevelStats(callCluster, clusterUuids, start, end, LOGSTASH_SYSTEM_ID, maxBucketSize), // stack_stats.logstash + getLogstashStats(callCluster, clusterUuids), // stack_stats.logstash getBeatsStats(callCluster, clusterUuids, start, end), // stack_stats.beats ]); @@ -63,7 +63,7 @@ export function handleAllStats( beats, }: { kibana: KibanaStats; - logstash: ClustersHighLevelStats; + logstash: LogstashStatsByClusterUuid; beats: BeatsStatsByClusterUuid; } ) { diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts index f7252c9d893649..63188be142fdd6 100644 --- a/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_high_level_stats.ts @@ -53,7 +53,7 @@ type Counter = Map; * @param {Map} map Map to update the counter for the {@code key}. * @param {String} key The key to increment a counter for. */ -function incrementByKey(map: Counter, key?: string) { +export function incrementByKey(map: Counter, key?: string) { if (!key) { return; } @@ -207,7 +207,7 @@ function groupInstancesByCluster( * { [keyName]: key2, count: value2 } * ] */ -function mapToList(map: Map, keyName: string): T[] { +export function mapToList(map: Map, keyName: string): T[] { const list: T[] = []; for (const [key, count] of map) { diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.test.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.test.ts new file mode 100644 index 00000000000000..f2f0c37255d924 --- /dev/null +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.test.ts @@ -0,0 +1,401 @@ +/* + * 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 { + fetchLogstashStats, + fetchLogstashState, + processStatsResults, + processLogstashStateResults, +} from './get_logstash_stats'; +import sinon from 'sinon'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const logstashStatsResultSet = require('./__mocks__/fixtures/logstash_stats_results'); + +const resultsMap = new Map(); + +// Load data for state results. +['1n1p', '1nmp', 'mnmp'].forEach((data) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + resultsMap.set(data, require(`./__mocks__/fixtures/logstash_state_results_${data}`)); +}); + +const getBaseOptions = () => ({ + clusters: {}, + allEphemeralIds: {}, + versions: {}, + plugins: {}, +}); + +describe('Get Logstash Stats', () => { + const clusterUuids = ['aCluster', 'bCluster', 'cCluster']; + let callCluster = sinon.stub(); + + beforeEach(() => { + callCluster = sinon.stub(); + }); + + describe('fetchLogstashState', () => { + const clusterUuid = 'a'; + const ephemeralIds = ['a', 'b', 'c']; + it('should create the logstash state query correctly', async () => { + const expected = { + bool: { + filter: [ + { + terms: { + 'logstash_state.pipeline.ephemeral_id': ['a', 'b', 'c'], + }, + }, + { + bool: { + must: { + term: { type: 'logstash_state' }, + }, + }, + }, + ], + }, + }; + + await fetchLogstashState(callCluster, clusterUuid, ephemeralIds, {} as any); + const { args } = callCluster.firstCall; + const [api, { body }] = args; + expect(api).toEqual('search'); + expect(body.query).toEqual(expected); + }); + + it('should set `from: 0, to: 10000` in the query', async () => { + await fetchLogstashState(callCluster, clusterUuid, ephemeralIds, {} as any); + const { args } = callCluster.firstCall; + const [api, { body }] = args; + expect(api).toEqual('search'); + expect(body.from).toEqual(0); + expect(body.size).toEqual(10000); + }); + + it('should set `from: 10000, to: 10000` in the query', async () => { + await fetchLogstashState(callCluster, clusterUuid, ephemeralIds, { + page: 1, + } as any); + const { args } = callCluster.firstCall; + const [api, { body }] = args; + + expect(api).toEqual('search'); + expect(body.from).toEqual(10000); + expect(body.size).toEqual(10000); + }); + + it('should set `from: 20000, to: 10000` in the query', async () => { + await fetchLogstashState(callCluster, clusterUuid, ephemeralIds, { + page: 2, + } as any); + const { args } = callCluster.firstCall; + const [api, { body }] = args; + + expect(api).toEqual('search'); + expect(body.from).toEqual(20000); + expect(body.size).toEqual(10000); + }); + }); + + describe('fetchLogstashStats', () => { + it('should set `from: 0, to: 10000` in the query', async () => { + await fetchLogstashStats(callCluster, clusterUuids, {} as any); + const { args } = callCluster.firstCall; + const [api, { body }] = args; + + expect(api).toEqual('search'); + expect(body.from).toEqual(0); + expect(body.size).toEqual(10000); + }); + + it('should set `from: 10000, to: 10000` in the query', async () => { + await fetchLogstashStats(callCluster, clusterUuids, { page: 1 } as any); + const { args } = callCluster.firstCall; + const [api, { body }] = args; + + expect(api).toEqual('search'); + expect(body.from).toEqual(10000); + expect(body.size).toEqual(10000); + }); + + it('should set `from: 20000, to: 10000` in the query', async () => { + await fetchLogstashStats(callCluster, clusterUuids, { page: 2 } as any); + const { args } = callCluster.firstCall; + const [api, { body }] = args; + + expect(api).toEqual('search'); + expect(body.from).toEqual(20000); + expect(body.size).toEqual(10000); + }); + }); + + describe('processLogstashStatsResults', () => { + it('should summarize empty results', () => { + const resultsEmpty = undefined; + + const options = getBaseOptions(); + processStatsResults(resultsEmpty as any, options); + + expect(options.clusters).toStrictEqual({}); + }); + + it('should summarize single result with some missing fields', () => { + const results = { + hits: { + hits: [ + { + _source: { + type: 'logstash_stats', + cluster_uuid: 'FlV4ckTxQ0a78hmBkzzc9A', + logstash_stats: { + logstash: { + uuid: '61de393a-f2b6-4b6c-8cea-22661f9c4134', + }, + pipelines: [ + { + id: 'main', + ephemeral_id: 'cf37c6fa-2f1a-41e2-9a89-36b420a8b9a5', + queue: { + type: 'memory', + }, + }, + ], + }, + }, + }, + ], + }, + }; + + const options = getBaseOptions(); + processStatsResults(results as any, options); + + expect(options.clusters).toStrictEqual({ + FlV4ckTxQ0a78hmBkzzc9A: { + count: 1, + cluster_stats: { + plugins: [], + collection_types: { + internal_collection: 1, + }, + pipelines: {}, + queues: { + memory: 1, + }, + }, + versions: [], + }, + }); + }); + + it('should summarize stats from hits across multiple result objects', () => { + const options = getBaseOptions(); + + // logstashStatsResultSet is an array of many small query results + logstashStatsResultSet.forEach((results: any) => { + processStatsResults(results, options); + }); + + resultsMap.forEach((value: string[], clusterUuid: string) => { + value.forEach((results: any) => { + processLogstashStateResults(results, clusterUuid, options); + }); + }); + + expect(options.clusters).toStrictEqual({ + '1n1p': { + count: 1, + versions: [ + { + count: 1, + version: '7.10.0', + }, + ], + cluster_stats: { + collection_types: { + internal_collection: 1, + }, + pipelines: { + batch_size_avg: 125, + batch_size_max: 125, + batch_size_min: 125, + batch_size_total: 125, + count: 1, + sources: { + file: true, + }, + workers_avg: 1, + workers_max: 1, + workers_min: 1, + workers_total: 1, + }, + plugins: [ + { + count: 1, + name: 'logstash-input-stdin', + }, + { + count: 1, + name: 'logstash-input-elasticsearch', + }, + { + count: 3, + name: 'logstash-filter-mutate', + }, + { + count: 3, + name: 'logstash-filter-ruby', + }, + { + count: 1, + name: 'logstash-filter-split', + }, + { + count: 1, + name: 'logstash-filter-elasticsearch', + }, + { + count: 1, + name: 'logstash-filter-aggregate', + }, + { + count: 1, + name: 'logstash-filter-drop', + }, + { + count: 1, + name: 'logstash-output-elasticsearch', + }, + { + count: 1, + name: 'logstash-output-stdout', + }, + ], + queues: { + memory: 1, + }, + }, + }, + '1nmp': { + count: 1, + versions: [ + { + count: 1, + version: '7.8.0', + }, + ], + cluster_stats: { + collection_types: { + metricbeat: 1, + }, + pipelines: { + batch_size_avg: 406.5, + batch_size_max: 1251, + batch_size_min: 125, + batch_size_total: 1626, + count: 4, + sources: { + xpack: true, + }, + workers_avg: 17.25, + workers_max: 44, + workers_min: 1, + workers_total: 69, + }, + plugins: [ + { + count: 4, + name: 'logstash-input-stdin', + }, + { + count: 4, + name: 'logstash-output-stdout', + }, + ], + queues: { + memory: 3, + persisted: 1, + }, + }, + }, + mnmp: { + count: 3, + versions: [ + { + count: 1, + version: '7.9.2', + }, + { + count: 1, + version: '7.9.1', + }, + { + count: 1, + version: '7.10.0', + }, + ], + cluster_stats: { + collection_types: { + internal_collection: 3, + }, + pipelines: { + batch_size_avg: 33.294117647058826, + batch_size_max: 125, + batch_size_min: 1, + batch_size_total: 566, + count: 17, + sources: { + file: true, + string: true, + }, + workers_avg: 7.411764705882353, + workers_max: 16, + workers_min: 1, + workers_total: 126, + }, + plugins: [ + { + count: 1, + name: 'logstash-input-stdin', + }, + { + count: 1, + name: 'logstash-filter-clone', + }, + { + count: 3, + name: 'logstash-output-pipeline', + }, + { + count: 2, + name: 'logstash-input-pipeline', + }, + { + count: 16, + name: 'logstash-filter-sleep', + }, + { + count: 14, + name: 'logstash-output-stdout', + }, + { + count: 14, + name: 'logstash-input-generator', + }, + ], + queues: { + memory: 3, + persisted: 14, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts new file mode 100644 index 00000000000000..93c69c644c0649 --- /dev/null +++ b/x-pack/plugins/monitoring/server/telemetry_collection/get_logstash_stats.ts @@ -0,0 +1,418 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SearchResponse } from 'elasticsearch'; +import { LegacyAPICaller } from 'kibana/server'; +import { createQuery } from './create_query'; +import { mapToList } from './get_high_level_stats'; +import { incrementByKey } from './get_high_level_stats'; + +import { INDEX_PATTERN_LOGSTASH, TELEMETRY_QUERY_SOURCE } from '../../common/constants'; + +type Counter = Map; + +const HITS_SIZE = 10000; // maximum hits to receive from ES with each search + +export interface LogstashBaseStats { + // stats + versions: Array<{ version: string; count: number }>; + count: number; + + cluster_stats?: { + collection_types?: { [collection_type_type: string]: number }; + queues?: { [queue_type: string]: number }; + plugins?: Array<{ name: string; count: number }>; + pipelines?: { + count?: number; + batch_size_max?: number; + batch_size_avg?: number; + batch_size_min?: number; + batch_size_total?: number; + workers_max?: number; + workers_avg?: number; + workers_min?: number; + workers_total?: number; + sources?: { [source_type: string]: boolean }; + }; + }; +} + +const getLogstashBaseStats = () => ({ + versions: [], + count: 0, + cluster_stats: { + pipelines: {}, + plugins: [], + }, +}); + +export interface LogstashStats { + cluster_uuid: string; + source_node: string; + type: string; + agent?: { + type: string; + }; + logstash_stats?: { + pipelines?: [ + { + id?: string; + ephemeral_id: string; + queue?: { + type: string; + }; + } + ]; + logstash?: { + version?: string; + uuid?: string; + snapshot?: string; + }; + }; +} + +export interface LogstashState { + logstash_state?: { + pipeline?: { + batch_size?: number; + workers?: number; + representation?: { + graph?: { + vertices?: [ + { + config_name?: string; + plugin_type?: string; + meta?: { + source?: { + protocol?: string; + }; + }; + } + ]; + }; + }; + }; + }; +} + +export interface LogstashProcessOptions { + clusters: { [clusterUuid: string]: LogstashBaseStats }; + allEphemeralIds: { [clusterUuid: string]: string[] }; + versions: { [clusterUuid: string]: Counter }; + plugins: { [clusterUuid: string]: Counter }; +} + +/* + * Update a clusters object with processed Logstash stats + * @param {Array} results - array of LogstashStats docs from ES + * @param {Object} clusters - LogstashBaseStats in an object keyed by the cluster UUIDs + * @param {Object} allEphemeralIds - EphemeralIds in an object keyed by cluster UUIDs to track the pipelines for the cluster + * @param {Object} versions - Versions in an object keyed by cluster UUIDs to track the logstash versions for the cluster + * @param {Object} plugins - plugin information keyed by cluster UUIDs to count the unique plugins + */ +export function processStatsResults( + results: SearchResponse, + { clusters, allEphemeralIds, versions, plugins }: LogstashProcessOptions +) { + const currHits = results?.hits?.hits || []; + currHits.forEach((hit) => { + const clusterUuid = hit._source.cluster_uuid; + if (clusters[clusterUuid] === undefined) { + clusters[clusterUuid] = getLogstashBaseStats(); + versions[clusterUuid] = new Map(); + plugins[clusterUuid] = new Map(); + } + const logstashStats = hit._source.logstash_stats; + const clusterStats = clusters[clusterUuid].cluster_stats; + + if (clusterStats !== undefined && logstashStats !== undefined) { + clusters[clusterUuid].count = (clusters[clusterUuid].count || 0) + 1; + + const thisVersion = logstashStats.logstash?.version; + const a: Counter = versions[clusterUuid]; + incrementByKey(a, thisVersion); + clusters[clusterUuid].versions = mapToList(a, 'version'); + + // Internal Collection has no agent field, so default to 'internal_collection' + let thisCollectionType = hit._source.agent?.type; + if (thisCollectionType === undefined) { + thisCollectionType = 'internal_collection'; + } + if (!clusterStats.hasOwnProperty('collection_types')) { + clusterStats.collection_types = {}; + } + clusterStats.collection_types![thisCollectionType] = + (clusterStats.collection_types![thisCollectionType] || 0) + 1; + + const theseEphemeralIds: string[] = []; + const pipelines = logstashStats.pipelines || []; + + pipelines.forEach((pipeline) => { + const thisQueueType = pipeline.queue?.type; + if (thisQueueType !== undefined) { + if (!clusterStats.hasOwnProperty('queues')) { + clusterStats.queues = {}; + } + clusterStats.queues![thisQueueType] = (clusterStats.queues![thisQueueType] || 0) + 1; + } + + const ephemeralId = pipeline.ephemeral_id; + if (ephemeralId !== undefined) { + theseEphemeralIds.push(ephemeralId); + } + }); + allEphemeralIds[clusterUuid] = theseEphemeralIds; + } + }); +} + +/* + * Update a clusters object with logstash state details + * @param {Array} results - array of LogstashState docs from ES + * @param {Object} clusters - LogstashBaseStats in an object keyed by the cluster UUIDs + * @param {Object} plugins - plugin information keyed by cluster UUIDs to count the unique plugins + */ +export function processLogstashStateResults( + results: SearchResponse, + clusterUuid: string, + { clusters, plugins }: LogstashProcessOptions +) { + const currHits = results?.hits?.hits || []; + const clusterStats = clusters[clusterUuid].cluster_stats; + const pipelineStats = clusters[clusterUuid].cluster_stats?.pipelines; + + currHits.forEach((hit) => { + const thisLogstashStatePipeline = hit._source.logstash_state?.pipeline; + + if (pipelineStats !== undefined && thisLogstashStatePipeline !== undefined) { + pipelineStats.count = (pipelineStats.count || 0) + 1; + + const thisPipelineBatchSize = thisLogstashStatePipeline.batch_size; + + if (thisPipelineBatchSize !== undefined) { + pipelineStats.batch_size_total = + (pipelineStats.batch_size_total || 0) + thisPipelineBatchSize; + pipelineStats.batch_size_max = pipelineStats.batch_size_max || 0; + pipelineStats.batch_size_min = pipelineStats.batch_size_min || 0; + pipelineStats.batch_size_avg = pipelineStats.batch_size_total / pipelineStats.count; + + if (thisPipelineBatchSize > pipelineStats.batch_size_max) { + pipelineStats.batch_size_max = thisPipelineBatchSize; + } + if ( + pipelineStats.batch_size_min === 0 || + thisPipelineBatchSize < pipelineStats.batch_size_min + ) { + pipelineStats.batch_size_min = thisPipelineBatchSize; + } + } + + const thisPipelineWorkers = thisLogstashStatePipeline.workers; + if (thisPipelineWorkers !== undefined) { + pipelineStats.workers_total = (pipelineStats.workers_total || 0) + thisPipelineWorkers; + pipelineStats.workers_max = pipelineStats.workers_max || 0; + pipelineStats.workers_min = pipelineStats.workers_min || 0; + pipelineStats.workers_avg = pipelineStats.workers_total / pipelineStats.count; + + if (thisPipelineWorkers > pipelineStats.workers_max) { + pipelineStats.workers_max = thisPipelineWorkers; + } + if (pipelineStats.workers_min === 0 || thisPipelineWorkers < pipelineStats.workers_min) { + pipelineStats.workers_min = thisPipelineWorkers; + } + } + + // Extract the vertices object from the pipeline representation. From this, we can + // retrieve the source of the pipeline element on the configuration(from file, string, or + // x-pack-config-management), and the input, filter and output plugins from that pipeline. + const vertices = thisLogstashStatePipeline.representation?.graph?.vertices; + + if (vertices !== undefined) { + vertices.forEach((vertex) => { + const configName = vertex.config_name; + const pluginType = vertex.plugin_type; + let pipelineConfig = vertex.meta?.source?.protocol; + + if (pipelineConfig !== undefined) { + if (pipelineConfig === 'string' || pipelineConfig === 'str') { + pipelineConfig = 'string'; + } else if (pipelineConfig === 'x-pack-config-management') { + pipelineConfig = 'xpack'; + } else { + pipelineConfig = 'file'; + } + if (!pipelineStats.hasOwnProperty('sources')) { + pipelineStats.sources = {}; + } + pipelineStats.sources![pipelineConfig] = true; + } + if (configName !== undefined && pluginType !== undefined) { + incrementByKey(plugins[clusterUuid], `logstash-${pluginType}-${configName}`); + } + }); + } + } + }); + if (clusterStats !== undefined) { + clusterStats.plugins = mapToList(plugins[clusterUuid], 'name'); + } +} + +export async function fetchLogstashStats( + callCluster: LegacyAPICaller, + clusterUuids: string[], + { page = 0, ...options }: { page?: number } & LogstashProcessOptions +): Promise { + const params = { + headers: { + 'X-QUERY-SOURCE': TELEMETRY_QUERY_SOURCE, + }, + index: INDEX_PATTERN_LOGSTASH, + ignoreUnavailable: true, + filterPath: [ + 'hits.hits._source.cluster_uuid', + 'hits.hits._source.type', + 'hits.hits._source.source_node', + 'hits.hits._source.agent.type', + 'hits.hits._source.logstash_stats.pipelines.id', + 'hits.hits._source.logstash_stats.pipelines.ephemeral_id', + 'hits.hits._source.logstash_stats.pipelines.queue.type', + 'hits.hits._source.logstash_stats.logstash.version', + 'hits.hits._source.logstash_stats.logstash.uuid', + ], + body: { + query: createQuery({ + filters: [ + { terms: { cluster_uuid: clusterUuids } }, + { + bool: { + must: { term: { type: 'logstash_stats' } }, + }, + }, + ], + }), + from: page * HITS_SIZE, + collapse: { field: 'logstash_stats.logstash.uuid' }, + sort: [{ ['logstash_stats.timestamp']: { order: 'desc', unmapped_type: 'long' } }], + size: HITS_SIZE, + }, + }; + + const results = await callCluster>('search', params); + const hitsLength = results?.hits?.hits.length || 0; + + if (hitsLength > 0) { + // further augment the clusters object with more stats + processStatsResults(results, options); + + if (hitsLength === HITS_SIZE) { + // call recursively + const nextOptions = { + page: page + 1, + ...options, + }; + + // returns a promise and keeps the caller blocked from returning until the entire clusters object is built + return fetchLogstashStats(callCluster, clusterUuids, nextOptions); + } + } + return Promise.resolve(); +} + +export async function fetchLogstashState( + callCluster: LegacyAPICaller, + clusterUuid: string, + ephemeralIds: string[], + { page = 0, ...options }: { page?: number } & LogstashProcessOptions +): Promise { + const params = { + headers: { + 'X-QUERY-SOURCE': TELEMETRY_QUERY_SOURCE, + }, + index: INDEX_PATTERN_LOGSTASH, + ignoreUnavailable: true, + filterPath: [ + 'hits.hits._source.logstash_state.pipeline.batch_size', + 'hits.hits._source.logstash_state.pipeline.workers', + 'hits.hits._source.logstash_state.pipeline.representation.graph.vertices.config_name', + 'hits.hits._source.logstash_state.pipeline.representation.graph.vertices.plugin_type', + 'hits.hits._source.logstash_state.pipeline.representation.graph.vertices.meta.source.protocol', + 'hits.hits._source.logstash_state.pipeline.representation.graph.vertices', + 'hits.hits._source.type', + ], + body: { + query: createQuery({ + filters: [ + { terms: { 'logstash_state.pipeline.ephemeral_id': ephemeralIds } }, + { + bool: { + must: { term: { type: 'logstash_state' } }, + }, + }, + ], + }), + from: page * HITS_SIZE, + collapse: { field: 'logstash_state.pipeline.ephemeral_id' }, + sort: [{ ['timestamp']: { order: 'desc', unmapped_type: 'long' } }], + size: HITS_SIZE, + }, + }; + + const results = await callCluster>('search', params); + const hitsLength = results?.hits?.hits.length || 0; + if (hitsLength > 0) { + // further augment the clusters object with more stats + processLogstashStateResults(results, clusterUuid, options); + + if (hitsLength === HITS_SIZE) { + // call recursively + const nextOptions = { + page: page + 1, + ...options, + }; + + // returns a promise and keeps the caller blocked from returning until the entire clusters object is built + return fetchLogstashState(callCluster, clusterUuid, ephemeralIds, nextOptions); + } + } + return Promise.resolve(); +} + +export interface LogstashStatsByClusterUuid { + [clusterUuid: string]: LogstashBaseStats; +} + +/* + * Call the function for fetching and summarizing Logstash stats + * @return {Object} - Logstash stats in an object keyed by the cluster UUIDs + */ +export async function getLogstashStats( + callCluster: LegacyAPICaller, + clusterUuids: string[] +): Promise { + const options: LogstashProcessOptions = { + clusters: {}, // the result object to be built up + allEphemeralIds: {}, + versions: {}, + plugins: {}, + }; + + await fetchLogstashStats(callCluster, clusterUuids, options); + await Promise.all( + clusterUuids.map(async (clusterUuid) => { + if (options.clusters[clusterUuid] !== undefined) { + await fetchLogstashState( + callCluster, + clusterUuid, + options.allEphemeralIds[clusterUuid], + options + ); + } + }) + ); + return options.clusters; +} diff --git a/x-pack/plugins/monitoring/tsconfig.json b/x-pack/plugins/monitoring/tsconfig.json new file mode 100644 index 00000000000000..1835c4a75d9d47 --- /dev/null +++ b/x-pack/plugins/monitoring/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_legacy/tsconfig.json" }, + { "path": "../../../src/plugins/navigation/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../alerts/tsconfig.json" }, + { "path": "../actions/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../encrypted_saved_objects/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../license_management/tsconfig.json" }, + { "path": "../observability/tsconfig.json" }, + { "path": "../telemetry_collection_xpack/tsconfig.json" }, + { "path": "../triggers_actions_ui/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index fa2aa5f4e60dff..e118d17e17c3fd 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -10,7 +10,7 @@ import { PluginInitializerContext } from 'src/core/server'; import { ObservabilityPlugin, ObservabilityPluginSetup } from './plugin'; import { createOrUpdateIndex, MappingsDefinition } from './utils/create_or_update_index'; import { ScopedAnnotationsClient } from './lib/annotations/bootstrap_annotations'; -import { unwrapEsResponse } from './utils/unwrap_es_response'; +import { unwrapEsResponse, WrappedElasticsearchClientError } from './utils/unwrap_es_response'; export const config = { schema: schema.object({ @@ -33,4 +33,5 @@ export { ObservabilityPluginSetup, ScopedAnnotationsClient, unwrapEsResponse, + WrappedElasticsearchClientError, }; diff --git a/x-pack/plugins/observability/server/utils/unwrap_es_response.ts b/x-pack/plugins/observability/server/utils/unwrap_es_response.ts index 0bbd53061f8512..81f8be4e0f696d 100644 --- a/x-pack/plugins/observability/server/utils/unwrap_es_response.ts +++ b/x-pack/plugins/observability/server/utils/unwrap_es_response.ts @@ -4,11 +4,45 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; import type { UnwrapPromise } from '@kbn/utility-types'; +import { inspect } from 'util'; + +export class WrappedElasticsearchClientError extends Error { + originalError: ElasticsearchClientError; + constructor(originalError: ElasticsearchClientError) { + super(originalError.message); + + const stack = this.stack; + + this.originalError = originalError; + + if (originalError instanceof ResponseError) { + // make sure ES response body is visible when logged to the console + // @ts-expect-error + this.stack = { + valueOf() { + const value = stack?.valueOf() ?? ''; + return value; + }, + toString() { + const value = + stack?.toString() + + `\nResponse: ${inspect(originalError.meta.body, { depth: null })}\n`; + return value; + }, + }; + } + } +} export function unwrapEsResponse>( responsePromise: T ): Promise['body']> { - return responsePromise.then((res) => res.body); + return responsePromise + .then((res) => res.body) + .catch((err) => { + // make sure stacktrace is relative to where client was called + throw new WrappedElasticsearchClientError(err); + }); } diff --git a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js index fd9f4e3503d106..4db5d1b333b7cf 100644 --- a/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js +++ b/x-pack/plugins/remote_clusters/public/application/sections/remote_cluster_list/components/remove_cluster_button_provider/remove_cluster_button_provider.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; export class RemoveClusterButtonProvider extends Component { static propTypes = { @@ -84,7 +84,7 @@ export class RemoveClusterButtonProvider extends Component { ); modal = ( - + <> {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */} {!isSingleCluster && content} - + ); } diff --git a/x-pack/plugins/remote_clusters/public/application/services/api.js b/x-pack/plugins/remote_clusters/public/application/services/api.js index c0d21f577dae87..6dd04b70902830 100644 --- a/x-pack/plugins/remote_clusters/public/application/services/api.js +++ b/x-pack/plugins/remote_clusters/public/application/services/api.js @@ -9,8 +9,8 @@ import { UIM_CLUSTER_ADD, UIM_CLUSTER_UPDATE } from '../constants'; import { trackUserRequest } from './ui_metric'; import { sendGet, sendPost, sendPut, sendDelete } from './http'; -export async function loadClusters() { - return await sendGet(); +export async function loadClusters(options) { + return await sendGet(undefined, options); } export async function addCluster(cluster) { diff --git a/x-pack/plugins/remote_clusters/public/application/services/http.ts b/x-pack/plugins/remote_clusters/public/application/services/http.ts index 7f205023dfa8a6..831e706b5fa083 100644 --- a/x-pack/plugins/remote_clusters/public/application/services/http.ts +++ b/x-pack/plugins/remote_clusters/public/application/services/http.ts @@ -10,11 +10,15 @@ import { API_BASE_PATH } from '../../../common/constants'; let _httpClient: HttpSetup; +interface SendGetOptions { + asSystemRequest?: boolean; +} + export function init(httpClient: HttpSetup): void { _httpClient = httpClient; } -export function getFullPath(path: string): string { +export function getFullPath(path?: string): string { if (path) { return `${API_BASE_PATH}/${path}`; } @@ -35,8 +39,11 @@ export function sendPost( }); } -export function sendGet(path: string): Promise { - return _httpClient.get(getFullPath(path)); +export function sendGet( + path?: string, + { asSystemRequest }: SendGetOptions = {} +): Promise { + return _httpClient.get(getFullPath(path), { asSystemRequest }); } export function sendPut( diff --git a/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js b/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js index 8a765e171a8af2..3dae779f0dc789 100644 --- a/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js +++ b/x-pack/plugins/remote_clusters/public/application/store/actions/refresh_clusters.js @@ -14,7 +14,7 @@ import { REFRESH_CLUSTERS_SUCCESS } from '../action_types'; export const refreshClusters = () => async (dispatch) => { let clusters; try { - clusters = await sendLoadClustersRequest(); + clusters = await sendLoadClustersRequest({ asSystemRequest: true }); } catch (error) { return showApiWarning( error, diff --git a/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap b/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap index 09e487591c164a..692b410bd7e5f3 100644 --- a/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap +++ b/x-pack/plugins/reporting/public/components/buttons/__snapshots__/report_info_button.test.tsx.snap @@ -58,7 +58,7 @@ Array [ >
    ,
    ,
    { }); return ( - - this.hideConfirm()} - onConfirm={() => this.props.performDelete()} - confirmButtonText={confirmButtonText} - cancelButtonText={cancelButtonText} - defaultFocusedButton="confirm" - buttonColor="danger" - > - {message} - - + this.hideConfirm()} + onConfirm={() => this.props.performDelete()} + confirmButtonText={confirmButtonText} + cancelButtonText={cancelButtonText} + defaultFocusedButton="confirm" + buttonColor="danger" + > + {message} + ); } diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.test.ts new file mode 100644 index 00000000000000..e2d44b43b674ba --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getFont } from './get_font'; + +describe('getFont', () => { + it(`returns 'noto-cjk' when matching cjk characters`, () => { + const cjkStrings = [ + 'vi-Hani: 关', + 'ko: 全', + 'ja: 入', + 'zh-Hant-HK: 免', + 'zh-Hant: 令', + 'zh-Hans: 令', + 'random: おあいい 漢字 あい 抵 令', + String.fromCharCode(0x4ee4), + String.fromCodePoint(0x9aa8), + ]; + + for (const cjkString of cjkStrings) { + expect(getFont(cjkString)).toBe('noto-cjk'); + } + }); + + it(`returns 'Roboto' for non Han characters`, () => { + expect(getFont('English text')).toBe('Roboto'); + expect(getFont('')).toBe('Roboto'); + expect(getFont(undefined!)).toBe('Roboto'); + }); +}); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts index e27489b5d66bc8..4997d37327102d 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts @@ -5,16 +5,11 @@ * 2.0. */ -// @ts-ignore: no module definition -import xRegExp from 'xregexp'; - export function getFont(text: string) { - // Once unicode regex scripts are fully supported we should be able to get rid of the dependency - // on xRegExp library. See https://github.com/tc39/proposal-regexp-unicode-property-escapes - // for more information. We are matching Han characters which is one of the supported unicode scripts + // We are matching Han characters which is one of the supported unicode scripts // (you can see the full list of supported scripts here: http://www.unicode.org/standard/supported.html). // This will match Chinese, Japanese, Korean and some other Asian languages. - const isCKJ = xRegExp('\\p{Han}').test(text, 'g'); + const isCKJ = /\p{Script=Han}/gu.test(text); if (isCKJ) { return 'noto-cjk'; } else { diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js index 650d63f38eeb52..ec7473c69dec19 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; export class ConfirmDeleteModal extends Component { static propTypes = { @@ -91,28 +91,26 @@ export class ConfirmDeleteModal extends Component { } return ( - - - {content} - - + + {content} + ); } } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js index e71b3b68702675..ce7e29af8323da 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.container.js @@ -32,8 +32,8 @@ const mapDispatchToProps = (dispatch) => { loadJobs: () => { dispatch(loadJobs()); }, - refreshJobs: () => { - dispatch(refreshJobs()); + refreshJobs: (options) => { + dispatch(refreshJobs(options)); }, openDetailPanel: (jobId) => { dispatch(openDetailPanel({ jobId: jobId })); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js index d5038f40a686b4..589546a11ef38e 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js @@ -73,7 +73,10 @@ export class JobListUi extends Component { } componentDidMount() { - this.interval = setInterval(this.props.refreshJobs, REFRESH_RATE_MS); + this.interval = setInterval( + () => this.props.refreshJobs({ asSystemRequest: true }), + REFRESH_RATE_MS + ); } componentWillUnmount() { diff --git a/x-pack/plugins/rollup/public/crud_app/services/api.js b/x-pack/plugins/rollup/public/crud_app/services/api.js index 66efb6c2f09a0f..b12cc62c9daa89 100644 --- a/x-pack/plugins/rollup/public/crud_app/services/api.js +++ b/x-pack/plugins/rollup/public/crud_app/services/api.js @@ -19,8 +19,9 @@ import { trackUserRequest } from './track_ui_metric'; const apiPrefix = '/api/rollup'; -export async function loadJobs() { - const { jobs } = await getHttp().get(`${apiPrefix}/jobs`); +export async function loadJobs({ asSystemRequest } = {}) { + const fetchOptions = { asSystemRequest }; + const { jobs } = await getHttp().get(`${apiPrefix}/jobs`, fetchOptions); return jobs; } diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js b/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js index 37b6e7a893fbe4..562341a020523a 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/refresh_jobs.js @@ -10,10 +10,10 @@ import { i18n } from '@kbn/i18n'; import { loadJobs as sendLoadJobsRequest, deserializeJobs, showApiWarning } from '../../services'; import { REFRESH_JOBS_SUCCESS } from '../action_types'; -export const refreshJobs = () => async (dispatch) => { +export const refreshJobs = (options) => async (dispatch) => { let jobs; try { - jobs = await sendLoadJobsRequest(); + jobs = await sendLoadJobsRequest(options); } catch (error) { return showApiWarning( error, diff --git a/x-pack/plugins/saved_objects_tagging/common/types.ts b/x-pack/plugins/saved_objects_tagging/common/types.ts index bd65f74044bc10..c0b92a71a3d1b7 100644 --- a/x-pack/plugins/saved_objects_tagging/common/types.ts +++ b/x-pack/plugins/saved_objects_tagging/common/types.ts @@ -21,5 +21,6 @@ export type TagWithRelations = Tag & { export type { Tag, TagAttributes, + GetAllTagsOptions, ITagsClient, } from '../../../../src/plugins/saved_objects_tagging_oss/common'; diff --git a/x-pack/plugins/saved_objects_tagging/public/plugin.ts b/x-pack/plugins/saved_objects_tagging/public/plugin.ts index 9821bfb3978027..d4e3f8678fe1f5 100644 --- a/x-pack/plugins/saved_objects_tagging/public/plugin.ts +++ b/x-pack/plugins/saved_objects_tagging/public/plugin.ts @@ -66,7 +66,7 @@ export class SavedObjectTaggingPlugin public start({ http, application, overlays }: CoreStart) { this.tagCache = new TagsCache({ - refreshHandler: () => this.tagClient!.getAll(), + refreshHandler: () => this.tagClient!.getAll({ asSystemRequest: true }), refreshInterval: this.config.cacheRefreshInterval, }); this.tagClient = new TagsClient({ http, changeListener: this.tagCache }); diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts index 39e2df073591e0..24409e8596265e 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.test.ts @@ -156,7 +156,17 @@ describe('TagsClient', () => { await tagsClient.getAll(); expect(http.get).toHaveBeenCalledTimes(1); - expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`); + expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`, { + asSystemRequest: undefined, + }); + }); + it('allows `asSystemRequest` option to be set', async () => { + await tagsClient.getAll({ asSystemRequest: true }); + + expect(http.get).toHaveBeenCalledTimes(1); + expect(http.get).toHaveBeenCalledWith(`/api/saved_objects_tagging/tags`, { + asSystemRequest: true, + }); }); it('returns the tag objects from the response', async () => { const tags = await tagsClient.getAll(); diff --git a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts index 8a99af7af6d024..ef484f0a550b1a 100644 --- a/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts +++ b/x-pack/plugins/saved_objects_tagging/public/services/tags/tags_client.ts @@ -6,7 +6,13 @@ */ import { HttpSetup } from 'src/core/public'; -import { Tag, TagAttributes, ITagsClient, TagWithRelations } from '../../../common/types'; +import { + Tag, + TagAttributes, + GetAllTagsOptions, + ITagsClient, + TagWithRelations, +} from '../../../common/types'; import { ITagsChangeListener } from './tags_cache'; export interface TagsClientOptions { @@ -83,8 +89,12 @@ export class TagsClient implements ITagInternalClient { return tag; } - public async getAll() { - const { tags } = await this.http.get<{ tags: Tag[] }>('/api/saved_objects_tagging/tags'); + public async getAll({ asSystemRequest }: GetAllTagsOptions = {}) { + const fetchOptions = { asSystemRequest }; + const { tags } = await this.http.get<{ tags: Tag[] }>( + '/api/saved_objects_tagging/tags', + fetchOptions + ); trapErrors(() => { if (this.changeListener) { diff --git a/x-pack/plugins/security/public/components/confirm_modal.tsx b/x-pack/plugins/security/public/components/confirm_modal.tsx index d0ca1de07314e2..3802ee368d735b 100644 --- a/x-pack/plugins/security/public/components/confirm_modal.tsx +++ b/x-pack/plugins/security/public/components/confirm_modal.tsx @@ -18,7 +18,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalProps, - EuiOverlayMask, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -29,13 +28,11 @@ export interface ConfirmModalProps extends Omit = ({ children, @@ -45,51 +42,42 @@ export const ConfirmModal: FunctionComponent = ({ isDisabled, onCancel, onConfirm, - ownFocus = true, title, ...rest -}) => { - const modal = ( - - - {title} - - {children} - - - - - - - - - - {confirmButtonText} - - - - - - ); - - return ownFocus ? ( - {modal} - ) : ( - modal - ); -}; +}) => ( + + + {title} + + {children} + + + + + + + + + + {confirmButtonText} + + + + + +); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx index ae142e76877cef..232847b63cb1ab 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/invalidate_provider/invalidate_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useRef, useState } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'src/core/public'; @@ -127,58 +127,56 @@ export const InvalidateProvider: React.FunctionComponent = ({ const isSingle = apiKeys.length === 1; return ( - - - {!isSingle ? ( - -

    - {i18n.translate( - 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription', - { defaultMessage: 'You are about to invalidate these API keys:' } - )} -

    -
      - {apiKeys.map(({ name, id }) => ( -
    • {name}
    • - ))} -
    -
    - ) : null} -
    -
    + )} + buttonColor="danger" + data-test-subj="invalidateApiKeyConfirmationModal" + > + {!isSingle ? ( + +

    + {i18n.translate( + 'xpack.security.management.apiKeys.invalidateApiKey.confirmModal.invalidateMultipleListDescription', + { defaultMessage: 'You are about to invalidate these API keys:' } + )} +

    +
      + {apiKeys.map(({ name, id }) => ( +
    • {name}
    • + ))} +
    +
    + ) : null} + ); }; diff --git a/x-pack/plugins/security/public/management/role_mappings/components/delete_provider/delete_provider.tsx b/x-pack/plugins/security/public/management/role_mappings/components/delete_provider/delete_provider.tsx index c5e6e3cb9860d0..680a4a40a7d9a1 100644 --- a/x-pack/plugins/security/public/management/role_mappings/components/delete_provider/delete_provider.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/components/delete_provider/delete_provider.tsx @@ -6,7 +6,7 @@ */ import React, { Fragment, useRef, useState, ReactElement } from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { NotificationsStart } from 'src/core/public'; @@ -140,59 +140,57 @@ export const DeleteProvider: React.FunctionComponent = ({ const isSingle = roleMappings.length === 1; return ( - - - {!isSingle ? ( - -

    - {i18n.translate( - 'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleListDescription', - { defaultMessage: 'You are about to delete these role mappings:' } - )} -

    -
      - {roleMappings.map(({ name }) => ( -
    • {name}
    • - ))} -
    -
    - ) : null} -
    -
    + )} + confirmButtonDisabled={isDeleteInProgress} + buttonColor="danger" + data-test-subj="deleteRoleMappingConfirmationModal" + > + {!isSingle ? ( + +

    + {i18n.translate( + 'xpack.security.management.roleMappings.deleteRoleMapping.confirmModal.deleteMultipleListDescription', + { defaultMessage: 'You are about to delete these role mappings:' } + )} +

    +
      + {roleMappings.map(({ name }) => ( +
    • {name}
    • + ))} +
    +
    + ) : null} + ); }; diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.tsx index b094f78a53e778..d027a1aeb7e1fc 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_editor_panel.tsx @@ -9,7 +9,6 @@ import React, { Component, Fragment } from 'react'; import { EuiSpacer, EuiConfirmModal, - EuiOverlayMask, EuiCallOut, EuiErrorBoundary, EuiIcon, @@ -228,40 +227,38 @@ export class RuleEditorPanel extends Component { return null; } return ( - - - } - onCancel={() => this.setState({ showConfirmModeChange: false })} - onConfirm={() => { - this.setState({ mode: 'visual', showConfirmModeChange: false }); - this.onValidityChange(true); - }} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -

    - -

    -
    -
    + + } + onCancel={() => this.setState({ showConfirmModeChange: false })} + onConfirm={() => { + this.setState({ mode: 'visual', showConfirmModeChange: false }); + this.onValidityChange(true); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

    + +

    +
    ); }; diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_group_title.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_group_title.tsx index 6e94abfb3f4a20..478e8d87abf95c 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_group_title.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/rule_editor_panel/rule_group_title.tsx @@ -12,7 +12,6 @@ import { EuiContextMenuItem, EuiLink, EuiIcon, - EuiOverlayMask, EuiConfirmModal, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -87,45 +86,43 @@ export const RuleGroupTitle = (props: Props) => { ); const confirmChangeModal = showConfirmChangeModal ? ( - - - } - onCancel={() => { - setShowConfirmChangeModal(false); - setPendingNewRule(null); - }} - onConfirm={() => { - setShowConfirmChangeModal(false); - changeRuleDiscardingSubRules(pendingNewRule!); - setPendingNewRule(null); - }} - cancelButtonText={ - - } - confirmButtonText={ - - } - > -

    - -

    -
    -
    + + } + onCancel={() => { + setShowConfirmChangeModal(false); + setPendingNewRule(null); + }} + onConfirm={() => { + setShowConfirmChangeModal(false); + changeRuleDiscardingSubRules(pendingNewRule!); + setPendingNewRule(null); + }} + cancelButtonText={ + + } + confirmButtonText={ + + } + > +

    + +

    +
    ) : null; return ( diff --git a/x-pack/plugins/security/public/management/roles/edit_role/delete_role_button.tsx b/x-pack/plugins/security/public/management/roles/edit_role/delete_role_button.tsx index bd3c86575c61a4..1b3a7fa024dd16 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/delete_role_button.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/delete_role_button.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiButtonEmpty, EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiButtonEmpty, EuiConfirmModal } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; @@ -46,44 +46,42 @@ export class DeleteRoleButton extends Component { return null; } return ( - - - } - onCancel={this.closeModal} - onConfirm={this.onConfirmDelete} - cancelButtonText={ - - } - confirmButtonText={ - - } - buttonColor={'danger'} - > -

    - -

    -

    - -

    -
    -
    + + } + onCancel={this.closeModal} + onConfirm={this.onConfirmDelete} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor={'danger'} + > +

    + +

    +

    + +

    +
    ); }; diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/confirm_delete/confirm_delete.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/confirm_delete/confirm_delete.tsx index dbbb09f1598b63..81302465bb3732 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/confirm_delete/confirm_delete.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/confirm_delete/confirm_delete.tsx @@ -14,7 +14,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -55,65 +54,61 @@ export class ConfirmDelete extends Component { // to disable the buttons since this could be a long-running operation return ( - - - - - {title} - - - - - {moreThanOne ? ( - -

    - -

    -
      - {rolesToDelete.map((roleName) => ( -
    • {roleName}
    • - ))} -
    -
    - ) : null} -

    - -

    -
    -
    - - + + + {title} + + + + {moreThanOne ? ( + +

    + +

    +
      + {rolesToDelete.map((roleName) => ( +
    • {roleName}
    • + ))} +
    +
    + ) : null} +

    - +

    +
    +
    + + + + - - - - -
    -
    + + + + + ); } diff --git a/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx b/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx index c670a9ce99f5bb..38adca145dfc5d 100644 --- a/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx +++ b/x-pack/plugins/security/public/management/users/components/confirm_delete_users/confirm_delete_users.tsx @@ -6,7 +6,7 @@ */ import React, { Component, Fragment } from 'react'; -import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import type { PublicMethodsOf } from '@kbn/utility-types'; @@ -35,46 +35,44 @@ export class ConfirmDeleteUsers extends Component { values: { userLength: usersToDelete[0] }, }); return ( - - -
    - {moreThanOne ? ( - -

    - -

    -
      - {usersToDelete.map((username) => ( -
    • {username}
    • - ))} -
    -
    - ) : null} -

    - -

    -
    -
    -
    + +
    + {moreThanOne ? ( + +

    + +

    +
      + {usersToDelete.map((username) => ( +
    • {username}
    • + ))} +
    +
    + ) : null} +

    + +

    +
    +
    ); } diff --git a/x-pack/plugins/security/public/management/users/edit_user/confirm_delete_users.tsx b/x-pack/plugins/security/public/management/users/edit_user/confirm_delete_users.tsx index 9e8745538e0ed3..189f0c3845d635 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/confirm_delete_users.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/confirm_delete_users.tsx @@ -68,7 +68,6 @@ export const ConfirmDeleteUsers: FunctionComponent = ({ )} confirmButtonColor="danger" isLoading={state.loading} - ownFocus >

    diff --git a/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx b/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx index 793f0e6c2a420b..e0fb4e554ee3c4 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/confirm_disable_users.tsx @@ -80,7 +80,6 @@ export const ConfirmDisableUsers: FunctionComponent = } confirmButtonColor={isSystemUser ? 'danger' : undefined} isLoading={state.loading} - ownFocus > {isSystemUser ? ( diff --git a/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx b/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx index a1aac5bc0a8cb4..2cb4cf8b4a9e2c 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/confirm_enable_users.tsx @@ -67,7 +67,6 @@ export const ConfirmEnableUsers: FunctionComponent = ({ } )} isLoading={state.loading} - ownFocus >

    diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index 3b07d766d7cb46..f59fd6ecdec919 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -76,9 +76,9 @@ describe('#setup', () => { config: { enabled: true, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }, @@ -102,9 +102,9 @@ describe('#setup', () => { config: { enabled: true, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }, @@ -251,9 +251,9 @@ describe('#createLoggingConfig', () => { createLoggingConfig({ enabled: true, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }) @@ -264,10 +264,10 @@ describe('#createLoggingConfig', () => { Object { "appenders": Object { "auditTrailAppender": Object { - "kind": "console", "layout": Object { - "kind": "pattern", + "type": "pattern", }, + "type": "console", }, }, "loggers": Array [ @@ -275,8 +275,8 @@ describe('#createLoggingConfig', () => { "appenders": Array [ "auditTrailAppender", ], - "context": "audit.ecs", "level": "info", + "name": "audit.ecs", }, ], } @@ -293,9 +293,9 @@ describe('#createLoggingConfig', () => { createLoggingConfig({ enabled: false, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }) @@ -331,9 +331,9 @@ describe('#createLoggingConfig', () => { createLoggingConfig({ enabled: true, appender: { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', }, }, }) diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index 42e36e50d6d42d..99dd2c82ec9fe5 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -224,16 +224,16 @@ export const createLoggingConfig = (config: ConfigType['audit']) => map, LoggerContextConfigInput>((features) => ({ appenders: { auditTrailAppender: config.appender ?? { - kind: 'console', + type: 'console', layout: { - kind: 'pattern', + type: 'pattern', highlight: true, }, }, }, loggers: [ { - context: 'audit.ecs', + name: 'audit.ecs', level: config.enabled && config.appender && features.allowAuditLogging ? 'info' : 'off', appenders: ['auditTrailAppender'], }, 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 76d284a21984e9..04190fbf5eacdd 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,12 +1,12 @@ // 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. Error: [application.foo-application]: [action3]: expected value of type [boolean] but got [string]"`; +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]: 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. Error: [application.foo-application]: [action2]: expected value of type [boolean] but got [undefined]"`; +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]: Payload did not match expected actions"`; 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. Error: [application.foo-application]: [action4]: definition for this key is missing"`; +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]: Payload did not match expected actions"`; 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"`; 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 93f5efed58fb8d..5bca46f22a5123 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. Error: [application.kibana-our_application]: [saved_object:bar-type/get]: definition for this key is missing]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); @@ -338,7 +338,7 @@ describe('#atSpace', () => { }, }); expect(result).toMatchInlineSnapshot( - `[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]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); }); @@ -1092,7 +1092,7 @@ describe('#atSpaces', () => { }, }); expect(result).toMatchInlineSnapshot( - `[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]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); @@ -2266,7 +2266,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[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]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); @@ -2384,7 +2384,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[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]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); @@ -2405,7 +2405,7 @@ describe('#globally', () => { }, }); expect(result).toMatchInlineSnapshot( - `[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]]` + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. Error: [application.kibana-our_application]: Payload did not match expected actions]` ); }); }); 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 19afaaf035c15e..270ff26716e3f2 100644 --- a/x-pack/plugins/security/server/authorization/validate_es_response.ts +++ b/x-pack/plugins/security/server/authorization/validate_es_response.ts @@ -8,6 +8,11 @@ import { schema } from '@kbn/config-schema'; import { HasPrivilegesResponse } from './types'; +/** + * Validates an Elasticsearch "Has privileges" response against the expected application, actions, and resources. + * + * Note: the `actions` and `resources` parameters must be unique string arrays; any duplicates will cause validation to fail. + */ export function validateEsPrivilegeResponse( response: HasPrivilegesResponse, application: string, @@ -24,21 +29,29 @@ export function validateEsPrivilegeResponse( return response; } -function buildActionsValidationSchema(actions: string[]) { - return schema.object({ - ...actions.reduce>((acc, action) => { - return { - ...acc, - [action]: schema.boolean(), - }; - }, {}), - }); -} - function buildValidationSchema(application: string, actions: string[], resources: string[]) { - const actionValidationSchema = buildActionsValidationSchema(actions); + const actionValidationSchema = schema.boolean(); + const actionsValidationSchema = schema.object( + {}, + { + unknowns: 'allow', + validate: (value) => { + const actualActions = Object.keys(value).sort(); + if ( + actions.length !== actualActions.length || + ![...actions].sort().every((x, i) => x === actualActions[i]) + ) { + throw new Error('Payload did not match expected actions'); + } + + Object.values(value).forEach((actionResult) => { + actionValidationSchema.validate(actionResult); + }); + }, + } + ); - const resourceValidationSchema = schema.object( + const resourcesValidationSchema = schema.object( {}, { unknowns: 'allow', @@ -46,13 +59,13 @@ function buildValidationSchema(application: string, actions: string[], resources const actualResources = Object.keys(value).sort(); if ( resources.length !== actualResources.length || - !resources.sort().every((x, i) => x === actualResources[i]) + ![...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); + actionsValidationSchema.validate(actionResult); }); }, } @@ -63,7 +76,7 @@ function buildValidationSchema(application: string, actions: string[], resources has_all_requested: schema.boolean(), cluster: schema.object({}, { unknowns: 'allow' }), application: schema.object({ - [application]: resourceValidationSchema, + [application]: resourcesValidationSchema, }), index: schema.object({}, { unknowns: 'allow' }), }); diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index d4dcca8bebb0c1..53e4152b3c8fbf 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -1558,21 +1558,21 @@ describe('createConfig()', () => { ConfigSchema.validate({ audit: { appender: { - kind: 'file', - path: '/path/to/file.txt', + type: 'file', + fileName: '/path/to/file.txt', layout: { - kind: 'json', + type: 'json', }, }, }, }).audit.appender ).toMatchInlineSnapshot(` Object { - "kind": "file", + "fileName": "/path/to/file.txt", "layout": Object { - "kind": "json", + "type": "json", }, - "path": "/path/to/file.txt", + "type": "file", } `); }); @@ -1583,12 +1583,12 @@ describe('createConfig()', () => { audit: { // no layout configured appender: { - kind: 'file', + type: 'file', path: '/path/to/file.txt', }, }, }) - ).toThrow('[audit.appender.2.kind]: expected value to equal [legacy-appender]'); + ).toThrow('[audit.appender.2.type]: expected value to equal [legacy-appender]'); }); it('rejects an ignore_filter when no appender is configured', () => { diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts index bdb02d8ed99750..c4c7f399e7b5d3 100644 --- a/x-pack/plugins/security/server/config_deprecations.test.ts +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -52,6 +52,117 @@ describe('Config Deprecations', () => { `); }); + it('renames audit.appender.kind to audit.appender.type', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + kind: 'console', + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.kind).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.type).toEqual('console'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.kind\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.type\\"", + ] + `); + }); + + it('renames audit.appender.layout.kind to audit.appender.layout.type', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + layout: { kind: 'pattern' }, + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.layout.kind).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.layout.type).toEqual('pattern'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.layout.kind\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.layout.type\\"", + ] + `); + }); + + it('renames audit.appender.policy.kind to audit.appender.policy.type', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + policy: { kind: 'time-interval' }, + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.policy.kind).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.policy.type).toEqual('time-interval'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.policy.kind\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.policy.type\\"", + ] + `); + }); + + it('renames audit.appender.strategy.kind to audit.appender.strategy.type', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + strategy: { kind: 'numeric' }, + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.strategy.kind).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.strategy.type).toEqual('numeric'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.strategy.kind\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.strategy.type\\"", + ] + `); + }); + + it('renames audit.appender.path to audit.appender.fileName', () => { + const config = { + xpack: { + security: { + audit: { + appender: { + type: 'file', + path: './audit.log', + }, + }, + }, + }, + }; + const { messages, migrated } = applyConfigDeprecations(cloneDeep(config)); + expect(migrated.xpack.security.audit.appender.path).not.toBeDefined(); + expect(migrated.xpack.security.audit.appender.fileName).toEqual('./audit.log'); + expect(messages).toMatchInlineSnapshot(` + Array [ + "\\"xpack.security.audit.appender.path\\" is deprecated and has been replaced by \\"xpack.security.audit.appender.fileName\\"", + ] + `); + }); + it(`warns that 'authorization.legacyFallback.enabled' is unused`, () => { const config = { xpack: { diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts index 65d18f0a4e7eb3..a7bb5e09fb919d 100644 --- a/x-pack/plugins/security/server/config_deprecations.ts +++ b/x-pack/plugins/security/server/config_deprecations.ts @@ -12,6 +12,13 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ unused, }) => [ rename('sessionTimeout', 'session.idleTimeout'), + + rename('audit.appender.kind', 'audit.appender.type'), + rename('audit.appender.layout.kind', 'audit.appender.layout.type'), + rename('audit.appender.policy.kind', 'audit.appender.policy.type'), + rename('audit.appender.strategy.kind', 'audit.appender.strategy.type'), + rename('audit.appender.path', 'audit.appender.fileName'), + unused('authorization.legacyFallback.enabled'), unused('authc.saml.maxRedirectURLSize'), // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 5d1f7572672990..aade8be4f503fb 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -325,7 +325,7 @@ export const job_status = t.keyof({ succeeded: null, failed: null, 'going to run': null, - 'partial failure': null, + warning: null, }); export type JobStatus = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index b76a762ca6cbf0..981a5422a05949 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -55,6 +55,7 @@ import { threat_filters, threat_mapping, threat_language, + threat_indicator_path, } from '../types/threat_mapping'; import { @@ -133,6 +134,7 @@ export const addPrepackagedRulesSchema = t.intersection([ threat_query, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode threat_language, // defaults "undefined" if not set during decode + threat_indicator_path, // defaults "undefined" if not set during decode concurrent_searches, // defaults to "undefined" if not set during decode items_per_search, // defaults to "undefined" if not set during decode }) diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index 0a7b8b120ba7ef..8fa5809abe68b4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -62,6 +62,7 @@ import { threat_filters, threat_mapping, threat_language, + threat_indicator_path, } from '../types/threat_mapping'; import { @@ -152,6 +153,7 @@ export const importRulesSchema = t.intersection([ threat_query, // defaults to "undefined" if not set during decode threat_index, // defaults to "undefined" if not set during decode threat_language, // defaults "undefined" if not set during decode + threat_indicator_path, // defaults to "undefined" if not set during decode concurrent_searches, // defaults to "undefined" if not set during decode items_per_search, // defaults to "undefined" if not set during decode }) diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index 9d5331aeab8e4e..920fbaf4915c5c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -57,6 +57,7 @@ import { threat_filters, threat_mapping, threat_language, + threat_indicator_path, } from '../types/threat_mapping'; import { listArrayOrUndefined } from '../types/lists'; @@ -112,6 +113,7 @@ export const patchRulesSchema = t.exact( threat_filters, threat_mapping, threat_language, + threat_indicator_path, concurrent_searches, items_per_search, }) diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts index 87e5acb5428df7..fb29e37a53fdbe 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts @@ -56,6 +56,7 @@ export const getCreateThreatMatchRulesSchemaMock = ( rule_id: ruleId, threat_query: '*:*', threat_index: ['list-index'], + threat_indicator_path: 'threat.indicator', threat_mapping: [ { entries: [ diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts index 14b47c8b2b3280..6b8211b23088ca 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts @@ -1152,7 +1152,7 @@ describe('create rules schema', () => { }); }); - describe('threat_mapping', () => { + describe('threat_match', () => { test('You can set a threat query, index, mapping, filters when creating a rule', () => { const payload = getCreateThreatMatchRulesSchemaMock(); const decoded = createRulesSchema.decode(payload); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index 1c9ebe00333157..5cf2b6242b2f89 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -13,6 +13,7 @@ import { threat_query, threat_mapping, threat_index, + threat_indicator_path, concurrent_searches, items_per_search, } from '../types/threat_mapping'; @@ -213,6 +214,7 @@ const threatMatchRuleParams = { filters, saved_id, threat_filters, + threat_indicator_path, threat_language: t.keyof({ kuery: null, lucene: null }), concurrent_searches, items_per_search, diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts index b14c646e862d36..cf07389e207b34 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts @@ -150,6 +150,7 @@ export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial { expect(fields).toEqual(expected); }); - test('should return 8 fields for a rule of type "threat_match"', () => { + test('should return nine (9) fields for a rule of type "threat_match"', () => { const fields = addThreatMatchFields({ type: 'threat_match' }); - expect(fields.length).toEqual(8); + expect(fields.length).toEqual(9); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts index bcdb0fa9b085d6..6bd54973e064f1 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts @@ -70,6 +70,7 @@ import { threat_filters, threat_mapping, threat_language, + threat_indicator_path, } from '../types/threat_mapping'; import { DefaultListArray } from '../types/lists_default_array'; @@ -151,6 +152,7 @@ export const dependentRulesSchema = t.partial({ items_per_search, threat_mapping, threat_language, + threat_indicator_path, }); /** @@ -286,6 +288,9 @@ export const addThreatMatchFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.exact(t.type({ threat_mapping: dependentRulesSchema.props.threat_mapping })), t.exact(t.partial({ threat_language: dependentRulesSchema.props.threat_language })), t.exact(t.partial({ threat_filters: dependentRulesSchema.props.threat_filters })), + t.exact( + t.partial({ threat_indicator_path: dependentRulesSchema.props.threat_indicator_path }) + ), t.exact(t.partial({ saved_id: dependentRulesSchema.props.saved_id })), t.exact(t.partial({ concurrent_searches: dependentRulesSchema.props.concurrent_searches })), t.exact( diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts index d3975df488de9e..aab06941686c26 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/threat_mapping.ts @@ -18,6 +18,11 @@ export type ThreatQuery = t.TypeOf; export const threatQueryOrUndefined = t.union([threat_query, t.undefined]); export type ThreatQueryOrUndefined = t.TypeOf; +export const threat_indicator_path = t.string; +export type ThreatIndicatorPath = t.TypeOf; +export const threatIndicatorPathOrUndefined = t.union([threat_indicator_path, t.undefined]); +export type ThreatIndicatorPathOrUndefined = t.TypeOf; + export const threat_filters = t.array(t.unknown); // Filters are not easily type-able yet export type ThreatFilters = t.TypeOf; export const threatFiltersOrUndefined = t.union([threat_filters, t.undefined]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts index 080b704e9c193b..725a2eb9fea7bb 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -30,4 +30,5 @@ export const isEqlRule = (ruleType: Type | undefined): boolean => ruleType === ' export const isThresholdRule = (ruleType: Type | undefined): boolean => ruleType === 'threshold'; export const isQueryRule = (ruleType: Type | undefined): boolean => ruleType === 'query' || ruleType === 'saved_query'; -export const isThreatMatchRule = (ruleType: Type): boolean => ruleType === 'threat_match'; +export const isThreatMatchRule = (ruleType: Type | undefined): boolean => + ruleType === 'threat_match'; diff --git a/x-pack/plugins/security_solution/common/ecs/file/index.ts b/x-pack/plugins/security_solution/common/ecs/file/index.ts index 06abc7fd87541c..5e409b1095cf59 100644 --- a/x-pack/plugins/security_solution/common/ecs/file/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/file/index.ts @@ -5,14 +5,22 @@ * 2.0. */ +interface Original { + name?: string[]; + path?: string[]; +} + export interface CodeSignature { subject_name: string[]; trusted: string[]; } export interface Ext { - code_signature: CodeSignature[] | CodeSignature; + code_signature?: CodeSignature[] | CodeSignature; + original?: Original; } export interface Hash { + md5?: string[]; + sha1?: string[]; sha256: string[]; } diff --git a/x-pack/plugins/security_solution/common/ecs/index.ts b/x-pack/plugins/security_solution/common/ecs/index.ts index e3bcd11097cf79..ec23b677168cda 100644 --- a/x-pack/plugins/security_solution/common/ecs/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/index.ts @@ -15,6 +15,7 @@ import { FileEcs } from './file'; import { GeoEcs } from './geo'; import { HostEcs } from './host'; import { NetworkEcs } from './network'; +import { RegistryEcs } from './registry'; import { RuleEcs } from './rule'; import { SignalEcs } from './signal'; import { SourceEcs } from './source'; @@ -40,6 +41,7 @@ export interface Ecs { geo?: GeoEcs; host?: HostEcs; network?: NetworkEcs; + registry?: RegistryEcs; rule?: RuleEcs; signal?: SignalEcs; source?: SourceEcs; @@ -55,4 +57,6 @@ export interface Ecs { process?: ProcessEcs; file?: FileEcs; system?: SystemEcs; + // This should be temporary + eql?: { parentId: string; sequenceNumber: string }; } 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 3a8ccc309aecb9..931adf2dd70b8b 100644 --- a/x-pack/plugins/security_solution/common/ecs/process/index.ts +++ b/x-pack/plugins/security_solution/common/ecs/process/index.ts @@ -28,6 +28,7 @@ export interface ProcessHashData { export interface ProcessParentData { name?: string[]; + pid?: number[]; } export interface Thread { diff --git a/x-pack/plugins/apm/common/utils/range_filter.ts b/x-pack/plugins/security_solution/common/ecs/registry/index.ts similarity index 60% rename from x-pack/plugins/apm/common/utils/range_filter.ts rename to x-pack/plugins/security_solution/common/ecs/registry/index.ts index 8d5b7d5e1beb1e..c756fb139199e7 100644 --- a/x-pack/plugins/apm/common/utils/range_filter.ts +++ b/x-pack/plugins/security_solution/common/ecs/registry/index.ts @@ -5,12 +5,9 @@ * 2.0. */ -export function rangeFilter(start: number, end: number) { - return { - '@timestamp': { - gte: start, - lte: end, - format: 'epoch_millis', - }, - }; +export interface RegistryEcs { + hive?: string[]; + key?: string[]; + path?: string[]; + value?: 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 ffeaf853828f13..8aec9768dd50d2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -101,6 +101,7 @@ const POLICY_RESPONSE_STATUSES: HostPolicyResponseActionStatus[] = [ HostPolicyResponseActionStatus.success, HostPolicyResponseActionStatus.failure, HostPolicyResponseActionStatus.warning, + HostPolicyResponseActionStatus.unsupported, ]; const APPLIED_POLICIES: Array<{ @@ -1492,7 +1493,7 @@ export class EndpointDocGenerator { { name: 'workflow', message: 'Failed to apply a portion of the configuration (kernel)', - status: HostPolicyResponseActionStatus.success, + status: HostPolicyResponseActionStatus.unsupported, }, { name: 'download_model', @@ -1637,6 +1638,7 @@ export class EndpointDocGenerator { HostPolicyResponseActionStatus.failure, HostPolicyResponseActionStatus.success, HostPolicyResponseActionStatus.warning, + HostPolicyResponseActionStatus.unsupported, ]); } diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index b38cabf33a3db0..3bfe2a7410c080 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -76,6 +76,6 @@ export const validateEntities = { /** * Indices to search in. */ - indices: schema.arrayOf(schema.string()), + indices: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]), }), }; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index d361c0d6282a34..94a09b385a08c8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -933,6 +933,7 @@ export enum HostPolicyResponseActionStatus { success = 'success', failure = 'failure', warning = 'warning', + unsupported = 'unsupported', } /** diff --git a/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts b/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts index e966e3fb714ad9..54c2beaa06b09b 100644 --- a/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts +++ b/x-pack/plugins/security_solution/common/machine_learning/empty_ml_capabilities.ts @@ -37,6 +37,7 @@ export const emptyMlCapabilities: MlCapabilitiesResponse = { canDeleteDataFrameAnalytics: false, canCreateDataFrameAnalytics: false, canStartStopDataFrameAnalytics: false, + canCreateMlAlerts: false, }, isPlatinumOrTrialLicense: false, mlFeatureEnabledInSpace: false, diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index 40e353263bcc8c..7e19944ea5856c 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -13,6 +13,7 @@ export enum HostPolicyResponseActionStatus { success = 'success', failure = 'failure', warning = 'warning', + unsupported = 'unsupported', } export enum HostsFields { diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts index 6f5bea87f55088..ada22437a1530d 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/all/index.ts @@ -37,4 +37,5 @@ export interface TimelineEventsAllStrategyResponse extends IEsSearchResponse { export interface TimelineEventsAllRequestOptions extends TimelineRequestOptionsPaginated { fields: string[]; fieldRequested: string[]; + language: 'eql' | 'kuery' | 'lucene'; } diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts new file mode 100644 index 00000000000000..6bf01e478a9727 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/eql/index.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; +import { + EqlSearchStrategyRequest, + EqlSearchStrategyResponse, +} from '../../../../../../data_enhanced/common'; +import { Inspect, Maybe, PaginationInputPaginated } from '../../..'; +import { TimelineEdges, TimelineEventsAllRequestOptions } from '../..'; +import { EqlSearchResponse } from '../../../../detection_engine/types'; + +export interface TimelineEqlRequestOptions + extends EqlSearchStrategyRequest, + Omit { + eventCategoryField?: string; + tiebreakerField?: string; + timestampField?: string; + size?: number; +} + +export interface TimelineEqlResponse extends EqlSearchStrategyResponse> { + edges: TimelineEdges[]; + totalCount: number; + pageInfo: Pick; + inspect: Maybe; +} + +export interface EqlOptionsData { + keywordFields: EuiComboBoxOptionOption[]; + dateFields: EuiComboBoxOptionOption[]; + nonDateFields: EuiComboBoxOptionOption[]; +} + +export interface EqlOptionsSelected { + eventCategoryField?: string; + tiebreakerField?: string; + timestampField?: string; + query?: string; + size?: number; +} + +export type FieldsEqlOptions = keyof EqlOptionsSelected; diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts index 7ffbde2ebec028..c4d6f70a275871 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/index.ts @@ -8,6 +8,7 @@ export * from './all'; export * from './details'; export * from './last_event_time'; +export * from './eql'; export enum TimelineEventsQueries { all = 'eventsAll', diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts index d6ec668e1b0f9f..988f0ad0c125d4 100644 --- a/x-pack/plugins/security_solution/common/shared_imports.ts +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -43,6 +43,7 @@ export { ExceptionListType, Type, ENDPOINT_LIST_ID, + ENDPOINT_TRUSTED_APPS_LIST_ID, osTypeArray, OsTypeArray, } from '../../lists/common'; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index cee8ccdea3e9e1..5fb7d1a74fc367 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -103,6 +103,17 @@ const SavedFilterRuntimeType = runtimeTypes.partial({ script: unionWithNullType(runtimeTypes.string), }); +/* + * eqlOptionsQuery -> filterQuery Types + */ +const EqlOptionsRuntimeType = runtimeTypes.partial({ + eventCategoryField: unionWithNullType(runtimeTypes.string), + query: unionWithNullType(runtimeTypes.string), + tiebreakerField: unionWithNullType(runtimeTypes.string), + timestampField: unionWithNullType(runtimeTypes.string), + size: unionWithNullType(runtimeTypes.union([runtimeTypes.string, runtimeTypes.number])), +}); + /* * kqlQuery -> filterQuery Types */ @@ -180,10 +191,13 @@ export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf< >; export enum RowRendererId { + alerts = 'alerts', auditd = 'auditd', auditd_file = 'auditd_file', + library = 'library', netflow = 'netflow', plain = 'plain', + registry = 'registry', suricata = 'suricata', system = 'system', system_dns = 'system_dns', @@ -243,6 +257,7 @@ export const SavedTimelineRuntimeType = runtimeTypes.partial({ columns: unionWithNullType(runtimeTypes.array(SavedColumnHeaderRuntimeType)), dataProviders: unionWithNullType(runtimeTypes.array(SavedDataProviderRuntimeType)), description: unionWithNullType(runtimeTypes.string), + eqlOptions: unionWithNullType(EqlOptionsRuntimeType), eventType: unionWithNullType(runtimeTypes.string), excludedRowRendererIds: unionWithNullType(runtimeTypes.array(RowRendererIdRuntimeType)), favorite: unionWithNullType(runtimeTypes.array(SavedFavoriteRuntimeType)), @@ -281,7 +296,7 @@ export enum TimelineId { active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes - test2 = 'test2', + alternateTest = 'alternateTest', } export const TimelineIdLiteralRt = runtimeTypes.union([ @@ -410,13 +425,14 @@ export const importTimelineResultSchema = runtimeTypes.exact( export type ImportTimelineResultSchema = runtimeTypes.TypeOf; -export type TimelineEventsType = 'all' | 'raw' | 'alert' | 'signal' | 'custom'; +export type TimelineEventsType = 'all' | 'raw' | 'alert' | 'signal' | 'custom' | 'eql'; export enum TimelineTabs { query = 'query', graph = 'graph', notes = 'notes', pinned = 'pinned', + eql = 'eql', } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/x-pack/plugins/security_solution/common/utility_types.ts b/x-pack/plugins/security_solution/common/utility_types.ts index 3c13e6af837bc0..498b18dccaca56 100644 --- a/x-pack/plugins/security_solution/common/utility_types.ts +++ b/x-pack/plugins/security_solution/common/utility_types.ts @@ -36,6 +36,12 @@ export const stringEnum = (enumObj: T, enumName = 'enum') => * * Optionally you can avoid the use of this by using early returns and TypeScript will clear your type checking without complaints * but there are situations and times where this function might still be needed. + * + * If you see an error, DO NOT cast "as never" such as: + * assertUnreachable(x as never) // BUG IN YOUR CODE NOW AND IT WILL THROW DURING RUNTIME + * If you see code like that remove it, as that deactivates the intent of this utility. + * If you need to do that, then you should remove assertUnreachable from your code and + * use a default at the end of the switch instead. * @param x Unreachable field * @param message Message of error thrown */ diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_index_outdated.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_index_outdated.spec.ts new file mode 100644 index 00000000000000..1c6c604b84fbb2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_index_outdated.spec.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ROLES } from '../../../common/test'; +import { DETECTIONS_RULE_MANAGEMENT_URL, DETECTIONS_URL } from '../../urls/navigation'; +import { newRule } from '../../objects/rule'; +import { PAGE_TITLE } from '../../screens/common/page'; + +import { + login, + loginAndWaitForPageWithoutDateRange, + waitForPageWithoutDateRange, +} from '../../tasks/login'; +import { waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; +import { createCustomRule, deleteCustomRule } from '../../tasks/api_calls/rules'; +import { getCallOut, waitForCallOutToBeShown } from '../../tasks/common/callouts'; +import { cleanKibana } from '../../tasks/common'; + +const loadPageAsPlatformEngineerUser = (url: string) => { + waitForPageWithoutDateRange(url, ROLES.soc_manager); + waitForPageTitleToBeShown(); +}; + +const waitForPageTitleToBeShown = () => { + cy.get(PAGE_TITLE).should('be.visible'); +}; + +describe('Detections > Need Admin Callouts indicating an admin is needed to migrate the alert data set', () => { + const NEED_ADMIN_FOR_UPDATE_CALLOUT = 'need-admin-for-update-rules'; + + before(() => { + // First, we have to open the app on behalf of a privileged user in order to initialize it. + // Otherwise the app will be disabled and show a "welcome"-like page. + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL, ROLES.platform_engineer); + waitForAlertsIndexToBeCreated(); + + // After that we can login as a soc manager. + login(ROLES.soc_manager); + }); + + context( + 'The users index_mapping_outdated is "true" and their admin callouts should show up', + () => { + beforeEach(() => { + // Index mapping outdated is forced to return true as being outdated so that we get the + // need admin callouts being shown. + cy.intercept('GET', '/api/detection_engine/index', { + index_mapping_outdated: true, + name: '.siem-signals-default', + }); + }); + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_URL); + }); + + it('We show the need admin primary callout', () => { + waitForCallOutToBeShown(NEED_ADMIN_FOR_UPDATE_CALLOUT, 'primary'); + }); + }); + + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('We show 1 primary callout of need admin', () => { + waitForCallOutToBeShown(NEED_ADMIN_FOR_UPDATE_CALLOUT, 'primary'); + }); + }); + + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); + + afterEach(() => { + deleteCustomRule(); + }); + + it('We show 1 primary callout', () => { + waitForCallOutToBeShown(NEED_ADMIN_FOR_UPDATE_CALLOUT, 'primary'); + }); + }); + } + ); + + context( + 'The users index_mapping_outdated is "false" and their admin callouts should not show up ', + () => { + beforeEach(() => { + // Index mapping outdated is forced to return true as being outdated so that we get the + // need admin callouts being shown. + cy.intercept('GET', '/api/detection_engine/index', { + index_mapping_outdated: false, + name: '.siem-signals-default', + }); + }); + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_URL); + }); + + it('We show the need admin primary callout', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('We show 1 primary callout of need admin', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); + + afterEach(() => { + deleteCustomRule(); + }); + + it('We show 1 primary callout', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + } + ); + + context( + 'The users index_mapping_outdated is "null" and their admin callouts should not show up ', + () => { + beforeEach(() => { + // Index mapping outdated is forced to return true as being outdated so that we get the + // need admin callouts being shown. + cy.intercept('GET', '/api/detection_engine/index', { + index_mapping_outdated: null, + name: '.siem-signals-default', + }); + }); + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_URL); + }); + + it('We show the need admin primary callout', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('We show 1 primary callout of need admin', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsPlatformEngineerUser(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); + + afterEach(() => { + deleteCustomRule(); + }); + + it('We show 1 primary callout', () => { + getCallOut(NEED_ADMIN_FOR_UPDATE_CALLOUT).should('not.exist'); + }); + }); + } + ); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_readonly.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_readonly.spec.ts index 85257f7d9176f5..d807857cd72bde 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_readonly.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/alerts_detection_callouts_readonly.spec.ts @@ -26,6 +26,11 @@ const loadPageAsReadOnlyUser = (url: string) => { waitForPageTitleToBeShown(); }; +const loadPageAsPlatformEngineer = (url: string) => { + waitForPageWithoutDateRange(url, ROLES.platform_engineer); + waitForPageTitleToBeShown(); +}; + const reloadPage = () => { cy.reload(); waitForPageTitleToBeShown(); @@ -35,7 +40,7 @@ const waitForPageTitleToBeShown = () => { cy.get(PAGE_TITLE).should('be.visible'); }; -describe('Detections > Callouts indicating read-only access to resources', () => { +describe('Detections > Callouts', () => { const ALERTS_CALLOUT = 'read-only-access-to-alerts'; const RULES_CALLOUT = 'read-only-access-to-rules'; @@ -50,75 +55,119 @@ describe('Detections > Callouts indicating read-only access to resources', () => login(ROLES.reader); }); - context('On Detections home page', () => { - beforeEach(() => { - loadPageAsReadOnlyUser(DETECTIONS_URL); - }); - - it('We show one primary callout', () => { - waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); - }); + context('indicating read-only access to resources', () => { + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsReadOnlyUser(DETECTIONS_URL); + }); - context('When a user clicks Dismiss on the callout', () => { - it('We hide it and persist the dismissal', () => { + it('We show one primary callout', () => { waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); - dismissCallOut(ALERTS_CALLOUT); - reloadPage(); - getCallOut(ALERTS_CALLOUT).should('not.exist'); }); - }); - }); - context('On Rules Management page', () => { - beforeEach(() => { - loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); + context('When a user clicks Dismiss on the callout', () => { + it('We hide it and persist the dismissal', () => { + waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); + dismissCallOut(ALERTS_CALLOUT); + reloadPage(); + getCallOut(ALERTS_CALLOUT).should('not.exist'); + }); + }); }); - it('We show one primary callout', () => { - waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); - }); + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); + }); - context('When a user clicks Dismiss on the callout', () => { - it('We hide it and persist the dismissal', () => { + it('We show one primary callout', () => { waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); - dismissCallOut(RULES_CALLOUT); - reloadPage(); - getCallOut(RULES_CALLOUT).should('not.exist'); }); - }); - }); - context('On Rule Details page', () => { - beforeEach(() => { - createCustomRule(newRule); - loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); - waitForPageTitleToBeShown(); - goToRuleDetails(); + context('When a user clicks Dismiss on the callout', () => { + it('We hide it and persist the dismissal', () => { + waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); + dismissCallOut(RULES_CALLOUT); + reloadPage(); + getCallOut(RULES_CALLOUT).should('not.exist'); + }); + }); }); - afterEach(() => { - deleteCustomRule(); - }); + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsReadOnlyUser(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); - it('We show two primary callouts', () => { - waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); - waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); - }); + afterEach(() => { + deleteCustomRule(); + }); - context('When a user clicks Dismiss on the callouts', () => { - it('We hide them and persist the dismissal', () => { + it('We show two primary callouts', () => { waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); + }); - dismissCallOut(ALERTS_CALLOUT); - reloadPage(); + context('When a user clicks Dismiss on the callouts', () => { + it('We hide them and persist the dismissal', () => { + waitForCallOutToBeShown(ALERTS_CALLOUT, 'primary'); + waitForCallOutToBeShown(RULES_CALLOUT, 'primary'); + dismissCallOut(ALERTS_CALLOUT); + reloadPage(); + + getCallOut(ALERTS_CALLOUT).should('not.exist'); + getCallOut(RULES_CALLOUT).should('be.visible'); + + dismissCallOut(RULES_CALLOUT); + reloadPage(); + + getCallOut(ALERTS_CALLOUT).should('not.exist'); + getCallOut(RULES_CALLOUT).should('not.exist'); + }); + }); + }); + }); + + context('indicating read-write access to resources', () => { + context('On Detections home page', () => { + beforeEach(() => { + loadPageAsPlatformEngineer(DETECTIONS_URL); + }); + + it('We show no callout', () => { + getCallOut(ALERTS_CALLOUT).should('not.exist'); + getCallOut(RULES_CALLOUT).should('not.exist'); + }); + }); + + context('On Rules Management page', () => { + beforeEach(() => { + loadPageAsPlatformEngineer(DETECTIONS_RULE_MANAGEMENT_URL); + }); + + it('We show no callout', () => { getCallOut(ALERTS_CALLOUT).should('not.exist'); - getCallOut(RULES_CALLOUT).should('be.visible'); + getCallOut(RULES_CALLOUT).should('not.exist'); + }); + }); - dismissCallOut(RULES_CALLOUT); - reloadPage(); + context('On Rule Details page', () => { + beforeEach(() => { + createCustomRule(newRule); + loadPageAsPlatformEngineer(DETECTIONS_RULE_MANAGEMENT_URL); + waitForPageTitleToBeShown(); + goToRuleDetails(); + }); + + afterEach(() => { + deleteCustomRule(); + }); + it('We show no callouts', () => { getCallOut(ALERTS_CALLOUT).should('not.exist'); getCallOut(RULES_CALLOUT).should('not.exist'); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/alerts_detection_exceptions_table.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/alerts_detection_exceptions_table.spec.ts new file mode 100644 index 00000000000000..aa469a0cb25311 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/alerts_detection_exceptions_table.spec.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { exception, exceptionList, expectedExportedExceptionList } from '../../objects/exception'; +import { newRule } from '../../objects/rule'; + +import { RULE_STATUS } from '../../screens/create_new_rule'; + +import { goToManageAlertsDetectionRules, waitForAlertsIndexToBeCreated } from '../../tasks/alerts'; +import { createCustomRule } from '../../tasks/api_calls/rules'; +import { goToRuleDetails, waitForRulesToBeLoaded } from '../../tasks/alerts_detection_rules'; +import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; +import { + addsExceptionFromRuleSettings, + goBackToAllRulesTable, + goToExceptionsTab, + waitForTheRuleToBeExecuted, +} from '../../tasks/rule_details'; + +import { DETECTIONS_URL } from '../../urls/navigation'; +import { cleanKibana } from '../../tasks/common'; +import { + deleteExceptionListWithRuleReference, + deleteExceptionListWithoutRuleReference, + exportExceptionList, + goToExceptionsTable, + searchForExceptionList, + waitForExceptionsTableToBeLoaded, + clearSearchSelection, +} from '../../tasks/exceptions_table'; +import { + EXCEPTIONS_TABLE_LIST_NAME, + EXCEPTIONS_TABLE_SHOWING_LISTS, +} from '../../screens/exceptions'; +import { createExceptionList } from '../../tasks/api_calls/exceptions'; + +describe('Exceptions Table', () => { + before(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsIndexToBeCreated(); + createCustomRule(newRule); + goToManageAlertsDetectionRules(); + goToRuleDetails(); + + cy.get(RULE_STATUS).should('have.text', '—'); + + esArchiverLoad('auditbeat_for_exceptions'); + + // Add a detections exception list + goToExceptionsTab(); + addsExceptionFromRuleSettings(exception); + waitForTheRuleToBeExecuted(); + + // Create exception list not used by any rules + createExceptionList(exceptionList).as('exceptionListResponse'); + + goBackToAllRulesTable(); + waitForRulesToBeLoaded(); + }); + + after(() => { + esArchiverUnload('auditbeat_for_exceptions'); + }); + + it('Filters exception lists on search', () => { + goToExceptionsTable(); + waitForExceptionsTableToBeLoaded(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + + // Single word search + searchForExceptionList('Endpoint'); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + // Multi word search + clearSearchSelection(); + searchForExceptionList('New Rule Test'); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(0).should('have.text', 'Test exception list'); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).eq(1).should('have.text', 'New Rule Test'); + + // Exact phrase search + clearSearchSelection(); + searchForExceptionList('"New Rule Test"'); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'New Rule Test'); + + // Field search + clearSearchSelection(); + searchForExceptionList('list_id:endpoint_list'); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + cy.get(EXCEPTIONS_TABLE_LIST_NAME).should('have.text', 'Endpoint Security Exception List'); + + clearSearchSelection(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + }); + + it('Exports exception list', async function () { + cy.intercept(/(\/api\/exception_lists\/_export)/).as('export'); + + goToExceptionsTable(); + waitForExceptionsTableToBeLoaded(); + + exportExceptionList(); + + cy.wait('@export').then(({ response }) => { + cy.wrap(response!.body).should( + 'eql', + expectedExportedExceptionList(this.exceptionListResponse) + ); + }); + }); + + it('Deletes exception list without rule reference', () => { + goToExceptionsTable(); + waitForExceptionsTableToBeLoaded(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 3 lists`); + + deleteExceptionListWithoutRuleReference(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); + }); + + it('Deletes exception list with rule reference', () => { + goToExceptionsTable(); + waitForExceptionsTableToBeLoaded(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 2 lists`); + + deleteExceptionListWithRuleReference(); + + cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/exception.ts b/x-pack/plugins/security_solution/cypress/objects/exception.ts index 8e22784087dd65..73457f10ccec6f 100644 --- a/x-pack/plugins/security_solution/cypress/objects/exception.ts +++ b/x-pack/plugins/security_solution/cypress/objects/exception.ts @@ -11,8 +11,32 @@ export interface Exception { values: string[]; } +export interface ExceptionList { + description: string; + list_id: string; + name: string; + namespace_type: 'single' | 'agnostic'; + tags: string[]; + type: 'detection' | 'endpoint'; +} + +export const exceptionList: ExceptionList = { + description: 'Test exception list description', + list_id: 'test_exception_list', + name: 'Test exception list', + namespace_type: 'single', + tags: ['test tag'], + type: 'detection', +}; + export const exception: Exception = { field: 'host.name', operator: 'is', values: ['suricata-iowa'], }; + +export const expectedExportedExceptionList = (exceptionListResponse: Cypress.Response) => { + const jsonrule = exceptionListResponse.body; + + return `{"_version":"${jsonrule._version}","created_at":"${jsonrule.created_at}","created_by":"elastic","description":"${jsonrule.description}","id":"${jsonrule.id}","immutable":false,"list_id":"test_exception_list","name":"Test exception list","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"${jsonrule.tie_breaker_id}","type":"detection","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","version":1}\n"\n""\n{"exception_list_items_details":"{"exported_count":0}\n"}`; +}; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index cbcfed8a4cf95a..a2fb94e462023e 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -129,6 +129,10 @@ export const RISK_MAPPING_OVERRIDE_OPTION = '#risk_score-mapping-override'; export const RISK_OVERRIDE = '[data-test-subj="detectionEngineStepAboutRuleRiskScore-riskOverride"]'; +export const RULES_CREATION_FORM = '[data-test-subj="stepDefineRule"]'; + +export const RULES_CREATION_PREVIEW = '[data-test-subj="ruleCreationQueryPreview"]'; + export const RULE_DESCRIPTION_INPUT = '[data-test-subj="detectionEngineStepAboutRuleDescription"] [data-test-subj="input"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index dbd55a293f6a0a..7cd273b1db746f 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -23,3 +23,23 @@ export const OPERATOR_INPUT = '[data-test-subj="operatorAutocompleteComboBox"]'; export const VALUES_INPUT = '[data-test-subj="valuesAutocompleteMatch"] [data-test-subj="comboBoxInput"]'; + +export const EXCEPTIONS_TABLE_TAB = '[data-test-subj="allRulesTableTab-exceptions"]'; + +export const EXCEPTIONS_TABLE = '[data-test-subj="exceptions-table"]'; + +export const EXCEPTIONS_TABLE_SEARCH = '[data-test-subj="header-section-supplements"] input'; + +export const EXCEPTIONS_TABLE_SHOWING_LISTS = '[data-test-subj="showingExceptionLists"]'; + +export const EXCEPTIONS_TABLE_DELETE_BTN = '[data-test-subj="exceptionsTableDeleteButton"]'; + +export const EXCEPTIONS_TABLE_EXPORT_BTN = '[data-test-subj="exceptionsTableExportButton"]'; + +export const EXCEPTIONS_TABLE_SEARCH_CLEAR = '[data-test-subj="header-section-supplements"] button'; + +export const EXCEPTIONS_TABLE_LIST_NAME = '[data-test-subj="exceptionsTableName"]'; + +export const EXCEPTIONS_TABLE_MODAL = '[data-test-subj="referenceErrorModal"]'; + +export const EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN = '[data-test-subj="confirmModalConfirmButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index a45b3f67457b98..f9590b34a0a11c 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -97,3 +97,5 @@ export const getDetails = (title: string) => export const removeExternalLinkText = (str: string) => str.replace(/\(opens in a new tab or window\)/g, ''); + +export const BACK_TO_RULES = '[data-test-subj="ruleDetailsBackToAllRules"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts new file mode 100644 index 00000000000000..7363bd5991b1c2 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/exceptions.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ExceptionList } from '../../objects/exception'; + +export const createExceptionList = ( + exceptionList: ExceptionList, + exceptionListId = 'exception_list_testing' +) => + cy.request({ + method: 'POST', + url: 'api/exception_lists', + body: { + list_id: exceptionListId != null ? exceptionListId : exceptionList.list_id, + description: exceptionList.description, + name: exceptionList.name, + type: exceptionList.type, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/common/callouts.ts b/x-pack/plugins/security_solution/cypress/tasks/common/callouts.ts index 4139c911e4063d..8440409f80f38e 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/common/callouts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/common/callouts.ts @@ -12,13 +12,11 @@ export const getCallOut = (id: string, options?: Cypress.Timeoutable) => { }; export const waitForCallOutToBeShown = (id: string, color: string) => { - getCallOut(id, { timeout: 10000 }) - .should('be.visible') - .should('have.class', `euiCallOut--${color}`); + getCallOut(id).should('be.visible').should('have.class', `euiCallOut--${color}`); }; export const dismissCallOut = (id: string) => { - getCallOut(id, { timeout: 10000 }).within(() => { + getCallOut(id).within(() => { cy.get(CALLOUT_DISMISS_BTN).should('be.visible').click(); cy.root().should('not.exist'); }); diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 475ce5ecb15727..02ba3937ed542a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -78,6 +78,8 @@ import { AT_LEAST_ONE_VALID_MATCH, AT_LEAST_ONE_INDEX_PATTERN, CUSTOM_QUERY_REQUIRED, + RULES_CREATION_FORM, + RULES_CREATION_PREVIEW, } from '../screens/create_new_rule'; import { TOAST_ERROR } from '../screens/shared'; import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; @@ -271,23 +273,26 @@ export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => { }; export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { - cy.get(EQL_QUERY_INPUT).should('exist'); - cy.get(EQL_QUERY_INPUT).should('be.visible'); - cy.get(EQL_QUERY_INPUT).type(rule.customQuery!); - cy.get(EQL_QUERY_VALIDATION_SPINNER).should('not.exist'); - cy.get(QUERY_PREVIEW_BUTTON).should('not.be.disabled').click({ force: true }); + cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('exist'); + cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('be.visible'); + cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).type(rule.customQuery!); + cy.get(RULES_CREATION_FORM).find(EQL_QUERY_VALIDATION_SPINNER).should('not.exist'); + cy.get(RULES_CREATION_PREVIEW) + .find(QUERY_PREVIEW_BUTTON) + .should('not.be.disabled') + .click({ force: true }); cy.get(EQL_QUERY_PREVIEW_HISTOGRAM) .invoke('text') .then((text) => { if (text !== 'Hits') { - cy.get(QUERY_PREVIEW_BUTTON).click({ force: true }); + cy.get(RULES_CREATION_PREVIEW).find(QUERY_PREVIEW_BUTTON).click({ force: true }); cy.get(EQL_QUERY_PREVIEW_HISTOGRAM).should('contain.text', 'Hits'); } }); cy.get(TOAST_ERROR).should('not.exist'); cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); - cy.get(EQL_QUERY_INPUT).should('not.exist'); + cy.get(`${RULES_CREATION_FORM} ${EQL_QUERY_INPUT}`).should('not.exist'); }; /** @@ -480,7 +485,7 @@ export const waitForAlertsToPopulate = async () => { export const waitForTheRuleToBeExecuted = () => { cy.waitUntil(() => { - cy.get(REFRESH_BUTTON).click(); + cy.get(REFRESH_BUTTON).click({ force: true }); return cy .get(RULE_STATUS) .invoke('text') diff --git a/x-pack/plugins/security_solution/cypress/tasks/date_picker.ts b/x-pack/plugins/security_solution/cypress/tasks/date_picker.ts index 018be0ec72f22f..5fef4f2f5569be 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/date_picker.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/date_picker.ts @@ -33,7 +33,7 @@ export const setStartDate = (date: string) => { }; export const setTimelineEndDate = (date: string) => { - cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE).click({ force: true }); + cy.get(DATE_PICKER_END_DATE_POPOVER_BUTTON_TIMELINE).first().click({ force: true }); cy.get(DATE_PICKER_ABSOLUTE_TAB).first().click({ force: true }); @@ -47,7 +47,7 @@ export const setTimelineEndDate = (date: string) => { }; export const setTimelineStartDate = (date: string) => { - cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).click({ + cy.get(DATE_PICKER_START_DATE_POPOVER_BUTTON_TIMELINE).first().click({ force: true, }); @@ -68,6 +68,7 @@ export const updateDates = () => { export const updateTimelineDates = () => { cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE) + .first() .click({ force: true }) .should('not.have.text', 'Updating'); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts b/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts new file mode 100644 index 00000000000000..5b9cff5ec158e7 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/exceptions_table.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EXCEPTIONS_TABLE_TAB, + EXCEPTIONS_TABLE, + EXCEPTIONS_TABLE_SEARCH, + EXCEPTIONS_TABLE_DELETE_BTN, + EXCEPTIONS_TABLE_SEARCH_CLEAR, + EXCEPTIONS_TABLE_MODAL, + EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN, + EXCEPTIONS_TABLE_EXPORT_BTN, +} from '../screens/exceptions'; + +export const goToExceptionsTable = () => { + cy.get(EXCEPTIONS_TABLE_TAB).should('exist').click({ force: true }); +}; + +export const waitForExceptionsTableToBeLoaded = () => { + cy.get(EXCEPTIONS_TABLE).should('exist'); + cy.get(EXCEPTIONS_TABLE_SEARCH).should('exist'); +}; + +export const searchForExceptionList = (searchText: string) => { + cy.get(EXCEPTIONS_TABLE_SEARCH).type(searchText, { force: true }).trigger('search'); +}; + +export const deleteExceptionListWithoutRuleReference = () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist'); +}; + +export const deleteExceptionListWithRuleReference = () => { + cy.get(EXCEPTIONS_TABLE_DELETE_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_MODAL).should('exist'); + cy.get(EXCEPTIONS_TABLE_MODAL_CONFIRM_BTN).first().click(); + cy.get(EXCEPTIONS_TABLE_MODAL).should('not.exist'); +}; + +export const exportExceptionList = () => { + cy.get(EXCEPTIONS_TABLE_EXPORT_BTN).first().click(); +}; + +export const clearSearchSelection = () => { + cy.get(EXCEPTIONS_TABLE_SEARCH_CLEAR).first().click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 06c4fb572650b9..57037e9f269b47 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -18,6 +18,7 @@ import { } from '../screens/exceptions'; import { ALERTS_TAB, + BACK_TO_RULES, EXCEPTIONS_TAB, REFRESH_BUTTON, REMOVE_EXCEPTION_BTN, @@ -90,3 +91,7 @@ export const waitForTheRuleToBeExecuted = async () => { status = await cy.get(RULE_STATUS).invoke('text').promisify(); } }; + +export const goBackToAllRulesTable = () => { + cy.get(BACK_TO_RULES).click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index ca4c869e0f2d38..c001f1fc2bc47d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -99,7 +99,7 @@ export const goToQueryTab = () => { export const addNotesToTimeline = (notes: string) => { goToNotesTab(); cy.get(NOTES_TEXT_AREA).type(notes); - cy.get(ADD_NOTE_BUTTON).click(); + cy.get(ADD_NOTE_BUTTON).click({ force: true }); cy.get(QUERY_TAB_BUTTON).click(); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index e1e9ac77a547ae..318143426af584 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -14,7 +14,7 @@ import { TestProviders } from '../../../common/mock'; import { casesStatus, useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../../case/common/api'; import { useKibana } from '../../../common/lib/kibana'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; @@ -544,7 +544,9 @@ describe('AllCases', () => { status: 'open', tags: ['coke', 'pepsi'], title: 'Another horrible breach!!', + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, updatedAt: '2020-02-20T15:02:57.995Z', updatedBy: { email: 'leslie.knope@elastic.co', diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx index be43704fcbba18..a1ee825aa5337e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.test.tsx @@ -5,13 +5,14 @@ * 2.0. */ -import { CommentType } from '../../../../../case/common/api'; +import { AssociationType, CommentType } from '../../../../../case/common/api'; import { Comment } from '../../containers/types'; import { getRuleIdsFromComments, buildAlertsQuery } from './helpers'; const comments: Comment[] = [ { + associationType: AssociationType.case, type: CommentType.alert, alertId: 'alert-id-1', index: 'alert-index-1', @@ -25,6 +26,7 @@ const comments: Comment[] = [ version: 'WzQ3LDFc', }, { + associationType: AssociationType.case, type: CommentType.alert, alertId: 'alert-id-2', index: 'alert-index-2', diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts index ac0dc96eda526b..6b92e414675e2c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/helpers.ts @@ -11,7 +11,8 @@ import { Comment } from '../../containers/types'; export const getRuleIdsFromComments = (comments: Comment[]) => comments.reduce((ruleIds, comment: Comment) => { if (comment.type === CommentType.alert) { - return [...ruleIds, comment.alertId]; + const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId]; + return [...ruleIds, ...ids]; } return ruleIds; diff --git a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx b/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx index 1b67aaeb795dd1..eb75d896ae7788 100644 --- a/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/confirm_delete_case/index.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { EuiConfirmModal } from '@elastic/eui'; import * as i18n from './translations'; interface ConfirmDeleteCaseModalProps { @@ -28,20 +28,18 @@ const ConfirmDeleteCaseModalComp: React.FC = ({ return null; } return ( - - - {isPlural ? i18n.CONFIRM_QUESTION_PLURAL : i18n.CONFIRM_QUESTION} - - + + {isPlural ? i18n.CONFIRM_QUESTION_PLURAL : i18n.CONFIRM_QUESTION} + ); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx index 1e2b34ddf38eae..656257f2b36c43 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/case/alert_fields.tsx @@ -26,9 +26,10 @@ const Container = styled.div` `; const defaultAlertComment = { - type: CommentType.alert, - alertId: '{{context.rule.id}}', + type: CommentType.generatedAlert, + alerts: '{{context.alerts}}', index: '{{context.rule.output_index}}', + ruleId: '{{context.rule.id}}', }; const CaseParamsFields: React.FunctionComponent> = ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx new file mode 100644 index 00000000000000..842fe9e00ab390 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.test.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* eslint-disable react/display-name */ +import React, { ReactNode } from 'react'; +import { mount } from 'enzyme'; + +import '../../../common/mock/match_media'; +import { CreateCaseFlyout } from './flyout'; +import { TestProviders } from '../../../common/mock'; + +jest.mock('../create/form_context', () => { + return { + FormContext: ({ + children, + onSuccess, + }: { + children: ReactNode; + onSuccess: ({ id }: { id: string }) => void; + }) => { + return ( + <> + + {children} + + ); + }, + }; +}); + +jest.mock('../create/form', () => { + return { + CreateCaseForm: () => { + return <>{'form'}; + }, + }; +}); + +jest.mock('../create/submit_button', () => { + return { + SubmitCaseButton: () => { + return <>{'Submit'}; + }, + }; +}); + +const onCloseFlyout = jest.fn(); +const onCaseCreated = jest.fn(); +const defaultProps = { + onCloseFlyout, + onCaseCreated, +}; + +describe('CreateCaseFlyout', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj='create-case-flyout']`).exists()).toBeTruthy(); + }); + + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + + + + ); + + wrapper.find('.euiFlyout__closeButton').first().simulate('click'); + expect(onCloseFlyout).toBeCalled(); + }); + + it('pass the correct props to FormContext component', () => { + const wrapper = mount( + + + + ); + + const props = wrapper.find('FormContext').props(); + expect(props).toEqual( + expect.objectContaining({ + onSuccess: onCaseCreated, + }) + ); + }); + + it('onSuccess called when creating a case', () => { + const wrapper = mount( + + + + ); + + wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click'); + expect(onCaseCreated).toHaveBeenCalledWith({ id: 'case-id' }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx new file mode 100644 index 00000000000000..cb3436f6ba3bcb --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/flyout.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import styled from 'styled-components'; +import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; + +import { FormContext } from '../create/form_context'; +import { CreateCaseForm } from '../create/form'; +import { SubmitCaseButton } from '../create/submit_button'; +import { Case } from '../../containers/types'; +import * as i18n from '../../translations'; + +export interface CreateCaseModalProps { + onCloseFlyout: () => void; + onCaseCreated: (theCase: Case) => void; +} + +const Container = styled.div` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSize}; + text-align: right; + `} +`; + +const StyledFlyout = styled(EuiFlyout)` + ${({ theme }) => ` + z-index: ${theme.eui.euiZModal}; + `} +`; + +// Adding bottom padding because timeline's +// bottom bar gonna hide the submit button. +const FormWrapper = styled.div` + padding-bottom: 50px; +`; + +const CreateCaseFlyoutComponent: React.FC = ({ + onCaseCreated, + onCloseFlyout, +}) => { + return ( + + + +

    {i18n.CREATE_TITLE}

    + + + + + + + + + + + + + + ); +}; + +export const CreateCaseFlyout = memo(CreateCaseFlyoutComponent); + +CreateCaseFlyout.displayName = 'CreateCaseFlyout'; diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 6067f3fd7b15aa..1b21db0491565f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -25,10 +25,11 @@ import { APP_ID } from '../../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { getCaseDetailsUrl } from '../../../common/components/link_to'; import { SecurityPageName } from '../../../app/types'; -import { useCreateCaseModal } from '../use_create_case_modal'; import { useAllCasesModal } from '../use_all_cases_modal'; import { createUpdateSuccessToaster } from './helpers'; import * as i18n from './translations'; +import { useControl } from '../../../common/hooks/use_control'; +import { CreateCaseFlyout } from '../create/flyout'; interface AddToCaseActionProps { ariaLabel?: string; @@ -61,8 +62,15 @@ const AddToCaseActionComponent: React.FC = ({ [navigateToApp] ); + const { + isControlOpen: isCreateCaseFlyoutOpen, + openControl: openCaseFlyoutOpen, + closeControl: closeCaseFlyoutOpen, + } = useControl(); + const attachAlertToCase = useCallback( (theCase: Case) => { + closeCaseFlyoutOpen(); postComment( theCase.id, { @@ -77,13 +85,9 @@ const AddToCaseActionComponent: React.FC = ({ }) ); }, - [postComment, eventId, eventIndex, dispatchToaster, onViewCaseClick] + [closeCaseFlyoutOpen, postComment, eventId, eventIndex, dispatchToaster, onViewCaseClick] ); - const { modal: createCaseModal, openModal: openCreateCaseModal } = useCreateCaseModal({ - onCaseCreated: attachAlertToCase, - }); - const onCaseClicked = useCallback( (theCase) => { /** @@ -92,13 +96,13 @@ const AddToCaseActionComponent: React.FC = ({ * We gonna open the create case modal. */ if (theCase == null) { - openCreateCaseModal(); + openCaseFlyoutOpen(); return; } attachAlertToCase(theCase); }, - [attachAlertToCase, openCreateCaseModal] + [attachAlertToCase, openCaseFlyoutOpen] ); const { modal: allCasesModal, openModal: openAllCaseModal } = useAllCasesModal({ @@ -107,8 +111,8 @@ const AddToCaseActionComponent: React.FC = ({ const addNewCaseClick = useCallback(() => { closePopover(); - openCreateCaseModal(); - }, [openCreateCaseModal, closePopover]); + openCaseFlyoutOpen(); + }, [openCaseFlyoutOpen, closePopover]); const addExistingCaseClick = useCallback(() => { closePopover(); @@ -173,7 +177,9 @@ const AddToCaseActionComponent: React.FC = ({ - {createCaseModal} + {isCreateCaseFlyoutOpen && ( + + )} {allCasesModal} ); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx index 1dfabda8068f17..eda8ed8cdfbcd5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx @@ -6,13 +6,7 @@ */ import React, { memo } from 'react'; -import { - EuiModal, - EuiModalBody, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, -} from '@elastic/eui'; +import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; import { Case } from '../../containers/types'; @@ -34,16 +28,14 @@ const AllCasesModalComponent: React.FC = ({ const userCanCrud = userPermissions?.crud ?? false; return isModalOpen ? ( - - - - {i18n.SELECT_CASE_TITLE} - - - - - - + + + {i18n.SELECT_CASE_TITLE} + + + + + ) : null; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx index 3595f2c916af71..8dd5080666cb38 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_create_case_modal/create_case_modal.tsx @@ -7,13 +7,7 @@ import React, { memo } from 'react'; import styled from 'styled-components'; -import { - EuiModal, - EuiModalBody, - EuiModalHeader, - EuiModalHeaderTitle, - EuiOverlayMask, -} from '@elastic/eui'; +import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { FormContext } from '../create/form_context'; import { CreateCaseForm } from '../create/form'; @@ -40,21 +34,19 @@ const CreateModalComponent: React.FC = ({ onSuccess, }) => { return isModalOpen ? ( - - - - {i18n.CREATE_TITLE} - - - - - - - - - - - + + + {i18n.CREATE_TITLE} + + + + + + + + + + ) : null; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index 4a567a38dc9f21..3b81fc0afccf3b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -335,8 +335,15 @@ export const UserActionTree = React.memo( ), }, ]; + // TODO: need to handle CommentType.generatedAlert here to } else if (comment != null && comment.type === CommentType.alert) { - const alert = alerts[comment.alertId]; + // TODO: clean this up + const alertId = Array.isArray(comment.alertId) + ? comment.alertId.length > 0 + ? comment.alertId[0] + : '' + : comment.alertId; + const alert = alerts[alertId]; return [...comments, getAlertComment({ action, alert, onShowAlertDetails })]; } } diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 444a87a57d2513..80d4816bedd53d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -18,6 +18,8 @@ import { CasesResponse, CasesFindResponse, CommentType, + AssociationType, + CaseType, } from '../../../../case/common/api'; import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { ConnectorTypes } from '../../../../case/common/api/connectors'; @@ -38,6 +40,7 @@ export const elasticUser = { export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { + associationType: AssociationType.case, comment: 'Solve this fast!', type: CommentType.user, id: basicCommentId, @@ -52,6 +55,7 @@ export const basicComment: Comment = { export const alertComment: Comment = { alertId: 'alert-id-1', + associationType: AssociationType.case, index: 'alert-index-1', type: CommentType.alert, id: 'alert-comment-id', @@ -65,6 +69,7 @@ export const alertComment: Comment = { }; export const basicCase: Case = { + type: CaseType.individual, closedAt: null, closedBy: null, id: basicCaseId, @@ -83,6 +88,7 @@ export const basicCase: Case = { tags, title: 'Another horrible breach!!', totalComment: 1, + totalAlerts: 0, updatedAt: basicUpdatedAt, updatedBy: elasticUser, version: 'WzQ3LDFd', @@ -181,6 +187,7 @@ export const elasticUserSnake = { }; export const basicCommentSnake: CommentResponse = { + associationType: AssociationType.case, comment: 'Solve this fast!', type: CommentType.user, id: basicCommentId, diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index e5477cbd951ae5..30ea8344434686 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -14,11 +14,14 @@ import { CaseStatuses, CaseAttributes, CasePatchRequest, + CaseType, + AssociationType, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; export type Comment = CommentRequest & { + associationType: AssociationType; id: string; createdAt: string; createdBy: ElasticUser; @@ -62,7 +65,9 @@ export interface Case { status: CaseStatuses; tags: string[]; title: string; + totalAlerts: number; totalComment: number; + type: CaseType; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 8f2e9a4f1d7cd0..45827a4bebff80 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -6,7 +6,7 @@ */ import { useEffect, useReducer, useCallback } from 'react'; -import { CaseStatuses } from '../../../../case/common/api'; +import { CaseStatuses, CaseType } from '../../../../case/common/api'; import { Case } from './types'; import * as i18n from './translations'; @@ -71,7 +71,9 @@ export const initialData: Case = { status: CaseStatuses.open, tags: [], title: '', + totalAlerts: 0, totalComment: 0, + type: CaseType.individual, updatedAt: null, updatedBy: null, version: '', diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx index 4ce6948ed8791c..c4fa0304735341 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx @@ -6,6 +6,7 @@ */ import { useReducer, useCallback, useRef, useEffect } from 'react'; + import { CasePostRequest } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { postCase } from './api'; @@ -16,6 +17,7 @@ interface NewCaseState { isError: boolean; } type Action = { type: 'FETCH_INIT' } | { type: 'FETCH_SUCCESS' } | { type: 'FETCH_FAILURE' }; + const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { switch (action.type) { case 'FETCH_INIT': @@ -76,6 +78,7 @@ export const usePostCase = (): UsePostCase => { }, [dispatchToaster] ); + useEffect(() => { return () => { abortCtrl.current.abort(); diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx new file mode 100644 index 00000000000000..f908a79361d0a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mount } from 'enzyme'; +import React from 'react'; +import { TestProviders } from '../../mock'; +import { CallOut } from './callout'; +import { CallOutMessage } from './callout_types'; + +describe('callout', () => { + let message: CallOutMessage = { + type: 'primary', + id: 'some-id', + title: 'title', + description: <>{'some description'}, + }; + + beforeEach(() => { + message = { + type: 'primary', + id: 'some-id', + title: 'title', + description: <>{'some description'}, + }; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('renders the callout data-test-subj from the given id', () => { + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-some-id"]')).toEqual(true); + }); + + test('renders the callout dismiss button by default', () => { + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(true); + }); + + test('renders the callout dismiss button if given an explicit true to enable it', () => { + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(true); + }); + + test('Does NOT render the callout dismiss button if given an explicit false to disable it', () => { + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(false); + }); + + test('onDismiss callback operates when dismiss button is clicked', () => { + const onDismiss = jest.fn(); + const wrapper = mount( + + + + ); + wrapper.find('[data-test-subj="callout-dismiss-btn"]').first().simulate('click'); + expect(onDismiss).toBeCalledWith(message); + }); + + test('dismissButtonText can be set', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="callout-dismiss-btn"]').first().text()).toEqual( + 'Some other text' + ); + }); + + test('a default icon type of "iInCircle" will be chosen if no iconType is set and the message type is "primary"', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="callout-some-id"]').first().prop('iconType')).toEqual( + 'iInCircle' + ); + }); + + test('icon type can be changed from the type within the message', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="callout-some-id"]').first().prop('iconType')).toEqual( + 'something_else' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout.tsx index f6e0c89cab266c..2077e421c427a1 100644 --- a/x-pack/plugins/security_solution/public/common/components/callouts/callout.tsx +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout.tsx @@ -8,8 +8,8 @@ import React, { FC, memo } from 'react'; import { EuiCallOut } from '@elastic/eui'; +import { assertUnreachable } from '../../../../common/utility_types'; import { CallOutType, CallOutMessage } from './callout_types'; -import { CallOutDescription } from './callout_description'; import { CallOutDismissButton } from './callout_dismiss_button'; export interface CallOutProps { @@ -17,6 +17,7 @@ export interface CallOutProps { iconType?: string; dismissButtonText?: string; onDismiss?: (message: CallOutMessage) => void; + showDismissButton?: boolean; } const CallOutComponent: FC = ({ @@ -24,8 +25,9 @@ const CallOutComponent: FC = ({ iconType, dismissButtonText, onDismiss, + showDismissButton = true, }) => { - const { type, id, title } = message; + const { type, id, title, description } = message; const finalIconType = iconType ?? getDefaultIconType(type); return ( @@ -36,8 +38,10 @@ const CallOutComponent: FC = ({ data-test-subj={`callout-${id}`} data-test-messages={`[${id}]`} > - - + {description} + {showDismissButton && ( + + )} ); }; @@ -53,7 +57,7 @@ const getDefaultIconType = (type: CallOutType): string => { case 'danger': return 'alert'; default: - return ''; + return assertUnreachable(type); } }; diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout_description.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout_description.tsx deleted file mode 100644 index dbb1267c73323d..00000000000000 --- a/x-pack/plugins/security_solution/public/common/components/callouts/callout_description.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC } from 'react'; -import { EuiDescriptionList } from '@elastic/eui'; -import { CallOutMessage } from './callout_types'; - -export interface CallOutDescriptionProps { - messages: CallOutMessage | CallOutMessage[]; -} - -export const CallOutDescription: FC = ({ messages }) => { - if (!Array.isArray(messages)) { - return messages.description; - } - - if (messages.length < 1) { - return null; - } - - return ; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout_persistent_switcher.tsx b/x-pack/plugins/security_solution/public/common/components/callouts/callout_persistent_switcher.tsx new file mode 100644 index 00000000000000..5b67410bb904a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout_persistent_switcher.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, memo } from 'react'; + +import { CallOutMessage } from './callout_types'; +import { CallOut } from './callout'; + +export interface CallOutPersistentSwitcherProps { + condition: boolean; + message: CallOutMessage; +} + +const CallOutPersistentSwitcherComponent: FC = ({ + condition, + message, +}): JSX.Element | null => + condition ? : null; + +export const CallOutPersistentSwitcher = memo(CallOutPersistentSwitcherComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/callout_types.ts b/x-pack/plugins/security_solution/public/common/components/callouts/callout_types.ts index 604f7b3e61c794..e04638a57ad06a 100644 --- a/x-pack/plugins/security_solution/public/common/components/callouts/callout_types.ts +++ b/x-pack/plugins/security_solution/public/common/components/callouts/callout_types.ts @@ -5,7 +5,9 @@ * 2.0. */ -export type CallOutType = 'primary' | 'success' | 'warning' | 'danger'; +import { EuiCallOutProps } from '@elastic/eui'; + +export type CallOutType = NonNullable; export interface CallOutMessage { type: CallOutType; diff --git a/x-pack/plugins/security_solution/public/common/components/callouts/index.ts b/x-pack/plugins/security_solution/public/common/components/callouts/index.ts index 222bf5daee6f57..0b7ec42744a6e4 100644 --- a/x-pack/plugins/security_solution/public/common/components/callouts/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/callouts/index.ts @@ -8,3 +8,4 @@ export * from './callout_switcher'; export * from './callout_types'; export * from './callout'; +export * from './callout_persistent_switcher'; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index a37528fcb24d7c..3ecc17589fe084 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -201,7 +201,7 @@ describe('EventsViewer', () => { testProps = { ...testProps, // Update with a new id, to force columns back to default. - id: TimelineId.test2, + id: TimelineId.alternateTest, }; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index dc7388438c012a..5ea11f61f9a7e5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -14,7 +14,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalFooter, - EuiOverlayMask, EuiButton, EuiButtonEmpty, EuiHorizontalRule, @@ -348,133 +347,129 @@ export const AddExceptionModal = memo(function AddExceptionModal({ }, [maybeRule]); return ( - - - - {addExceptionMessage} - - {ruleName} - - - - {fetchOrCreateListError != null && ( - - - + + + {addExceptionMessage} + + {ruleName} + + + + {fetchOrCreateListError != null && ( + + + + )} + {fetchOrCreateListError == null && + (isLoadingExceptionList || + isIndexPatternLoading || + isSignalIndexLoading || + isSignalIndexPatternLoading) && ( + )} - {fetchOrCreateListError == null && - (isLoadingExceptionList || - isIndexPatternLoading || - isSignalIndexLoading || - isSignalIndexPatternLoading) && ( - - )} - {fetchOrCreateListError == null && - !isSignalIndexLoading && - !isSignalIndexPatternLoading && - !isLoadingExceptionList && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && - ruleExceptionList && ( - <> - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - - - - - - - - - {alertData !== undefined && alertStatus !== 'closed' && ( - - - - )} + {fetchOrCreateListError == null && + !isSignalIndexLoading && + !isSignalIndexPatternLoading && + !isLoadingExceptionList && + !isIndexPatternLoading && + !isRuleLoading && + !mlJobLoading && + ruleExceptionList && ( + <> + + {isRuleEQLSequenceStatement && ( + <> + + + + )} + {i18n.EXCEPTION_BUILDER_INFO} + + + + + + + + + + {alertData !== undefined && alertStatus !== 'closed' && ( - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - - - )} - {fetchOrCreateListError == null && ( - - {i18n.CANCEL} - - - {addExceptionMessage} - - + )} + + + + {exceptionListType === 'endpoint' && ( + <> + + + {i18n.ENDPOINT_QUARANTINE_TEXT} + + + )} + + )} - - + {fetchOrCreateListError == null && ( + + {i18n.CANCEL} + + + {addExceptionMessage} + + + )} + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 75b7bf2aabd7fd..336732016e9369 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -12,7 +12,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalFooter, - EuiOverlayMask, EuiButton, EuiButtonEmpty, EuiHorizontalRule, @@ -281,125 +280,121 @@ export const EditExceptionModal = memo(function EditExceptionModal({ }, [maybeRule]); return ( - - - - - {exceptionListType === 'endpoint' - ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE - : i18n.EDIT_EXCEPTION_TITLE} - - - {ruleName} - - - {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( - - )} - {!isSignalIndexLoading && - !addExceptionIsLoading && - !isIndexPatternLoading && - !isRuleLoading && - !mlJobLoading && ( - <> - - {isRuleEQLSequenceStatement && ( - <> - - - - )} - {i18n.EXCEPTION_BUILDER_INFO} - - - - - - - - - - - + + + {exceptionListType === 'endpoint' + ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + : i18n.EDIT_EXCEPTION_TITLE} + + + {ruleName} + + + {(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && ( + + )} + {!isSignalIndexLoading && + !addExceptionIsLoading && + !isIndexPatternLoading && + !isRuleLoading && + !mlJobLoading && ( + <> + + {isRuleEQLSequenceStatement && ( + <> + - - {exceptionListType === 'endpoint' && ( - <> - - - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - - - )} - {updateError != null && ( - - - - )} - {hasVersionConflict && ( - - -

    {i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

    -
    -
    - )} - {updateError == null && ( - - {i18n.CANCEL} - - - {i18n.EDIT_EXCEPTION_SAVE_BUTTON} - - + + + )} + {i18n.EXCEPTION_BUILDER_INFO} + + + + + + + + + + + + + {exceptionListType === 'endpoint' && ( + <> + + + {i18n.ENDPOINT_QUARANTINE_TEXT} + + + )} + + )} -
    -
    + {updateError != null && ( + + + + )} + {hasVersionConflict && ( + + +

    {i18n.VERSION_CONFLICT_ERROR_DESCRIPTION}

    +
    +
    + )} + {updateError == null && ( + + {i18n.CANCEL} + + + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} + + + )} + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap index 6503dd8dfb5086..d1a41b1c32c102 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/__snapshots__/index.test.tsx.snap @@ -2,64 +2,62 @@ exports[`ImportDataModal renders correctly against snapshot 1`] = ` - - - - - title - - - - -

    - description -

    -
    - - - - -
    - - - Cancel - - - submitBtnText - - -
    -
    + + + + title + + + + +

    + description +

    +
    + + + + +
    + + + Cancel + + + submitBtnText + + +
    `; diff --git a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx index 8a29ce3799321f..4c3dc2a249b4ff 100644 --- a/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/import_data_modal/index.tsx @@ -15,7 +15,6 @@ import { EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, - EuiOverlayMask, EuiSpacer, EuiText, } from '@elastic/eui'; @@ -132,51 +131,49 @@ export const ImportDataModalComponent = ({ return ( <> {showModal && ( - - - - {title} - - - - -

    {description}

    -
    - - - { - setSelectedFiles(files && files.length > 0 ? files : null); - }} - display={'large'} - fullWidth={true} - isLoading={isImporting} + + + {title} + + + + +

    {description}

    +
    + + + { + setSelectedFiles(files && files.length > 0 ? files : null); + }} + display={'large'} + fullWidth={true} + isLoading={isImporting} + /> + + {showCheckBox && ( + setOverwrite(!overwrite)} /> - - {showCheckBox && ( - setOverwrite(!overwrite)} - /> - )} -
    - - - {i18n.CANCEL_BUTTON} - - {submitBtnText} - - -
    -
    + )} + + + + {i18n.CANCEL_BUTTON} + + {submitBtnText} + + + )} ); diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx index ece29cd360ce71..a5c0144531110a 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/modal.tsx @@ -15,7 +15,6 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiModalFooter, - EuiOverlayMask, EuiSpacer, EuiTabbedContent, } from '@elastic/eui'; @@ -211,24 +210,22 @@ export const ModalInspectQuery = ({ ]; return ( - - - - - {i18n.INSPECT} {title} - - - - - - - - - - {i18n.CLOSE} - - - - + + + + {i18n.INSPECT} {title} + + + + + + + + + + {i18n.CLOSE} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap index 778916ad2d07ac..be5702550a44c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -246,6 +246,12 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, "euiFilePickerTallHeight": "128px", "euiFlyoutBorder": "1px solid #343741", + "euiFlyoutPaddingModifiers": Object { + "paddingLarge": "24px", + "paddingMedium": "16px", + "paddingNone": 0, + "paddingSmall": "8px", + }, "euiFocusBackgroundColor": "#08334a", "euiFocusRingAnimStartColor": "rgba(27, 169, 245, 0)", "euiFocusRingAnimStartSize": "6px", @@ -357,6 +363,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta }, "euiMarkdownEditorMinHeight": "150px", "euiPageBackgroundColor": "#1a1b20", + "euiPageDefaultMaxWidth": "1000px", "euiPaletteColorBlind": Object { "euiColorVis0": Object { "behindText": "#6dccb1", @@ -534,6 +541,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta "euiSwitchWidthCompressed": "28px", "euiSwitchWidthMini": "22px", "euiTabFontSize": "16px", + "euiTabFontSizeL": "18px", "euiTabFontSizeS": "14px", "euiTableActionsAreaWidth": "40px", "euiTableActionsBorderColor": "rgba(83, 89, 102, 0.09999999999999998)", diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap index f7924f37d2c173..5e008e28073de1 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/toasters/__snapshots__/modal_all_errors.test.tsx.snap @@ -1,50 +1,48 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Modal all errors rendering it renders the default all errors modal when isShowing is positive 1`] = ` - - - - - Your visualization has error(s) - - - - - - - - Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - - - - - - Close - - - - + + + + Your visualization has error(s) + + + + + + + + Error 1, Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + + + + + Close + + + `; diff --git a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx index 873ebe97317f4f..0a78139f5fe3a1 100644 --- a/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx +++ b/x-pack/plugins/security_solution/public/common/components/toasters/modal_all_errors.tsx @@ -7,7 +7,6 @@ import { EuiButton, - EuiOverlayMask, EuiModal, EuiModalHeader, EuiModalHeaderTitle, @@ -36,36 +35,34 @@ const ModalAllErrorsComponent: React.FC = ({ isShowing, toast, t if (!isShowing || toast == null) return null; return ( - - - - {i18n.TITLE_ERROR_MODAL} - + + + {i18n.TITLE_ERROR_MODAL} + - - - - {toast.errors != null && - toast.errors.map((error, index) => ( - 100 ? `${error.substring(0, 100)} ...` : error} - data-test-subj="modal-all-errors-accordion" - > - {error} - - ))} - + + + + {toast.errors != null && + toast.errors.map((error, index) => ( + 100 ? `${error.substring(0, 100)} ...` : error} + data-test-subj="modal-all-errors-accordion" + > + {error} + + ))} + - - - {i18n.CLOSE_ERROR_MODAL} - - - - + + + {i18n.CLOSE_ERROR_MODAL} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index 2c744b228ba212..2fa63205ffe975 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -66,7 +66,12 @@ export const useInitSourcerer = ( selectedPatterns: [...ConfigIndexPatterns, signalIndexName], }) ); - } else if (signalIndexNameSelector != null && initialTimelineSourcerer.current) { + } else if ( + signalIndexNameSelector != null && + (activeTimeline == null || + (activeTimeline != null && activeTimeline.savedObjectId == null)) && + initialTimelineSourcerer.current + ) { initialTimelineSourcerer.current = false; dispatch( sourcererActions.setSelectedIndexPatterns({ diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_control.test.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_control.test.tsx new file mode 100644 index 00000000000000..953f39fcf23726 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_control.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useControl, UseControlsReturn } from './use_control'; + +describe('useControl', () => { + it('init', async () => { + const { result } = renderHook<{}, UseControlsReturn>(() => useControl()); + expect(result.current.isControlOpen).toBe(false); + }); + + it('should open the control', async () => { + const { result } = renderHook<{}, UseControlsReturn>(() => useControl()); + + act(() => { + result.current.openControl(); + }); + + expect(result.current.isControlOpen).toBe(true); + }); + + it('should close the control', async () => { + const { result } = renderHook<{}, UseControlsReturn>(() => useControl()); + + act(() => { + result.current.openControl(); + }); + + expect(result.current.isControlOpen).toBe(true); + + act(() => { + result.current.closeControl(); + }); + + expect(result.current.isControlOpen).toBe(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_control.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_control.tsx new file mode 100644 index 00000000000000..37d7f4ff79986f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_control.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useCallback } from 'react'; + +export interface UseControlsReturn { + isControlOpen: boolean; + openControl: () => void; + closeControl: () => void; +} + +export const useControl = (): UseControlsReturn => { + const [isControlOpen, setIsControlOpen] = useState(false); + const openControl = useCallback(() => setIsControlOpen(true), []); + const closeControl = useCallback(() => setIsControlOpen(false), []); + + return { isControlOpen, openControl, closeControl }; +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx index 714da27908423f..e801db0190799d 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx +++ b/x-pack/plugins/security_solution/public/common/hooks/use_timeline_events_count.tsx @@ -12,17 +12,28 @@ import { createPortalNode, OutPortal } from 'react-reverse-portal'; * A singleton portal for rendering content in the global header */ const timelineEventsCountPortalNodeSingleton = createPortalNode(); +const eqlEventsCountPortalNodeSingleton = createPortalNode(); export const useTimelineEventsCountPortal = () => { const [timelineEventsCountPortalNode] = useState(timelineEventsCountPortalNodeSingleton); - - return { timelineEventsCountPortalNode }; + return { portalNode: timelineEventsCountPortalNode }; }; export const TimelineEventsCountBadge = React.memo(() => { - const { timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); - - return ; + const { portalNode } = useTimelineEventsCountPortal(); + return ; }); TimelineEventsCountBadge.displayName = 'TimelineEventsCountBadge'; + +export const useEqlEventsCountPortal = () => { + const [eqlEventsCountPortalNode] = useState(eqlEventsCountPortalNodeSingleton); + return { portalNode: eqlEventsCountPortalNode }; +}; + +export const EqlEventsCountBadge = React.memo(() => { + const { portalNode } = useEqlEventsCountPortal(); + return ; +}); + +EqlEventsCountBadge.displayName = 'EqlEventsCountBadge'; diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index bfd25aa469c931..5eae3a4d729882 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -212,6 +212,11 @@ export const mockGlobalState: State = { itemsPerPage: 5, dataProviders: [], description: '', + eqlOptions: { + eventCategoryField: 'event.category', + tiebreakerField: 'event.sequence', + timestampField: '@timestamp', + }, eventIdToNoteIds: {}, excludedRowRendererIds: [], expandedDetail: {}, 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 1082b5f9474e53..3400844e671b30 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 @@ -343,6 +343,885 @@ export const mockEndpointFileDeletionEvent: Ecs = { _id: 'mnXHO3cBPmkOXwyNlyv_', }; +export const mockEndpointFileCreationMalwarePreventionAlert: Ecs = { + process: { + hash: { + md5: ['efca0a88adab8b92e4a333b56db5fbaa'], + sha256: ['8c177f6129dddbd36cae196ef9d9eb71f50cee44640068f24830e83d6a9dd1d0'], + sha1: ['e55e587058112c60d015994424f70a7a8e78afb1'], + }, + parent: { + name: ['explorer.exe'], + pid: [1008], + }, + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTg5NDQtMTMyNDkwNjg0NzIuNzM4OTY4NTAw', + ], + executable: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'], + name: ['chrome.exe'], + pid: [8944], + args: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'], + }, + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1518)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1518)'], + platform: ['windows'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1518)'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + name: ['win2019-endpoint-1'], + }, + event: { + category: ['malware', 'intrusion_detection', 'file'], + outcome: ['success'], + code: ['malicious_file'], + action: ['creation'], + id: ['LsuMZVr+sdhvehVM++++Ic8J'], + kind: ['alert'], + module: ['endpoint'], + type: ['info', 'creation', 'denied'], + dataset: ['endpoint.alerts'], + }, + file: { + path: ['C:\\Users\\sean\\Downloads\\6a5eabd6-1c79-4962-b411-a5e7d9e967d4.tmp'], + owner: ['sean'], + hash: { + md5: ['c1f8d2b73b4c2488f95e7305f0421bdf'], + sha256: ['7cc42618e580f233fee47e82312cc5c3476cb5de9219ba3f9eb7f99ac0659c30'], + sha1: ['542b2796e9f57a92504f852b6698148bba9ff289'], + }, + name: ['6a5eabd6-1c79-4962-b411-a5e7d9e967d4.tmp'], + extension: ['tmp'], + size: [196608], + }, + agent: { + type: ['endpoint'], + }, + timestamp: '2020-11-05T16:48:19.923Z', + message: ['Malware Prevention Alert'], + _id: 'dGZQmXUB-o9SpDeMqvln', +}; + +export const mockEndpointFileCreationMalwareDetectionAlert: Ecs = { + process: { + hash: { + md5: ['16d6a536bb2115dcbd16011e6991a9fd'], + sha256: ['6637eca55fedbabc510168f0c4696d41971c89e5d1fb440f2f9391e6ab0e8f54'], + sha1: ['05cc6d37603ca9076f3baf4dc421500c5cf69e4c'], + }, + entity_id: [ + 'Yjk3ZWYwODktNzYyZi00ZTljLTg3OWMtNmQ5MDM1ZjBmYTUzLTQ0MDAtMTMyNDM2MTgwMzIuMjA0MzMxMDA=', + ], + executable: ['C:\\Python27\\python.exe'], + parent: { + name: ['pythonservice.exe'], + pid: [2936], + }, + name: ['python.exe'], + args: ['C:\\Python27\\python.exe', 'main.py', '-a,execute', '-p', 'c:\\temp'], + pid: [4400], + }, + host: { + os: { + full: ['Windows 10 Pro 1903 (10.0.18362.1016)'], + name: ['Windows'], + version: ['1903 (10.0.18362.1016)'], + platform: ['windows'], + family: ['windows'], + kernel: ['1903 (10.0.18362.1016)'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + ip: ['10.1.2.3'], + id: ['c85e6c40-d4a1-db21-7458-2565a6b857f3'], + architecture: ['x86_64'], + name: ['DESKTOP-1'], + }, + file: { + path: ['C:\\temp\\mimikatz_write.exe'], + owner: ['Administrators'], + hash: { + md5: ['cc52aebdf82048364119f117f52dbba0'], + sha256: ['263f09eeee80e03aa27a2d19530e2451978e18bf733c5f1c64ff2389c5dc17b0'], + sha1: ['c929f6ff2d6d1085ee69625cd8efb92101a0e906'], + }, + name: ['mimikatz_write.exe'], + extension: ['exe'], + size: [1265456], + }, + event: { + id: ['Lp/73XQ38EF48a6i+++++5Ds'], + module: ['endpoint'], + category: ['malware', 'intrusion_detection', 'file'], + outcome: ['success'], + code: ['malicious_file'], + action: ['creation'], + kind: ['signal'], + type: ['info', 'creation', 'allowed'], + dataset: ['endpoint.alerts'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Malware Detection Alert'], + timestamp: '2020-09-03T15:51:50.209Z', + _id: '51e04f7dad15fe394a3f7ed582ad4528c8ce62948e315571fc3388befd9aa0e6', +}; + +export const mockEndpointFilesEncryptedRansomwarePreventionAlert: Ecs = { + process: { + hash: { + md5: ['85bc517e37fe24f909e4378a46a4b567'], + sha256: ['e9fa973eb5ad446e0be31c7b8ae02d48281319e7f492e1ddaadddfbdd5b480c7'], + sha1: ['10a3671c0fbc2bce14fc94891e87e2f4ba07e0df'], + }, + parent: { + name: ['cmd.exe'], + pid: [10680], + }, + entity_id: [ + 'OTI1MTRiMTYtMWJkNi05NzljLWE2MDMtOTgwY2ZkNzQ4M2IwLTYwNTYtMTMyNTczODEzMzYuNzIxNTIxODAw', + ], + name: ['powershell.exe'], + pid: [6056], + args: ['powershell.exe', '-file', 'mock_ransomware_v3.ps1'], + }, + host: { + os: { + full: ['Windows 7 Enterprise Service Pack 1 (6.1.7601)'], + name: ['Windows'], + version: ['Service Pack 1 (6.1.7601)'], + platform: ['windows'], + family: ['windows'], + kernel: ['Service Pack 1 (6.1.7601)'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['c6bb2832-d58c-4c57-9d1f-3b102ea74d46'], + name: ['DESKTOP-1'], + }, + event: { + category: ['malware', 'intrusion_detection', 'process', 'file'], + outcome: ['success'], + code: ['ransomware'], + action: ['files-encrypted'], + id: ['M0A1DXHIg6/kaeku+++++1Gv'], + kind: ['alert'], + module: ['endpoint'], + type: ['info', 'start', 'change', 'denied'], + dataset: ['endpoint.alerts'], + }, + agent: { + type: ['endpoint'], + }, + timestamp: '2021-02-09T21:55:48.941Z', + message: ['Ransomware Prevention Alert'], + _id: 'BfvLiHcBVXUk10dUK1Pk', +}; + +export const mockEndpointFilesEncryptedRansomwareDetectionAlert: Ecs = { + process: { + hash: { + md5: ['85bc517e37fe24f909e4378a46a4b567'], + sha256: ['e9fa973eb5ad446e0be31c7b8ae02d48281319e7f492e1ddaadddfbdd5b480c7'], + sha1: ['10a3671c0fbc2bce14fc94891e87e2f4ba07e0df'], + }, + parent: { + name: ['cmd.exe'], + pid: [8616], + }, + entity_id: [ + 'MDAwODRkOTAtZDRhOC1kOTZhLWVmYWItZDU1ZWFhNDY1N2M2LTQ2ODQtMTMyNTc0NjE2MzEuNDM3NDUzMDA=', + ], + executable: ['C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'], + name: ['powershell.exe'], + pid: [4684], + args: ['powershell.exe', '-file', 'mock_ransomware_v3.ps1'], + }, + host: { + os: { + full: ['Windows 7 Enterprise Service Pack 1 (6.1.7601)'], + name: ['Windows'], + version: ['Service Pack 1 (6.1.7601)'], + platform: ['windows'], + family: ['windows'], + kernel: ['Service Pack 1 (6.1.7601)'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['c6bb2832-d58c-4c57-9d1f-3b102ea74d46'], + name: ['DESKTOP-1'], + }, + event: { + category: ['malware', 'intrusion_detection', 'process', 'file'], + code: ['ransomware'], + action: ['files-encrypted'], + id: ['M0ExfR/BggxoHQ1e+++++1Zv'], + kind: ['alert'], + module: ['endpoint'], + type: ['info', 'start', 'change', 'allowed'], + dataset: ['endpoint.alerts'], + }, + agent: { + type: ['endpoint'], + }, + timestamp: '2021-02-10T20:14:03.927Z', + message: ['Ransomware Detection Alert'], + _id: 'enyUjXcBxUk8qlINZEJr', +}; + +export const mockEndpointFileModificationMalwarePreventionAlert: Ecs = { + process: { + hash: { + md5: ['47ea9e07b7dbfbeba368bd95a3a2d25b'], + sha256: ['f45557c0b57dec4c000d8cb7d7068c8a4dccf392de740501b1046994460d77ea'], + sha1: ['da714f84a7bbaee2be9f1ca0262aca649657cf3e'], + }, + parent: { + name: ['C:\\Windows\\System32\\userinit.exe'], + pid: [356], + }, + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTEwMDgtMTMyNDc1Njk3ODUuODA0NzQyMDA=', + ], + executable: ['C:\\Windows\\explorer.exe'], + name: ['explorer.exe'], + pid: [1008], + args: ['C:\\Windows\\Explorer.EXE'], + }, + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1518)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1518)'], + platform: ['windows'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1518)'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + name: ['win2019-endpoint-1'], + }, + file: { + path: ['C:\\Users\\sean\\Downloads\\mimikatz_trunk (1)\\x64\\mimikatz - Copy.exe'], + owner: ['sean'], + hash: { + md5: ['a3cb3b02a683275f7e0a0f8a9a5c9e07'], + sha256: ['31eb1de7e840a342fd468e558e5ab627bcb4c542a8fe01aec4d5ba01d539a0fc'], + sha1: ['d241df7b9d2ec0b8194751cd5ce153e27cc40fa4'], + }, + name: ['mimikatz - Copy.exe'], + extension: ['exe'], + size: [1309448], + }, + event: { + category: ['malware', 'intrusion_detection', 'file'], + outcome: ['success'], + code: ['malicious_file'], + action: ['modification'], + id: ['LsuMZVr+sdhvehVM++++GvWi'], + kind: ['alert'], + created: ['2020-11-04T22:40:51.724Z'], + module: ['endpoint'], + type: ['info', 'change', 'denied'], + dataset: ['endpoint.alerts'], + }, + agent: { + type: ['endpoint'], + }, + timestamp: '2020-11-04T22:40:51.724Z', + message: ['Malware Prevention Alert'], + _id: 'j0RtlXUB-o9SpDeMLdEE', +}; + +export const mockEndpointFileModificationMalwareDetectionAlert: Ecs = { + process: { + hash: { + md5: ['c93876879542fc4710ab1d3b52382d95'], + sha256: ['0ead4d0131ca81aa4820efdcd3c6053eab23179a46c5480c94d7c11eb8451d62'], + sha1: ['def88472b5d92022b6182bfe031c043ddfc5ff0f'], + }, + parent: { + name: ['Python'], + pid: [97], + }, + entity_id: [ + 'ZGQ0NDBhNjMtZjcyNy00NGY4LWI5M2UtNzQzZWEzMDBiYTk2LTU5OTUtMTMyNDM2MTg1MzkuOTUyNjkwMDA=', + ], + executable: [ + '/usr/local/Cellar/python/2.7.14/Frameworks/Python.framework/Versions/2.7/Resources/Python.app/Contents/MacOS/Python', + ], + name: ['Python'], + args: [ + '/usr/local/Cellar/python/2.7.14/Frameworks/Python.framework/Versions/2.7/Resources/Python.app/Contents/MacOS/Python', + 'main.py', + '-a', + 'modify', + ], + pid: [5995], + }, + host: { + os: { + full: ['macOS 10.14.1'], + name: ['macOS'], + version: ['10.14.1'], + platform: ['macos'], + family: ['macos'], + kernel: [ + 'Darwin Kernel Version 18.2.0: Fri Oct 5 19:40:55 PDT 2018; root:xnu-4903.221.2~1/RELEASE_X86_64', + ], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + ip: ['10.1.2.3'], + id: ['7d59b1a5-afa1-6531-07ea-691602558230'], + architecture: ['x86_64'], + name: ['mac-1.local'], + }, + file: { + mtime: ['2020-09-03T14:55:42.842Z'], + path: ['/private/var/root/write_malware/modules/write_malware/aircrack'], + owner: ['root'], + hash: { + md5: ['59328cdab10fb4f25a026eb362440422'], + sha256: ['f0954d9673878b2223b00b7ec770c7b438d876a9bb44ec78457e5c618f31f52b'], + sha1: ['f10b043652da8c444e04aede3a9ce4a10ef9028e'], + }, + name: ['aircrack'], + size: [240916], + }, + event: { + id: ['Lp21aufnU2nkG+fO++++++7h'], + module: ['endpoint'], + category: ['malware', 'intrusion_detection', 'file'], + outcome: ['success'], + code: ['malicious_file'], + action: ['modification'], + type: ['info', 'change', 'allowed'], + dataset: ['endpoint.alerts'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Malware Detection Alert'], + timestamp: '2020-09-03T15:01:19.445Z', + _id: '04d309c7e4cf7c4e54b7e3d93c38399e51797eed2484078487f4d6661f94da2c', +}; + +export const mockEndpointFileRenameMalwarePreventionAlert: Ecs = { + process: { + hash: { + md5: ['47ea9e07b7dbfbeba368bd95a3a2d25b'], + sha256: ['f45557c0b57dec4c000d8cb7d7068c8a4dccf392de740501b1046994460d77ea'], + sha1: ['da714f84a7bbaee2be9f1ca0262aca649657cf3e'], + }, + parent: { + name: ['C:\\Windows\\System32\\userinit.exe'], + pid: [356], + }, + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTEwMDgtMTMyNDc1Njk3ODUuODA0NzQyMDA=', + ], + executable: ['C:\\Windows\\explorer.exe'], + name: ['explorer.exe'], + pid: [1008], + args: ['C:\\Windows\\Explorer.EXE'], + }, + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1518)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1518)'], + platform: ['windows'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1518)'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + name: ['win2019-endpoint-1'], + }, + file: { + mtime: ['2020-11-04T21:48:47.559Z'], + path: [ + 'C:\\Users\\sean\\Downloads\\23361f8f413dd9258545030e42056a352fe35f66bac376d49954551c9b4bcf97.exe', + ], + owner: ['sean'], + hash: { + md5: ['9798063a1fe056ef2f1d6f5217e7b82b'], + sha256: ['23361f8f413dd9258545030e42056a352fe35f66bac376d49954551c9b4bcf97'], + sha1: ['ced72fe7fc3835385faea41c657efab7b9f883cd'], + }, + name: ['23361f8f413dd9258545030e42056a352fe35f66bac376d49954551c9b4bcf97.exe'], + extension: ['exe'], + size: [242010], + }, + event: { + category: ['malware', 'intrusion_detection', 'file'], + outcome: ['success'], + code: ['malicious_file'], + action: ['rename'], + id: ['LsuMZVr+sdhvehVM++++GppA'], + kind: ['alert'], + module: ['endpoint'], + type: ['info', 'change', 'denied'], + dataset: ['endpoint.alerts'], + }, + agent: { + type: ['endpoint'], + }, + timestamp: '2020-11-04T21:48:57.847Z', + message: ['Malware Prevention Alert'], + _id: 'qtA9lXUBn9bLIbfPj-Tu', +}; + +export const mockEndpointFileRenameMalwareDetectionAlert: Ecs = { + ...mockEndpointFileRenameMalwarePreventionAlert, + event: { + ...mockEndpointFileRenameMalwarePreventionAlert.event, + type: ['info', 'change', 'allowed'], + }, + message: ['Malware Detection Alert'], + _id: 'CD7B6A22-809C-4502-BB94-BC38901EC942', +}; + +// NOTE: see `mock_timeline_data.ts` for the mockEndpointProcessExecutionMalwarePreventionAlert + +export const mockEndpointProcessExecutionMalwareDetectionAlert: Ecs = { + process: { + hash: { + md5: ['cc52aebdf82048364119f117f52dbba0'], + sha256: ['263f09eeee80e03aa27a2d19530e2451978e18bf733c5f1c64ff2389c5dc17b0'], + sha1: ['c929f6ff2d6d1085ee69625cd8efb92101a0e906'], + }, + entity_id: [ + 'Yjk3ZWYwODktNzYyZi00ZTljLTg3OWMtNmQ5MDM1ZjBmYTUzLTg2NjgtMTMyNDM2MTgwMzQuODU3Njg5MDA=', + ], + executable: ['C:\\temp\\mimikatz_write.exe'], + parent: { + name: ['python.exe'], + }, + name: ['mimikatz_write.exe'], + args: ['c:\\temp\\mimikatz_write.exe'], + pid: [8668], + }, + host: { + os: { + full: ['Windows 10 Pro 1903 (10.0.18362.1016)'], + name: ['Windows'], + version: ['1903 (10.0.18362.1016)'], + platform: ['windows'], + family: ['windows'], + kernel: ['1903 (10.0.18362.1016)'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + ip: ['10.1.2.3'], + id: ['c85e6c40-d4a1-db21-7458-2565a6b857f3'], + architecture: ['x86_64'], + name: ['DESKTOP-1'], + }, + file: { + mtime: ['2020-09-03T14:47:14.647Z'], + path: ['C:\\temp\\mimikatz_write.exe'], + owner: ['Administrators'], + hash: { + md5: ['cc52aebdf82048364119f117f52dbba0'], + sha256: ['263f09eeee80e03aa27a2d19530e2451978e18bf733c5f1c64ff2389c5dc17b0'], + sha1: ['c929f6ff2d6d1085ee69625cd8efb92101a0e906'], + }, + name: ['mimikatz_write.exe'], + extension: ['exe'], + size: [1265456], + }, + event: { + id: ['Lp/73XQ38EF48a6i+++++5Do'], + module: ['endpoint'], + category: ['malware', 'intrusion_detection', 'process'], + outcome: ['success'], + code: ['malicious_file'], + action: ['execution'], + kind: ['signal'], + type: ['info', 'start', 'allowed'], + dataset: ['endpoint.alerts'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Malware Detection Alert'], + timestamp: '2020-09-03T15:51:50.209Z', + _id: '96b3db3079891faaf155f1ada645b7364a03018c65677ce002f18038e7ce1c47', +}; + +export const mockEndpointFileModificationEvent: Ecs = { + file: { + path: ['/Users/admin/Library/Application Support/CrashReporter/.dat.nosync01a5.6hoWv1'], + name: ['.dat.nosync01a5.6hoWv1'], + }, + host: { + os: { + full: ['macOS 10.14.6'], + name: ['macOS'], + version: ['10.14.6'], + family: ['macos'], + kernel: [ + 'Darwin Kernel Version 18.7.0: Mon Aug 31 20:53:32 PDT 2020; root:xnu-4903.278.44~1/RELEASE_X86_64', + ], + platform: ['macos'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['test-Mac.local'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['fce6b9f1-5c09-d8f0-3d99-9ecb30f995df'], + }, + event: { + category: ['file'], + kind: ['event'], + module: ['endpoint'], + action: ['modification'], + type: ['change'], + dataset: ['endpoint.events.file'], + }, + process: { + name: ['diagnostics_agent'], + pid: [421], + entity_id: ['OTA1ZDkzMTctMjIxOS00ZjQ1LTg4NTMtYzNiYzk1NGU1ZGU4LTQyMS0xMzI0OTEwNTIwOC4w'], + executable: ['/System/Library/CoreServices/diagnostics_agent'], + }, + user: { + id: ['501'], + name: ['admin'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Endpoint file event'], + timestamp: '2021-02-02T18:56:12.871Z', + _id: 'ulkWZHcBGrBB52F2vFf_', +}; + +export const mockEndpointFileOverwriteEvent: Ecs = { + file: { + path: ['C:\\Windows\\ServiceState\\EventLog\\Data\\lastalive0.dat'], + extension: ['dat'], + name: ['lastalive0.dat'], + }, + 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: ['file'], + kind: ['event'], + created: ['2021-02-02T21:40:14.400Z'], + module: ['endpoint'], + action: ['overwrite'], + type: ['change'], + id: ['Lzty2lsJxA05IUWg++++Icrn'], + dataset: ['endpoint.events.file'], + }, + process: { + name: ['svchost.exe'], + pid: [1228], + entity_id: [ + 'YjUwNDNiMTMtYTdjNi0xZGFlLTEyZWQtODQ1ZDlhNTRhZmQyLTEyMjgtMTMyNTQ5ODc1MDcuODc1MTIxNjAw', + ], + executable: ['C:\\Windows\\System32\\svchost.exe'], + }, + user: { + id: ['S-1-5-19'], + name: ['LOCAL SERVICE'], + domain: ['NT AUTHORITY'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Endpoint file event'], + timestamp: '2021-02-02T21:40:14.400Z', + _id: 'LBmxZHcBtgfIO53sCImw', +}; + +export const mockEndpointFileRenameEvent: Ecs = { + file: { + path: ['C:\\Windows\\System32\\sru\\SRU.log'], + Ext: { + original: { + path: ['C:\\Windows\\System32\\sru\\SRUtmp.log'], + name: ['SRUtmp.log'], + }, + }, + extension: ['log'], + name: ['SRU.log'], + }, + 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)'], + }, + 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: ['file'], + kind: ['event'], + created: ['2021-02-01T16:43:00.373Z'], + module: ['endpoint'], + action: ['rename'], + type: ['change'], + id: ['Lzty2lsJxA05IUWg++++I3jv'], + dataset: ['endpoint.events.file'], + }, + process: { + name: ['svchost.exe'], + pid: [1204], + entity_id: [ + 'YjUwNDNiMTMtYTdjNi0xZGFlLTEyZWQtODQ1ZDlhNTRhZmQyLTEyMDQtMTMyNTQ5ODc2NzQuNzQ5MjUzNzAw', + ], + executable: ['C:\\Windows\\System32\\svchost.exe'], + }, + user: { + id: ['S-1-5-19'], + name: ['LOCAL SERVICE'], + domain: ['NT AUTHORITY'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Endpoint file event'], + timestamp: '2021-02-01T16:43:00.373Z', + _id: 'OlJ8XncBGrBB52F2Oga7', +}; + +// NOTE: see `mock_timeline_data.ts` for the mockEndpointRegistryModificationEvent + +// NOTE: see `mock_timeline_data.ts` for the mockEndpointLibraryLoadEvent + +export const mockEndpointNetworkHttpRequestEvent: 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-1'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['network'], + kind: ['event'], + module: ['endpoint'], + action: ['http_request'], + type: ['protocol'], + id: ['LzzWB9jjGmCwGMvk++++FD+p'], + dataset: ['endpoint.events.network'], + }, + process: { + name: ['svchost.exe'], + pid: [2232], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTIyMzItMTMyNTUwNzg2ODkuNTA1NzEzMDA=', + ], + executable: ['C:\\Windows\\System32\\svchost.exe'], + }, + destination: { + geo: { + region_name: ['Arizona'], + continent_name: ['North America'], + city_name: ['Phoenix'], + country_name: ['United States'], + region_iso_code: ['US-AZ'], + country_iso_code: ['US'], + }, + port: [80], + ip: ['10.11.12.13'], + }, + source: { + ip: ['10.1.2.3'], + port: [51570], + }, + http: { + request: { + body: { + content: [ + 'GET /msdownload/update/v3/static/trustedr/en/authrootstl.cab?b3d6249cb8dde683 HTTP/1.1\r\nConnection: Keep-Alive\r\nAccept: */*\r\nIf-Modified-Since: Fri, 15 Jan 2021 00:46:38 GMT\r\nIf-None-Match: "0ebbae1d7ead61:0"\r\nUser-Agent: Microsoft-CryptoAPI/10.0\r\nHost: ctldl.windowsupdate.com\r\n\r\n', + ], + bytes: [281], + }, + }, + }, + agent: { + type: ['endpoint'], + }, + user: { + name: ['NETWORK SERVICE'], + domain: ['NT AUTHORITY'], + }, + network: { + protocol: ['http'], + direction: ['outgoing'], + transport: ['tcp'], + }, + message: ['Endpoint network event'], + timestamp: '2021-02-08T19:19:38.241Z', + _id: '5Qwdg3cBX5UUcOOY03W7', +}; + +export const mockEndpointProcessExecEvent: Ecs = { + process: { + hash: { + md5: ['fbc61bd19421211e341e6d9b3f65e334'], + sha256: ['4bc018ac461706496302d1faab0a8bb39aad974eb432758665103165f3a2dd2b'], + sha1: ['1dc525922869533265fbeac8f7d3021489b60129'], + }, + name: ['mdworker_shared'], + parent: { + name: ['launchd'], + pid: [1], + }, + pid: [4454], + entity_id: [ + 'OTA1ZDkzMTctMjIxOS00ZjQ1LTg4NTMtYzNiYzk1NGU1ZGU4LTQ0NTQtMTMyNTY3NjYwMDEuNzIwMjkwMDA=', + ], + executable: [ + '/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + ], + args: [ + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + '-s', + 'mdworker', + '-c', + 'MDSImporterWorker', + '-m', + 'com.apple.mdworker.shared', + ], + }, + host: { + os: { + full: ['macOS 10.14.6'], + name: ['macOS'], + version: ['10.14.6'], + family: ['macos'], + kernel: [ + 'Darwin Kernel Version 18.7.0: Mon Aug 31 20:53:32 PDT 2020; root:xnu-4903.278.44~1/RELEASE_X86_64', + ], + platform: ['macos'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['test-mac.local'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['fce6b9f1-5c09-d8f0-3d99-9ecb30f995df'], + }, + event: { + category: ['process'], + kind: ['event'], + module: ['endpoint'], + action: ['exec'], + type: ['start'], + id: ['LuH/UjERrFf60dea+++++NW7'], + dataset: ['endpoint.events.process'], + }, + user: { + id: ['501'], + name: ['admin'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Endpoint process event'], + timestamp: '2021-02-02T19:00:01.972Z', + _id: '8lkaZHcBGrBB52F2aN8c', +}; + +export const mockEndpointProcessForkEvent: Ecs = { + process: { + hash: { + md5: ['24a77cf54ab89f3d0772c65204074710'], + sha256: ['cbf3d059cc9f9c0adff5ef15bf331b95ab381837fa0adecd965a41b5846f4bd4'], + sha1: ['6cc7c36da55c7af0969539fae73768fbef11aa1a'], + }, + name: ['zoom.us'], + parent: { + name: ['zoom.us'], + pid: [3961], + }, + pid: [4042], + entity_id: [ + 'OTA1ZDkzMTctMjIxOS00ZjQ1LTg4NTMtYzNiYzk1NGU1ZGU4LTQwNDItMTMyNTY2ODI5MjQuNzYxNDAwMA==', + ], + executable: ['/Applications/zoom.us.app/Contents/MacOS/zoom.us'], + args: ['/Applications/zoom.us.app/Contents/MacOS/zoom.us'], + }, + host: { + os: { + full: ['macOS 10.14.6'], + name: ['macOS'], + version: ['10.14.6'], + family: ['macos'], + kernel: [ + 'Darwin Kernel Version 18.7.0: Mon Aug 31 20:53:32 PDT 2020; root:xnu-4903.278.44~1/RELEASE_X86_64', + ], + platform: ['macos'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + name: ['test-mac.local'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['fce6b9f1-5c09-d8f0-3d99-9ecb30f995df'], + }, + event: { + category: ['process'], + kind: ['event'], + module: ['endpoint'], + action: ['fork'], + type: ['start'], + id: ['LuH/UjERrFf60dea+++++KYC'], + dataset: ['endpoint.events.process'], + }, + user: { + id: ['501'], + name: ['admin'], + }, + agent: { + type: ['endpoint'], + }, + message: ['Endpoint process event'], + timestamp: '2021-02-01T19:55:24.907Z', + _id: 'KXomX3cBGrBB52F2S9XY', +}; + export const mockEndgameIpv4ConnectionAcceptEvent: Ecs = { _id: 'LsjPcG0BOpWiDweSCNfu', user: { diff --git a/x-pack/plugins/security_solution/public/common/mock/mock_timeline_data.ts b/x-pack/plugins/security_solution/public/common/mock/mock_timeline_data.ts index cc75518cf2899d..f016b6cc34539c 100644 --- a/x-pack/plugins/security_solution/public/common/mock/mock_timeline_data.ts +++ b/x-pack/plugins/security_solution/public/common/mock/mock_timeline_data.ts @@ -1302,3 +1302,186 @@ export const mockDnsEvent: Ecs = { ip: ['10.9.9.9'], }, }; + +export const mockEndpointProcessExecutionMalwarePreventionAlert: Ecs = { + process: { + hash: { + md5: ['177afc1eb0be88eb9983fb74111260c4'], + sha256: ['3be13acde2f4dcded4fd8d518a513bfc9882407a6e384ffb17d12710db7d76fb'], + sha1: ['f573b85e9beb32121f1949217947b2adc6749e3d'], + }, + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTY5MjAtMTMyNDg5OTk2OTAuNDgzMzA3NzAw', + ], + executable: [ + 'C:\\Users\\sean\\Downloads\\3be13acde2f4dcded4fd8d518a513bfc9882407a6e384ffb17d12710db7d76fb.exe', + ], + name: [ + 'C:\\Users\\sean\\Downloads\\3be13acde2f4dcded4fd8d518a513bfc9882407a6e384ffb17d12710db7d76fb.exe', + ], + pid: [6920], + args: [ + 'C:\\Users\\sean\\Downloads\\3be13acde2f4dcded4fd8d518a513bfc9882407a6e384ffb17d12710db7d76fb.exe', + ], + }, + host: { + os: { + full: ['Windows Server 2019 Datacenter 1809 (10.0.17763.1518)'], + name: ['Windows'], + version: ['1809 (10.0.17763.1518)'], + platform: ['windows'], + family: ['windows'], + kernel: ['1809 (10.0.17763.1518)'], + }, + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + name: ['win2019-endpoint-1'], + }, + file: { + mtime: ['2020-11-04T21:40:51.494Z'], + path: [ + 'C:\\Users\\sean\\Downloads\\3be13acde2f4dcded4fd8d518a513bfc9882407a6e384ffb17d12710db7d76fb.exe', + ], + owner: ['sean'], + hash: { + md5: ['177afc1eb0be88eb9983fb74111260c4'], + sha256: ['3be13acde2f4dcded4fd8d518a513bfc9882407a6e384ffb17d12710db7d76fb'], + sha1: ['f573b85e9beb32121f1949217947b2adc6749e3d'], + }, + name: ['3be13acde2f4dcded4fd8d518a513bfc9882407a6e384ffb17d12710db7d76fb.exe'], + extension: ['exe'], + size: [1604112], + }, + event: { + category: ['malware', 'intrusion_detection', 'process'], + outcome: ['success'], + severity: [73], + code: ['malicious_file'], + action: ['execution'], + id: ['LsuMZVr+sdhvehVM++++Gp2Y'], + kind: ['alert'], + created: ['2020-11-04T21:41:30.533Z'], + module: ['endpoint'], + type: ['info', 'start', 'denied'], + dataset: ['endpoint.alerts'], + }, + agent: { + type: ['endpoint'], + }, + timestamp: '2020-11-04T21:41:30.533Z', + message: ['Malware Prevention Alert'], + _id: '0dA2lXUBn9bLIbfPkY7d', +}; + +export const mockEndpointLibraryLoadEvent: Ecs = { + file: { + path: ['C:\\Windows\\System32\\bcrypt.dll'], + hash: { + md5: ['00439016776de367bad087d739a03797'], + sha1: ['2c4ba5c1482987d50a182bad915f52cd6611ee63'], + sha256: ['e70f5d8f87aab14e3160227d38387889befbe37fa4f8f5adc59eff52804b35fd'], + }, + name: ['bcrypt.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-1'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['library'], + kind: ['event'], + created: ['2021-02-05T21:27:23.921Z'], + module: ['endpoint'], + action: ['load'], + type: ['start'], + id: ['LzzWB9jjGmCwGMvk++++Da5H'], + dataset: ['endpoint.events.library'], + }, + process: { + name: ['sshd.exe'], + pid: [9644], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTk2NDQtMTMyNTcwMzQwNDEuNzgyMTczODAw', + ], + executable: ['C:\\Program Files\\OpenSSH-Win64\\sshd.exe'], + }, + agent: { + type: ['endpoint'], + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + message: ['Endpoint DLL load event'], + timestamp: '2021-02-05T21:27:23.921Z', + _id: 'IAUYdHcBGrBB52F2zo8Q', +}; + +export const mockEndpointRegistryModificationEvent: 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-1'], + architecture: ['x86_64'], + ip: ['10.1.2.3'], + id: ['d8ad572e-d224-4044-a57d-f5a84c0dfe5d'], + }, + event: { + category: ['registry'], + kind: ['event'], + created: ['2021-02-04T13:44:31.559Z'], + module: ['endpoint'], + action: ['modification'], + type: ['change'], + id: ['LzzWB9jjGmCwGMvk++++CbOn'], + dataset: ['endpoint.events.registry'], + }, + process: { + name: ['GoogleUpdate.exe'], + pid: [7408], + entity_id: [ + 'MWQxNWNmOWUtM2RjNy01Yjk3LWY1ODYtNzQzZjdjMjUxOGIyLTc0MDgtMTMyNTY5MTk4NDguODY4NTI0ODAw', + ], + executable: ['C:\\Program Files (x86)\\Google\\Update\\GoogleUpdate.exe'], + }, + registry: { + hive: ['HKLM'], + key: [ + 'SOFTWARE\\WOW6432Node\\Google\\Update\\ClientState\\{430FD4D0-B729-4F61-AA34-91526481799D}\\CurrentState', + ], + path: [ + 'HKLM\\SOFTWARE\\WOW6432Node\\Google\\Update\\ClientState\\{430FD4D0-B729-4F61-AA34-91526481799D}\\CurrentState\\StateValue', + ], + value: ['StateValue'], + }, + agent: { + type: ['endpoint'], + }, + user: { + name: ['SYSTEM'], + domain: ['NT AUTHORITY'], + }, + message: ['Endpoint registry event'], + timestamp: '2021-02-04T13:44:31.559Z', + _id: '4cxLbXcBGrBB52F2uOfF', +}; diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 351caa2df3e317..70ed497ce0caca 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -2106,6 +2106,11 @@ export const mockTimelineModel: TimelineModel = { }, deletedEventIds: [], description: 'This is a sample rule description', + eqlOptions: { + eventCategoryField: 'event.category', + tiebreakerField: 'event.sequence', + timestampField: '@timestamp', + }, eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], @@ -2229,6 +2234,13 @@ export const defaultTimelineProps: CreateTimelineProps = { dateRange: { end: '2018-11-05T19:03:25.937Z', start: '2018-11-05T18:58:25.937Z' }, deletedEventIds: [], description: '', + eqlOptions: { + eventCategoryField: 'event.category', + query: '', + size: 100, + tiebreakerField: 'event.sequence', + timestampField: '@timestamp', + }, eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts index 3835e708514252..fbf4caad9793dc 100644 --- a/x-pack/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/types.ts @@ -37,7 +37,7 @@ export type StoreState = HostsPluginState & */ export type State = CombinedState; -export type KueryFilterQueryKind = 'kuery' | 'lucene'; +export type KueryFilterQueryKind = 'kuery' | 'lucene' | 'eql'; export interface KueryFilterQuery { kind: KueryFilterQueryKind; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 3c3d79c0c518fa..143c39daace66a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -153,6 +153,13 @@ describe('alert actions', () => { }, deletedEventIds: [], description: 'This is a sample rule description', + eqlOptions: { + eventCategoryField: 'event.category', + query: '', + size: 100, + tiebreakerField: 'event.sequence', + timestampField: '@timestamp', + }, eventIdToNoteIds: {}, eventType: 'all', excludedRowRendererIds: [], diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx new file mode 100644 index 00000000000000..66b2bae98c1ae4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.test.tsx @@ -0,0 +1,195 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; +import { NeedAdminForUpdateRulesCallOut } from './index'; +import { TestProviders } from '../../../../common/mock'; +import * as userInfo from '../../user_info'; + +describe('need_admin_for_update_callout', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('hasIndexManage is "null"', () => { + const hasIndexManage = null; + test('Does NOT render when "signalIndexMappingOutdated" is true', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation( + jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true, hasIndexManage }]) + ); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does not render a button as this is always persistent', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(false); + }); + + test('Does NOT render when signalIndexMappingOutdated is false', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: false }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does NOT render when signalIndexMappingOutdated is null', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: null }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + }); + + describe('hasIndexManage is "false"', () => { + const hasIndexManage = false; + test('renders when "signalIndexMappingOutdated" is true', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation( + jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true, hasIndexManage }]) + ); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + true + ); + }); + + test('Does not render a button as this is always persistent', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(false); + }); + + test('Does NOT render when signalIndexMappingOutdated is false', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: false }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does NOT render when signalIndexMappingOutdated is null', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: null }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + }); + + describe('hasIndexManage is "true"', () => { + const hasIndexManage = true; + test('Does not render when "signalIndexMappingOutdated" is true', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation( + jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true, hasIndexManage }]) + ); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does not render a button as this is always persistent', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: true }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-dismiss-btn"]')).toEqual(false); + }); + + test('Does NOT render when signalIndexMappingOutdated is false', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: false }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + + test('Does NOT render when signalIndexMappingOutdated is null', () => { + jest + .spyOn(userInfo, 'useUserData') + .mockImplementation(jest.fn().mockReturnValue([{ signalIndexMappingOutdated: null }])); + const wrapper = mount( + + + + ); + expect(wrapper.exists('[data-test-subj="callout-need-admin-for-update-rules"]')).toEqual( + false + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx new file mode 100644 index 00000000000000..fd0be8e0021933 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/index.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { CallOutMessage, CallOutPersistentSwitcher } from '../../../../common/components/callouts'; +import { useUserData } from '../../user_info'; + +import * as i18n from './translations'; + +const needAdminForUpdateRulesMessage: CallOutMessage = { + type: 'primary', + id: 'need-admin-for-update-rules', + title: i18n.NEED_ADMIN_CALLOUT_TITLE, + description: i18n.needAdminForUpdateCallOutBody(), +}; + +/** + * Callout component that lets the user know that an administrator is needed for performing + * and auto-update of signals or not. For this component to render the user must: + * - Have the permissions to be able to read "signalIndexMappingOutdated" and that condition is "true" + * - Have the permissions to be able to read "hasIndexManage" and that condition is "false" + * + * Some users do not have sufficient privileges to be able to determine if "signalIndexMappingOutdated" + * is outdated or not. Same could apply to "hasIndexManage". When users do not have enough permissions + * to determine if "signalIndexMappingOutdated" is true or false, the permissions system returns a "null" + * instead. + * + * If the user has the permissions to see that signalIndexMappingOutdated is true and that + * hasIndexManage is also true, then the user should be performing the update on the page which is + * why we do not show it for that condition. + */ +const NeedAdminForUpdateCallOutComponent = (): JSX.Element => { + const [{ signalIndexMappingOutdated, hasIndexManage }] = useUserData(); + + const signalIndexMappingIsOutdated = + signalIndexMappingOutdated != null && signalIndexMappingOutdated; + + const userDoesntHaveIndexManage = hasIndexManage != null && !hasIndexManage; + + return ( + + ); +}; + +export const NeedAdminForUpdateRulesCallOut = memo(NeedAdminForUpdateCallOutComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/translations.tsx new file mode 100644 index 00000000000000..791093788b8e1e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/callouts/need_admin_for_update_callout/translations.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + SecuritySolutionRequirementsLink, + DetectionsRequirementsLink, +} from '../../../../common/components/links_to_docs'; + +export const NEED_ADMIN_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.needAdminForUpdateCallOutBody.messageTitle', + { + defaultMessage: 'Administration permissions required for alert migration', + } +); + +/** + * Returns the formatted message of the call out body as a JSX Element with both the message + * and two documentation links. + */ +export const needAdminForUpdateCallOutBody = (): JSX.Element => ( + + +

    + ), + docs: ( +
      +
    • + +
    • +
    • + +
    • +
    + ), + }} + /> +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/eql_query_bar.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/eql_query_bar.tsx index df09fc1d12e58e..e37fd2eb26ff9b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/eql_query_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/eql_query_bar.tsx @@ -15,6 +15,11 @@ import { DefineStepRule } from '../../../pages/detection_engine/rules/types'; import * as i18n from './translations'; import { EqlQueryBarFooter } from './footer'; import { getValidationResults } from './validators'; +import { + EqlOptionsData, + EqlOptionsSelected, + FieldsEqlOptions, +} from '../../../../../common/search_strategy'; const TextArea = styled(EuiTextArea)` display: block; @@ -28,14 +33,22 @@ export interface EqlQueryBarProps { dataTestSubj: string; field: FieldHook; idAria?: string; + optionsData?: EqlOptionsData; + optionsSelected?: EqlOptionsSelected; + onOptionsChange?: (field: FieldsEqlOptions, newValue: string | null) => void; onValidityChange?: (arg: boolean) => void; + onValiditingChange?: (arg: boolean) => void; } export const EqlQueryBar: FC = ({ dataTestSubj, field, idAria, + optionsData, + optionsSelected, + onOptionsChange, onValidityChange, + onValiditingChange, }) => { const { addError } = useAppToasts(); const [errorMessages, setErrorMessages] = useState([]); @@ -62,10 +75,18 @@ export const EqlQueryBar: FC = ({ } }, [error, addError]); + useEffect(() => { + if (onValiditingChange) { + onValiditingChange(isValidating); + } + }, [isValidating, onValiditingChange]); + const handleChange = useCallback( (e: ChangeEvent) => { const newQuery = e.target.value; - + if (onValiditingChange) { + onValiditingChange(true); + } setErrorMessages([]); setValue({ filters: [], @@ -75,7 +96,7 @@ export const EqlQueryBar: FC = ({ }, }); }, - [setValue] + [setValue, onValiditingChange] ); return ( @@ -84,7 +105,7 @@ export const EqlQueryBar: FC = ({ labelAppend={field.labelAppend} helpText={field.helpText} error={message} - isInvalid={!isValid} + isInvalid={!isValid && !isValidating} fullWidth data-test-subj={dataTestSubj} describedByIds={idAria ? [idAria] : undefined} @@ -93,11 +114,17 @@ export const EqlQueryBar: FC = ({