diff --git a/.buildkite/pipelines/hourly.yml b/.buildkite/pipelines/hourly.yml index fa19719239aa0..6953c146050eb 100644 --- a/.buildkite/pipelines/hourly.yml +++ b/.buildkite/pipelines/hourly.yml @@ -127,9 +127,10 @@ steps: - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' + parallelism: 2 agents: queue: n2-4 - timeout_in_minutes: 120 + timeout_in_minutes: 90 key: jest-integration - command: .buildkite/scripts/steps/test/api_integration.sh diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index efe522f592ecd..e5f6dcc2d1d5f 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -13,12 +13,20 @@ steps: agents: queue: c2-8 timeout_in_minutes: 60 + retry: + automatic: + - exit_status: '*' + limit: 1 - command: .buildkite/scripts/steps/on_merge_ts_refs_api_docs.sh label: Build TS Refs and Check Public API Docs agents: queue: c2-4 timeout_in_minutes: 80 + retry: + automatic: + - exit_status: '*' + limit: 1 - wait: ~ continue_on_failure: true diff --git a/.buildkite/pipelines/pull_request/base.yml b/.buildkite/pipelines/pull_request/base.yml index 9b9d8ddfcde69..d832717906bb1 100644 --- a/.buildkite/pipelines/pull_request/base.yml +++ b/.buildkite/pipelines/pull_request/base.yml @@ -127,9 +127,10 @@ steps: - command: .buildkite/scripts/steps/test/jest_integration.sh label: 'Jest Integration Tests' + parallelism: 2 agents: queue: n2-4 - timeout_in_minutes: 120 + timeout_in_minutes: 90 key: jest-integration - command: .buildkite/scripts/steps/test/api_integration.sh diff --git a/.buildkite/scripts/steps/test/jest_integration.sh b/.buildkite/scripts/steps/test/jest_integration.sh index d07da0584d46d..13412881cb6fa 100755 --- a/.buildkite/scripts/steps/test/jest_integration.sh +++ b/.buildkite/scripts/steps/test/jest_integration.sh @@ -9,5 +9,5 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh echo '--- Jest Integration Tests' -checks-reporter-with-killswitch "Jest Integration Tests" \ - node --max-old-space-size=6144 scripts/jest_integration --ci +checks-reporter-with-killswitch "Jest Integration Tests $((BUILDKITE_PARALLEL_JOB+1))" \ + .buildkite/scripts/steps/test/jest_parallel.sh jest.integration.config.js diff --git a/.buildkite/scripts/steps/test/jest_parallel.sh b/.buildkite/scripts/steps/test/jest_parallel.sh index c9e0e1aff5cf2..bc6184c74eb4a 100755 --- a/.buildkite/scripts/steps/test/jest_parallel.sh +++ b/.buildkite/scripts/steps/test/jest_parallel.sh @@ -13,7 +13,7 @@ exitCode=0 while read -r config; do if [ "$((i % JOB_COUNT))" -eq "$JOB" ]; then echo "--- $ node scripts/jest --config $config" - node --max-old-space-size=14336 ./node_modules/.bin/jest --config="$config" --runInBand --coverage=false + node --max-old-space-size=14336 ./node_modules/.bin/jest --config="$config" --runInBand --coverage=false --passWithNoTests lastCode=$? if [ $lastCode -ne 0 ]; then @@ -25,6 +25,6 @@ while read -r config; do ((i=i+1)) # uses heredoc to avoid the while loop being in a sub-shell thus unable to overwrite exitCode -done <<< "$(find src x-pack packages -name jest.config.js -not -path "*/__fixtures__/*" | sort)" +done <<< "$(find src x-pack packages -name ${1:-jest.config.js} -not -path "*/__fixtures__/*" | sort)" -exit $exitCode \ No newline at end of file +exit $exitCode diff --git a/dev_docs/tutorials/debugging.mdx b/dev_docs/tutorials/debugging.mdx index c612893e4f1f9..598c6119910cb 100644 --- a/dev_docs/tutorials/debugging.mdx +++ b/dev_docs/tutorials/debugging.mdx @@ -21,7 +21,9 @@ Next we will go over how to exactly enable the inspector for different aspects o You will need to run Jest directly from the Node script: -`node --inspect-brk scripts/jest [TestPathPattern]` +`node --inspect-brk node_modules/.bin/jest --runInBand --config [JestConfig] [TestPathPattern]` + +Additional information can be found in the [Jest troubleshooting documentation](https://jestjs.io/docs/troubleshooting). ### Functional Test Runner diff --git a/docs/api/upgrade-assistant/status.asciidoc b/docs/api/upgrade-assistant/status.asciidoc index b0c11939ca784..aec280e8d16f9 100644 --- a/docs/api/upgrade-assistant/status.asciidoc +++ b/docs/api/upgrade-assistant/status.asciidoc @@ -31,22 +31,8 @@ The API returns the following: "cluster": [ { "message": "Cluster deprecated issue", - "details": "...", - "level": "warning", - "url": "https://docs.elastic.co/..." - } - ], - "indices": [ - { - "message": "Index was created before 6.0", - "details": "...", - "index": "myIndex", - "level": "critical", - "reindex": true, <1> - "url": "https://docs.elastic.co/..." + "details":"You have 2 system indices that must be migrated and 5 Elasticsearch deprecation issues and 0 Kibana deprecation issues that must be resolved before upgrading." } ] } -------------------------------------------------- - -<1> To fix indices with the `reindex` attribute, set to `true` using the <>. diff --git a/docs/developer/architecture/core/logging-configuration-migration.asciidoc b/docs/developer/architecture/core/logging-configuration-migration.asciidoc index 4a9d03d3b5312..aefb81b37d4b6 100644 --- a/docs/developer/architecture/core/logging-configuration-migration.asciidoc +++ b/docs/developer/architecture/core/logging-configuration-migration.asciidoc @@ -1,6 +1,5 @@ -[discrete] [[logging-config-changes]] -=== Logging configuration changes +== Logging configuration changes WARNING: {kib} 8.0.0 and later uses a new logging system. Before you upgrade, read the documentation for your {kib} version. @@ -43,4 +42,3 @@ WARNING: {kib} 8.0.0 and later uses a new logging system. Before you upgrade, re | error | `{ message, name, stack }` | `{ message, name, stack, code, signal }` |=== - diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md new file mode 100644 index 0000000000000..6a3c790cd17a2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) > [buttonColor](./kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md) + +## AppLeaveConfirmAction.buttonColor property + +Signature: + +```typescript +buttonColor?: ButtonColor; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md new file mode 100644 index 0000000000000..10ccb6d220f3f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) > [confirmButtonText](./kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md) + +## AppLeaveConfirmAction.confirmButtonText property + +Signature: + +```typescript +confirmButtonText?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md index e44fe49c27c8c..9f18643787019 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md @@ -18,7 +18,9 @@ export interface AppLeaveConfirmAction | Property | Type | Description | | --- | --- | --- | +| [buttonColor?](./kibana-plugin-core-public.appleaveconfirmaction.buttoncolor.md) | ButtonColor | (Optional) | | [callback?](./kibana-plugin-core-public.appleaveconfirmaction.callback.md) | () => void | (Optional) | +| [confirmButtonText?](./kibana-plugin-core-public.appleaveconfirmaction.confirmbuttontext.md) | string | (Optional) | | [text](./kibana-plugin-core-public.appleaveconfirmaction.text.md) | string | | | [title?](./kibana-plugin-core-public.appleaveconfirmaction.title.md) | string | (Optional) | | [type](./kibana-plugin-core-public.appleaveconfirmaction.type.md) | AppLeaveActionType.confirm | | diff --git a/docs/osquery/osquery.asciidoc b/docs/osquery/osquery.asciidoc index 70effbc2b3c96..3231d2162f2e1 100644 --- a/docs/osquery/osquery.asciidoc +++ b/docs/osquery/osquery.asciidoc @@ -112,7 +112,7 @@ or <>. When you add a saved query to a pack, . Click a pack name to view the status. + Details include the last time each query ran, how many results were returned, and the number of agents the query ran against. -If there are errors, expand the row to view the details. +If there are errors, expand the row to view the details, including an option to view more information in the Logs. + [role="screenshot"] image::images/scheduled-pack.png[Shows queries in the pack and details about each query, including the last time it ran, how many results were returned, the number of agents it ran against, and if there are errors] diff --git a/docs/setup/upgrade.asciidoc b/docs/setup/upgrade.asciidoc index 3069d78cc692e..db9d302709092 100644 --- a/docs/setup/upgrade.asciidoc +++ b/docs/setup/upgrade.asciidoc @@ -42,7 +42,7 @@ complete the upgrade migration before bringing up the remaining instances. [[preventing-migration-failures]] === Preparing for migration -There are extra steps you can follow to ensure you are ready for migration. +Take these extra steps to ensure you are ready for migration. [float] ==== Ensure your {es} cluster is healthy diff --git a/docs/setup/upgrade/resolving-migration-failures.asciidoc b/docs/setup/upgrade/resolving-migration-failures.asciidoc index 454dfe948fe4e..5b590c359cc69 100644 --- a/docs/setup/upgrade/resolving-migration-failures.asciidoc +++ b/docs/setup/upgrade/resolving-migration-failures.asciidoc @@ -5,7 +5,7 @@ Migrating {kib} primarily involves migrating saved object documents to be compat with the new version. [float] -==== Resolve saved object migration failures +==== Saved object migration failures If {kib} unexpectedly terminates while migrating a saved object index, {kib} automatically attempts to perform the migration again when the process restarts. Do not delete any saved objects indices to @@ -21,14 +21,14 @@ If you're unable to resolve a failed migration, contact Support. [float] [[upgrade-migrations-old-indices]] -==== Handle old `.kibana_N` indices +==== Old `.kibana_N` indices After the migrations complete, multiple {kib} indices are created in {es}: (`.kibana_1`, `.kibana_2`, `.kibana_7.12.0` etc). {kib} only uses the index that the `.kibana` and `.kibana_task_manager` aliases point to. The other {kib} indices can be safely deleted, but are left around as a matter of historical record, and to facilitate rolling {kib} back to a previous version. [float] -==== Handle known issues with {fleet} beta +==== Known issues with {fleet} beta If you see a`timeout_exception` or `receive_timeout_transport_exception` error, it might be from a known known issue in 7.12.0 if you tried the {fleet} beta. Upgrade migrations fail because of a large number of documents in the `.kibana` index, @@ -45,7 +45,7 @@ For instructions on how to mitigate the known issue, refer to https://github.com [float] -==== Handle corrupt saved objects +==== Corrupt saved objects To find and remedy problems caused by corrupt documents, we highly recommend testing your {kib} upgrade in a development cluster, especially when there are custom integrations that create saved objects in your environment. @@ -87,13 +87,13 @@ The dashboard with the `e3c5fc71-ac71-4805-bcab-2bcc9cc93275` ID that belongs to [float] [[unknown-saved-object-types]] -==== Handle documents for unknown saved objects +==== Documents for unknown saved objects Migrations will fail if saved objects belong to an unknown saved object type. Unknown saved objects are typically caused by to the {es} index, or by disabling a plugin that had previously created a saved object. -We recommend using the {kibana-ref-all}/7.17/upgrade-assistant.html[Upgrade Assistant] +We recommend using the {kibana-ref-all}/7.17/upgrade-assistant.html[Upgrade Assistant] to discover and remedy any unknown saved object types. {kib} version 7.17.0 deployments containing unknown saved object types will also log the following warning message: @@ -110,7 +110,7 @@ Unable to complete saved object migrations for the [.kibana] index: Migration fa -------------------------------------------- [float] -==== Handle incompatible settings or mappings +==== Incompatible settings or mappings Matching index templates that specify `settings.refresh_interval` or `mappings` are known to interfere with {kib} upgrades. This can happen when index templates are defined manually. @@ -118,7 +118,7 @@ This can happen when index templates are defined manually. To make sure the index templates won't apply to new `.kibana*` indices, narrow down the {data-sources} of any user-defined index templates. [float] -==== Handle incompatible `xpack.tasks.index` configuration setting +==== Incompatible `xpack.tasks.index` configuration setting In {kib} 7.5.0 and earlier, when the task manager index is set to `.tasks` with the configuration setting `xpack.tasks.index: ".tasks"`, upgrade migrations fail. In {kib} 7.5.1 and later, the incompatible configuration diff --git a/docs/setup/upgrade/rollback-migration.asciidoc b/docs/setup/upgrade/rollback-migration.asciidoc index 1b87d0f335b8c..c0cb126b37825 100644 --- a/docs/setup/upgrade/rollback-migration.asciidoc +++ b/docs/setup/upgrade/rollback-migration.asciidoc @@ -18,7 +18,13 @@ To roll back after a failed upgrade migration, you must also rollback the saved . Before proceeding, {ref}/snapshots-take-snapshot.html[take a snapshot] that contains the `kibana` feature state. By default, snapshots include the `kibana` feature state. . To make sure no {kib} instances are performing an upgrade migration, shut down all {kib} instances. -. To delete all saved object indices, use `DELETE /.kibana*`. +. To delete all saved object indices, enter: ++ +[source,sh] +-------------------------------------------- +DELETE /.kibana* +-------------------------------------------- + . {ref}/snapshots-restore-snapshot.html[Restore] the `kibana` feature state from the snapshot. . Start all {kib} instances on the older version you want to rollback to. @@ -30,12 +36,29 @@ To roll back after a failed upgrade migration, you must also rollback the saved . Delete the version-specific indices created by the failed upgrade migration. + For example, to rollback from a failed upgrade -to v7.12.0, use `DELETE /.kibana_7.12.0_*,.kibana_task_manager_7.12.0_*`. +to v7.12.0, enter: ++ +[source,sh] +-------------------------------------------- +DELETE /.kibana_7.12.0_*,.kibana_task_manager_7.12.0_* +-------------------------------------------- + . Inspect the output of `GET /_cat/aliases`. + If the `.kibana` or `.kibana_task_manager` aliases are missing, you must create them manually. Find the latest index from the output of `GET /_cat/indices` and create the missing alias to point to the latest index. -For example, if the `.kibana` alias is missing, and the latest index is `.kibana_3`, create a new alias using `POST /.kibana_3/_aliases/.kibana`. -. To remove the write block from the roll back indices, use -`PUT /.kibana,.kibana_task_manager/_settings {"index.blocks.write": false}` -. Start {kib} on the older version you want to rollback to. +For example, if the `.kibana` alias is missing, and the latest index is `.kibana_3`, create a new alias using: ++ +[source,sh] +-------------------------------------------- +POST /.kibana_3/_aliases/.kibana +-------------------------------------------- + +. To remove the write block from the roll back indices, enter: ++ +[source,sh] +-------------------------------------------- +PPUT /.kibana,.kibana_task_manager/_settings {"index.blocks.write": false} +-------------------------------------------- + +. Start {kib} on the older version you want to roll back to. diff --git a/docs/setup/upgrade/saved-objects-migration.asciidoc b/docs/setup/upgrade/saved-objects-migration.asciidoc index cc4406f8cdd1f..5d84ece1c3c9f 100644 --- a/docs/setup/upgrade/saved-objects-migration.asciidoc +++ b/docs/setup/upgrade/saved-objects-migration.asciidoc @@ -25,7 +25,9 @@ the most up-to-date saved object indices. When you start a new {kib} installation, an upgrade migration is performed before starting plugins or serving HTTP traffic. Before you upgrade, shut down old nodes to prevent losing acknowledged writes. To reduce the likelihood of old nodes losing acknowledged writes, {kib} 7.12.0 and later -adds a write block to the outdated index. Table 1 lists the saved objects indices used by previous {kib} versions. +adds a write block to the outdated index. + +The following tables lists the saved objects indices used by previous {kib} versions. .Saved object indices and aliases per {kib} version [options="header"] diff --git a/docs/user/alerting/rule-types/es-query.asciidoc b/docs/user/alerting/rule-types/es-query.asciidoc index e3ce35687260f..dba8a4878cb26 100644 --- a/docs/user/alerting/rule-types/es-query.asciidoc +++ b/docs/user/alerting/rule-types/es-query.asciidoc @@ -2,12 +2,17 @@ [[rule-type-es-query]] === {es} query -The {es} query rule type runs a user-configured {es} query, compares the number of matches to a configured threshold, and schedules actions to run when the threshold condition is met. +The {es} query rule type runs a user-configured query, compares the number of +matches to a configured threshold, and schedules actions to run when the +threshold condition is met. + [float] ==== Create the rule -Fill in the <>, then select *{es} query*. +Fill in the <>, then select +*{es} query*. + [float] ==== Define the conditions @@ -17,30 +22,55 @@ Define properties to detect the condition. [role="screenshot"] image::user/alerting/images/rule-types-es-query-conditions.png[Five clauses define the condition to detect] -Index:: This clause requires an *index or data view* and a *time field* that will be used for the *time window*. -Size:: This clause specifies the number of documents to pass to the configured actions when the the threshold condition is met. -{es} query:: This clause specifies the ES DSL query to execute. The number of documents that match this query will be evaluated against the threshold -condition. Aggregations are not supported at this time. -Threshold:: This clause defines a threshold value and a comparison operator (`is above`, `is above or equals`, `is below`, `is below or equals`, or `is between`). The number of documents that match the specified query is compared to this threshold. -Time window:: This clause determines how far back to search for documents, using the *time field* set in the *index* clause. Generally this value should be set to a value higher than the *check every* value in the <>, to avoid gaps in detection. +Index:: Specifies an *index or data view* and a *time field* that is used for +the *time window*. +Size:: Specifies the number of documents to pass to the configured actions when +the threshold condition is met. +{es} query:: Specifies the ES DSL query to execute. The number of documents that +match this query is evaluated against the threshold condition. Only the `query` +field is used, other DSL fields are not considered. +Threshold:: Defines a threshold value and a comparison operator (`is above`, +`is above or equals`, `is below`, `is below or equals`, or `is between`). The +number of documents that match the specified query is compared to this +threshold. +Time window:: Defines how far back to search for documents, using the +*time field* set in the *index* clause. Generally this value should be set to a +value higher than the *check every* value in the +<>, to avoid gaps in +detection. + [float] ==== Add action variables -<> to run when the rule condition is met. The following variables are specific to the {es} query rule. You can also specify <>. +<> to run when the rule condition +is met. The following variables are specific to the {es} query rule. You can +also specify +<>. + +`context.title`:: A preconstructed title for the rule. Example: +`rule term match alert query matched`. -`context.title`:: A preconstructed title for the rule. Example: `rule term match alert query matched`. `context.message`:: A preconstructed message for the rule. Example: + -`rule 'term match alert' is active:` + -`- Value: 42` + -`- Conditions Met: count greater than 4 over 5m` + -`- Timestamp: 2020-01-01T00:00:00.000Z` +`rule 'my es-query' is active:` + +`- Value: 2` + +`- Conditions Met: Number of matching documents is greater than 1 over 5m` + +`- Timestamp: 2022-02-03T20:29:27.732Z` + +`context.group`:: The name of the action group associated with the condition. +Example: `query matched`. + +`context.date`:: The date, in ISO format, that the rule met the condition. +Example: `2022-02-03T20:29:27.732Z`. -`context.group`:: The name of the action group associated with the condition. Example: `query matched`. -`context.date`:: The date, in ISO format, that the rule met the condition. Example: `2020-01-01T00:00:00.000Z`. `context.value`:: The value of the rule that met the condition. -`context.conditions`:: A description of the condition. Example: `count greater than 4`. -`context.hits`:: The most recent ES documents that matched the query. Using the https://mustache.github.io/[Mustache] template array syntax, you can iterate over these hits to get values from the ES documents into your actions. + +`context.conditions`:: A description of the condition. Example: +`count greater than 4`. + +`context.hits`:: The most recent documents that matched the query. Using the +https://mustache.github.io/[Mustache] template array syntax, you can iterate +over these hits to get values from the ES documents into your actions. + [role="screenshot"] image::images/rule-types-es-query-example-action-variable.png[Iterate over hits using Mustache template syntax] @@ -51,8 +81,8 @@ image::images/rule-types-es-query-example-action-variable.png[Iterate over hits Use the *Test query* feature to verify that your query DSL is valid. -* Valid queries are executed against the configured *index* using the configured *time window*. The number of documents that -match the query will be displayed. +* Valid queries are executed against the configured *index* using the configured +*time window*. The number of documents that match the query is displayed. + [role="screenshot"] image::user/alerting/images/rule-types-es-query-valid.png[Test {es} query returns number of matches when valid] @@ -63,29 +93,35 @@ image::user/alerting/images/rule-types-es-query-valid.png[Test {es} query return image::user/alerting/images/rule-types-es-query-invalid.png[Test {es} query shows error when invalid] [float] -==== Match de-duplication +==== Handling multiple matches of the same document + +This rule type checks for duplication of document matches across rule +executions. If you configure the rule with a schedule interval smaller than the +time window, and a document matches a query in multiple rule executions, it is +alerted on only once. -The {es} query rule type performs de-duplication of document matches across rule executions. If you configure the rule with a schedule interval smaller than the time window, and a document matches a query in multiple rule executions, it will be alerted on only once. +The rule uses the timestamp of the matches to avoid alerting on the same match +multiple times. The timestamp of the latest match is used for evaluating the +rule conditions when the rule is executed. Only matches between the latest +timestamp from the previous execution and the actual rule execution are +considered. -Suppose you have a rule configured to run every minute. The rule uses a time window of 1 hour and checks if there are more than 99 matches for the query. The {es} query rule type will do the following: +Suppose you have a rule configured to run every minute. The rule uses a time +window of 1 hour and checks if there are more than 99 matches for the query. The +{es} query rule type does the following: [cols="3*<"] |=== - | `Execution 1 (0:00)` | Rule finds 113 matches in the last hour: `113 > 99` -| Rule is active and user will be alerted. - +| Rule is active and user is alerted. | `Execution 2 (0:01)` -| Rule finds 127 matches in the last hour. 105 of the matches are duplicates that were alerted on in Execution 1, so you actually have 22 matches: `22 !> 99` +| Rule finds 127 matches in the last hour. 105 of the matches are duplicates that were already alerted on previously, so you actually have 22 matches: `22 !> 99` | No alert. - | `Execution 3 (0:02)` -| Rule finds 159 matches in the last hour. 88 of the matches are duplicates that were alerted on in Execution 1, so you actually have 71 matches: `71 !> 99` +| Rule finds 159 matches in the last hour. 88 of the matches are duplicates that were already alerted on previously, so you actually have 71 matches: `71 !> 99` | No alert. - | `Execution 4 (0:03)` -| Rule finds 190 matches in the last hour. 71 of them are duplicates that were alerted on in Exeuction 1, so you actually have 119 matches: `119 > 99` -| Rule is active and user will be alerted. - +| Rule finds 190 matches in the last hour. 71 of them are duplicates that were already alerted on previously, so you actually have 119 matches: `119 > 99` +| Rule is active and user is alerted. |=== \ No newline at end of file diff --git a/examples/embeddable_explorer/public/plugin.tsx b/examples/embeddable_explorer/public/plugin.tsx index 55c2a215e436d..ea8c412e443ee 100644 --- a/examples/embeddable_explorer/public/plugin.tsx +++ b/examples/embeddable_explorer/public/plugin.tsx @@ -34,7 +34,12 @@ export class EmbeddableExplorerPlugin implements Plugin; +} + +export class CiStatsClient { + /** + * Create a CiStatsReporter by inspecting the ENV for the necessary config + */ + static fromEnv(log: ToolingLog) { + return new CiStatsClient(parseConfig(log)); + } + + constructor(private readonly config?: Config) {} + + isEnabled() { + return !!this.config?.apiToken; + } + + async getLatestTestGroupStats(options: LatestTestGroupStatsOptions) { + if (!this.config || !this.config.apiToken) { + throw new Error('No ciStats config available, call `isEnabled()` before using the client'); + } + + const resp = await Axios.request({ + baseURL: 'https://ci-stats.kibana.dev', + url: '/v1/test_group_stats', + params: { + branch: options.branch, + ci_job_name: options.ciJobNames.join(','), + test_group_type: options.testGroupType, + }, + headers: { + Authentication: `token ${this.config.apiToken}`, + }, + }); + + return resp.data; + } +} diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_metadata.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_metadata.ts new file mode 100644 index 0000000000000..edf78eed64974 --- /dev/null +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_metadata.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 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. + */ + +/** Container for metadata that can be attached to different ci-stats objects */ +export interface CiStatsMetadata { + /** + * Arbitrary key-value pairs which can be attached to CiStatsTiming and CiStatsMetric + * objects stored in the ci-stats service + */ + [key: string]: string | string[] | number | boolean | undefined; +} diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts index f16cdcc80f286..f710f7ec70843 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts @@ -20,18 +20,10 @@ import httpAdapter from 'axios/lib/adapters/http'; import { ToolingLog } from '../tooling_log'; import { parseConfig, Config } from './ci_stats_config'; import type { CiStatsTestGroupInfo, CiStatsTestRun } from './ci_stats_test_group_types'; +import { CiStatsMetadata } from './ci_stats_metadata'; const BASE_URL = 'https://ci-stats.kibana.dev'; -/** Container for metadata that can be attached to different ci-stats objects */ -export interface CiStatsMetadata { - /** - * Arbitrary key-value pairs which can be attached to CiStatsTiming and CiStatsMetric - * objects stored in the ci-stats service - */ - [key: string]: string | string[] | number | boolean | undefined; -} - /** A ci-stats metric record */ export interface CiStatsMetric { /** Top-level categorization for the metric, e.g. "page load bundle size" */ diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_test_group_types.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_test_group_types.ts index 147d4e19325b2..b786981fb8437 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_test_group_types.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_test_group_types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { CiStatsMetadata } from './ci_stats_reporter'; +import type { CiStatsMetadata } from './ci_stats_metadata'; export type CiStatsTestResult = 'fail' | 'pass' | 'skip'; export type CiStatsTestType = diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts index cf80d06613dbf..fab2e61755a5c 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts @@ -11,3 +11,4 @@ export type { Config } from './ci_stats_config'; export * from './ship_ci_stats_cli'; export { getTimeReporter } from './report_time'; export * from './ci_stats_test_group_types'; +export * from './ci_stats_client'; diff --git a/packages/kbn-es/jest.integration.config.js b/packages/kbn-es/jest.integration.config.js new file mode 100644 index 0000000000000..58ed5614f26be --- /dev/null +++ b/packages/kbn-es/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../..', + roots: ['/packages/kbn-es'], +}; diff --git a/packages/kbn-optimizer/jest.integration.config.js b/packages/kbn-optimizer/jest.integration.config.js new file mode 100644 index 0000000000000..7357f8f6a34b0 --- /dev/null +++ b/packages/kbn-optimizer/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../..', + roots: ['/packages/kbn-optimizer'], +}; diff --git a/packages/kbn-plugin-generator/jest.integration.config.js b/packages/kbn-plugin-generator/jest.integration.config.js new file mode 100644 index 0000000000000..0eac4b764101a --- /dev/null +++ b/packages/kbn-plugin-generator/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../..', + roots: ['/packages/kbn-plugin-generator'], +}; diff --git a/packages/kbn-plugin-helpers/jest.integration.config.js b/packages/kbn-plugin-helpers/jest.integration.config.js new file mode 100644 index 0000000000000..069989abc01e3 --- /dev/null +++ b/packages/kbn-plugin-helpers/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../..', + roots: ['/packages/kbn-plugin-helpers'], +}; diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index a4b6f4938ddcd..607afa266da83 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -9049,7 +9049,7 @@ var _ci_stats_config = __webpack_require__(218); */ // @ts-expect-error not "public", but necessary to prevent Jest shimming from breaking things const BASE_URL = 'https://ci-stats.kibana.dev'; -/** Container for metadata that can be attached to different ci-stats objects */ +/** A ci-stats metric record */ /** Object that helps report data to the ci-stats service */ class CiStatsReporter { diff --git a/packages/kbn-test/jest.integration.config.js b/packages/kbn-test/jest.integration.config.js new file mode 100644 index 0000000000000..091a7a73de484 --- /dev/null +++ b/packages/kbn-test/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../..', + roots: ['/packages/kbn-test'], +}; diff --git a/packages/kbn-test/jest_integration/jest-preset.js b/packages/kbn-test/jest_integration/jest-preset.js index be007262477d3..1d665a4e6a16c 100644 --- a/packages/kbn-test/jest_integration/jest-preset.js +++ b/packages/kbn-test/jest_integration/jest-preset.js @@ -20,7 +20,13 @@ module.exports = { ], reporters: [ 'default', - ['@kbn/test/target_node/jest/junit_reporter', { reportName: 'Jest Integration Tests' }], + [ + '@kbn/test/target_node/jest/junit_reporter', + { + rootDirectory: '.', + reportName: 'Jest Integration Tests', + }, + ], [ '@kbn/test/target_node/jest/ci_stats_jest_reporter', { diff --git a/packages/kbn-test/src/jest/configs/__snapshots__/jest_configs.test.ts.snap b/packages/kbn-test/src/jest/configs/__snapshots__/jest_configs.test.ts.snap new file mode 100644 index 0000000000000..8de7ea9a41367 --- /dev/null +++ b/packages/kbn-test/src/jest/configs/__snapshots__/jest_configs.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`jestConfigs #expected throws if test file outside root 1`] = `[Error: Test file (bad.test.js) can not exist outside roots (packages/b/nested, packages). Move it to a root or configure additional root.]`; diff --git a/packages/kbn-test/src/jest/configs/index.ts b/packages/kbn-test/src/jest/configs/index.ts new file mode 100644 index 0000000000000..155c385ec761d --- /dev/null +++ b/packages/kbn-test/src/jest/configs/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 './jest_configs'; diff --git a/packages/kbn-test/src/jest/configs/jest_configs.test.ts b/packages/kbn-test/src/jest/configs/jest_configs.test.ts new file mode 100644 index 0000000000000..4d68733f58d32 --- /dev/null +++ b/packages/kbn-test/src/jest/configs/jest_configs.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 mockFs from 'mock-fs'; +import fs from 'fs'; + +import { JestConfigs } from './jest_configs'; + +describe('jestConfigs', () => { + let jestConfigs: JestConfigs; + + beforeEach(async () => { + mockFs({ + '/kbn-test/packages': { + a: { + 'jest.config.js': '', + 'a_first.test.js': '', + 'a_second.test.js': '', + }, + b: { + 'b.test.js': '', + integration_tests: { + 'b_integration.test.js': '', + }, + nested: { + d: { + 'd.test.js': '', + }, + }, + }, + c: { + 'jest.integration.config.js': '', + integration_tests: { + 'c_integration.test.js': '', + }, + }, + }, + }); + jestConfigs = new JestConfigs('/kbn-test', ['packages/b/nested', 'packages']); + }); + + afterEach(mockFs.restore); + + describe('#files', () => { + it('lists unit test files', async () => { + const files = await jestConfigs.files('unit'); + expect(files).toEqual([ + 'packages/a/a_first.test.js', + 'packages/a/a_second.test.js', + 'packages/b/b.test.js', + 'packages/b/nested/d/d.test.js', + ]); + }); + + it('lists integration test files', async () => { + const files = await jestConfigs.files('integration'); + expect(files).toEqual([ + 'packages/b/integration_tests/b_integration.test.js', + 'packages/c/integration_tests/c_integration.test.js', + ]); + }); + }); + + describe('#expected', () => { + it('expects unit config files', async () => { + const files = await jestConfigs.expected('unit'); + expect(files).toEqual([ + 'packages/a/jest.config.js', + 'packages/b/jest.config.js', + 'packages/b/nested/d/jest.config.js', + ]); + }); + + it('expects integration config files', async () => { + const files = await jestConfigs.expected('integration'); + expect(files).toEqual([ + 'packages/b/jest.integration.config.js', + 'packages/c/jest.integration.config.js', + ]); + }); + + it('throws if test file outside root', async () => { + fs.writeFileSync('/kbn-test/bad.test.js', ''); + await expect(() => jestConfigs.expected('unit')).rejects.toMatchSnapshot(); + }); + }); + + describe('#existing', () => { + it('lists existing unit test config files', async () => { + const files = await jestConfigs.existing('unit'); + expect(files).toEqual(['packages/a/jest.config.js']); + }); + + it('lists existing integration test config files', async () => { + const files = await jestConfigs.existing('integration'); + expect(files).toEqual(['packages/c/jest.integration.config.js']); + }); + }); + + describe('#missing', () => { + it('lists existing unit test config files', async () => { + const files = await jestConfigs.missing('unit'); + expect(files).toEqual(['packages/b/jest.config.js', 'packages/b/nested/d/jest.config.js']); + }); + + it('lists existing integration test config files', async () => { + const files = await jestConfigs.missing('integration'); + expect(files).toEqual(['packages/b/jest.integration.config.js']); + }); + }); +}); diff --git a/packages/kbn-test/src/jest/configs/jest_configs.ts b/packages/kbn-test/src/jest/configs/jest_configs.ts new file mode 100644 index 0000000000000..a2a55d4a1b649 --- /dev/null +++ b/packages/kbn-test/src/jest/configs/jest_configs.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 globby from 'globby'; + +// @ts-ignore +import { testMatch } from '../../../jest-preset'; + +export const CONFIG_NAMES = { + unit: 'jest.config.js', + integration: 'jest.integration.config.js', +}; + +export class JestConfigs { + cwd: string; + roots: string[]; + allFiles: string[] | undefined; + + constructor(cwd: string, roots: string[]) { + this.cwd = cwd; + this.roots = roots; + } + + async files(type: 'unit' | 'integration') { + if (!this.allFiles) { + this.allFiles = await globby(testMatch, { + gitignore: true, + cwd: this.cwd, + }); + } + + return this.allFiles.filter((f) => + type === 'integration' ? f.includes('integration_tests') : !f.includes('integration_tests') + ); + } + + async expected(type: 'unit' | 'integration') { + const filesForType = await this.files(type); + const directories: Set = new Set(); + + filesForType.forEach((file) => { + const root = this.roots.find((r) => file.startsWith(r)); + + if (root) { + const splitPath = file.substring(root.length).split(path.sep); + + if (splitPath.length > 2) { + const name = splitPath[1]; + directories.add([root, name].join(path.sep)); + } + } else { + throw new Error( + `Test file (${file}) can not exist outside roots (${this.roots.join( + ', ' + )}). Move it to a root or configure additional root.` + ); + } + }); + + return [...directories].map((d) => [d, CONFIG_NAMES[type]].join(path.sep)); + } + + async existing(type: 'unit' | 'integration') { + return await globby(`**/${CONFIG_NAMES[type]}`, { + gitignore: true, + cwd: this.cwd, + }); + } + + async missing(type: 'unit' | 'integration') { + const expectedConfigs = await this.expected(type); + const existingConfigs = await this.existing(type); + return await expectedConfigs.filter((x) => !existingConfigs.includes(x)); + } + + async allMissing() { + return (await this.missing('unit')).concat(await this.missing('integration')); + } +} diff --git a/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts b/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts index cf37ee82d61e9..6f7836e98d346 100644 --- a/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts +++ b/packages/kbn-test/src/jest/run_check_jest_configs_cli.ts @@ -6,26 +6,29 @@ * Side Public License, v 1. */ -import { relative, resolve, sep } from 'path'; import { writeFileSync } from 'fs'; - -import execa from 'execa'; -import globby from 'globby'; +import path from 'path'; import Mustache from 'mustache'; import { run } from '@kbn/dev-utils'; import { REPO_ROOT } from '@kbn/utils'; -// @ts-ignore -import { testMatch } from '../../jest-preset'; +import { JestConfigs, CONFIG_NAMES } from './configs'; -const template: string = `module.exports = { +const unitTestingTemplate: string = `module.exports = { preset: '@kbn/test', rootDir: '{{{relToRoot}}}', roots: ['/{{{modulePath}}}'], }; `; +const integrationTestingTemplate: string = `module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '{{{relToRoot}}}', + roots: ['/{{{modulePath}}}'], +}; +`; + const roots: string[] = [ 'x-pack/plugins/security_solution/public', 'x-pack/plugins/security_solution/server', @@ -40,68 +43,43 @@ const roots: string[] = [ export async function runCheckJestConfigsCli() { run( async ({ flags: { fix = false }, log }) => { - const { stdout: coveredFiles } = await execa( - 'yarn', - ['--silent', 'jest', '--listTests', '--json'], - { - cwd: REPO_ROOT, - } - ); + const jestConfigs = new JestConfigs(REPO_ROOT, roots); - const allFiles = new Set( - await globby(testMatch.concat(['!**/integration_tests/**']), { - gitignore: true, - }) - ); + const missing = await jestConfigs.allMissing(); - JSON.parse(coveredFiles).forEach((file: string) => { - const pathFromRoot = relative(REPO_ROOT, file); - allFiles.delete(pathFromRoot); - }); - - if (allFiles.size) { + if (missing.length) { log.error( - `The following files do not belong to a jest.config.js file, or that config is not included from the root jest.config.js\n${[ - ...allFiles, + `The following Jest config files do not exist for which there are test files for:\n${[ + ...missing, ] .map((file) => ` - ${file}`) .join('\n')}` ); - } else { - log.success('All test files are included by a Jest configuration'); - return; - } - - if (fix) { - allFiles.forEach((file) => { - const root = roots.find((r) => file.startsWith(r)); - if (root) { - const name = relative(root, file).split(sep)[0]; - const modulePath = [root, name].join('/'); + if (fix) { + missing.forEach((file) => { + const template = file.endsWith(CONFIG_NAMES.unit) + ? unitTestingTemplate + : integrationTestingTemplate; + const modulePath = path.dirname(file); const content = Mustache.render(template, { - relToRoot: relative(modulePath, '.'), + relToRoot: path.relative(modulePath, '.'), modulePath, }); - const configPath = resolve(root, name, 'jest.config.js'); - log.info('created %s', configPath); - writeFileSync(configPath, content); - } else { - log.warning(`Unable to determind where to place jest.config.js for ${file}`); - } - }); - } else { - log.info( - `Run 'node scripts/check_jest_configs --fix' to attempt to create the missing config files` - ); + writeFileSync(file, content); + log.info('created %s', file); + }); + } else { + log.info( + `Run 'node scripts/check_jest_configs --fix' to create the missing config files` + ); + } } - - process.exit(1); }, { - description: 'Check that all test files are covered by a jest.config.js', + description: 'Check that all test files are covered by a Jest config', flags: { boolean: ['fix'], help: ` diff --git a/src/cli/jest.integration.config.js b/src/cli/jest.integration.config.js new file mode 100644 index 0000000000000..96f02d0524688 --- /dev/null +++ b/src/cli/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../..', + roots: ['/src/cli'], +}; diff --git a/src/core/jest.integration.config.js b/src/core/jest.integration.config.js new file mode 100644 index 0000000000000..3b84ae88ad7a7 --- /dev/null +++ b/src/core/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../..', + roots: ['/src/core'], +}; diff --git a/src/core/public/application/application_leave.test.ts b/src/core/public/application/application_leave.test.ts index 62ebb52ebc38f..6df4e0d13cc44 100644 --- a/src/core/public/application/application_leave.test.ts +++ b/src/core/public/application/application_leave.test.ts @@ -54,5 +54,17 @@ describe('getLeaveAction', () => { title: 'a title', callback, }); + expect( + getLeaveAction((actions) => + actions.confirm('another message', 'a title', callback, 'confirm button text', 'danger') + ) + ).toEqual({ + type: AppLeaveActionType.confirm, + text: 'another message', + title: 'a title', + callback, + confirmButtonText: 'confirm button text', + buttonColor: 'danger', + }); }); }); diff --git a/src/core/public/application/application_leave.tsx b/src/core/public/application/application_leave.tsx index 058b11728e907..f3f5932519a28 100644 --- a/src/core/public/application/application_leave.tsx +++ b/src/core/public/application/application_leave.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import type { ButtonColor } from '@elastic/eui'; import { AppLeaveActionFactory, AppLeaveActionType, @@ -15,8 +15,21 @@ import { } from './types'; const appLeaveActionFactory: AppLeaveActionFactory = { - confirm(text: string, title?: string, callback?: () => void) { - return { type: AppLeaveActionType.confirm, text, title, callback }; + confirm( + text: string, + title?: string, + callback?: () => void, + confirmButtonText?: string, + buttonColor?: ButtonColor + ) { + return { + type: AppLeaveActionType.confirm, + text, + title, + confirmButtonText, + buttonColor, + callback, + }; }, default() { return { type: AppLeaveActionType.default }; diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 3010a781b4e9e..1cfae598f67c8 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -365,6 +365,8 @@ export class ApplicationService { const confirmed = await overlays.openConfirm(action.text, { title: action.title, 'data-test-subj': 'appLeaveConfirmModal', + confirmButtonText: action.confirmButtonText, + buttonColor: action.buttonColor, }); if (!confirmed) { if (action.callback) { diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 187cee8d0a29a..af5fdc08e9b45 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - +import type { ButtonColor } from '@elastic/eui'; import { Observable } from 'rxjs'; import { History } from 'history'; import { RecursiveReadonly } from '@kbn/utility-types'; @@ -597,6 +597,8 @@ export interface AppLeaveConfirmAction { type: AppLeaveActionType.confirm; text: string; title?: string; + confirmButtonText?: string; + buttonColor?: ButtonColor; callback?: () => void; } @@ -621,9 +623,17 @@ export interface AppLeaveActionFactory { * @param text The text to display in the confirmation message * @param title (optional) title to display in the confirmation message * @param callback (optional) to know that the user want to stay on the page + * @param confirmButtonText (optional) text for the confirmation button + * @param buttonColor (optional) color for the confirmation button * so we can show to the user the right UX for him to saved his/her/their changes */ - confirm(text: string, title?: string, callback?: () => void): AppLeaveConfirmAction; + confirm( + text: string, + title?: string, + callback?: () => void, + confirmButtonText?: string, + buttonColor?: ButtonColor + ): AppLeaveConfirmAction; /** * Returns a default action, resulting on executing the default behavior when diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 6d2ee9a5dd4e1..c610c98c53646 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -8,6 +8,7 @@ import { Action } from 'history'; import Boom from '@hapi/boom'; +import type { ButtonColor } from '@elastic/eui'; import { ByteSizeValue } from '@kbn/config-schema'; import type { Client } from '@elastic/elasticsearch'; import { ConfigPath } from '@kbn/config'; @@ -115,9 +116,13 @@ export enum AppLeaveActionType { // // @public export interface AppLeaveConfirmAction { + // (undocumented) + buttonColor?: ButtonColor; // (undocumented) callback?: () => void; // (undocumented) + confirmButtonText?: string; + // (undocumented) text: string; // (undocumented) title?: string; diff --git a/src/dev/jest.integration.config.js b/src/dev/jest.integration.config.js new file mode 100644 index 0000000000000..1225651687834 --- /dev/null +++ b/src/dev/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../..', + roots: ['/src/dev'], +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/kibana.json b/src/plugins/chart_expressions/expression_partition_vis/kibana.json index 226d1681cd3fc..08a030d466eab 100755 --- a/src/plugins/chart_expressions/expression_partition_vis/kibana.json +++ b/src/plugins/chart_expressions/expression_partition_vis/kibana.json @@ -12,7 +12,7 @@ "extraPublicDirs": [ "common" ], - "requiredPlugins": ["charts", "data", "expressions", "visualizations", "fieldFormats"], + "requiredPlugins": ["charts", "data", "expressions", "visualizations", "fieldFormats", "presentationUtil"], "requiredBundles": ["kibanaReact"], "optionalPlugins": [] } diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap index b367db1af5437..2df18b5813473 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap @@ -5,33 +5,34 @@ exports[`PartitionVisComponent should render correct structure for donut 1`] = ` css={ Object { "map": undefined, - "name": "1bdmk0u", + "name": "13h2mjc", "next": undefined, "styles": " - display:flex;flex:1 1 auto;min-height:0;min-width:0;;; + + min-height: 0; + min-width: 0; + margin-left: auto; + margin-right: auto; + width: 100%; + height: 100%; +; + inset: 0; position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; padding: 8px; - margin-left: auto; - margin-right: auto; - overflow: hidden; ", "toString": [Function], } } - data-test-subj="visTypePieChart" + data-test-subj="partitionVisChart" >
css` - ${partitionVisWrapperStyle}; +export const partitionVisContainerStyle = css` + min-height: 0; + min-width: 0; + margin-left: auto; + margin-right: auto; + width: 100%; + height: 100%; +`; + +export const partitionVisContainerWithToggleStyleFactory = (theme: EuiThemeComputed) => css` + ${partitionVisContainerStyle} + inset: 0; position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; padding: ${theme.size.s}; - margin-left: auto; - margin-right: auto; - overflow: hidden; `; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index ddade06c2c7e0..001f2390799e6 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -221,7 +221,9 @@ describe('PartitionVisComponent', function () { } as unknown as Datatable; const newProps = { ...wrapperProps, visData: newVisData }; const component = mount(); - expect(findTestSubject(component, 'pieVisualizationError').text()).toEqual('No results found'); + expect(findTestSubject(component, 'partitionVisEmptyValues').text()).toEqual( + 'No results found' + ); }); it('renders the no results component if there are negative values', () => { @@ -250,8 +252,8 @@ describe('PartitionVisComponent', function () { } as unknown as Datatable; const newProps = { ...wrapperProps, visData: newVisData }; const component = mount(); - expect(findTestSubject(component, 'pieVisualizationError').text()).toEqual( - "Pie/donut charts can't render with negative values." + expect(findTestSubject(component, 'partitionVisNegativeValues').text()).toEqual( + "Pie chart can't render with negative values." ); }); }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index cc96baac3a8ae..42a298d00d48c 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -20,12 +20,7 @@ import { SeriesIdentifier, } from '@elastic/charts'; import { useEuiTheme } from '@elastic/eui'; -import { - LegendToggle, - ClickTriggerEvent, - ChartsPluginSetup, - PaletteRegistry, -} from '../../../../charts/public'; +import { LegendToggle, ChartsPluginSetup, PaletteRegistry } from '../../../../charts/public'; import type { PersistedState } from '../../../../visualizations/public'; import { Datatable, @@ -63,10 +58,12 @@ import { VisualizationNoResults } from './visualization_noresults'; import { VisTypePiePluginStartDependencies } from '../plugin'; import { partitionVisWrapperStyle, - partitionVisContainerStyleFactory, + partitionVisContainerStyle, + partitionVisContainerWithToggleStyleFactory, } from './partition_vis_component.styles'; import { ChartTypes } from '../../common/types'; import { filterOutConfig } from '../utils/filter_out_config'; +import { FilterEvent } from '../types'; declare global { interface Window { @@ -93,7 +90,6 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { const { visData, visParams: preVisParams, visType, services, syncColors } = props; const visParams = useMemo(() => filterOutConfig(visType, preVisParams), [preVisParams, visType]); - const theme = useEuiTheme(); const chartTheme = props.chartsThemeService.useChartsTheme(); const chartBaseTheme = props.chartsThemeService.useChartsBaseTheme(); @@ -103,8 +99,8 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { ); const formatters = useMemo( - () => generateFormatters(visParams, visData, services.fieldFormats.deserialize), - [services.fieldFormats.deserialize, visData, visParams] + () => generateFormatters(visData, services.fieldFormats.deserialize), + [services.fieldFormats.deserialize, visData] ); const showLegendDefault = useCallback(() => { @@ -114,6 +110,8 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { const [showLegend, setShowLegend] = useState(() => showLegendDefault()); + const showToggleLegendElement = props.uiState !== undefined; + const [dimensions, setDimensions] = useState(); const parentRef = useRef(null); @@ -157,11 +155,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { splitChartDimension, splitChartFormatter ); - const event = { - name: 'filterBucket', - data: { data }, - }; - props.fireEvent(event); + props.fireEvent({ name: 'filter', data: { data } }); }, [props] ); @@ -169,11 +163,11 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { // handles legend action event data const getLegendActionEventData = useCallback( (vData: Datatable) => - (series: SeriesIdentifier): ClickTriggerEvent | null => { + (series: SeriesIdentifier): FilterEvent => { const data = getFilterEventData(vData, series); return { - name: 'filterBucket', + name: 'filter', data: { negate: false, data, @@ -184,7 +178,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { ); const handleLegendAction = useCallback( - (event: ClickTriggerEvent, negate = false) => { + (event: FilterEvent, negate = false) => { props.fireEvent({ ...event, data: { @@ -318,6 +312,9 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { [visData.rows, metricColumn] ); + const isEmpty = visData.rows.length === 0; + const isMetricEmpty = visData.rows.every((row) => !row[metricColumn.id]); + /** * Checks whether data have negative values. * If so, the no data container is loaded. @@ -330,14 +327,23 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { }), [visData.rows, metricColumn] ); + const flatLegend = isLegendFlat(visType, splitChartDimension); - const canShowPieChart = !isAllZeros && !hasNegative; + + const canShowPieChart = !isEmpty && !isMetricEmpty && !isAllZeros && !hasNegative; + + const { euiTheme } = useEuiTheme(); + + const chartContainerStyle = showToggleLegendElement + ? partitionVisContainerWithToggleStyleFactory(euiTheme) + : partitionVisContainerStyle; + const partitionType = getPartitionType(visType); return ( -
+
{!canShowPieChart ? ( - + ) : (
{ distinctColors: visParams.distinctColors ?? false, }} > - + {showToggleLegendElement && ( + + )} { /> { - return ( - - {hasNegativeValues - ? i18n.translate('expressionPartitionVis.negativeValuesFound', { - defaultMessage: "Pie/donut charts can't render with negative values.", - }) - : i18n.translate('expressionPartitionVis.noResultsFoundTitle', { - defaultMessage: 'No results found', - })} - - } - /> - ); +interface Props { + hasNegativeValues?: boolean; + chartType: ChartTypes; +} + +export const VisualizationNoResults: FC = ({ hasNegativeValues = false, chartType }) => { + if (hasNegativeValues) { + const message = ( + + ); + + return ( + + ); + } + + return ; }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx index c3521c7346a81..53e729466c1d2 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx @@ -10,35 +10,27 @@ import React, { lazy } from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { Datatable, ExpressionRenderDefinition } from '../../../../expressions/public'; -import { VisualizationContainer } from '../../../../visualizations/public'; +import { ExpressionRenderDefinition } from '../../../../expressions/public'; import type { PersistedState } from '../../../../visualizations/public'; +import { VisTypePieDependencies } from '../plugin'; +import { withSuspense } from '../../../../presentation_util/public'; import { KibanaThemeProvider } from '../../../../kibana_react/public'; - import { PARTITION_VIS_RENDERER_NAME } from '../../common/constants'; import { ChartTypes, RenderValue } from '../../common/types'; -import { VisTypePieDependencies } from '../plugin'; - export const strings = { getDisplayName: () => - i18n.translate('expressionPartitionVis.renderer.pieVis.displayName', { - defaultMessage: 'Pie visualization', + i18n.translate('expressionPartitionVis.renderer.partitionVis.pie.displayName', { + defaultMessage: 'Partition visualization', }), getHelpDescription: () => - i18n.translate('expressionPartitionVis.renderer.pieVis.helpDescription', { - defaultMessage: 'Render a pie', + i18n.translate('expressionPartitionVis.renderer.partitionVis.pie.helpDescription', { + defaultMessage: 'Render pie/donut/treemap/mosaic/waffle charts', }), }; -const PartitionVisComponent = lazy(() => import('../components/partition_vis_component')); - -function shouldShowNoResultsMessage(visData: Datatable | undefined): boolean { - const rows: object[] | undefined = visData?.rows; - const isZeroHits = !rows || !rows.length; - - return Boolean(isZeroHits); -} +const LazyPartitionVisComponent = lazy(() => import('../components/partition_vis_component')); +const PartitionVisComponent = withSuspense(LazyPartitionVisComponent); export const getPartitionVisRenderer: ( deps: VisTypePieDependencies @@ -48,8 +40,6 @@ export const getPartitionVisRenderer: ( help: strings.getHelpDescription(), reuseDomNode: true, render: async (domNode, { visConfig, visData, visType, syncColors }, handlers) => { - const showNoResult = shouldShowNoResultsMessage(visData); - handlers.onDestroy(() => { unmountComponentAtNode(domNode); }); @@ -60,7 +50,7 @@ export const getPartitionVisRenderer: ( render( - +
- +
, - domNode + domNode, + () => { + handlers.done(); + } ); }, }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/icons/donut.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/icons/donut.tsx new file mode 100644 index 0000000000000..5846fe0e7e8ba --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/icons/donut.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiIconProps } from '@elastic/eui'; + +export const DonutIcon = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + + +); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/icons/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/icons/index.ts new file mode 100644 index 0000000000000..e61bd6557d581 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/icons/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { PieIcon } from './pie'; +export { DonutIcon } from './donut'; +export { TreemapIcon } from './treemap'; +export { MosaicIcon } from './mosaic'; +export { WaffleIcon } from './waffle'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/icons/mosaic.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/icons/mosaic.tsx new file mode 100644 index 0000000000000..f8582495f2e0c --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/icons/mosaic.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { EuiIconProps } from '@elastic/eui'; + +export const MosaicIcon = ({ title, titleId, ...props }: Omit) => ( + + {title ? : null} + + + +); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/icons/pie.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/icons/pie.tsx new file mode 100644 index 0000000000000..9176ac3fdd5c1 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/icons/pie.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiIconProps } from '@elastic/eui'; + +export const PieIcon = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + + +); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/icons/treemap.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/icons/treemap.tsx new file mode 100644 index 0000000000000..1860132fa9ffd --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/icons/treemap.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiIconProps } from '@elastic/eui'; + +export const TreemapIcon = ({ title, titleId, ...props }: Omit) => ( + + {title ? {title} : null} + + + + +); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/icons/waffle.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/icons/waffle.tsx new file mode 100644 index 0000000000000..30f05dd57f348 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/icons/waffle.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { EuiIconProps } from '@elastic/eui'; + +export const WaffleIcon = ({ title, titleId, ...props }: Omit) => ( + + {title ? : null} + + + +); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/types.ts b/src/plugins/chart_expressions/expression_partition_vis/public/types.ts index 64e132d2ddadb..aa87124ed2b4b 100755 --- a/src/plugins/chart_expressions/expression_partition_vis/public/types.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/types.ts @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import type { ValueClickContext } from '../../../embeddable/public'; import { ChartsPluginSetup } from '../../../charts/public'; import { ExpressionsPublicPlugin, ExpressionsServiceStart } from '../../../expressions/public'; @@ -19,3 +20,8 @@ export interface SetupDeps { export interface StartDeps { expression: ExpressionsServiceStart; } + +export interface FilterEvent { + name: 'filter'; + data: ValueClickContext['data']; +} diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts index 47641a7f270c2..5b48d68f68201 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts @@ -9,13 +9,13 @@ import { LayerValue, SeriesIdentifier } from '@elastic/charts'; import { Datatable, DatatableColumn } from '../../../../expressions/public'; import { DataPublicPluginStart } from '../../../../data/public'; -import { ClickTriggerEvent } from '../../../../charts/public'; import { ValueClickContext } from '../../../../embeddable/public'; import type { FieldFormat } from '../../../../field_formats/common'; import { BucketColumns } from '../../common/types'; +import { FilterEvent } from '../types'; export const canFilter = async ( - event: ClickTriggerEvent | null, + event: FilterEvent | null, actions: DataPublicPluginStart['actions'] ): Promise => { if (!event) { diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.test.ts index 69443dcfea5fb..18f89cb5f3e4e 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.test.ts @@ -8,31 +8,19 @@ import { fieldFormatsMock } from '../../../../field_formats/common/mocks'; import { Datatable } from '../../../../expressions'; -import { createMockPieParams, createMockVisData } from '../mocks'; +import { createMockVisData } from '../mocks'; import { generateFormatters, getAvailableFormatter, getFormatter } from './formatters'; import { BucketColumns } from '../../common/types'; describe('generateFormatters', () => { - const visParams = createMockPieParams(); const visData = createMockVisData(); const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); beforeEach(() => { defaultFormatter.mockClear(); }); - it('returns empty object, if labels should not be should ', () => { - const formatters = generateFormatters( - { ...visParams, labels: { ...visParams.labels, show: false } }, - visData, - defaultFormatter - ); - - expect(formatters).toEqual({}); - expect(defaultFormatter).toHaveBeenCalledTimes(0); - }); - it('returns formatters, if columns have meta parameters', () => { - const formatters = generateFormatters(visParams, visData, defaultFormatter); + const formatters = generateFormatters(visData, defaultFormatter); const formattingResult = fieldFormatsMock.deserialize(); const serializedFormatters = Object.keys(formatters).reduce( @@ -62,7 +50,7 @@ describe('generateFormatters', () => { columns: visData.columns.map(({ meta, ...col }) => ({ ...col, meta: { type: 'string' } })), }; - const formatters = generateFormatters(visParams, newVisData, defaultFormatter); + const formatters = generateFormatters(newVisData, defaultFormatter); expect(formatters).toEqual({ 'col-0-2': undefined, diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.ts index 59574dd248518..bbb30169928d4 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/formatters.ts @@ -8,25 +8,16 @@ import type { FieldFormat, FormatFactory } from '../../../../field_formats/common'; import type { Datatable } from '../../../../expressions/public'; -import { BucketColumns, PartitionVisParams } from '../../common/types'; +import { BucketColumns } from '../../common/types'; -export const generateFormatters = ( - visParams: PartitionVisParams, - visData: Datatable, - formatFactory: FormatFactory -) => { - if (!visParams.labels.show) { - return {}; - } - - return visData.columns.reduce | undefined>>( +export const generateFormatters = (visData: Datatable, formatFactory: FormatFactory) => + visData.columns.reduce | undefined>>( (newFormatters, column) => ({ ...newFormatters, [column.id]: column?.meta?.params ? formatFactory(column.meta.params) : undefined, }), {} ); -}; export const getAvailableFormatter = ( column: Partial, diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_icon.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_icon.ts new file mode 100644 index 0000000000000..cac282553af11 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_icon.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ChartTypes } from '../../common/types'; +import { PieIcon, DonutIcon, TreemapIcon, MosaicIcon, WaffleIcon } from '../icons'; + +export const getIcon = (chart: ChartTypes) => + ({ + [ChartTypes.PIE]: PieIcon, + [ChartTypes.DONUT]: DonutIcon, + [ChartTypes.TREEMAP]: TreemapIcon, + [ChartTypes.MOSAIC]: MosaicIcon, + [ChartTypes.WAFFLE]: WaffleIcon, + }[chart]); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx index 28b85f6300977..72793d771a0ee 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx @@ -13,16 +13,16 @@ import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } fr import { LegendAction, SeriesIdentifier, useLegendAction } from '@elastic/charts'; import { DataPublicPluginStart } from '../../../../data/public'; import { PartitionVisParams } from '../../common/types'; -import { ClickTriggerEvent } from '../../../../charts/public'; import { FieldFormatsStart } from '../../../../field_formats/public'; +import { FilterEvent } from '../types'; export const getLegendActions = ( canFilter: ( - data: ClickTriggerEvent | null, + data: FilterEvent | null, actions: DataPublicPluginStart['actions'] ) => Promise, - getFilterEventData: (series: SeriesIdentifier) => ClickTriggerEvent | null, - onFilter: (data: ClickTriggerEvent, negate?: any) => void, + getFilterEventData: (series: SeriesIdentifier) => FilterEvent | null, + onFilter: (data: FilterEvent, negate?: any) => void, visParams: PartitionVisParams, actions: DataPublicPluginStart['actions'], formatter: FieldFormatsStart diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts index afa0b82a87eb1..b0ce92f1205e8 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/index.ts @@ -18,3 +18,4 @@ export { getColumnByAccessor } from './accessor'; export { isLegendFlat, shouldShowLegend } from './legend'; export { generateFormatters, getAvailableFormatter, getFormatter } from './formatters'; export { getPartitionType } from './get_partition_type'; +export { getIcon } from './get_icon'; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts new file mode 100644 index 0000000000000..efeb1f038232d --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/get_color.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { PaletteDefinition, PaletteOutput } from '../../../../../charts/public'; +import { chartPluginMock } from '../../../../../charts/public/mocks'; +import { Datatable } from '../../../../../expressions'; +import { byDataColorPaletteMap } from './get_color'; + +describe('#byDataColorPaletteMap', () => { + let datatable: Datatable; + let paletteDefinition: PaletteDefinition; + let palette: PaletteOutput; + const columnId = 'foo'; + + beforeEach(() => { + datatable = { + rows: [ + { + [columnId]: '1', + }, + { + [columnId]: '2', + }, + ], + } as unknown as Datatable; + paletteDefinition = chartPluginMock.createPaletteRegistry().get('default'); + palette = { type: 'palette' } as PaletteOutput; + }); + + it('should create byDataColorPaletteMap', () => { + expect(byDataColorPaletteMap(datatable.rows, columnId, paletteDefinition, palette)) + .toMatchInlineSnapshot(` + Object { + "getColor": [Function], + } + `); + }); + + it('should get color', () => { + const colorPaletteMap = byDataColorPaletteMap( + datatable.rows, + columnId, + paletteDefinition, + palette + ); + + expect(colorPaletteMap.getColor('1')).toBe('black'); + }); + + it('should return undefined in case if values not in datatable', () => { + const colorPaletteMap = byDataColorPaletteMap( + datatable.rows, + columnId, + paletteDefinition, + palette + ); + + expect(colorPaletteMap.getColor('wrong')).toBeUndefined(); + }); + + it('should increase rankAtDepth for each new value', () => { + const colorPaletteMap = byDataColorPaletteMap( + datatable.rows, + columnId, + paletteDefinition, + palette + ); + colorPaletteMap.getColor('1'); + colorPaletteMap.getColor('2'); + + expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith( + 1, + [{ name: '1', rankAtDepth: 0, totalSeriesAtDepth: 2 }], + { behindText: false }, + undefined + ); + + expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith( + 2, + [{ name: '2', rankAtDepth: 1, totalSeriesAtDepth: 2 }], + { behindText: false }, + undefined + ); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/sort_predicate.test.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/sort_predicate.test.ts new file mode 100644 index 0000000000000..1ccfdb7a5b1f9 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/layers/sort_predicate.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Datatable } from '../../../../../expressions'; +import { extractUniqTermsMap } from './sort_predicate'; + +describe('#extractUniqTermsMap', () => { + it('should extract map', () => { + const table: Datatable = { + type: 'datatable', + columns: [ + { id: 'a', name: 'A', meta: { type: 'string' } }, + { id: 'b', name: 'B', meta: { type: 'string' } }, + { id: 'c', name: 'C', meta: { type: 'number' } }, + ], + rows: [ + { a: 'Hi', b: 'Two', c: 2 }, + { a: 'Test', b: 'Two', c: 5 }, + { a: 'Foo', b: 'Three', c: 6 }, + ], + }; + expect(extractUniqTermsMap(table, 'a')).toMatchInlineSnapshot(` + Object { + "Foo": 2, + "Hi": 0, + "Test": 1, + } + `); + expect(extractUniqTermsMap(table, 'b')).toMatchInlineSnapshot(` + Object { + "Three": 1, + "Two": 0, + } + `); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/tsconfig.json b/src/plugins/chart_expressions/expression_partition_vis/tsconfig.json index d480d7d27df5a..97a0c8a9fc515 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/tsconfig.json +++ b/src/plugins/chart_expressions/expression_partition_vis/tsconfig.json @@ -15,6 +15,7 @@ "references": [ { "path": "../../../core/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, + { "path": "../../presentation_util/tsconfig.json" }, { "path": "../../data/tsconfig.json" }, { "path": "../../field_formats/tsconfig.json" }, { "path": "../../charts/tsconfig.json" }, diff --git a/src/plugins/chart_expressions/jest.config.js b/src/plugins/chart_expressions/jest.config.js new file mode 100644 index 0000000000000..503ef441c0359 --- /dev/null +++ b/src/plugins/chart_expressions/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/chart_expressions'], +}; diff --git a/src/plugins/charts/public/static/components/empty_placeholder.tsx b/src/plugins/charts/public/static/components/empty_placeholder.tsx index e376120c9cd9e..6989ea7a7a63b 100644 --- a/src/plugins/charts/public/static/components/empty_placeholder.tsx +++ b/src/plugins/charts/public/static/components/empty_placeholder.tsx @@ -13,14 +13,24 @@ import './empty_placeholder.scss'; export const EmptyPlaceholder = ({ icon, + iconColor = 'subdued', message = , + dataTestSubj = 'emptyPlaceholder', }: { icon: IconType; + iconColor?: string; message?: JSX.Element; + dataTestSubj?: string; }) => ( <> - - + +

{message}

diff --git a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts index 5752a6445d2a9..4db7eabe6d78d 100644 --- a/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts +++ b/src/plugins/dashboard/public/application/lib/build_dashboard_container.ts @@ -122,7 +122,9 @@ export const buildDashboardContainer = async ({ gridData: originalPanelState.gridData, type: incomingEmbeddable.type, explicitInput: { - ...originalPanelState.explicitInput, + ...(incomingEmbeddable.type === originalPanelState.type && { + ...originalPanelState.explicitInput, + }), ...incomingEmbeddable.input, id: incomingEmbeddable.embeddableId, }, diff --git a/src/plugins/expressions/common/execution/execution_contract.test.ts b/src/plugins/expressions/common/execution/execution_contract.test.ts index de209f1dfb4a1..6b0fa0d0db592 100644 --- a/src/plugins/expressions/common/execution/execution_contract.test.ts +++ b/src/plugins/expressions/common/execution/execution_contract.test.ts @@ -6,11 +6,13 @@ * Side Public License, v 1. */ +import { Observable, Subscriber } from 'rxjs'; import { first } from 'rxjs/operators'; import { Execution } from './execution'; import { parseExpression } from '../ast'; import { createUnitTestExecutor } from '../test_helpers'; import { ExecutionContract } from './execution_contract'; +import { ExpressionFunctionDefinition } from '../expression_functions'; const createExecution = ( expression: string = 'foo bar=123', @@ -117,11 +119,40 @@ describe('ExecutionContract', () => { const contract = new ExecutionContract(execution); execution.start(); - await execution.result.pipe(first()).toPromise(); execution.state.get().state = 'error'; expect(contract.isPending).toBe(false); expect(execution.state.get().state).toBe('error'); }); + + test('is true when execution is in progress but got partial result, is false once we get final result', async () => { + let mySubscriber: Subscriber; + const arg = new Observable((subscriber) => { + mySubscriber = subscriber; + subscriber.next(1); + }); + + const observable: ExpressionFunctionDefinition<'observable', unknown, {}, unknown> = { + name: 'observable', + args: {}, + help: '', + fn: () => arg, + }; + const executor = createUnitTestExecutor(); + executor.registerFunction(observable); + + const execution = executor.createExecution('observable'); + execution.start(null); + await execution.result.pipe(first()).toPromise(); + + expect(execution.contract.isPending).toBe(true); + expect(execution.state.get().state).toBe('result'); + + mySubscriber!.next(2); + mySubscriber!.complete(); + + expect(execution.contract.isPending).toBe(false); + expect(execution.state.get().state).toBe('result'); + }); }); }); diff --git a/src/plugins/expressions/common/execution/execution_contract.ts b/src/plugins/expressions/common/execution/execution_contract.ts index 69587c58f1045..5167868582332 100644 --- a/src/plugins/expressions/common/execution/execution_contract.ts +++ b/src/plugins/expressions/common/execution/execution_contract.ts @@ -19,8 +19,8 @@ import { Adapters } from '../../../inspector/common/adapters'; */ export class ExecutionContract { public get isPending(): boolean { - const state = this.execution.state.get().state; - const finished = state === 'error' || state === 'result'; + const { state, result } = this.execution.state.get(); + const finished = state === 'error' || (state === 'result' && !result?.partial); return !finished; } diff --git a/src/plugins/kibana_usage_collection/jest.integration.config.js b/src/plugins/kibana_usage_collection/jest.integration.config.js new file mode 100644 index 0000000000000..b4edb79789bbe --- /dev/null +++ b/src/plugins/kibana_usage_collection/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../../..', + roots: ['/src/plugins/kibana_usage_collection'], +}; diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index c0d5ee5a7593d..db6cf1bc3d006 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -12,3 +12,16 @@ .kbnTopNavMenu__badgeGroup { margin-right: $euiSizeM; } + +.kbnTopNavMenu__betaBadgeItem { + margin-right: $euiSizeS; + vertical-align: middle; + + button:hover &, + button:focus & { + text-decoration: underline; + } + button:hover & { + cursor: pointer; + } +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index b6b056134361a..b74fe5249e66c 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { EuiButtonProps } from '@elastic/eui'; +import { EuiButtonProps, EuiBetaBadgeProps } from '@elastic/eui'; export type TopNavMenuAction = (anchorElement: HTMLElement) => void; @@ -19,6 +19,7 @@ export interface TopNavMenuData { className?: string; disableButton?: boolean | (() => boolean); tooltip?: string | (() => string | undefined); + badge?: EuiBetaBadgeProps; emphasize?: boolean; isLoading?: boolean; iconType?: string; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index dd542d5240d9e..721a0fae0e62f 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -8,7 +8,7 @@ import { upperFirst, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; -import { EuiToolTip, EuiButton, EuiHeaderLink } from '@elastic/eui'; +import { EuiToolTip, EuiButton, EuiHeaderLink, EuiBetaBadge } from '@elastic/eui'; import { TopNavMenuData } from './top_nav_menu_data'; export function TopNavMenuItem(props: TopNavMenuData) { @@ -22,6 +22,19 @@ export function TopNavMenuItem(props: TopNavMenuData) { return val!; } + function getButtonContainer() { + if (props.badge) { + return ( + <> + + {upperFirst(props.label || props.id!)} + + ); + } else { + return upperFirst(props.label || props.id!); + } + } + function handleClick(e: MouseEvent) { if (isDisabled()) return; props.run(e.currentTarget); @@ -39,11 +52,11 @@ export function TopNavMenuItem(props: TopNavMenuData) { const btn = props.emphasize ? ( - {upperFirst(props.label || props.id!)} + {getButtonContainer()} ) : ( - {upperFirst(props.label || props.id!)} + {getButtonContainer()} ); diff --git a/src/plugins/usage_collection/jest.integration.config.js b/src/plugins/usage_collection/jest.integration.config.js new file mode 100644 index 0000000000000..b63bcb880a642 --- /dev/null +++ b/src/plugins/usage_collection/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../../..', + roots: ['/src/plugins/usage_collection'], +}; diff --git a/src/plugins/vis_types/jest.config.js b/src/plugins/vis_types/jest.config.js new file mode 100644 index 0000000000000..af7f2b462b89f --- /dev/null +++ b/src/plugins/vis_types/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/src/plugins/vis_types'], +}; diff --git a/src/plugins/vis_types/timeseries/common/types/panel_model.ts b/src/plugins/vis_types/timeseries/common/types/panel_model.ts index b4b167310a194..40bd5632c3a80 100644 --- a/src/plugins/vis_types/timeseries/common/types/panel_model.ts +++ b/src/plugins/vis_types/timeseries/common/types/panel_model.ts @@ -78,7 +78,7 @@ export interface Series { chart_type: string; color: string; color_rules?: ColorRules[]; - fill?: number; + fill?: string; filter?: Query; formatter: string; hidden?: boolean; diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/moving_average.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/moving_average.js index f13dd49bf2a29..e1fe13d410e35 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/moving_average.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/moving_average.js @@ -7,7 +7,7 @@ */ import PropTypes from 'prop-types'; -import React, { Fragment } from 'react'; +import React, { Fragment, useCallback, useState } from 'react'; import { AggRow } from './agg_row'; import { AggSelect } from './agg_select'; import { MetricSelect } from './metric_select'; @@ -43,8 +43,15 @@ const shouldShowHint = ({ model_type: type, window, period }) => export const MovingAverageAgg = (props) => { const { siblings, fields, indexPattern } = props; + const [model, setModel] = useState({ ...DEFAULTS, ...props.model }); + const onModelChange = useCallback( + (newModel) => { + props.onChange(newModel); + setModel(newModel); + }, + [props] + ); - const model = { ...DEFAULTS, ...props.model }; const modelOptions = [ { label: i18n.translate('visTypeTimeseries.movingAverage.modelOptions.simpleLabel', { @@ -81,7 +88,7 @@ export const MovingAverageAgg = (props) => { }, ]; - const handleChange = createChangeHandler(props.onChange, model); + const handleChange = createChangeHandler(onModelChange, model); const handleSelectChange = createSelectHandler(handleChange); const handleNumberChange = createNumberHandler(handleChange); @@ -188,15 +195,12 @@ export const MovingAverageAgg = (props) => { }) } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - Should it be text or number? - */} - diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/serial_diff.js b/src/plugins/vis_types/timeseries/public/application/components/aggs/serial_diff.js index 7bd673e1f8175..e1f26e99d0e84 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/serial_diff.js +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/serial_diff.js @@ -22,10 +22,14 @@ import { EuiFormLabel, EuiFormRow, EuiSpacer, + EuiFieldNumber, + EuiIconTip, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { getIndexPatternKey } from '../../../../common/index_patterns_utils'; +const SERIAL_DIFF_DEFAULT_VALUE = 1; + export const SerialDiffAgg = (props) => { const { siblings, fields, indexPattern, model } = props; @@ -85,24 +89,33 @@ export const SerialDiffAgg = (props) => { + />{' '} + + } + type="questionInCircle" + /> + } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - Should it be text or number? - */} - diff --git a/src/plugins/vis_types/timeseries/public/application/components/lib/create_number_handler.ts b/src/plugins/vis_types/timeseries/public/application/components/lib/create_number_handler.ts index 8c46e6ac96f2a..f8ab371d060f1 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/lib/create_number_handler.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/lib/create_number_handler.ts @@ -12,6 +12,12 @@ import { TimeseriesVisParams } from '../../../types'; export const createNumberHandler = ( handleChange: (partialModel: Partial) => void ) => { - return (name: keyof Metric, defaultValue?: string) => (e: React.ChangeEvent) => - handleChange?.({ [name]: Number(e.target.value ?? defaultValue) }); + return (name: keyof Metric, params?: { defaultValue?: string; isClearable?: boolean }) => + (e: React.ChangeEvent) => + handleChange?.({ + [name]: + params?.isClearable && !e.target.value + ? undefined + : Number(e.target.value ?? params?.defaultValue), + }); }; diff --git a/src/plugins/vis_types/timeseries/public/metrics_type.ts b/src/plugins/vis_types/timeseries/public/metrics_type.ts index 4695748661299..ff613c0eadb06 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_type.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_type.ts @@ -26,6 +26,7 @@ import { } from '../../../visualizations/public'; import { getDataStart } from './services'; import type { TimeseriesVisDefaultParams, TimeseriesVisParams } from './types'; +import { triggerTSVBtoLensConfiguration } from './trigger_action'; import type { IndexPatternValue, Panel } from '../common/types'; import { RequestAdapter } from '../../../inspector/public'; @@ -167,6 +168,12 @@ export const metricsVisDefinition: VisTypeDefinition< } return []; }, + navigateToLens: async (params?: VisParams) => { + const triggerConfiguration = params + ? await triggerTSVBtoLensConfiguration(params as Panel) + : null; + return triggerConfiguration; + }, inspectorAdapters: () => ({ requests: new RequestAdapter(), }), diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts new file mode 100644 index 0000000000000..5a3c545d80aa0 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { DataView } from '../../../../data/common'; +import { getDataSourceInfo } from './get_datasource_info'; +const dataViewsMap: Record = { + test1: { id: 'test1', title: 'test1', timeFieldName: 'timeField1' } as DataView, + test2: { + id: 'test2', + title: 'test2', + timeFieldName: 'timeField2', + } as DataView, + test3: { id: 'test3', title: 'test3', timeFieldName: 'timeField3' } as DataView, +}; + +const getDataview = (id: string): DataView | undefined => dataViewsMap[id]; +jest.mock('../services', () => { + return { + getDataStart: jest.fn(() => { + return { + dataViews: { + getDefault: jest.fn(() => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, + }, + }; + }), + }; +}); + +describe('getDataSourceInfo', () => { + test('should return the default dataview if model_indexpattern is string', async () => { + const { indexPatternId, timeField } = await getDataSourceInfo( + 'test', + undefined, + false, + undefined + ); + expect(indexPatternId).toBe('12345'); + expect(timeField).toBe('@timestamp'); + }); + + test('should return the correct dataview if model_indexpattern is object', async () => { + const { indexPatternId, timeField } = await getDataSourceInfo( + { id: 'dataview-1-id' }, + 'timeField-1', + false, + undefined + ); + expect(indexPatternId).toBe('dataview-1-id'); + expect(timeField).toBe('timeField-1'); + }); + + test('should fetch the correct data if overwritten dataview is provided', async () => { + const { indexPatternId, timeField } = await getDataSourceInfo( + { id: 'dataview-1-id' }, + 'timeField-1', + true, + { id: 'test2' } + ); + expect(indexPatternId).toBe('test2'); + expect(timeField).toBe('timeField2'); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.ts new file mode 100644 index 0000000000000..0b4d6e6eacd3a --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_datasource_info.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 { fetchIndexPattern, isStringTypeIndexPattern } from '../../common/index_patterns_utils'; +import type { IndexPatternValue } from '../../common/types'; +import { getDataStart } from '../services'; + +export const getDataSourceInfo = async ( + modelIndexPattern: IndexPatternValue, + modelTimeField: string | undefined, + isOverwritten: boolean, + overwrittenIndexPattern: IndexPatternValue | undefined +) => { + const { dataViews } = getDataStart(); + let indexPatternId = + modelIndexPattern && !isStringTypeIndexPattern(modelIndexPattern) ? modelIndexPattern.id : ''; + + let timeField = modelTimeField; + // handle override index pattern + if (isOverwritten) { + const { indexPattern } = await fetchIndexPattern(overwrittenIndexPattern, dataViews); + if (indexPattern) { + indexPatternId = indexPattern.id ?? ''; + timeField = indexPattern.timeFieldName; + } + } + + if (!indexPatternId) { + const defaultIndex = await dataViews.getDefault(); + indexPatternId = defaultIndex?.id ?? ''; + timeField = defaultIndex?.timeFieldName; + } + if (!timeField) { + const indexPattern = await dataViews.get(indexPatternId); + timeField = indexPattern.timeFieldName; + } + + return { + indexPatternId, + timeField, + }; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.test.ts new file mode 100644 index 0000000000000..67ee8a1fb290c --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { Panel } from '../../common/types'; +import { getYExtents } from './get_extents'; + +const model = { + axis_position: 'left', + series: [ + { + axis_position: 'right', + chart_type: 'line', + fill: '0', + id: '85147356-c185-4636-9182-d55f3ab2b6fa', + line_width: 1, + metrics: [ + { + id: '3fa8b32f-5c38-4813-9361-1f2817ae5b18', + type: 'count', + }, + ], + override_index_pattern: 0, + separate_axis: 0, + }, + ], +} as Panel; + +describe('getYExtents', () => { + test('should return no extents if no extents are given from the user', () => { + const { yLeftExtent } = getYExtents(model); + expect(yLeftExtent).toStrictEqual({ mode: 'full' }); + }); + + test('should return the global extents, if no specific extents are given per series', () => { + const modelOnlyGlobalSettings = { + ...model, + axis_max: '10', + axis_min: '2', + }; + const { yLeftExtent } = getYExtents(modelOnlyGlobalSettings); + expect(yLeftExtent).toStrictEqual({ mode: 'custom', lowerBound: 2, upperBound: 10 }); + }); + + test('should return the series extents, if specific extents are given per series', () => { + const modelWithExtentsOnSeries = { + ...model, + axis_max: '10', + axis_min: '2', + series: [ + { + ...model.series[0], + axis_max: '14', + axis_min: '1', + separate_axis: 1, + axis_position: 'left', + }, + ], + }; + const { yLeftExtent } = getYExtents(modelWithExtentsOnSeries); + expect(yLeftExtent).toStrictEqual({ mode: 'custom', lowerBound: 1, upperBound: 14 }); + }); + + test('should not send the lowerbound for a bar chart', () => { + const modelWithExtentsOnSeries = { + ...model, + axis_max: '10', + axis_min: '2', + series: [ + { + ...model.series[0], + axis_max: '14', + axis_min: '1', + separate_axis: 1, + axis_position: 'left', + chart_type: 'bar', + }, + ], + }; + const { yLeftExtent } = getYExtents(modelWithExtentsOnSeries); + expect(yLeftExtent).toStrictEqual({ mode: 'custom', upperBound: 14 }); + }); + + test('should merge the extents for 2 series on the same axis', () => { + const modelWithExtentsOnSeries = { + ...model, + axis_max: '10', + axis_min: '2', + series: [ + { + ...model.series[0], + axis_max: '14', + axis_min: '1', + separate_axis: 1, + axis_position: 'left', + }, + { + ...model.series[0], + axis_max: '20', + axis_min: '5', + separate_axis: 1, + axis_position: 'left', + }, + ], + }; + const { yLeftExtent } = getYExtents(modelWithExtentsOnSeries); + expect(yLeftExtent).toStrictEqual({ mode: 'custom', lowerBound: 1, upperBound: 20 }); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.ts new file mode 100644 index 0000000000000..857de8390a6a3 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_extents.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Panel, Series } from '../../common/types'; + +const lowerBoundShouldBeZero = ( + lowerBound: number | null, + upperBound: number | null, + hasBarAreaChart: boolean +) => { + return (hasBarAreaChart && lowerBound && lowerBound > 0) || (upperBound && upperBound < 0); +}; + +const computeBounds = (series: Series, lowerBound: number | null, upperBound: number | null) => { + if (!lowerBound) { + lowerBound = Number(series.axis_min); + } else if (Number(series.axis_min) < lowerBound) { + lowerBound = Number(series.axis_min); + } + + if (!upperBound) { + upperBound = Number(series.axis_max); + } else if (Number(series.axis_max) > upperBound) { + upperBound = Number(series.axis_max); + } + + return { lowerBound, upperBound }; +}; + +const getLowerValue = ( + minValue: number | null, + maxValue: number | null, + hasBarOrAreaRight: boolean +) => { + return lowerBoundShouldBeZero(minValue, maxValue, hasBarOrAreaRight) ? 0 : minValue; +}; + +/* + * In TSVB the user can have different axis with different bounds. + * In Lens, we only allow 2 axis, one left and one right. We need an assumption here. + * We will transfer in Lens the "collapsed" axes with both bounds. + */ +export const getYExtents = (model: Panel) => { + let lowerBoundLeft: number | null = null; + let upperBoundLeft: number | null = null; + let lowerBoundRight: number | null = null; + let upperBoundRight: number | null = null; + let ignoreGlobalSettingsLeft = false; + let ignoreGlobalSettingsRight = false; + let hasBarOrAreaLeft = false; + let hasBarOrAreaRight = false; + + model.series.forEach((s) => { + if (s.axis_position === 'left') { + if (s.chart_type !== 'line' || (s.chart_type === 'line' && s.fill !== '0')) { + hasBarOrAreaLeft = true; + } + if (s.separate_axis) { + ignoreGlobalSettingsLeft = true; + const { lowerBound, upperBound } = computeBounds(s, lowerBoundLeft, upperBoundLeft); + lowerBoundLeft = lowerBound; + upperBoundLeft = upperBound; + } + } + if (s.axis_position === 'right' && s.separate_axis) { + if (s.chart_type !== 'line' || (s.chart_type === 'line' && s.fill !== '0')) { + hasBarOrAreaRight = true; + } + if (s.separate_axis) { + ignoreGlobalSettingsRight = true; + const { lowerBound, upperBound } = computeBounds(s, lowerBoundRight, upperBoundRight); + lowerBoundRight = lowerBound; + upperBoundRight = upperBound; + } + } + }); + + const finalLowerBoundLeft = ignoreGlobalSettingsLeft + ? getLowerValue(lowerBoundLeft, upperBoundLeft, hasBarOrAreaLeft) + : model.axis_position === 'left' + ? getLowerValue(Number(model.axis_min), Number(model.axis_max), hasBarOrAreaLeft) + : null; + + const finalUpperBoundLeft = ignoreGlobalSettingsLeft + ? upperBoundLeft + : model.axis_position === 'left' + ? model.axis_max + : null; + + const finalLowerBoundRight = ignoreGlobalSettingsRight + ? getLowerValue(lowerBoundRight, upperBoundRight, hasBarOrAreaRight) + : model.axis_position === 'right' + ? model.axis_min + : null; + const finalUpperBoundRight = ignoreGlobalSettingsRight + ? upperBoundRight + : model.axis_position === 'right' + ? getLowerValue(Number(model.axis_min), Number(model.axis_max), hasBarOrAreaRight) + : null; + return { + yLeftExtent: { + ...(finalLowerBoundLeft && { + lowerBound: Number(finalLowerBoundLeft), + }), + ...(finalUpperBoundLeft && { upperBound: Number(finalUpperBoundLeft) }), + mode: finalLowerBoundLeft || finalUpperBoundLeft ? 'custom' : 'full', + }, + yRightExtent: { + ...(finalLowerBoundRight && { + lowerBound: Number(finalUpperBoundRight), + }), + ...(finalUpperBoundRight && { upperBound: Number(finalUpperBoundRight) }), + mode: finalLowerBoundRight || finalUpperBoundRight ? 'custom' : 'full', + }, + }; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.ts new file mode 100644 index 0000000000000..c71955942c91c --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_field_type.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 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 { getDataStart } from '../services'; + +export const getFieldType = async (indexPatternId: string, fieldName: string) => { + const { dataViews } = getDataStart(); + const dataView = await dataViews.get(indexPatternId); + const field = await dataView.getFieldByName(fieldName); + return field?.type; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts new file mode 100644 index 0000000000000..7410c95677cff --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.test.ts @@ -0,0 +1,369 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { Metric } from '../../common/types'; +import { getSeries } from './get_series'; + +describe('getSeries', () => { + test('should return the correct config for an average aggregation', () => { + const metric = [ + { + id: '12345', + type: 'avg', + field: 'day_of_week_i', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'average', + fieldName: 'day_of_week_i', + isFullReference: false, + params: {}, + }, + ]); + }); + + test('should return the correct formula config for a filter ratio aggregation', () => { + const metric = [ + { + id: '12345', + type: 'filter_ratio', + field: 'day_of_week_i', + numerator: { + query: 'category.keyword : "Men\'s Clothing" ', + language: 'kuery', + }, + denominator: { + query: 'customer_gender : "FEMALE" ', + language: 'kuery', + }, + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: + "count(kql='category.keyword : \"Men\\'s Clothing\" ') / count(kql='customer_gender : \"FEMALE\" ')", + }, + }, + ]); + }); + + test('should return the correct formula config for an overall function', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + id: '891011', + type: 'max_bucket', + field: '123456', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: 'overall_max(max(day_of_week_i))', + }, + }, + ]); + }); + + test('should return the correct config for the cumulative sum on count', () => { + const metric = [ + { + id: '123456', + type: 'count', + }, + { + id: '7891011', + type: 'cumulative_sum', + field: '123456', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'cumulative_sum', + fieldName: 'document', + isFullReference: true, + params: {}, + pipelineAggType: 'count', + }, + ]); + }); + + test('should return the correct formula config for the cumulative sum on max', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + id: '7891011', + type: 'cumulative_sum', + field: '123456', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: 'cumulative_sum(max(day_of_week_i))', + }, + }, + ]); + }); + + test('should return the correct config for the derivative aggregation', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + field: '123456', + id: '7891011', + type: 'derivative', + unit: '1m', + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'differences', + fieldName: 'day_of_week_i', + isFullReference: true, + params: { + timeScale: 'm', + }, + pipelineAggType: 'max', + }, + ]); + }); + + test('should return the correct config for the moving average aggregation', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + field: '123456', + id: '7891011', + type: 'moving_average', + window: 6, + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'moving_average', + fieldName: 'day_of_week_i', + isFullReference: true, + params: { window: 6 }, + pipelineAggType: 'max', + }, + ]); + }); + + test('should return the correct formula for the math aggregation', () => { + const metric = [ + { + field: 'day_of_week_i', + id: '123456', + type: 'max', + }, + { + field: 'day_of_week_i', + id: '7891011', + type: 'min', + }, + { + field: '123456', + id: 'fab31880-7d11-11ec-a13a-b52b40401df4', + script: 'params.max - params.min', + type: 'math', + variables: [ + { + field: '123456', + id: 'c47c7a00-7d15-11ec-a13a-b52b40401df4', + name: 'max', + }, + { + field: '7891011', + id: 'c7a38390-7d15-11ec-a13a-b52b40401df4', + name: 'min', + }, + ], + window: 6, + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: 'max(day_of_week_i) - min(day_of_week_i)', + }, + }, + ]); + }); + + test('should return the correct config for the percentiles aggregation', () => { + const metric = [ + { + field: 'day_of_week_i', + id: 'id1', + type: 'percentile', + percentiles: [ + { + value: '90', + percentile: '', + shade: 0.2, + color: 'rgba(211,96,134,1)', + id: 'id2', + mode: 'line', + }, + { + value: '85', + percentile: '', + shade: 0.2, + color: 'rgba(155,33,230,1)', + id: 'id3', + mode: 'line', + }, + { + value: '70', + percentile: '', + shade: 0.2, + color: '#68BC00', + id: 'id4', + mode: 'line', + }, + ], + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'percentile', + color: 'rgba(211,96,134,1)', + fieldName: 'day_of_week_i', + isFullReference: false, + params: { + percentile: '90', + }, + }, + { + agg: 'percentile', + color: 'rgba(155,33,230,1)', + fieldName: 'day_of_week_i', + isFullReference: false, + params: { + percentile: '85', + }, + }, + { + agg: 'percentile', + color: '#68BC00', + fieldName: 'day_of_week_i', + isFullReference: false, + params: { + percentile: '70', + }, + }, + ]); + }); + + test('should return the correct formula for the math aggregation with percentiles as variables', () => { + const metric = [ + { + field: 'day_of_week_i', + id: 'e72265d2-2106-4af9-b646-33afd9cddcad', + percentiles: [ + { + color: 'rgba(211,96,134,1)', + id: '381a6850-7d16-11ec-a13a-b52b40401df4', + mode: 'line', + percentile: '', + shade: 0.2, + value: '90', + }, + { + color: 'rgba(0,107,188,1)', + id: '52f02970-7d1c-11ec-bfa7-3798d98f8341', + mode: 'line', + percentile: '', + shade: 0.2, + value: '50', + }, + ], + type: 'percentile', + unit: '', + }, + { + field: 'day_of_week_i', + id: '6280b080-7d1c-11ec-bfa7-3798d98f8341', + type: 'avg', + }, + { + id: '23a05540-7d18-11ec-a589-45a3784fc1ce', + script: 'params.perc90 + params.perc70 + params.avg', + type: 'math', + variables: [ + { + field: 'e72265d2-2106-4af9-b646-33afd9cddcad[90.0]', + id: '25840960-7d18-11ec-a589-45a3784fc1ce', + name: 'perc90', + }, + { + field: 'e72265d2-2106-4af9-b646-33afd9cddcad[50.0]', + id: '2a440270-7d18-11ec-a589-45a3784fc1ce', + name: 'perc70', + }, + { + field: '6280b080-7d1c-11ec-bfa7-3798d98f8341', + id: '64c82f80-7d1c-11ec-bfa7-3798d98f8341', + name: 'avg', + }, + ], + }, + ] as Metric[]; + const config = getSeries(metric); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { + formula: + 'percentile(day_of_week_i, percentile=90) + percentile(day_of_week_i, percentile=50) + average(day_of_week_i)', + }, + }, + ]); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts new file mode 100644 index 0000000000000..eed1594300b92 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/get_series.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { VisualizeEditorLayersContext } from '../../../../visualizations/public'; +import type { Metric } from '../../common/types'; +import { SUPPORTED_METRICS } from './supported_metrics'; +import { + getPercentilesSeries, + getFormulaSeries, + getParentPipelineSeries, + getSiblingPipelineSeriesFormula, + getPipelineAgg, + computeParentSeries, + getFormulaEquivalent, + getParentPipelineSeriesFormula, + getFilterRatioFormula, + getTimeScale, +} from './metrics_helpers'; + +export const getSeries = (metrics: Metric[]): VisualizeEditorLayersContext['metrics'] | null => { + const metricIdx = metrics.length - 1; + const aggregation = metrics[metricIdx].type; + const fieldName = metrics[metricIdx].field; + const aggregationMap = SUPPORTED_METRICS[aggregation]; + if (!aggregationMap) { + return null; + } + let metricsArray: VisualizeEditorLayersContext['metrics'] = []; + switch (aggregation) { + case 'percentile': { + const percentiles = metrics[metricIdx].percentiles; + if (percentiles?.length) { + const percentilesSeries = getPercentilesSeries( + percentiles, + fieldName + ) as VisualizeEditorLayersContext['metrics']; + metricsArray = [...metricsArray, ...percentilesSeries]; + } + break; + } + case 'math': { + // find the metric idx that has math expression + const mathMetricIdx = metrics.findIndex((metric) => metric.type === 'math'); + let finalScript = metrics[mathMetricIdx].script; + + const variables = metrics[mathMetricIdx].variables; + const layerMetricsArray = metrics; + if (!finalScript || !variables) return null; + + // create the script + for (let layerMetricIdx = 0; layerMetricIdx < layerMetricsArray.length; layerMetricIdx++) { + if (layerMetricsArray[layerMetricIdx].type === 'math') { + continue; + } + const currentMetric = metrics[layerMetricIdx]; + + // should treat percentiles differently + if (currentMetric.type === 'percentile') { + variables.forEach((variable) => { + const [_, meta] = variable?.field?.split('[') ?? []; + const metaValue = Number(meta?.replace(']', '')); + if (!metaValue) return; + const script = getFormulaEquivalent(currentMetric, layerMetricsArray, metaValue); + if (!script) return; + finalScript = finalScript?.replace(`params.${variable.name}`, script); + }); + } else { + const script = getFormulaEquivalent(currentMetric, layerMetricsArray); + if (!script) return null; + const variable = variables.find((v) => v.field === currentMetric.id); + finalScript = finalScript?.replaceAll(`params.${variable?.name}`, script); + } + } + const scripthasNoStaticNumber = isNaN(Number(finalScript)); + if (finalScript.includes('params') || !scripthasNoStaticNumber) return null; + metricsArray = getFormulaSeries(finalScript); + break; + } + case 'moving_average': + case 'derivative': { + metricsArray = getParentPipelineSeries( + aggregation, + metricIdx, + metrics + ) as VisualizeEditorLayersContext['metrics']; + break; + } + case 'cumulative_sum': { + // percentile value is derived from the field Id. It has the format xxx-xxx-xxx-xxx[percentile] + const [fieldId, meta] = metrics[metricIdx]?.field?.split('[') ?? []; + const subFunctionMetric = metrics.find((metric) => metric.id === fieldId); + if (!subFunctionMetric) { + return null; + } + const pipelineAgg = getPipelineAgg(subFunctionMetric); + if (!pipelineAgg) { + return null; + } + // lens supports cumulative sum for count and sum as quick function + // and everything else as formula + if (pipelineAgg !== 'count' && pipelineAgg !== 'sum') { + const metaValue = Number(meta?.replace(']', '')); + const formula = getParentPipelineSeriesFormula( + metrics, + subFunctionMetric, + pipelineAgg, + aggregation, + metaValue + ); + if (!formula) return null; + metricsArray = getFormulaSeries(formula); + } else { + const series = computeParentSeries( + aggregation, + metrics[metricIdx], + subFunctionMetric, + pipelineAgg + ); + if (!series) return null; + metricsArray = series; + } + break; + } + case 'avg_bucket': + case 'max_bucket': + case 'min_bucket': + case 'sum_bucket': { + const formula = getSiblingPipelineSeriesFormula(aggregation, metrics[metricIdx], metrics); + if (!formula) { + return null; + } + metricsArray = getFormulaSeries(formula) as VisualizeEditorLayersContext['metrics']; + break; + } + case 'filter_ratio': { + const formula = getFilterRatioFormula(metrics[metricIdx]); + if (!formula) { + return null; + } + metricsArray = getFormulaSeries(formula); + break; + } + default: { + const timeScale = getTimeScale(metrics[metricIdx]); + metricsArray = [ + { + agg: aggregationMap.name, + isFullReference: aggregationMap.isFullReference, + fieldName: aggregation !== 'count' && fieldName ? fieldName : 'document', + params: { + ...(timeScale && { timeScale }), + }, + }, + ]; + } + } + return metricsArray; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts new file mode 100644 index 0000000000000..2fad7f1d3d70f --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/index.test.ts @@ -0,0 +1,306 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { DataView } from '../../../../data/common'; +import type { Panel, Series } from '../../common/types'; +import { triggerTSVBtoLensConfiguration } from './'; + +const dataViewsMap: Record = { + test1: { id: 'test1', title: 'test1', timeFieldName: 'timeField1' } as DataView, + test2: { + id: 'test2', + title: 'test2', + timeFieldName: 'timeField2', + } as DataView, + test3: { id: 'test3', title: 'test3', timeFieldName: 'timeField3' } as DataView, +}; + +const getDataview = (id: string): DataView | undefined => dataViewsMap[id]; +jest.mock('../services', () => { + return { + getDataStart: jest.fn(() => { + return { + dataViews: { + getDefault: jest.fn(() => { + return { id: '12345', title: 'default', timeFieldName: '@timestamp' }; + }), + get: getDataview, + }, + }; + }), + }; +}); + +const model = { + axis_position: 'left', + type: 'timeseries', + index_pattern: { id: 'test2' }, + use_kibana_indexes: true, + series: [ + { + color: '#000000', + chart_type: 'line', + fill: '0', + id: '85147356-c185-4636-9182-d55f3ab2b6fa', + palette: { + name: 'default', + type: 'palette', + }, + split_mode: 'everything', + metrics: [ + { + id: '3fa8b32f-5c38-4813-9361-1f2817ae5b18', + type: 'count', + }, + ], + override_index_pattern: 0, + }, + ], +} as Panel; + +describe('triggerTSVBtoLensConfiguration', () => { + test('should return null for a non timeseries chart', async () => { + const metricModel = { + ...model, + type: 'metric', + } as Panel; + const triggerOptions = await triggerTSVBtoLensConfiguration(metricModel); + expect(triggerOptions).toBeNull(); + }); + + test('should return null for a string index pattern', async () => { + const stringIndexPatternModel = { + ...model, + use_kibana_indexes: false, + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(stringIndexPatternModel); + expect(triggerOptions).toBeNull(); + }); + + test('should return null for a non supported aggregation', async () => { + const nonSupportedAggModel = { + ...model, + series: [ + { + ...model.series[0], + metrics: [ + { + type: 'percentile_rank', + }, + ] as Series['metrics'], + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(nonSupportedAggModel); + expect(triggerOptions).toBeNull(); + }); + + test('should return options for a supported aggregation', async () => { + const triggerOptions = await triggerTSVBtoLensConfiguration(model); + expect(triggerOptions).toStrictEqual({ + configuration: { + extents: { yLeftExtent: { mode: 'full' }, yRightExtent: { mode: 'full' } }, + fill: '0', + gridLinesVisibility: { x: false, yLeft: false, yRight: false }, + legend: { + isVisible: false, + maxLines: 1, + position: 'right', + shouldTruncate: false, + showSingleSeries: false, + }, + }, + type: 'lnsXY', + layers: { + '0': { + axisPosition: 'left', + chartType: 'line', + indexPatternId: 'test2', + metrics: [ + { + agg: 'count', + color: '#000000', + fieldName: 'document', + isFullReference: false, + params: {}, + }, + ], + palette: { + name: 'default', + type: 'palette', + }, + splitWithDateHistogram: false, + timeFieldName: 'timeField2', + timeInterval: 'auto', + }, + }, + }); + }); + + test('should return area for timeseries line chart with fill > 0', async () => { + const modelWithFill = { + ...model, + series: [ + { + ...model.series[0], + fill: '0.3', + stacked: 'none', + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithFill); + expect(triggerOptions?.layers[0].chartType).toBe('area'); + }); + + test('should return timeShift in the params if it is provided', async () => { + const modelWithFill = { + ...model, + series: [ + { + ...model.series[0], + offset_time: '1h', + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithFill); + expect(triggerOptions?.layers[0]?.metrics?.[0]?.params?.shift).toBe('1h'); + }); + + test('should return filter in the params if it is provided', async () => { + const modelWithFill = { + ...model, + series: [ + { + ...model.series[0], + filter: { + language: 'kuery', + query: 'test', + }, + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithFill); + expect(triggerOptions?.layers[0]?.metrics?.[0]?.params?.kql).toBe('test'); + }); + + test('should return splitFilters information if the chart is broken down by filters', async () => { + const modelWithSplitFilters = { + ...model, + series: [ + { + ...model.series[0], + split_mode: 'filters', + split_filters: [ + { + color: 'rgba(188,0,85,1)', + filter: { + language: 'kuery', + query: '', + }, + id: '89afac60-7d2b-11ec-917c-c18cd38d60b5', + }, + ], + }, + ], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithSplitFilters); + expect(triggerOptions?.layers[0]?.splitFilters).toStrictEqual([ + { + color: 'rgba(188,0,85,1)', + filter: { + language: 'kuery', + query: '', + }, + id: '89afac60-7d2b-11ec-917c-c18cd38d60b5', + }, + ]); + }); + + test('should return termsParams information if the chart is broken down by terms', async () => { + const modelWithTerms = { + ...model, + series: [ + { + ...model.series[0], + split_mode: 'terms', + terms_size: 6, + terms_direction: 'desc', + terms_order_by: '_key', + }, + ] as unknown as Series[], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithTerms); + expect(triggerOptions?.layers[0]?.termsParams).toStrictEqual({ + size: 6, + otherBucket: false, + orderDirection: 'desc', + orderBy: { type: 'alphabetical' }, + parentFormat: { + id: 'terms', + }, + }); + }); + + test('should return custom time interval if it is given', async () => { + const modelWithTerms = { + ...model, + interval: '1h', + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithTerms); + expect(triggerOptions?.layers[0]?.timeInterval).toBe('1h'); + }); + + test('should return the correct chart configuration', async () => { + const modelWithConfig = { + ...model, + show_legend: 1, + legend_position: 'bottom', + truncate_legend: 0, + show_grid: 1, + series: [{ ...model.series[0], fill: '0.3', separate_axis: 1, axis_position: 'right' }], + }; + const triggerOptions = await triggerTSVBtoLensConfiguration(modelWithConfig); + expect(triggerOptions).toStrictEqual({ + configuration: { + extents: { yLeftExtent: { mode: 'full' }, yRightExtent: { mode: 'full' } }, + fill: '0.3', + gridLinesVisibility: { x: true, yLeft: true, yRight: true }, + legend: { + isVisible: true, + maxLines: 1, + position: 'bottom', + shouldTruncate: false, + showSingleSeries: true, + }, + }, + type: 'lnsXY', + layers: { + '0': { + axisPosition: 'right', + chartType: 'area_stacked', + indexPatternId: 'test2', + metrics: [ + { + agg: 'count', + color: '#000000', + fieldName: 'document', + isFullReference: false, + params: {}, + }, + ], + palette: { + name: 'default', + type: 'palette', + }, + splitWithDateHistogram: false, + timeFieldName: 'timeField2', + timeInterval: 'auto', + }, + }, + }); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/index.ts b/src/plugins/vis_types/timeseries/public/trigger_action/index.ts new file mode 100644 index 0000000000000..d3329bee803a1 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/index.ts @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { PaletteOutput } from '../../../../charts/public'; +import type { + NavigateToLensContext, + VisualizeEditorLayersContext, +} from '../../../../visualizations/public'; +import type { Panel } from '../../common/types'; +import { PANEL_TYPES } from '../../common/enums'; +import { getDataSourceInfo } from './get_datasource_info'; +import { getFieldType } from './get_field_type'; +import { getSeries } from './get_series'; +import { getYExtents } from './get_extents'; + +const SUPPORTED_FORMATTERS = ['bytes', 'percent', 'number']; + +/* + * This function is used to convert the TSVB model to compatible Lens model. + * Returns the Lens model, only if it is supported. If not, it returns null. + * In case of null, the menu item is disabled and the user can't navigate to Lens. + */ +export const triggerTSVBtoLensConfiguration = async ( + model: Panel +): Promise => { + // Disables the option for not timeseries charts, for the string mode and for series with annotations + if ( + model.type !== PANEL_TYPES.TIMESERIES || + !model.use_kibana_indexes || + (model.annotations && model.annotations.length > 0) + ) { + return null; + } + const layersConfiguration: { [key: string]: VisualizeEditorLayersContext } = {}; + + // handle multiple layers/series + for (let layerIdx = 0; layerIdx < model.series.length; layerIdx++) { + const layer = model.series[layerIdx]; + if (layer.hidden) continue; + + const { indexPatternId, timeField } = await getDataSourceInfo( + model.index_pattern, + model.time_field, + Boolean(layer.override_index_pattern), + layer.series_index_pattern + ); + + const timeShift = layer.offset_time; + // translate to Lens seriesType + const layerChartType = + layer.chart_type === 'line' && layer.fill !== '0' ? 'area' : layer.chart_type; + let chartType = layerChartType; + + if (layer.stacked !== 'none' && layer.stacked !== 'percent') { + chartType = layerChartType !== 'line' ? `${layerChartType}_stacked` : 'line'; + } + if (layer.stacked === 'percent') { + chartType = layerChartType !== 'line' ? `${layerChartType}_percentage_stacked` : 'line'; + } + + // handle multiple metrics + let metricsArray = getSeries(layer.metrics); + if (!metricsArray) { + return null; + } + let filter: { + kql?: string | { [key: string]: any } | undefined; + lucene?: string | { [key: string]: any } | undefined; + }; + if (layer.filter) { + if (layer.filter.language === 'kuery') { + filter = { kql: layer.filter.query }; + } else if (layer.filter.language === 'lucene') { + filter = { lucene: layer.filter.query }; + } + } + + metricsArray = metricsArray.map((metric) => { + return { + ...metric, + color: metric.color ?? layer.color, + params: { + ...metric.params, + ...(timeShift && { shift: timeShift }), + ...(filter && filter), + }, + }; + }); + const splitFilters: VisualizeEditorLayersContext['splitFilters'] = []; + if (layer.split_mode === 'filter' && layer.filter) { + splitFilters.push({ filter: layer.filter }); + } + if (layer.split_filters) { + splitFilters.push(...layer.split_filters); + } + + const palette = layer.palette as PaletteOutput; + + // in case of terms in a date field, we want to apply the date_histogram + let splitWithDateHistogram = false; + if (layer.terms_field && layer.split_mode === 'terms') { + const fieldType = await getFieldType(indexPatternId, layer.terms_field); + if (fieldType === 'date') { + splitWithDateHistogram = true; + } + } + + const layerConfiguration: VisualizeEditorLayersContext = { + indexPatternId, + timeFieldName: timeField, + chartType, + axisPosition: layer.separate_axis ? layer.axis_position : model.axis_position, + ...(layer.terms_field && { splitField: layer.terms_field }), + splitWithDateHistogram, + ...(layer.split_mode !== 'everything' && { splitMode: layer.split_mode }), + ...(splitFilters.length > 0 && { splitFilters }), + // for non supported palettes, we will use the default palette + palette: + !palette || palette.name === 'gradient' || palette.name === 'rainbow' + ? { name: 'default', type: 'palette' } + : palette, + ...(layer.split_mode === 'terms' && { + termsParams: { + size: layer.terms_size ?? 10, + otherBucket: false, + orderDirection: layer.terms_direction ?? 'desc', + orderBy: layer.terms_order_by === '_key' ? { type: 'alphabetical' } : { type: 'column' }, + parentFormat: { id: 'terms' }, + }, + }), + metrics: [...metricsArray], + timeInterval: model.interval && !model.interval?.includes('=') ? model.interval : 'auto', + ...(SUPPORTED_FORMATTERS.includes(layer.formatter) && { format: layer.formatter }), + ...(layer.label && { label: layer.label }), + }; + layersConfiguration[layerIdx] = layerConfiguration; + } + + const extents = getYExtents(model); + + return { + layers: layersConfiguration, + type: 'lnsXY', + configuration: { + fill: model.series[0].fill ?? 0.3, + legend: { + isVisible: Boolean(model.show_legend), + showSingleSeries: Boolean(model.show_legend), + position: model.legend_position ?? 'right', + shouldTruncate: Boolean(model.truncate_legend), + maxLines: model.max_lines_legend ?? 1, + }, + gridLinesVisibility: { + x: Boolean(model.show_grid), + yLeft: Boolean(model.show_grid), + yRight: Boolean(model.show_grid), + }, + extents, + }, + }; +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.test.ts b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.test.ts new file mode 100644 index 0000000000000..8b1a5f5e68dec --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.test.ts @@ -0,0 +1,193 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { METRIC_TYPES } from 'src/plugins/data/public'; +import type { Metric, MetricType } from '../../common/types'; +import { getPercentilesSeries, getParentPipelineSeries } from './metrics_helpers'; + +describe('getPercentilesSeries', () => { + test('should return correct config for multiple percentiles', () => { + const percentiles = [ + { + color: '#68BC00', + id: 'aef159f0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + shade: 0.2, + value: 50, + }, + { + color: 'rgba(0,63,188,1)', + id: 'b0e0a6d0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + percentile: '', + shade: 0.2, + value: '70', + }, + { + color: 'rgba(188,38,0,1)', + id: 'b2e04760-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + percentile: '', + shade: 0.2, + value: '80', + }, + { + color: 'rgba(188,0,3,1)', + id: 'b503eab0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + percentile: '', + shade: 0.2, + value: '90', + }, + ] as Metric['percentiles']; + const config = getPercentilesSeries(percentiles, 'bytes'); + expect(config).toStrictEqual([ + { + agg: 'percentile', + color: '#68BC00', + fieldName: 'bytes', + isFullReference: false, + params: { percentile: 50 }, + }, + { + agg: 'percentile', + color: 'rgba(0,63,188,1)', + fieldName: 'bytes', + isFullReference: false, + params: { percentile: '70' }, + }, + { + agg: 'percentile', + color: 'rgba(188,38,0,1)', + fieldName: 'bytes', + isFullReference: false, + params: { percentile: '80' }, + }, + { + agg: 'percentile', + color: 'rgba(188,0,3,1)', + fieldName: 'bytes', + isFullReference: false, + params: { percentile: '90' }, + }, + ]); + }); +}); + +describe('getParentPipelineSeries', () => { + test('should return correct config for pipeline agg on percentiles', () => { + const metrics = [ + { + field: 'AvgTicketPrice', + id: '04558549-f19f-4a87-9923-27df8b81af3e', + percentiles: [ + { + color: '#68BC00', + id: 'aef159f0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + shade: 0.2, + value: 50, + }, + { + color: 'rgba(0,63,188,1)', + id: 'b0e0a6d0-7db8-11ec-9d0c-e57521cec076', + mode: 'line', + percentile: '', + shade: 0.2, + value: '70', + }, + ], + type: 'percentile', + }, + { + field: '04558549-f19f-4a87-9923-27df8b81af3e[70.0]', + id: '764f4110-7db9-11ec-9fdf-91a8881dd06b', + type: 'derivative', + unit: '', + }, + ] as Metric[]; + const config = getParentPipelineSeries(METRIC_TYPES.DERIVATIVE, 1, metrics); + expect(config).toStrictEqual([ + { + agg: 'differences', + fieldName: 'AvgTicketPrice', + isFullReference: true, + params: { + percentile: 70, + }, + pipelineAggType: 'percentile', + }, + ]); + }); + + test('should return null config for pipeline agg on non-supported sub-aggregation', () => { + const metrics = [ + { + field: 'AvgTicketPrice', + id: '04558549-f19f-4a87-9923-27df8b81af3e', + type: 'std_deviation', + }, + { + field: '04558549-f19f-4a87-9923-27df8b81af3e', + id: '764f4110-7db9-11ec-9fdf-91a8881dd06b', + type: 'derivative', + unit: '', + }, + ] as Metric[]; + const config = getParentPipelineSeries(METRIC_TYPES.DERIVATIVE, 1, metrics); + expect(config).toBeNull(); + }); + + test('should return null config for pipeline agg when sub-agregation is not given', () => { + const metrics = [ + { + field: 'AvgTicketPrice', + id: '04558549-f19f-4a87-9923-27df8b81af3e', + type: 'avg', + }, + { + field: '123456', + id: '764f4110-7db9-11ec-9fdf-91a8881dd06b', + type: 'derivative', + unit: '', + }, + ] as Metric[]; + const config = getParentPipelineSeries(METRIC_TYPES.DERIVATIVE, 1, metrics); + expect(config).toBeNull(); + }); + + test('should return formula config for pipeline agg when applied on nested aggregations', () => { + const metrics = [ + { + field: 'AvgTicketPrice', + id: '04558549-f19f-4a87-9923-27df8b81af3e', + type: 'avg', + }, + { + field: '04558549-f19f-4a87-9923-27df8b81af3e', + id: '6e4932d0-7dbb-11ec-8d79-e163106679dc', + model_type: 'simple', + type: 'cumulative_sum', + }, + { + field: '6e4932d0-7dbb-11ec-8d79-e163106679dc', + id: 'a51de940-7dbb-11ec-8d79-e163106679dc', + type: 'moving_average', + window: 5, + }, + ] as Metric[]; + const config = getParentPipelineSeries('moving_average' as MetricType, 2, metrics); + expect(config).toStrictEqual([ + { + agg: 'formula', + fieldName: 'document', + isFullReference: true, + params: { formula: 'moving_average(cumulative_sum(average(AvgTicketPrice)))' }, + }, + ]); + }); +}); diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts new file mode 100644 index 0000000000000..07140c9fdd9d1 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/metrics_helpers.ts @@ -0,0 +1,306 @@ +/* + * 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 { Query } from '../../../../data/common'; +import type { Metric, MetricType } from '../../common/types'; +import { SUPPORTED_METRICS } from './supported_metrics'; + +export const getPercentilesSeries = (percentiles: Metric['percentiles'], fieldName?: string) => { + return percentiles?.map((percentile) => { + return { + agg: 'percentile', + isFullReference: false, + color: percentile.color, + fieldName: fieldName ?? 'document', + params: { percentile: percentile.value }, + }; + }); +}; + +export const getFormulaSeries = (script: string) => { + return [ + { + agg: 'formula', + isFullReference: true, + fieldName: 'document', + params: { formula: script }, + }, + ]; +}; + +export const getPipelineAgg = (subFunctionMetric: Metric) => { + const pipelineAggMap = SUPPORTED_METRICS[subFunctionMetric.type]; + if (!pipelineAggMap) { + return null; + } + return pipelineAggMap.name; +}; + +export const getTimeScale = (metric: Metric) => { + const supportedTimeScales = ['1s', '1m', '1h', '1d']; + let timeScale; + if (metric.unit && supportedTimeScales.includes(metric.unit)) { + timeScale = metric.unit.replace('1', ''); + } + return timeScale; +}; + +export const computeParentSeries = ( + aggregation: MetricType, + currentMetric: Metric, + subFunctionMetric: Metric, + pipelineAgg: string, + meta?: number +) => { + const aggregationMap = SUPPORTED_METRICS[aggregation]; + if (subFunctionMetric.type === 'filter_ratio') { + const script = getFilterRatioFormula(subFunctionMetric); + if (!script) { + return null; + } + const formula = `${aggregationMap.name}(${script})`; + return getFormulaSeries(formula); + } + const timeScale = getTimeScale(currentMetric); + return [ + { + agg: aggregationMap.name, + isFullReference: aggregationMap.isFullReference, + pipelineAggType: pipelineAgg, + fieldName: + subFunctionMetric?.field && pipelineAgg !== 'count' ? subFunctionMetric?.field : 'document', + params: { + ...(currentMetric.window && { window: currentMetric.window }), + ...(timeScale && { timeScale }), + ...(pipelineAgg === 'percentile' && meta && { percentile: meta }), + }, + }, + ]; +}; + +export const getParentPipelineSeries = ( + aggregation: MetricType, + currentMetricIdx: number, + metrics: Metric[] +) => { + const currentMetric = metrics[currentMetricIdx]; + // percentile value is derived from the field Id. It has the format xxx-xxx-xxx-xxx[percentile] + const [fieldId, meta] = currentMetric?.field?.split('[') ?? []; + const subFunctionMetric = metrics.find((metric) => metric.id === fieldId); + if (!subFunctionMetric) { + return null; + } + const pipelineAgg = getPipelineAgg(subFunctionMetric); + if (!pipelineAgg) { + return null; + } + const metaValue = Number(meta?.replace(']', '')); + const subMetricField = subFunctionMetric.field; + const [nestedFieldId, _] = subMetricField?.split('[') ?? []; + // support nested aggs with formula + const additionalSubFunction = metrics.find((metric) => metric.id === nestedFieldId); + if (additionalSubFunction) { + const formula = getParentPipelineSeriesFormula( + metrics, + subFunctionMetric, + pipelineAgg, + aggregation, + metaValue + ); + if (!formula) { + return null; + } + return getFormulaSeries(formula); + } else { + return computeParentSeries( + aggregation, + currentMetric, + subFunctionMetric, + pipelineAgg, + metaValue + ); + } +}; + +export const getParentPipelineSeriesFormula = ( + metrics: Metric[], + subFunctionMetric: Metric, + pipelineAgg: string, + aggregation: MetricType, + percentileValue?: number +) => { + let formula = ''; + const aggregationMap = SUPPORTED_METRICS[aggregation]; + const subMetricField = subFunctionMetric.field; + const [nestedFieldId, nestedMeta] = subMetricField?.split('[') ?? []; + // support nested aggs + const additionalSubFunction = metrics.find((metric) => metric.id === nestedFieldId); + if (additionalSubFunction) { + // support nested aggs with formula + const additionalPipelineAggMap = SUPPORTED_METRICS[additionalSubFunction.type]; + if (!additionalPipelineAggMap) { + return null; + } + const nestedMetaValue = Number(nestedMeta?.replace(']', '')); + const aggMap = SUPPORTED_METRICS[aggregation]; + let additionalFunctionArgs; + if (additionalPipelineAggMap.name === 'percentile' && nestedMetaValue) { + additionalFunctionArgs = `, percentile=${nestedMetaValue}`; + } + formula = `${aggMap.name}(${pipelineAgg}(${additionalPipelineAggMap.name}(${ + additionalSubFunction.field ?? '' + }${additionalFunctionArgs ?? ''})))`; + } else { + let additionalFunctionArgs; + if (pipelineAgg === 'percentile' && percentileValue) { + additionalFunctionArgs = `, percentile=${percentileValue}`; + } + if (pipelineAgg === 'filter_ratio') { + const script = getFilterRatioFormula(subFunctionMetric); + if (!script) { + return null; + } + formula = `${aggregationMap.name}(${script}${additionalFunctionArgs ?? ''})`; + } else if (pipelineAgg === 'counter_rate') { + formula = `${aggregationMap.name}(${pipelineAgg}(max(${subFunctionMetric.field}${ + additionalFunctionArgs ? `${additionalFunctionArgs}` : '' + })))`; + } else { + formula = `${aggregationMap.name}(${pipelineAgg}(${subFunctionMetric.field}${ + additionalFunctionArgs ? `${additionalFunctionArgs}` : '' + }))`; + } + } + return formula; +}; + +export const getSiblingPipelineSeriesFormula = ( + aggregation: MetricType, + currentMetric: Metric, + metrics: Metric[] +) => { + const subFunctionMetric = metrics.find((metric) => metric.id === currentMetric.field); + if (!subFunctionMetric) { + return null; + } + const pipelineAggMap = SUPPORTED_METRICS[subFunctionMetric.type]; + if (!pipelineAggMap) { + return null; + } + const aggregationMap = SUPPORTED_METRICS[aggregation]; + const subMetricField = subFunctionMetric.field; + // support nested aggs with formula + const additionalSubFunction = metrics.find((metric) => metric.id === subMetricField); + let formula = `${aggregationMap.name}(`; + if (additionalSubFunction) { + const additionalPipelineAggMap = SUPPORTED_METRICS[additionalSubFunction.type]; + if (!additionalPipelineAggMap) { + return null; + } + formula += `${pipelineAggMap.name}(${additionalPipelineAggMap.name}(${ + additionalSubFunction.field ?? '' + })))`; + } else { + formula += `${pipelineAggMap.name}(${subFunctionMetric.field ?? ''}))`; + } + return formula; +}; + +const escapeQuotes = (str: string) => { + return str?.replace(/'/g, "\\'"); +}; + +const constructFilterRationFormula = (operation: string, metric?: Query) => { + return `${operation}${metric?.language === 'lucene' ? 'lucene' : 'kql'}='${ + metric?.query && typeof metric?.query === 'string' + ? escapeQuotes(metric?.query) + : metric?.query ?? '*' + }')`; +}; + +export const getFilterRatioFormula = (currentMetric: Metric) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { numerator, denominator, metric_agg, field } = currentMetric; + let aggregation = SUPPORTED_METRICS.count; + if (metric_agg) { + aggregation = SUPPORTED_METRICS[metric_agg]; + if (!aggregation) { + return null; + } + } + const operation = + metric_agg && metric_agg !== 'count' ? `${aggregation.name}('${field}',` : 'count('; + + if (aggregation.name === 'counter_rate') { + const numeratorFormula = constructFilterRationFormula( + `${aggregation.name}(max('${field}',`, + numerator + ); + const denominatorFormula = constructFilterRationFormula( + `${aggregation.name}(max('${field}',`, + denominator + ); + return `${numeratorFormula}) / ${denominatorFormula})`; + } else { + const numeratorFormula = constructFilterRationFormula(operation, numerator); + const denominatorFormula = constructFilterRationFormula(operation, denominator); + return `${numeratorFormula} / ${denominatorFormula}`; + } +}; + +export const getFormulaEquivalent = ( + currentMetric: Metric, + metrics: Metric[], + metaValue?: number +) => { + const aggregation = SUPPORTED_METRICS[currentMetric.type]?.name; + switch (currentMetric.type) { + case 'avg_bucket': + case 'max_bucket': + case 'min_bucket': + case 'sum_bucket': { + return getSiblingPipelineSeriesFormula(currentMetric.type, currentMetric, metrics); + } + case 'count': { + return `${aggregation}()`; + } + case 'percentile': { + return `${aggregation}(${currentMetric.field}${ + metaValue ? `, percentile=${metaValue}` : '' + })`; + } + case 'cumulative_sum': + case 'derivative': + case 'moving_average': { + const [fieldId, _] = currentMetric?.field?.split('[') ?? []; + const subFunctionMetric = metrics.find((metric) => metric.id === fieldId); + if (!subFunctionMetric) { + return null; + } + const pipelineAgg = getPipelineAgg(subFunctionMetric); + if (!pipelineAgg) { + return null; + } + return getParentPipelineSeriesFormula( + metrics, + subFunctionMetric, + pipelineAgg, + currentMetric.type, + metaValue + ); + } + case 'positive_rate': { + return `${aggregation}(max(${currentMetric.field}))`; + } + case 'filter_ratio': { + return getFilterRatioFormula(currentMetric); + } + default: { + return `${aggregation}(${currentMetric.field})`; + } + } +}; diff --git a/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts b/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.ts new file mode 100644 index 0000000000000..b3d58d81105ab --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/trigger_action/supported_metrics.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. + */ + +interface AggOptions { + name: string; + isFullReference: boolean; +} + +// list of supported TSVB aggregation types in Lens +// some of them are supported on the quick functions tab and some of them +// are supported with formulas + +export const SUPPORTED_METRICS: { [key: string]: AggOptions } = { + avg: { + name: 'average', + isFullReference: false, + }, + cardinality: { + name: 'unique_count', + isFullReference: false, + }, + count: { + name: 'count', + isFullReference: false, + }, + positive_rate: { + name: 'counter_rate', + isFullReference: true, + }, + moving_average: { + name: 'moving_average', + isFullReference: true, + }, + derivative: { + name: 'differences', + isFullReference: true, + }, + cumulative_sum: { + name: 'cumulative_sum', + isFullReference: true, + }, + avg_bucket: { + name: 'overall_average', + isFullReference: true, + }, + max_bucket: { + name: 'overall_max', + isFullReference: true, + }, + min_bucket: { + name: 'overall_min', + isFullReference: true, + }, + sum_bucket: { + name: 'overall_sum', + isFullReference: true, + }, + max: { + name: 'max', + isFullReference: false, + }, + min: { + name: 'min', + isFullReference: false, + }, + percentile: { + name: 'percentile', + isFullReference: false, + }, + sum: { + name: 'sum', + isFullReference: false, + }, + filter_ratio: { + name: 'filter_ratio', + isFullReference: false, + }, + math: { + name: 'formula', + isFullReference: true, + }, +}; diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/annotations/get_request_params.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/annotations/get_request_params.ts index 41f7e7c86708f..815598007030d 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/annotations/get_request_params.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/annotations/get_request_params.ts @@ -31,6 +31,7 @@ export async function getAnnotationRequestParams( capabilities, uiSettings, cachedIndexPatternFetcher, + buildSeriesMetaParams, }: AnnotationServices ): Promise { const annotationIndex = await cachedIndexPatternFetcher(annotation.index_pattern); @@ -43,6 +44,7 @@ export async function getAnnotationRequestParams( annotationIndex, capabilities, uiSettings, + getMetaParams: () => buildSeriesMetaParams(annotationIndex, Boolean(panel.use_kibana_indexes)), }); return { diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/helpers/bucket_transform.js b/src/plugins/vis_types/timeseries/server/lib/vis_data/helpers/bucket_transform.js index 13b890189325c..0d5012b11a351 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/helpers/bucket_transform.js +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/helpers/bucket_transform.js @@ -192,8 +192,8 @@ export const bucketTransform = { }, }; if (bucket.gap_policy) body.serial_diff.gap_policy = bucket.gap_policy; - if (bucket.lag) { - body.serial_diff.lag = /^([\d]+)$/.test(bucket.lag) ? bucket.lag : 0; + if (bucket.lag && /^([\d]+)$/.test(bucket.lag)) { + body.serial_diff.lag = bucket.lag; } return body; }, diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.ts index a52e15eb90fee..c1bd0a11f550a 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/date_histogram.ts @@ -23,16 +23,26 @@ export const dateHistogram: AnnotationsRequestProcessorsFunction = ({ annotationIndex, capabilities, uiSettings, + getMetaParams, }) => { return (next) => async (doc) => { + const maxBarsUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS); const barTargetUiSettings = await uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET); const timeField = annotation.time_field || annotationIndex.indexPattern?.timeFieldName || ''; + const { interval, maxBars } = await getMetaParams(); if (panel.use_kibana_indexes) { validateField(timeField, annotationIndex); } const { bucketSize, intervalString } = getBucketSize( + req, + interval, + capabilities, + maxBars ? Math.min(maxBarsUiSettings, maxBars) : barTargetUiSettings + ); + + const { bucketSize: autoBucketSize, intervalString: autoIntervalString } = getBucketSize( req, 'auto', capabilities, @@ -49,7 +59,7 @@ export const dateHistogram: AnnotationsRequestProcessorsFunction = ({ min: from.valueOf(), max: to.valueOf() - bucketSize * 1000, }, - ...dateHistogramInterval(intervalString), + ...dateHistogramInterval(autoBucketSize < bucketSize ? autoIntervalString : intervalString), }); return next(doc); }; diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/types.ts b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/types.ts index 0b67d6f0d1984..ae2563fbfb64b 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/types.ts +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/request_processors/annotations/types.ts @@ -22,6 +22,11 @@ export interface AnnotationsRequestProcessorsParams { annotationIndex: FetchedIndexPattern; capabilities: SearchCapabilities; uiSettings: IUiSettingsClient; + getMetaParams: () => Promise<{ + maxBars: number; + timeField?: string | undefined; + interval: string; + }>; } export type AnnotationSearchRequest = Record; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index bcb3e0f4c7216..de2af1d5cdcfb 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -24,7 +24,15 @@ export { getVisSchemas } from './vis_schemas'; /** @public types */ export type { VisualizationsSetup, VisualizationsStart }; export { VisGroups } from './vis_types/vis_groups_enum'; -export type { BaseVisType, VisTypeAlias, VisTypeDefinition, Schema, ISchemas } from './vis_types'; +export type { + BaseVisType, + VisTypeAlias, + VisTypeDefinition, + Schema, + ISchemas, + NavigateToLensContext, + VisualizeEditorLayersContext, +} from './vis_types'; export type { Vis, SerializedVis, SerializedVisData, VisData } from './vis'; export type VisualizeEmbeddableFactoryContract = PublicContract; export type VisualizeEmbeddableContract = PublicContract; @@ -57,3 +65,5 @@ export type { export { urlFor, getFullPath } from './utils/saved_visualize_utils'; export type { IEditorController, EditorRenderProps } from './visualize_app/types'; + +export { VISUALIZE_EDITOR_TRIGGER, ACTION_CONVERT_TO_LENS } from './triggers'; diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index e51258cf8a1e7..0fc142aeead63 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -50,6 +50,7 @@ const createInstance = async () => { inspector: inspectorPluginMock.createSetupContract(), usageCollection: usageCollectionPluginMock.createSetupContract(), urlForwarding: urlForwardingPluginMock.createSetupContract(), + uiActions: uiActionsPluginMock.createSetupContract(), }); const doStart = () => plugin.start(coreMock.createStart(), { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index eae4f704b7c3c..c8c4d57543a02 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -58,6 +58,7 @@ import { VisualizeLocatorDefinition } from '../common/locator'; import { showNewVisModal } from './wizard'; import { createVisEditorsRegistry, VisEditorsRegistry } from './vis_editors_registry'; import { FeatureCatalogueCategory } from '../../home/public'; +import { visualizeEditorTrigger } from './triggers'; import type { VisualizeServices } from './visualize_app/types'; import type { @@ -69,7 +70,7 @@ import type { SavedObjectsClientContract, } from '../../../core/public'; import type { UsageCollectionSetup } from '../../usage_collection/public'; -import type { UiActionsStart } from '../../ui_actions/public'; +import type { UiActionsStart, UiActionsSetup } from '../../ui_actions/public'; import type { SavedObjectsStart } from '../../saved_objects/public'; import type { TypesSetup, TypesStart } from './vis_types'; import type { @@ -105,6 +106,7 @@ export interface VisualizationsSetupDeps { embeddable: EmbeddableSetup; expressions: ExpressionsSetup; inspector: InspectorSetup; + uiActions: UiActionsSetup; usageCollection: UsageCollectionSetup; urlForwarding: UrlForwardingSetup; home?: HomePublicPluginSetup; @@ -165,6 +167,7 @@ export class VisualizationsPlugin home, urlForwarding, share, + uiActions, }: VisualizationsSetupDeps ): VisualizationsSetup { const { @@ -325,6 +328,7 @@ export class VisualizationsPlugin expressions.registerFunction(rangeExpressionFunction); expressions.registerFunction(visDimensionExpressionFunction); expressions.registerFunction(xyDimensionExpressionFunction); + uiActions.registerTrigger(visualizeEditorTrigger); const embeddableFactory = new VisualizeEmbeddableFactory({ start }); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); diff --git a/src/plugins/visualizations/public/triggers/index.ts b/src/plugins/visualizations/public/triggers/index.ts new file mode 100644 index 0000000000000..eedeac1695717 --- /dev/null +++ b/src/plugins/visualizations/public/triggers/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Trigger } from '../../../ui_actions/public'; + +export const VISUALIZE_EDITOR_TRIGGER = 'VISUALIZE_EDITOR_TRIGGER'; +export const visualizeEditorTrigger: Trigger = { + id: VISUALIZE_EDITOR_TRIGGER, + title: 'Convert legacy visualizations to Lens', + description: 'Triggered when user navigates from a legacy visualization to Lens.', +}; + +export const ACTION_CONVERT_TO_LENS = 'ACTION_CONVERT_TO_LENS'; diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index 675a1783274aa..80295e5af2e40 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -27,6 +27,7 @@ export class BaseVisType { public readonly description; public readonly note; public readonly getSupportedTriggers; + public readonly navigateToLens; public readonly icon; public readonly image; public readonly stage; @@ -55,6 +56,7 @@ export class BaseVisType { this.description = opts.description ?? ''; this.note = opts.note ?? ''; this.getSupportedTriggers = opts.getSupportedTriggers; + this.navigateToLens = opts.navigateToLens; this.title = opts.title; this.icon = opts.icon; this.image = opts.image; diff --git a/src/plugins/visualizations/public/vis_types/index.ts b/src/plugins/visualizations/public/vis_types/index.ts index 365f0d51bf4f3..e297d9192ed21 100644 --- a/src/plugins/visualizations/public/vis_types/index.ts +++ b/src/plugins/visualizations/public/vis_types/index.ts @@ -10,4 +10,10 @@ export * from './types_service'; export { Schemas } from './schemas'; export { VisGroups } from './vis_groups_enum'; export { BaseVisType } from './base_vis_type'; -export type { VisTypeDefinition, ISchemas, Schema } from './types'; +export type { + VisTypeDefinition, + ISchemas, + Schema, + NavigateToLensContext, + VisualizeEditorLayersContext, +} from './types'; diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index 724f9d6ccc662..b89af7bd2cdbf 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -9,7 +9,14 @@ import type { IconType } from '@elastic/eui'; import type { ReactNode } from 'react'; import type { Adapters } from 'src/plugins/inspector'; -import type { IndexPattern, AggGroupNames, AggParam, AggGroupName } from '../../../data/public'; +import type { + IndexPattern, + AggGroupNames, + AggParam, + AggGroupName, + Query, +} from '../../../data/public'; +import { PaletteOutput } from '../../../charts/public'; import type { Vis, VisEditorOptionsProps, VisParams, VisToExpressionAst } from '../types'; import { VisGroups } from './vis_groups_enum'; @@ -67,6 +74,73 @@ interface CustomEditorConfig { editor: string; } +interface SplitByFilters { + color?: string; + filter?: Query; + id?: string; + label?: string; +} + +interface VisualizeEditorMetricContext { + agg: string; + fieldName: string; + pipelineAggType?: string; + params?: Record; + isFullReference: boolean; + color?: string; + accessor?: string; +} + +export interface VisualizeEditorLayersContext { + indexPatternId: string; + splitWithDateHistogram?: boolean; + timeFieldName?: string; + chartType?: string; + axisPosition?: string; + termsParams?: Record; + splitField?: string; + splitMode?: string; + splitFilters?: SplitByFilters[]; + palette?: PaletteOutput; + metrics: VisualizeEditorMetricContext[]; + timeInterval?: string; + format?: string; + label?: string; + layerId?: string; +} + +interface AxisExtents { + mode: string; + lowerBound?: number; + upperBound?: number; +} + +export interface NavigateToLensContext { + layers: { + [key: string]: VisualizeEditorLayersContext; + }; + type: string; + configuration: { + fill: number | string; + legend: { + isVisible: boolean; + position: string; + shouldTruncate: boolean; + maxLines: number; + showSingleSeries: boolean; + }; + gridLinesVisibility: { + x: boolean; + yLeft: boolean; + yRight: boolean; + }; + extents: { + yLeftExtent: AxisExtents; + yRightExtent: AxisExtents; + }; + }; +} + /** * A visualization type definition representing a spec of one specific type of "classical" * visualizations (i.e. not Lens visualizations). @@ -92,6 +166,15 @@ export interface VisTypeDefinition { * If given, it will return the supported triggers for this vis. */ readonly getSupportedTriggers?: (params?: VisParams) => string[]; + /** + * If given, it will navigateToLens with the given viz params. + * Every visualization that wants to be edited also in Lens should have this function. + * It receives the current visualization params as a parameter and should return the correct config + * in order to be displayed in the Lens editor. + */ + readonly navigateToLens?: ( + params?: VisParams + ) => Promise | undefined; /** * Some visualizations are created without SearchSource and may change the used indexes during the visualization configuration. diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index 0ef26a8b72f05..245441d26f3f0 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -10,6 +10,7 @@ import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; import { AppMountParameters, OverlayRef } from 'kibana/public'; import { i18n } from '@kbn/i18n'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { useKibana } from '../../../../kibana_react/public'; import { VisualizeServices, @@ -20,6 +21,9 @@ import { import { VISUALIZE_APP_NAME } from '../../../common/constants'; import { getTopNavConfig } from '../utils'; import type { IndexPattern } from '../../../../data/public'; +import type { NavigateToLensContext } from '../../../../visualizations/public'; + +const LOCAL_STORAGE_EDIT_IN_LENS_BADGE = 'EDIT_IN_LENS_BADGE_VISIBLE'; interface VisualizeTopNavProps { currentAppState: VisualizeAppState; @@ -59,6 +63,18 @@ const TopNav = ({ const { setHeaderActionMenu, visualizeCapabilities } = services; const { embeddableHandler, vis } = visInstance; const [inspectorSession, setInspectorSession] = useState(); + const [editInLensConfig, setEditInLensConfig] = useState(); + const [navigateToLens, setNavigateToLens] = useState(false); + // If the user has clicked the edit in lens button, we want to hide the badge. + // The information is stored in local storage to persist across reloads. + const [hideTryInLensBadge, setHideTryInLensBadge] = useLocalStorage( + LOCAL_STORAGE_EDIT_IN_LENS_BADGE, + false + ); + const hideLensBadge = useCallback(() => { + setHideTryInLensBadge(true); + }, [setHideTryInLensBadge]); + const openInspector = useCallback(() => { const session = embeddableHandler.openInspector(); setInspectorSession(session); @@ -80,6 +96,17 @@ const TopNav = ({ [doReload] ); + useEffect(() => { + const asyncGetTriggerContext = async () => { + if (vis.type.navigateToLens) { + const triggerConfig = await vis.type.navigateToLens(vis.params); + setEditInLensConfig(triggerConfig); + } + }; + asyncGetTriggerContext(); + }, [vis.params, vis.type]); + + const displayEditInLensItem = Boolean(vis.type.navigateToLens && editInLensConfig); const config = useMemo(() => { if (isEmbeddableRendered) { return getTopNavConfig( @@ -96,6 +123,11 @@ const TopNav = ({ visualizationIdFromUrl, stateTransfer: services.stateTransferService, embeddableId, + editInLensConfig, + displayEditInLensItem, + hideLensBadge, + setNavigateToLens, + showBadge: !hideTryInLensBadge && displayEditInLensItem, }, services ); @@ -107,13 +139,17 @@ const TopNav = ({ hasUnappliedChanges, openInspector, originatingApp, + setOriginatingApp, originatingPath, visInstance, - setOriginatingApp, stateContainer, visualizationIdFromUrl, services, embeddableId, + editInLensConfig, + displayEditInLensItem, + hideLensBadge, + hideTryInLensBadge, ]); const [indexPatterns, setIndexPatterns] = useState( vis.data.indexPattern ? [vis.data.indexPattern] : [] @@ -140,10 +176,12 @@ const TopNav = ({ onAppLeave((actions) => { // Confirm when the user has made any changes to an existing visualizations // or when the user has configured something without saving + // the warning won't appear if you navigate from the Viz editor to Lens if ( originatingApp && (hasUnappliedChanges || hasUnsavedChanges) && - !services.stateTransferService.isTransferInProgress + !services.stateTransferService.isTransferInProgress && + !navigateToLens ) { return actions.confirm( i18n.translate('visualizations.confirmModal.confirmTextDescription', { @@ -167,6 +205,7 @@ const TopNav = ({ hasUnappliedChanges, visualizeCapabilities.save, services.stateTransferService.isTransferInProgress, + navigateToLens, ]); useEffect(() => { diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx index 81f9c83dec2b1..7ddece73d54b7 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.test.tsx @@ -250,4 +250,147 @@ describe('getTopNavConfig', () => { ] `); }); + + test('returns correct for visualization that allows editing in Lens editor', () => { + const vis = { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'conflict', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance; + const topNavLinks = getTopNavConfig( + { + hasUnsavedChanges: false, + setHasUnsavedChanges: jest.fn(), + hasUnappliedChanges: false, + onOpenInspector: jest.fn(), + originatingApp: 'dashboards', + setOriginatingApp: jest.fn(), + visInstance: vis, + stateContainer, + visualizationIdFromUrl: undefined, + stateTransfer: createEmbeddableStateTransferMock(), + editInLensConfig: { + layers: { + '0': { + indexPatternId: 'test-id', + timeFieldName: 'timefield-1', + chartType: 'area', + axisPosition: 'left', + palette: { + name: 'default', + type: 'palette', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + }, + configuration: { + fill: 0.5, + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: 1, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + }, + displayEditInLensItem: true, + hideLensBadge: false, + } as unknown as TopNavConfigParams, + services as unknown as VisualizeServices + ); + + expect(topNavLinks).toMatchInlineSnapshot(` + Array [ + Object { + "className": "visNavItem__goToLens", + "description": "Go to Lens with your current configuration", + "disableButton": false, + "emphasize": false, + "id": "goToLens", + "label": "Edit visualization in Lens", + "run": [Function], + "testId": "visualizeEditInLensButton", + }, + Object { + "description": "Open Inspector for visualization", + "disableButton": [Function], + "id": "inspector", + "label": "inspect", + "run": undefined, + "testId": "openInspectorButton", + "tooltip": [Function], + }, + Object { + "description": "Share Visualization", + "disableButton": false, + "id": "share", + "label": "share", + "run": [Function], + "testId": "shareTopNavButton", + }, + Object { + "description": "Return to the last app without saving changes", + "emphasize": false, + "id": "cancel", + "label": "Cancel", + "run": [Function], + "testId": "visualizeCancelAndReturnButton", + "tooltip": [Function], + }, + Object { + "description": "Save Visualization", + "disableButton": false, + "emphasize": false, + "iconType": undefined, + "id": "save", + "label": "Save as", + "run": [Function], + "testId": "visualizeSaveButton", + "tooltip": [Function], + }, + Object { + "description": "Finish editing visualization and return to the last app", + "disableButton": false, + "emphasize": true, + "iconType": "checkInCircleFilled", + "id": "saveAndReturn", + "label": "Save and return", + "run": [Function], + "testId": "visualizesaveAndReturnButton", + "tooltip": [Function], + }, + ] + `); + }); }); diff --git a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx index fcf446021e9f9..362749cb206df 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx +++ b/src/plugins/visualizations/public/visualize_app/utils/get_top_nav_config.tsx @@ -10,6 +10,7 @@ import React from 'react'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; +import { EuiBetaBadgeProps } from '@elastic/eui'; import { parse } from 'query-string'; import { Capabilities } from 'src/core/public'; @@ -19,6 +20,7 @@ import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput, getFullPath, + NavigateToLensContext, } from '../../../../visualizations/public'; import { showSaveModal, @@ -41,6 +43,11 @@ import { VISUALIZE_APP_NAME, VisualizeConstants } from '../../../common/constant import { getEditBreadcrumbs } from './breadcrumbs'; import { EmbeddableStateTransfer } from '../../../../embeddable/public'; import { VISUALIZE_APP_LOCATOR, VisualizeLocatorParams } from '../../../common/locator'; +import { getUiActions } from '../../services'; +import { VISUALIZE_EDITOR_TRIGGER } from '../../triggers'; +import { getVizEditorOriginatingAppUrl } from './utils'; + +import './visualize_navigation.scss'; interface VisualizeCapabilities { createShortUrl: boolean; @@ -63,6 +70,11 @@ export interface TopNavConfigParams { visualizationIdFromUrl?: string; stateTransfer: EmbeddableStateTransfer; embeddableId?: string; + editInLensConfig?: NavigateToLensContext | null; + displayEditInLensItem: boolean; + hideLensBadge: () => void; + setNavigateToLens: (flag: boolean) => void; + showBadge: boolean; } const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); @@ -89,6 +101,11 @@ export const getTopNavConfig = ( visualizationIdFromUrl, stateTransfer, embeddableId, + editInLensConfig, + displayEditInLensItem, + hideLensBadge, + setNavigateToLens, + showBadge, }: TopNavConfigParams, { data, @@ -272,6 +289,45 @@ export const getTopNavConfig = ( visualizeCapabilities.save || (!originatingApp && dashboardCapabilities.showWriteControls); const topNavMenu: TopNavMenuData[] = [ + ...(displayEditInLensItem + ? [ + { + id: 'goToLens', + label: i18n.translate('visualizations.topNavMenu.goToLensButtonLabel', { + defaultMessage: 'Edit visualization in Lens', + }), + emphasize: false, + description: i18n.translate('visualizations.topNavMenu.goToLensButtonAriaLabel', { + defaultMessage: 'Go to Lens with your current configuration', + }), + className: 'visNavItem__goToLens', + disableButton: !editInLensConfig, + testId: 'visualizeEditInLensButton', + ...(showBadge && { + badge: { + label: i18n.translate('visualizations.tonNavMenu.tryItBadgeText', { + defaultMessage: 'Try it', + }), + color: 'accent' as EuiBetaBadgeProps['color'], + }, + }), + run: async () => { + const updatedWithMeta = { + ...editInLensConfig, + savedObjectId: visInstance.vis.id, + embeddableId, + vizEditorOriginatingAppUrl: getVizEditorOriginatingAppUrl(history), + originatingApp, + }; + if (editInLensConfig) { + hideLensBadge(); + setNavigateToLens(true); + getUiActions().getTrigger(VISUALIZE_EDITOR_TRIGGER).exec(updatedWithMeta); + } + }, + }, + ] + : []), { id: 'inspector', label: i18n.translate('visualizations.topNavMenu.openInspectorButtonLabel', { diff --git a/src/plugins/visualizations/public/visualize_app/utils/utils.ts b/src/plugins/visualizations/public/visualize_app/utils/utils.ts index b3257f03354a6..6f71cb33e7321 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/utils.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/utils.ts @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; - +import type { History } from 'history'; import type { ChromeStart, DocLinksStart } from 'kibana/public'; import type { Filter } from '@kbn/es-query'; import { redirectWhenMissing } from '../../../../kibana_utils/public'; @@ -95,3 +95,7 @@ export const redirectToSavedObjectPage = ( theme: services.theme, })(error); }; + +export function getVizEditorOriginatingAppUrl(history: History) { + return `#/${history.location.pathname}${history.location.search}`; +} diff --git a/src/plugins/visualizations/public/visualize_app/utils/visualize_navigation.scss b/src/plugins/visualizations/public/visualize_app/utils/visualize_navigation.scss new file mode 100644 index 0000000000000..fb8acced47c83 --- /dev/null +++ b/src/plugins/visualizations/public/visualize_app/utils/visualize_navigation.scss @@ -0,0 +1,19 @@ +// Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. +.visNavItem__goToLens { + @include euiBreakpoint('m', 'l', 'xl') { + margin-right: $euiSizeM; + position: relative; + } + &::after { + @include euiBreakpoint('m', 'l', 'xl') { + border-right: $euiBorderThin; + bottom: 0; + content: ''; + display: block; + pointer-events: none; + position: absolute; + right: -$euiSizeS; + top: 0; + } + } +} \ No newline at end of file diff --git a/test/functional/apps/discover/_runtime_fields_editor.ts b/test/functional/apps/discover/_runtime_fields_editor.ts index 2e21b2e1f8ec6..23325ef5aa084 100644 --- a/test/functional/apps/discover/_runtime_fields_editor.ts +++ b/test/functional/apps/discover/_runtime_fields_editor.ts @@ -31,7 +31,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await fieldEditor.save(); }; - describe('discover integration with runtime fields editor', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/123372 + describe.skip('discover integration with runtime fields editor', function describeIndexTests() { before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); diff --git a/test/functional/apps/visualize/_pie_chart.ts b/test/functional/apps/visualize/_pie_chart.ts index 744ba3caa719e..48d49d3007b68 100644 --- a/test/functional/apps/visualize/_pie_chart.ts +++ b/test/functional/apps/visualize/_pie_chart.ts @@ -432,7 +432,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '360,000', 'CN', ].sort(); - if (await PageObjects.visChart.isNewLibraryChart('visTypePieChart')) { + if (await PageObjects.visChart.isNewLibraryChart('partitionVisChart')) { await PageObjects.visEditor.clickOptionsTab(); await PageObjects.visEditor.togglePieLegend(); await PageObjects.visEditor.togglePieNestedLegend(); diff --git a/test/functional/apps/visualize/_tsvb_time_series.ts b/test/functional/apps/visualize/_tsvb_time_series.ts index 4fa8cd6f2d7f5..d21581fba56d7 100644 --- a/test/functional/apps/visualize/_tsvb_time_series.ts +++ b/test/functional/apps/visualize/_tsvb_time_series.ts @@ -468,12 +468,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await visualBuilder.setAnnotationFilter('geo.dest : "AW" or geo.src : "AM"'); await visualBuilder.setAnnotationFields('extension.raw'); await visualBuilder.setAnnotationRowTemplate('extension: {{extension.raw}}'); - const annotationsData = await visualBuilder.getAnnotationsData(); - expect(annotationsData).to.eql(expectedAnnotationsData); }); - it('should display correct annotations data for machine.os.raw and memory fields', async () => { const expectedAnnotationsData = [ { @@ -512,12 +509,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await visualBuilder.setAnnotationRowTemplate( 'OS: {{machine.os.raw}}, memory: {{memory}}' ); - const annotationsData = await visualBuilder.getAnnotationsData(); - expect(annotationsData).to.eql(expectedAnnotationsData); }); - it('should display correct annotations data when using runtime field', async () => { const expectedAnnotationsData = [ { diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 60d7c6e7d7435..3eec4e2ce1a2b 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -11,7 +11,7 @@ import chroma from 'chroma-js'; import { FtrService } from '../ftr_provider_context'; -const pieChartSelector = 'visTypePieChart'; +const partitionVisChartSelector = 'partitionVisChart'; const heatmapChartSelector = 'heatmapChart'; export class VisualizeChartPageObject extends FtrService { @@ -149,7 +149,7 @@ export class VisualizeChartPageObject extends FtrService { } private async toggleLegend(force = false) { - const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(partitionVisChartSelector); const legendSelector = force || isVisTypePieChart ? '.echLegend' : '.visLegend'; await this.retry.try(async () => { @@ -182,10 +182,11 @@ export class VisualizeChartPageObject extends FtrService { } public async doesSelectedLegendColorExistForPie(matchingColor: string) { - if (await this.isNewLibraryChart(pieChartSelector)) { + if (await this.isNewLibraryChart(partitionVisChartSelector)) { const hexMatchingColor = chroma(matchingColor).hex().toUpperCase(); const slices = - (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + (await this.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]?.partitions ?? + []; return slices.some(({ color }) => { return hexMatchingColor === chroma(color).hex().toUpperCase(); }); @@ -195,7 +196,7 @@ export class VisualizeChartPageObject extends FtrService { } public async expectError() { - if (!this.isNewLibraryChart(pieChartSelector)) { + if (!this.isNewLibraryChart(partitionVisChartSelector)) { await this.testSubjects.existOrFail('vislibVisualizeError'); } } @@ -244,12 +245,13 @@ export class VisualizeChartPageObject extends FtrService { } public async getLegendEntries() { - const isVisTypePieChart = await this.isNewLibraryChart(pieChartSelector); + const isVisTypePieChart = await this.isNewLibraryChart(partitionVisChartSelector); const isVisTypeHeatmapChart = await this.isNewLibraryChart(heatmapChartSelector); if (isVisTypePieChart) { const slices = - (await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? []; + (await this.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0]?.partitions ?? + []; return slices.map(({ name }) => name); } @@ -290,7 +292,7 @@ export class VisualizeChartPageObject extends FtrService { public async openLegendOptionColorsForPie(name: string, chartSelector: string) { await this.waitForVisualizationRenderingStabilized(); await this.retry.try(async () => { - if (await this.isNewLibraryChart(pieChartSelector)) { + if (await this.isNewLibraryChart(partitionVisChartSelector)) { const chart = await this.find.byCssSelector(chartSelector); const legendItemColor = await chart.findByCssSelector( `[data-ech-series-name="${name}"] .echLegendItem__color` diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index ff0c24e2830cf..16133140e4abf 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -10,7 +10,7 @@ import expect from '@kbn/expect'; import { isNil } from 'lodash'; import { FtrService } from '../../ftr_provider_context'; -const pieChartSelector = 'visTypePieChart'; +const partitionVisChartSelector = 'partitionVisChart'; export class PieChartService extends FtrService { private readonly log = this.ctx.getService('log'); @@ -27,16 +27,16 @@ export class PieChartService extends FtrService { async clickOnPieSlice(name?: string) { this.log.debug(`PieChart.clickOnPieSlice(${name})`); - if (await this.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) { const slices = - (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? - []; + (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] + ?.partitions ?? []; let sliceLabel = name || slices[0].name; if (name === 'Other') { sliceLabel = '__other__'; } const pieSlice = slices.find((slice) => slice.name === sliceLabel); - const pie = await this.testSubjects.find(pieChartSelector); + const pie = await this.testSubjects.find(partitionVisChartSelector); if (pieSlice) { const pieSize = await pie.getSize(); const pieHeight = pieSize.height; @@ -88,10 +88,10 @@ export class PieChartService extends FtrService { async getPieSliceStyle(name: string) { this.log.debug(`VisualizePage.getPieSliceStyle(${name})`); - if (await this.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) { const slices = - (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? - []; + (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] + ?.partitions ?? []; const selectedSlice = slices.filter((slice) => { return slice.name.toString() === name.replace(',', ''); }); @@ -103,10 +103,10 @@ export class PieChartService extends FtrService { async getAllPieSliceColor(name: string) { this.log.debug(`VisualizePage.getAllPieSliceColor(${name})`); - if (await this.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) { const slices = - (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? - []; + (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] + ?.partitions ?? []; const selectedSlice = slices.filter((slice) => { return slice.name.toString() === name.replace(',', ''); }); @@ -143,10 +143,10 @@ export class PieChartService extends FtrService { } async getPieChartLabels() { - if (await this.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) { const slices = - (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? - []; + (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] + ?.partitions ?? []; return slices.map((slice) => { if (slice.name === '__missing__') { return 'Missing'; @@ -169,10 +169,10 @@ export class PieChartService extends FtrService { async getPieSliceCount() { this.log.debug('PieChart.getPieSliceCount'); - if (await this.visChart.isNewLibraryChart(pieChartSelector)) { + if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) { const slices = - (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? - []; + (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] + ?.partitions ?? []; return slices?.length; } const slices = await this.find.allByCssSelector('svg > g > g.arcs > path.slice'); @@ -181,8 +181,8 @@ export class PieChartService extends FtrService { async expectPieSliceCountEsCharts(expectedCount: number) { const slices = - (await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? - []; + (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] + ?.partitions ?? []; expect(slices.length).to.be(expectedCount); } diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_keys/agent_keys_table.stories.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_keys/agent_keys_table.stories.tsx new file mode 100644 index 0000000000000..08d26569b8ece --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_keys/agent_keys_table.stories.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Meta, Story } from '@storybook/react'; +import React, { ComponentProps } from 'react'; +import { CoreStart } from '../../../../../../../../src/core/public'; +import { createKibanaReactContext } from '../../../../../../../../src/plugins/kibana_react/public'; +import type { ApmPluginContextValue } from '../../../../context/apm_plugin/apm_plugin_context'; +import { MockApmPluginContextWrapper } from '../../../../context/apm_plugin/mock_apm_plugin_context'; +import { AgentKeysTable } from './agent_keys_table'; +import { ApiKey } from '../../../../../../security/common/model'; + +type Args = ComponentProps; + +const coreMock = { + http: { + get: async () => { + return { fallBackToTransactions: false }; + }, + }, + notifications: { toasts: { add: () => {} } }, + uiSettings: { get: () => ({}) }, +} as unknown as CoreStart; + +const KibanaReactContext = createKibanaReactContext(coreMock); + +const agentKeys: ApiKey[] = [ + { + id: 'M96XSX4BQcLuJqE2VX29', + name: 'apm_api_key1', + creation: 1641912161726, + invalidated: false, + username: 'elastic', + realm: 'reserved', + expiration: 0, + metadata: { application: 'apm' }, + }, + + { + id: 'Nd6XSX4BQcLuJqE2eH2A', + name: 'apm_api_key2', + creation: 1641912170624, + invalidated: false, + username: 'elastic', + realm: 'reserved', + expiration: 0, + metadata: { application: 'apm' }, + }, +]; + +const stories: Meta = { + title: 'app/Settings/AgentKeys/AgentKeysTable', + component: AgentKeysTable, + decorators: [ + (StoryComponent) => { + return ( + + + + + + ); + }, + ], +}; +export default stories; + +export const ExampleData: Story = (args) => { + return ; +}; + +ExampleData.args = { + agentKeys, +}; diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_keys/create_agent_key.stories.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_keys/create_agent_key.stories.tsx new file mode 100644 index 0000000000000..47b4a519da6da --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_keys/create_agent_key.stories.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Meta, Story } from '@storybook/react'; +import React, { ComponentProps } from 'react'; +import { CreateAgentKeyFlyout } from './create_agent_key'; +import { CreateApiKeyResponse } from '../../../../../common/agent_key_types'; + +type Args = ComponentProps; + +const stories: Meta = { + title: 'app/Settings/AgentKeys/CreateAgentKeyFlyout', + component: CreateAgentKeyFlyout, +}; +export default stories; + +export const Example: Story = (args) => { + return ; +}; + +Example.args = { + onCancel: () => {}, + onSuccess: (agentKey: CreateApiKeyResponse) => {}, + onError: (keyName: string, message: string) => {}, +}; diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_keys/prompts/api_keys_not_enabled.stories.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_keys/prompts/api_keys_not_enabled.stories.tsx new file mode 100644 index 0000000000000..eaf50108a7689 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_keys/prompts/api_keys_not_enabled.stories.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 { Story } from '@storybook/react'; +import React from 'react'; +import { ApiKeysNotEnabled } from './api_keys_not_enabled'; + +const stories = { + title: 'app/Settings/AgentKeys/prompts/ApiKeysNotEnabled', + component: ApiKeysNotEnabled, +}; +export default stories; + +export const Example: Story = () => { + return ; +}; diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_keys/prompts/permission_denied.stories.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_keys/prompts/permission_denied.stories.tsx new file mode 100644 index 0000000000000..5fcc6ded1991b --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/settings/agent_keys/prompts/permission_denied.stories.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 { Story } from '@storybook/react'; +import React from 'react'; +import { PermissionDenied } from './permission_denied'; + +const stories = { + title: 'app/Settings/AgentKeys/prompts/PermissionDenied', + component: PermissionDenied, +}; +export default stories; + +export const Example: Story = (args) => { + return ; +}; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx index 3a24364e57c36..80119d8de4b5f 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx @@ -9,10 +9,6 @@ import React, { FC, useState, useEffect } from 'react'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFlexGroup, EuiFlexItem, EuiCard, EuiIcon } from '@elastic/eui'; -import { - DISCOVER_APP_URL_GENERATOR, - DiscoverUrlGeneratorState, -} from '../../../../../../../../src/plugins/discover/public'; import { TimeRange, RefreshInterval } from '../../../../../../../../src/plugins/data/public'; import { FindFileStructureResponse } from '../../../../../../file_upload/common'; import type { FileUploadPluginStart } from '../../../../../../file_upload/public'; @@ -61,9 +57,7 @@ export const ResultsLinks: FC = ({ services: { fileUpload, application: { getUrlForApp, capabilities }, - share: { - urlGenerators: { getUrlGenerator }, - }, + discover, }, } = useDataVisualizerKibana(); @@ -83,32 +77,18 @@ export const ResultsLinks: FC = ({ const getDiscoverUrl = async (): Promise => { const isDiscoverAvailable = capabilities.discover?.show ?? false; - if (!isDiscoverAvailable) { + if (!isDiscoverAvailable) return; + if (!discover.locator) { + // eslint-disable-next-line no-console + console.error('Discover locator not available'); return; } - - const state: DiscoverUrlGeneratorState = { + const discoverUrl = await discover.locator.getUrl({ indexPatternId, - }; - - if (globalState?.time) { - state.timeRange = globalState.time; - } - - let discoverUrlGenerator; - try { - discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); - } catch (error) { - // ignore error thrown when url generator is not available - } - - if (!discoverUrlGenerator) { - return; - } - const discoverUrl = await discoverUrlGenerator.createUrl(state); - if (!unmounted) { - setDiscoverLink(discoverUrl); - } + timeRange: globalState?.time ? globalState.time : undefined, + }); + if (unmounted) return; + setDiscoverLink(discoverUrl); }; getDiscoverUrl(); @@ -148,7 +128,7 @@ export const ResultsLinks: FC = ({ unmounted = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [indexPatternId, getUrlGenerator, JSON.stringify(globalState)]); + }, [indexPatternId, discover, JSON.stringify(globalState)]); useEffect(() => { updateTimeValues(); diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx index 6b2657bf357b8..e378d2a853bfd 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx @@ -23,11 +23,13 @@ interface Props { export type FileDataVisualizerSpec = typeof FileDataVisualizer; export const FileDataVisualizer: FC = ({ additionalLinks }) => { const coreStart = getCoreStart(); - const { data, maps, embeddable, share, security, fileUpload, cloud } = getPluginsStart(); + const { data, maps, embeddable, discover, share, security, fileUpload, cloud } = + getPluginsStart(); const services = { data, maps, embeddable, + discover, share, security, fileUpload, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx index 2d086ab5ae700..66522fd3a9735 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx @@ -10,10 +10,6 @@ import React, { FC, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiTitle } from '@elastic/eui'; -import { - DISCOVER_APP_URL_GENERATOR, - DiscoverUrlGeneratorState, -} from '../../../../../../../../src/plugins/discover/public'; import type { IndexPattern } from '../../../../../../../../src/plugins/data/common'; import { useDataVisualizerKibana } from '../../../kibana_context'; import { useUrlState } from '../../../common/util/url_state'; @@ -42,9 +38,7 @@ export const ActionsPanel: FC = ({ services: { data, application: { capabilities }, - share: { - urlGenerators: { getUrlGenerator }, - }, + discover, }, } = useDataVisualizerKibana(); @@ -54,38 +48,24 @@ export const ActionsPanel: FC = ({ const indexPatternId = indexPattern.id; const getDiscoverUrl = async (): Promise => { const isDiscoverAvailable = capabilities.discover?.show ?? false; - if (!isDiscoverAvailable) { + if (!isDiscoverAvailable) return; + if (!discover.locator) { + // eslint-disable-next-line no-console + console.error('Discover locator not available'); return; } - - const state: DiscoverUrlGeneratorState = { + const discoverUrl = await discover.locator.getUrl({ indexPatternId, - }; - - state.filters = data.query.filterManager.getFilters() ?? []; - - if (searchString && searchQueryLanguage !== undefined) { - state.query = { query: searchString, language: searchQueryLanguage }; - } - if (globalState?.time) { - state.timeRange = globalState.time; - } - if (globalState?.refreshInterval) { - state.refreshInterval = globalState.refreshInterval; - } - - let discoverUrlGenerator; - try { - discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); - } catch (error) { - // ignore error thrown when url generator is not available - return; - } - - const discoverUrl = await discoverUrlGenerator.createUrl(state); - if (!unmounted) { - setDiscoverLink(discoverUrl); - } + filters: data.query.filterManager.getFilters() ?? [], + query: + searchString && searchQueryLanguage !== undefined + ? { query: searchString, language: searchQueryLanguage } + : undefined, + timeRange: globalState?.time ? globalState.time : undefined, + refreshInterval: globalState?.refreshInterval ? globalState.refreshInterval : undefined, + }); + if (unmounted) return; + setDiscoverLink(discoverUrl); }; Promise.all( @@ -115,7 +95,7 @@ export const ActionsPanel: FC = ({ searchQueryLanguage, globalState, capabilities, - getUrlGenerator, + discover, additionalLinks, data.query, ]); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index c0fc46b01cb74..c03bdeb56d069 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -267,6 +267,7 @@ export const IndexDataVisualizer: FC<{ additionalLinks: ResultLink[] }> = ({ add data, maps, embeddable, + discover, share, security, fileUpload, @@ -279,6 +280,7 @@ export const IndexDataVisualizer: FC<{ additionalLinks: ResultLink[] }> = ({ add data, maps, embeddable, + discover, share, security, fileUpload, diff --git a/x-pack/plugins/data_visualizer/public/plugin.ts b/x-pack/plugins/data_visualizer/public/plugin.ts index 265f7e11e3b09..06ec021d28ba8 100644 --- a/x-pack/plugins/data_visualizer/public/plugin.ts +++ b/x-pack/plugins/data_visualizer/public/plugin.ts @@ -10,6 +10,7 @@ import { ChartsPluginStart } from 'src/plugins/charts/public'; import type { CloudStart } from '../../cloud/public'; import type { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { SharePluginSetup, SharePluginStart } from '../../../../src/plugins/share/public'; +import type { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; import { Plugin } from '../../../../src/core/public'; import { setStartServices } from './kibana_services'; @@ -32,6 +33,7 @@ export interface DataVisualizerSetupDependencies { home?: HomePublicPluginSetup; embeddable: EmbeddableSetup; share: SharePluginSetup; + discover: DiscoverSetup; } export interface DataVisualizerStartDependencies { data: DataPublicPluginStart; @@ -40,6 +42,7 @@ export interface DataVisualizerStartDependencies { embeddable: EmbeddableStart; security?: SecurityPluginSetup; share: SharePluginStart; + discover: DiscoverStart; lens?: LensPublicStart; charts: ChartsPluginStart; dataViewFieldEditor?: IndexPatternFieldEditorStart; diff --git a/x-pack/plugins/fleet/README.md b/x-pack/plugins/fleet/README.md index 8fc7967234e8f..5dd7e3088b7e3 100644 --- a/x-pack/plugins/fleet/README.md +++ b/x-pack/plugins/fleet/README.md @@ -154,4 +154,5 @@ The projects below are dependent on Fleet, most using Fleet API as well. In case * [e2e-testing](https://github.com/elastic/e2e-testing): internal project that runs Fleet and tests Fleet API [Check here](https://github.com/elastic/e2e-testing/tree/main/internal/kibana) * [observability-test-environments](https://github.com/elastic/observability-test-environments): internal project, uses Fleet API [Check here](https://github.com/elastic/observability-test-environments/blob/master/ansible/tasks-fleet-config.yml) * [ECK](https://github.com/elastic/cloud-on-k8s): Elastic Cloud on Kubernetes, orchestrates Elastic Stack applications, including Kibana with Fleet (no direct dependency, has examples that include Fleet config) [Check here](https://github.com/elastic/cloud-on-k8s/blob/main/docs/orchestrating-elastic-stack-applications/agent-fleet.asciidoc) - * [APM Server](https://github.com/elastic/apm-server) APM Server, receives data from Elastic APM agents. Using docker compose for testing. [Check here](https://github.com/elastic/apm-server/pull/7227/files) \ No newline at end of file + * [APM Server](https://github.com/elastic/apm-server) APM Server, receives data from Elastic APM agents. Using docker compose for testing. [Check here](https://github.com/elastic/apm-server/pull/7227/files) + * [APM Integration Testing](https://github.com/elastic/apm-integration-testing) APM integration testing. [Check here](https://github.com/elastic/apm-integration-testing/blob/53ec49f80bb8dc8175e21e9ac26452fa8c3b7cf0/docker/apm-server/managed/main.go#L188) \ No newline at end of file diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/index.ts b/x-pack/plugins/fleet/jest.integration.config.js similarity index 59% rename from x-pack/plugins/lens/common/expressions/pie_chart/index.ts rename to x-pack/plugins/fleet/jest.integration.config.js index 1c1f6fdae4578..f1b9ee2f5f7e0 100644 --- a/x-pack/plugins/lens/common/expressions/pie_chart/index.ts +++ b/x-pack/plugins/fleet/jest.integration.config.js @@ -5,12 +5,8 @@ * 2.0. */ -export { pie } from './pie_chart'; - -export type { - SharedPieLayerState, - PieLayerState, - PieVisualizationState, - PieExpressionArgs, - PieExpressionProps, -} from './types'; +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../../..', + roots: ['/x-pack/plugins/fleet'], +}; diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx index 80ab845aaa49c..87382ac70a9bb 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx @@ -25,6 +25,7 @@ interface Props { selectedApiKeyId?: string; onKeyChange?: (key?: string) => void; isFleetServerPolicy?: boolean; + policyId?: string; } export const SelectCreateAgentPolicy: React.FC = ({ @@ -35,6 +36,7 @@ export const SelectCreateAgentPolicy: React.FC = ({ selectedApiKeyId, onKeyChange, isFleetServerPolicy, + policyId, }) => { const [showCreatePolicy, setShowCreatePolicy] = useState(agentPolicies.length === 0); @@ -42,7 +44,7 @@ export const SelectCreateAgentPolicy: React.FC = ({ const [newName, setNewName] = useState(incrementPolicyName(agentPolicies, isFleetServerPolicy)); - const [selectedAgentPolicy, setSelectedAgentPolicy] = useState(undefined); + const [selectedAgentPolicy, setSelectedAgentPolicy] = useState(policyId); useEffect(() => { setShowCreatePolicy(agentPolicies.length === 0); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx index 539f9f990262c..f8ae02fb5a664 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -154,7 +154,7 @@ export const EnrollmentStepAgentPolicy: React.FC = (props) => { value: agentPolicy.id, text: agentPolicy.name, }))} - value={selectedAgentPolicyId || undefined} + value={selectedAgentPolicyId} onChange={(e) => setSelectedAgentPolicyId(e.target.value)} aria-label={i18n.translate( 'xpack.fleet.enrollmentStepAgentPolicy.policySelectAriaLabel', diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx index b740d0ea62f0a..9018f508e93ea 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { EuiFlyout, EuiFlyoutBody, @@ -22,7 +22,12 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useGetSettings, sendGetOneAgentPolicy, useFleetStatus } from '../../hooks'; +import { + useGetSettings, + sendGetOneAgentPolicy, + useFleetStatus, + useGetAgentPolicies, +} from '../../hooks'; import { FLEET_SERVER_PACKAGE } from '../../constants'; import type { PackagePolicy } from '../../types'; @@ -47,7 +52,6 @@ export * from './steps'; export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, agentPolicy, - agentPolicies, viewDataStep, defaultMode = 'managed', }) => { @@ -60,6 +64,24 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ const [policyId, setSelectedPolicyId] = useState(agentPolicy?.id); const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); + // loading the latest agentPolicies for add agent flyout + const { + data: agentPoliciesData, + isLoading: isLoadingAgentPolicies, + resendRequest: refreshAgentPolicies, + } = useGetAgentPolicies({ + page: 1, + perPage: 1000, + full: true, + }); + + const agentPolicies = useMemo(() => { + if (!isLoadingAgentPolicies) { + return agentPoliciesData?.items; + } + return []; + }, [isLoadingAgentPolicies, agentPoliciesData?.items]); + useEffect(() => { async function checkPolicyIsFleetServer() { if (policyId && setIsFleetServerPolicySelected) { @@ -143,9 +165,14 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ agentPolicies={agentPolicies} viewDataStep={viewDataStep} isFleetServerPolicySelected={isFleetServerPolicySelected} + refreshAgentPolicies={refreshAgentPolicies} /> ) : ( - + )} diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx index d3294692c9e55..6fac9b889a679 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx @@ -11,13 +11,7 @@ import type { EuiContainedStepProps } from '@elastic/eui/src/components/steps/st import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - useGetOneEnrollmentAPIKey, - useLink, - useFleetStatus, - useGetAgents, - useGetAgentPolicies, -} from '../../hooks'; +import { useGetOneEnrollmentAPIKey, useLink, useFleetStatus, useGetAgents } from '../../hooks'; import { ManualInstructions } from '../../components/enrollment_instructions'; import { @@ -34,9 +28,7 @@ import { policyHasFleetServer } from '../../applications/fleet/sections/agents/s import { FLEET_SERVER_PACKAGE } from '../../constants'; import { DownloadStep, AgentPolicySelectionStep, AgentEnrollmentKeySelectionStep } from './steps'; -import type { BaseProps } from './types'; - -type Props = BaseProps; +import type { InstructionProps } from './types'; const DefaultMissingRequirements = () => { const { getHref } = useLink(); @@ -65,7 +57,7 @@ const FleetServerMissingRequirements = () => { return ; }; -export const ManagedInstructions = React.memo( +export const ManagedInstructions = React.memo( ({ agentPolicy, agentPolicies, @@ -73,6 +65,7 @@ export const ManagedInstructions = React.memo( setSelectedPolicyId, isFleetServerPolicySelected, settings, + refreshAgentPolicies, }) => { const fleetStatus = useFleetStatus(); @@ -87,24 +80,15 @@ export const ManagedInstructions = React.memo( showInactive: false, }); - const { data: agentPoliciesData, isLoading: isLoadingAgentPolicies } = useGetAgentPolicies({ - page: 1, - perPage: 1000, - full: true, - }); - const fleetServers = useMemo(() => { - let policies = agentPolicies; - if (!agentPolicies && !isLoadingAgentPolicies) { - policies = agentPoliciesData?.items; - } + const policies = agentPolicies; const fleetServerAgentPolicies: string[] = (policies ?? []) .filter((pol) => policyHasFleetServer(pol)) .map((pol) => pol.id); return (agents?.items ?? []).filter((agent) => fleetServerAgentPolicies.includes(agent.policy_id ?? '') ); - }, [agents, agentPolicies, agentPoliciesData, isLoadingAgentPolicies]); + }, [agents, agentPolicies]); const fleetServerSteps = useMemo(() => { const { @@ -137,6 +121,7 @@ export const ManagedInstructions = React.memo( setSelectedAPIKeyId, setSelectedPolicyId, excludeFleetServer: true, + refreshAgentPolicies, }) : AgentEnrollmentKeySelectionStep({ agentPolicy, selectedApiKeyId, setSelectedAPIKeyId }), DownloadStep(isFleetServerPolicySelected || false), @@ -165,6 +150,7 @@ export const ManagedInstructions = React.memo( setSelectedPolicyId, setSelectedAPIKeyId, agentPolicies, + refreshAgentPolicies, apiKey.data, fleetServerSteps, isFleetServerPolicySelected, diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/standalone_instructions.tsx index 4e5f17509fb2d..fa039a73e206e 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -43,233 +43,240 @@ import { import { PlatformSelector } from '../enrollment_instructions/manual/platform_selector'; import { DownloadStep, AgentPolicySelectionStep } from './steps'; -import type { BaseProps } from './types'; +import type { InstructionProps } from './types'; -type Props = BaseProps; +export const StandaloneInstructions = React.memo( + ({ agentPolicy, agentPolicies, refreshAgentPolicies }) => { + const { getHref } = useLink(); + const core = useStartServices(); + const { notifications } = core; -export const StandaloneInstructions = React.memo(({ agentPolicy, agentPolicies }) => { - const { getHref } = useLink(); - const core = useStartServices(); - const { notifications } = core; - - const [selectedPolicyId, setSelectedPolicyId] = useState(agentPolicy?.id); - const [fullAgentPolicy, setFullAgentPolicy] = useState(); - const [isK8s, setIsK8s] = useState<'IS_LOADING' | 'IS_KUBERNETES' | 'IS_NOT_KUBERNETES'>( - 'IS_LOADING' - ); - const [yaml, setYaml] = useState(''); - const linuxMacCommand = - isK8s === 'IS_KUBERNETES' ? KUBERNETES_RUN_INSTRUCTIONS : STANDALONE_RUN_INSTRUCTIONS_LINUXMAC; - const windowsCommand = - isK8s === 'IS_KUBERNETES' ? KUBERNETES_RUN_INSTRUCTIONS : STANDALONE_RUN_INSTRUCTIONS_WINDOWS; - const { docLinks } = useStartServices(); - - useEffect(() => { - async function checkifK8s() { - if (!selectedPolicyId) { - return; - } - const agentPolicyRequest = await sendGetOneAgentPolicy(selectedPolicyId); - const agentPol = agentPolicyRequest.data ? agentPolicyRequest.data.item : null; - - if (!agentPol) { - setIsK8s('IS_NOT_KUBERNETES'); - return; - } - const k8s = (pkg: PackagePolicy) => pkg.package?.name === FLEET_KUBERNETES_PACKAGE; - setIsK8s( - (agentPol.package_policies as PackagePolicy[]).some(k8s) - ? 'IS_KUBERNETES' - : 'IS_NOT_KUBERNETES' - ); - } - checkifK8s(); - }, [selectedPolicyId, notifications.toasts]); + const [selectedPolicyId, setSelectedPolicyId] = useState(agentPolicy?.id); + const [fullAgentPolicy, setFullAgentPolicy] = useState(); + const [isK8s, setIsK8s] = useState<'IS_LOADING' | 'IS_KUBERNETES' | 'IS_NOT_KUBERNETES'>( + 'IS_LOADING' + ); + const [yaml, setYaml] = useState(''); + const linuxMacCommand = + isK8s === 'IS_KUBERNETES' + ? KUBERNETES_RUN_INSTRUCTIONS + : STANDALONE_RUN_INSTRUCTIONS_LINUXMAC; + const windowsCommand = + isK8s === 'IS_KUBERNETES' ? KUBERNETES_RUN_INSTRUCTIONS : STANDALONE_RUN_INSTRUCTIONS_WINDOWS; + const { docLinks } = useStartServices(); - useEffect(() => { - async function fetchFullPolicy() { - try { + useEffect(() => { + async function checkifK8s() { if (!selectedPolicyId) { return; } - let query = { standalone: true, kubernetes: false }; - if (isK8s === 'IS_KUBERNETES') { - query = { standalone: true, kubernetes: true }; - } - const res = await sendGetOneAgentPolicyFull(selectedPolicyId, query); - if (res.error) { - throw res.error; - } + const agentPolicyRequest = await sendGetOneAgentPolicy(selectedPolicyId); + const agentPol = agentPolicyRequest.data ? agentPolicyRequest.data.item : null; - if (!res.data) { - throw new Error('No data while fetching full agent policy'); + if (!agentPol) { + setIsK8s('IS_NOT_KUBERNETES'); + return; } - setFullAgentPolicy(res.data.item); - } catch (error) { - notifications.toasts.addError(error, { - title: 'Error', - }); + const k8s = (pkg: PackagePolicy) => pkg.package?.name === FLEET_KUBERNETES_PACKAGE; + setIsK8s( + (agentPol.package_policies as PackagePolicy[]).some(k8s) + ? 'IS_KUBERNETES' + : 'IS_NOT_KUBERNETES' + ); } - } - if (isK8s !== 'IS_LOADING') { - fetchFullPolicy(); - } - }, [selectedPolicyId, notifications.toasts, isK8s, core.http.basePath]); + checkifK8s(); + }, [selectedPolicyId, notifications.toasts]); - useEffect(() => { - if (isK8s === 'IS_KUBERNETES') { - if (typeof fullAgentPolicy === 'object') { - return; + useEffect(() => { + async function fetchFullPolicy() { + try { + if (!selectedPolicyId) { + return; + } + let query = { standalone: true, kubernetes: false }; + if (isK8s === 'IS_KUBERNETES') { + query = { standalone: true, kubernetes: true }; + } + const res = await sendGetOneAgentPolicyFull(selectedPolicyId, query); + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data while fetching full agent policy'); + } + setFullAgentPolicy(res.data.item); + } catch (error) { + notifications.toasts.addError(error, { + title: 'Error', + }); + } } - setYaml(fullAgentPolicy); - } else { - if (typeof fullAgentPolicy === 'string') { - return; + if (isK8s !== 'IS_LOADING') { + fetchFullPolicy(); } - setYaml(fullAgentPolicyToYaml(fullAgentPolicy, safeDump)); - } - }, [fullAgentPolicy, isK8s]); + }, [selectedPolicyId, notifications.toasts, isK8s, core.http.basePath]); - const policyMsg = - isK8s === 'IS_KUBERNETES' ? ( - ES_USERNAME, - ESPasswordVariable: ES_PASSWORD, - }} - /> - ) : ( - elastic-agent.yml, - ESUsernameVariable: ES_USERNAME, - ESPasswordVariable: ES_PASSWORD, - outputSection: outputs, - }} - /> - ); + useEffect(() => { + if (isK8s === 'IS_KUBERNETES') { + if (typeof fullAgentPolicy === 'object') { + return; + } + setYaml(fullAgentPolicy); + } else { + if (typeof fullAgentPolicy === 'string') { + return; + } + setYaml(fullAgentPolicyToYaml(fullAgentPolicy, safeDump)); + } + }, [fullAgentPolicy, isK8s]); - let downloadLink = ''; - if (selectedPolicyId) { - downloadLink = - isK8s === 'IS_KUBERNETES' - ? core.http.basePath.prepend( - `${agentPolicyRouteService.getInfoFullDownloadPath(selectedPolicyId)}?kubernetes=true` - ) - : core.http.basePath.prepend( - `${agentPolicyRouteService.getInfoFullDownloadPath(selectedPolicyId)}?standalone=true` - ); - } + const policyMsg = + isK8s === 'IS_KUBERNETES' ? ( + ES_USERNAME, + ESPasswordVariable: ES_PASSWORD, + }} + /> + ) : ( + elastic-agent.yml, + ESUsernameVariable: ES_USERNAME, + ESPasswordVariable: ES_PASSWORD, + outputSection: outputs, + }} + /> + ); - const downloadMsg = - isK8s === 'IS_KUBERNETES' ? ( - - ) : ( - - ); + let downloadLink = ''; + if (selectedPolicyId) { + downloadLink = + isK8s === 'IS_KUBERNETES' + ? core.http.basePath.prepend( + `${agentPolicyRouteService.getInfoFullDownloadPath(selectedPolicyId)}?kubernetes=true` + ) + : core.http.basePath.prepend( + `${agentPolicyRouteService.getInfoFullDownloadPath(selectedPolicyId)}?standalone=true` + ); + } - const steps = [ - !agentPolicy - ? AgentPolicySelectionStep({ agentPolicies, setSelectedPolicyId, excludeFleetServer: true }) - : undefined, - DownloadStep(false), - { - title: i18n.translate('xpack.fleet.agentEnrollment.stepConfigureAgentTitle', { - defaultMessage: 'Configure the agent', - }), - children: ( - <> - - <>{policyMsg} - - - - - {(copy) => ( - - - - )} - - - - - <>{downloadMsg} - - - - - - {yaml} - - - - ), - }, - { - title: i18n.translate('xpack.fleet.agentEnrollment.stepRunAgentTitle', { - defaultMessage: 'Start the agent', - }), - children: ( - - ), - }, - { - title: i18n.translate('xpack.fleet.agentEnrollment.stepCheckForDataTitle', { - defaultMessage: 'Check for data', - }), - children: ( - <> - - - - - ), - }} - /> - - - ), - }, - ].filter(Boolean) as EuiContainedStepProps[]; - - return ( - <> - + ) : ( - - - - - ); -}); + ); + + const steps = [ + !agentPolicy + ? AgentPolicySelectionStep({ + agentPolicies, + setSelectedPolicyId, + excludeFleetServer: true, + refreshAgentPolicies, + }) + : undefined, + DownloadStep(false), + { + title: i18n.translate('xpack.fleet.agentEnrollment.stepConfigureAgentTitle', { + defaultMessage: 'Configure the agent', + }), + children: ( + <> + + <>{policyMsg} + + + + + {(copy) => ( + + + + )} + + + + + <>{downloadMsg} + + + + + + {yaml} + + + + ), + }, + { + title: i18n.translate('xpack.fleet.agentEnrollment.stepRunAgentTitle', { + defaultMessage: 'Start the agent', + }), + children: ( + + ), + }, + { + title: i18n.translate('xpack.fleet.agentEnrollment.stepCheckForDataTitle', { + defaultMessage: 'Check for data', + }), + children: ( + <> + + + + + ), + }} + /> + + + ), + }, + ].filter(Boolean) as EuiContainedStepProps[]; + + return ( + <> + + + + + + + ); + } +); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx index 953918a10f157..5e5f26b7317e4 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx @@ -81,32 +81,35 @@ export const AgentPolicySelectionStep = ({ selectedApiKeyId, setSelectedAPIKeyId, excludeFleetServer, + refreshAgentPolicies, }: { agentPolicies?: AgentPolicy[]; setSelectedPolicyId?: (policyId?: string) => void; selectedApiKeyId?: string; setSelectedAPIKeyId?: (key?: string) => void; excludeFleetServer?: boolean; + refreshAgentPolicies: () => void; }) => { - const [agentPolicyList, setAgentPolicyList] = useState(agentPolicies || []); - + // storing the created agent policy id as the child component is being recreated + const [policyId, setPolicyId] = useState(undefined); const regularAgentPolicies = useMemo(() => { - return agentPolicyList.filter( + return (agentPolicies ?? []).filter( (policy) => policy && !policy.is_managed && (!excludeFleetServer || !policyHasFleetServer(policy)) ); - }, [agentPolicyList, excludeFleetServer]); + }, [agentPolicies, excludeFleetServer]); const onAgentPolicyChange = useCallback( async (key?: string, policy?: AgentPolicy) => { if (policy) { - setAgentPolicyList([...agentPolicyList, policy]); + refreshAgentPolicies(); } if (setSelectedPolicyId) { setSelectedPolicyId(key); + setPolicyId(key); } }, - [setSelectedPolicyId, setAgentPolicyList, agentPolicyList] + [setSelectedPolicyId, refreshAgentPolicies] ); return { @@ -122,6 +125,7 @@ export const AgentPolicySelectionStep = ({ onKeyChange={setSelectedAPIKeyId} onAgentPolicyChange={onAgentPolicyChange} excludeFleetServer={excludeFleetServer} + policyId={policyId} /> ), diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts index 282a5b243caed..e5a3d345dba32 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts @@ -34,3 +34,7 @@ export interface BaseProps { isFleetServerPolicySelected?: boolean; } + +export interface InstructionProps extends BaseProps { + refreshAgentPolicies: () => void; +} diff --git a/x-pack/plugins/fleet/server/integration_tests/docker_registry_helper.ts b/x-pack/plugins/fleet/server/integration_tests/docker_registry_helper.ts index 902be3aa35bcd..31b0831d7f3e5 100644 --- a/x-pack/plugins/fleet/server/integration_tests/docker_registry_helper.ts +++ b/x-pack/plugins/fleet/server/integration_tests/docker_registry_helper.ts @@ -8,6 +8,7 @@ import { spawn } from 'child_process'; import type { ChildProcess } from 'child_process'; +import pRetry from 'p-retry'; import fetch from 'node-fetch'; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -49,8 +50,12 @@ export function useDockerRegistry() { await delay(3000); } + if (isExited && dockerProcess.exitCode !== 0) { + throw new Error(`Unable to setup docker registry exit code ${dockerProcess.exitCode}`); + } + dockerProcess.kill(); - throw new Error('Unable to setup docker registry'); + throw new pRetry.AbortError('Unable to setup docker registry after timeout'); } async function cleanupDockerRegistryServer() { @@ -60,8 +65,11 @@ export function useDockerRegistry() { } beforeAll(async () => { - jest.setTimeout(5 * 60 * 1000); // 5 minutes timeout - await startDockerRegistryServer(); + const testTimeout = 5 * 60 * 1000; // 5 minutes timeout + jest.setTimeout(testTimeout); + await pRetry(() => startDockerRegistryServer(), { + retries: 3, + }); }); afterAll(async () => { diff --git a/x-pack/plugins/fleet/server/services/agent_policies/monitoring_permissions.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/monitoring_permissions.test.ts index 3d928bed0f661..097cbd551fad5 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/monitoring_permissions.test.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/monitoring_permissions.test.ts @@ -43,6 +43,15 @@ describe('getMonitoringPermissions', () => { ); expect(permissions).toMatchSnapshot(); }); + + it('should an empty valid permission entry if neither metrics and logs are enabled', async () => { + const permissions = await getMonitoringPermissions( + savedObjectsClientMock.create(), + { logs: false, metrics: false }, + 'testnamespace123' + ); + expect(permissions).toEqual({ _elastic_agent_monitoring: { indices: [] } }); + }); }); describe('With elastic agent package installed', () => { diff --git a/x-pack/plugins/fleet/server/services/agent_policies/monitoring_permissions.ts b/x-pack/plugins/fleet/server/services/agent_policies/monitoring_permissions.ts index 3533d829e1342..7e897d62c8be9 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/monitoring_permissions.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/monitoring_permissions.ts @@ -30,6 +30,14 @@ function buildDefault(enabled: { logs: boolean; metrics: boolean }, namespace: s ); } + if (names.length === 0) { + return { + _elastic_agent_monitoring: { + indices: [], + }, + }; + } + return { _elastic_agent_monitoring: { indices: [ diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index 8cfb2844159bc..5996ce5404b70 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -116,10 +116,8 @@ function setKibanaVersion(url: URL) { } const kibanaVersion = appContextService.getKibanaVersion().split('-')[0]; // may be x.y.z-SNAPSHOT - const kibanaBranch = appContextService.getKibanaBranch(); - // on main, request all packages regardless of version - if (kibanaVersion && kibanaBranch !== 'main') { + if (kibanaVersion) { url.searchParams.set('kibana.version', kibanaVersion); } } diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index 3e185de8f8618..cb93933bb0d05 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -103,6 +103,8 @@ class PackagePolicyService { overwrite?: boolean; } ): Promise { + // trailing whitespace causes issues creating API keys + packagePolicy.name = packagePolicy.name.trim(); if (!options?.skipUniqueNameVerification) { const existingPoliciesWithName = await this.list(soClient, { perPage: 1, @@ -365,6 +367,7 @@ class PackagePolicyService { options?: { user?: AuthenticatedUser }, currentVersion?: string ): Promise { + packagePolicy.name = packagePolicy.name.trim(); const oldPackagePolicy = await this.get(soClient, id); const { version, ...restOfPackagePolicy } = packagePolicy; diff --git a/x-pack/plugins/global_search/jest.integration.config.js b/x-pack/plugins/global_search/jest.integration.config.js new file mode 100644 index 0000000000000..6fb4e4bfe6d68 --- /dev/null +++ b/x-pack/plugins/global_search/jest.integration.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../../..', + roots: ['/x-pack/plugins/global_search'], +}; diff --git a/x-pack/plugins/lens/common/constants.ts b/x-pack/plugins/lens/common/constants.ts index f0db3385cefc1..bd507be52e2ab 100644 --- a/x-pack/plugins/lens/common/constants.ts +++ b/x-pack/plugins/lens/common/constants.ts @@ -17,6 +17,32 @@ export const NOT_INTERNATIONALIZED_PRODUCT_NAME = 'Lens Visualizations'; export const BASE_API_URL = '/api/lens'; export const LENS_EDIT_BY_VALUE = 'edit_by_value'; +export const PieChartTypes = { + PIE: 'pie', + DONUT: 'donut', + TREEMAP: 'treemap', + MOSAIC: 'mosaic', + WAFFLE: 'waffle', +} as const; + +export const CategoryDisplay = { + DEFAULT: 'default', + INSIDE: 'inside', + HIDE: 'hide', +} as const; + +export const NumberDisplay = { + HIDDEN: 'hidden', + PERCENT: 'percent', + VALUE: 'value', +} as const; + +export const LegendDisplay = { + DEFAULT: 'default', + SHOW: 'show', + HIDE: 'hide', +} as const; + export const layerTypes: Record = { DATA: 'data', REFERENCELINE: 'referenceLine', diff --git a/x-pack/plugins/lens/common/expressions/index.ts b/x-pack/plugins/lens/common/expressions/index.ts index c5ee16ed4bcfd..d7c27c4436b42 100644 --- a/x-pack/plugins/lens/common/expressions/index.ts +++ b/x-pack/plugins/lens/common/expressions/index.ts @@ -12,7 +12,6 @@ export * from './merge_tables'; export * from './time_scale'; export * from './datatable'; export * from './metric_chart'; -export * from './pie_chart'; export * from './xy_chart'; export * from './expression_types'; diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts b/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts deleted file mode 100644 index feec2117632c0..0000000000000 --- a/x-pack/plugins/lens/common/expressions/pie_chart/pie_chart.ts +++ /dev/null @@ -1,131 +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 { Position } from '@elastic/charts'; -import { i18n } from '@kbn/i18n'; - -import type { ExpressionFunctionDefinition } from '../../../../../../src/plugins/expressions/common'; -import type { LensMultiTable } from '../../types'; -import type { PieExpressionProps, PieExpressionArgs } from './types'; - -interface PieRender { - type: 'render'; - as: 'lens_pie_renderer'; - value: PieExpressionProps; -} - -export const pie: ExpressionFunctionDefinition< - 'lens_pie', - LensMultiTable, - PieExpressionArgs, - PieRender -> = { - name: 'lens_pie', - type: 'render', - help: i18n.translate('xpack.lens.pie.expressionHelpLabel', { - defaultMessage: 'Pie renderer', - }), - args: { - title: { - types: ['string'], - help: 'The chart title.', - }, - description: { - types: ['string'], - help: '', - }, - groups: { - types: ['string'], - multi: true, - help: '', - }, - metric: { - types: ['string'], - help: '', - }, - shape: { - types: ['string'], - options: ['pie', 'donut', 'treemap', 'mosaic'], - help: '', - }, - hideLabels: { - types: ['boolean'], - help: '', - }, - numberDisplay: { - types: ['string'], - options: ['hidden', 'percent', 'value'], - help: '', - }, - categoryDisplay: { - types: ['string'], - options: ['default', 'inside', 'hide'], - help: '', - }, - legendDisplay: { - types: ['string'], - options: ['default', 'show', 'hide'], - help: '', - }, - nestedLegend: { - types: ['boolean'], - help: '', - }, - legendMaxLines: { - types: ['number'], - help: '', - }, - truncateLegend: { - types: ['boolean'], - help: '', - }, - showValuesInLegend: { - types: ['boolean'], - help: '', - }, - legendPosition: { - types: ['string'], - options: [Position.Top, Position.Right, Position.Bottom, Position.Left], - help: '', - }, - percentDecimals: { - types: ['number'], - help: '', - }, - palette: { - default: `{theme "palette" default={system_palette name="default"} }`, - help: '', - types: ['palette'], - }, - emptySizeRatio: { - types: ['number'], - help: '', - }, - ariaLabel: { - types: ['string'], - help: '', - required: false, - }, - }, - inputTypes: ['lens_multitable'], - fn(data: LensMultiTable, args: PieExpressionArgs, handlers) { - return { - type: 'render', - as: 'lens_pie_renderer', - value: { - data, - args: { - ...args, - ariaLabel: - args.ariaLabel ?? - (handlers.variables?.embeddableTitle as string) ?? - handlers.getExecutionContext?.()?.description, - }, - }, - }; - }, -}; diff --git a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts b/x-pack/plugins/lens/common/expressions/pie_chart/types.ts deleted file mode 100644 index aa84488dbc2c2..0000000000000 --- a/x-pack/plugins/lens/common/expressions/pie_chart/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { PaletteOutput } from '../../../../../../src/plugins/charts/common'; -import type { LensMultiTable, LayerType } from '../../types'; - -export type PieChartTypes = 'donut' | 'pie' | 'treemap' | 'mosaic' | 'waffle'; - -export interface SharedPieLayerState { - groups: string[]; - metric?: string; - numberDisplay: 'hidden' | 'percent' | 'value'; - categoryDisplay: 'default' | 'inside' | 'hide'; - legendDisplay: 'default' | 'show' | 'hide'; - legendPosition?: 'left' | 'right' | 'top' | 'bottom'; - showValuesInLegend?: boolean; - nestedLegend?: boolean; - percentDecimals?: number; - emptySizeRatio?: number; - legendMaxLines?: number; - truncateLegend?: boolean; -} - -export type PieLayerState = SharedPieLayerState & { - layerId: string; - layerType: LayerType; -}; - -export interface PieVisualizationState { - shape: PieChartTypes; - layers: PieLayerState[]; - palette?: PaletteOutput; -} - -export type PieExpressionArgs = SharedPieLayerState & { - title?: string; - description?: string; - shape: PieChartTypes; - hideLabels: boolean; - palette: PaletteOutput; - ariaLabel?: string; -}; - -export interface PieExpressionProps { - data: LensMultiTable; - args: PieExpressionArgs; -} diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index f3572fea90f9e..0b2b5d5d739d0 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -6,12 +6,16 @@ */ import type { Filter, FilterMeta } from '@kbn/es-query'; +import { Position } from '@elastic/charts'; +import { $Values } from '@kbn/utility-types'; import type { IFieldFormat, SerializedFieldFormat, } from '../../../../src/plugins/field_formats/common'; import type { Datatable } from '../../../../src/plugins/expressions/common'; import type { PaletteContinuity } from '../../../../src/plugins/charts/common'; +import type { PaletteOutput } from '../../../../src/plugins/charts/common'; +import { CategoryDisplay, LegendDisplay, NumberDisplay, PieChartTypes } from './constants'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; @@ -73,3 +77,41 @@ export type LayerType = 'data' | 'referenceLine'; // Shared by XY Chart and Heatmap as for now export type ValueLabelConfig = 'hide' | 'inside' | 'outside'; + +export type PieChartType = $Values; +export type CategoryDisplayType = $Values; +export type NumberDisplayType = $Values; + +export type LegendDisplayType = $Values; + +export enum EmptySizeRatios { + SMALL = 0.3, + MEDIUM = 0.54, + LARGE = 0.7, +} + +export interface SharedPieLayerState { + groups: string[]; + metric?: string; + numberDisplay: NumberDisplayType; + categoryDisplay: CategoryDisplayType; + legendDisplay: LegendDisplayType; + legendPosition?: Position; + showValuesInLegend?: boolean; + nestedLegend?: boolean; + percentDecimals?: number; + emptySizeRatio?: number; + legendMaxLines?: number; + truncateLegend?: boolean; +} + +export type PieLayerState = SharedPieLayerState & { + layerId: string; + layerType: LayerType; +}; + +export interface PieVisualizationState { + shape: $Values; + layers: PieLayerState[]; + palette?: PaletteOutput; +} diff --git a/x-pack/plugins/lens/public/app_plugin/app.scss b/x-pack/plugins/lens/public/app_plugin/app.scss index 00245384ec8b4..83b0a39be9229 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.scss +++ b/x-pack/plugins/lens/public/app_plugin/app.scss @@ -38,3 +38,23 @@ fill: makeGraphicContrastColor($euiColorVis0, $euiColorDarkShade); } } + +// Less-than-ideal styles to add a vertical divider after this button. Consider restructuring markup for better semantics and styling options in the future. +.lnsNavItem__goBack { + @include euiBreakpoint('m', 'l', 'xl') { + margin-right: $euiSizeM; + position: relative; + } + &::after { + @include euiBreakpoint('m', 'l', 'xl') { + border-right: $euiBorderThin; + bottom: 0; + content: ''; + display: block; + pointer-events: none; + position: absolute; + right: -$euiSizeS; + top: 0; + } + } +} diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 8b868539d325f..b16afbfc56a4a 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -1328,6 +1328,82 @@ describe('Lens App', () => { expect(defaultLeave).not.toHaveBeenCalled(); }); + it('should confirm when leaving from a context initial doc with changes made in lens', async () => { + const initialProps = { + ...makeDefaultProps(), + contextOriginatingApp: 'TSVB', + initialContext: { + layers: [ + { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + timeFieldName: 'order_date', + chartType: 'area', + axisPosition: 'left', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + fill: 0.5, + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: 1, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + savedObjectId: '', + vizEditorOriginatingAppUrl: '#/tsvb-link', + isVisualizeAction: true, + }, + }; + + const mountedApp = await mountWith({ + props: initialProps as unknown as jest.Mocked, + preloadedState: { + persistedDoc: defaultDoc, + visualization: { + activeId: 'testVis', + state: {}, + }, + isSaveable: true, + }, + }); + const lastCall = + mountedApp.props.onAppLeave.mock.calls[ + mountedApp.props.onAppLeave.mock.calls.length - 1 + ][0]; + lastCall({ default: defaultLeave, confirm: confirmLeave }); + expect(defaultLeave).not.toHaveBeenCalled(); + expect(confirmLeave).toHaveBeenCalled(); + }); + it('should not confirm when changes are saved', async () => { const preloadedState = { persistedDoc: { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 44552c12d680d..3660c3d3db0cb 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -6,10 +6,9 @@ */ import './app.scss'; - import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBreadcrumb } from '@elastic/eui'; +import { EuiBreadcrumb, EuiConfirmModal } from '@elastic/eui'; import { createKbnUrlStateStorage, withNotifyOnErrors, @@ -55,6 +54,7 @@ export function App({ setHeaderActionMenu, datasourceMap, visualizationMap, + contextOriginatingApp, topNavMenuEntryGenerators, initialContext, }: LensAppProps) { @@ -107,6 +107,10 @@ export function App({ const [indicateNoData, setIndicateNoData] = useState(false); const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); const [lastKnownDoc, setLastKnownDoc] = useState(undefined); + const [initialDocFromContext, setInitialDocFromContext] = useState( + undefined + ); + const [isGoBackToVizEditorModalVisible, setIsGoBackToVizEditorModalVisible] = useState(false); useEffect(() => { if (currentDoc) { @@ -169,7 +173,12 @@ export function App({ }), i18n.translate('xpack.lens.app.unsavedWorkTitle', { defaultMessage: 'Unsaved changes', - }) + }), + undefined, + i18n.translate('xpack.lens.app.unsavedWorkConfirmBtn', { + defaultMessage: 'Discard changes', + }), + 'danger' ); } else { return actions.default(); @@ -210,8 +219,14 @@ export function App({ // Sync Kibana breadcrumbs any time the saved document's title changes useEffect(() => { const isByValueMode = getIsByValueMode(); + const comesFromVizEditorDashboard = + initialContext && 'originatingApp' in initialContext && initialContext.originatingApp; const breadcrumbs: EuiBreadcrumb[] = []; - if (isLinkedToOriginatingApp && getOriginatingAppName() && redirectToOrigin) { + if ( + (isLinkedToOriginatingApp || comesFromVizEditorDashboard) && + getOriginatingAppName() && + redirectToOrigin + ) { breadcrumbs.push({ onClick: () => { redirectToOrigin(); @@ -250,6 +265,7 @@ export function App({ chrome, isLinkedToOriginatingApp, persistedDoc, + initialContext, ]); const runSave = useCallback( @@ -298,6 +314,65 @@ export function App({ ] ); + // keeping the initial doc state created by the context + useEffect(() => { + if (lastKnownDoc && !initialDocFromContext) { + setInitialDocFromContext(lastKnownDoc); + } + }, [lastKnownDoc, initialDocFromContext]); + + // if users comes to Lens from the Viz editor, they should have the option to navigate back + const goBackToOriginatingApp = useCallback(() => { + if ( + initialContext && + 'vizEditorOriginatingAppUrl' in initialContext && + initialContext.vizEditorOriginatingAppUrl + ) { + const initialDocFromContextHasChanged = !isLensEqual( + initialDocFromContext, + lastKnownDoc, + data.query.filterManager.inject, + datasourceMap + ); + if (!initialDocFromContextHasChanged) { + onAppLeave((actions) => { + return actions.default(); + }); + application.navigateToApp('visualize', { path: initialContext.vizEditorOriginatingAppUrl }); + } else { + setIsGoBackToVizEditorModalVisible(true); + } + } + }, [ + application, + data.query.filterManager.inject, + datasourceMap, + initialContext, + initialDocFromContext, + lastKnownDoc, + onAppLeave, + ]); + + const navigateToVizEditor = useCallback(() => { + setIsGoBackToVizEditorModalVisible(false); + if ( + initialContext && + 'vizEditorOriginatingAppUrl' in initialContext && + initialContext.vizEditorOriginatingAppUrl + ) { + onAppLeave((actions) => { + return actions.default(); + }); + application.navigateToApp('visualize', { path: initialContext.vizEditorOriginatingAppUrl }); + } + }, [application, initialContext, onAppLeave]); + + const initialContextIsEmbedded = useMemo(() => { + return Boolean( + initialContext && 'originatingApp' in initialContext && initialContext.originatingApp + ); + }, [initialContext]); + return ( <>
@@ -313,10 +388,12 @@ export function App({ datasourceMap={datasourceMap} title={persistedDoc?.title} lensInspector={lensInspector} + goBackToOriginatingApp={goBackToOriginatingApp} + contextOriginatingApp={contextOriginatingApp} + initialContextIsEmbedded={initialContextIsEmbedded} topNavMenuEntryGenerators={topNavMenuEntryGenerators} initialContext={initialContext} /> - {getLegacyUrlConflictCallout()} {(!isLoading || persistedDoc) && ( )} + {isGoBackToVizEditorModalVisible && ( + setIsGoBackToVizEditorModalVisible(false)} + onConfirm={navigateToVizEditor} + cancelButtonText={i18n.translate('xpack.lens.app.goBackModalCancelBtn', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('xpack.lens.app.goBackModalTitle', { + defaultMessage: 'Discard changes?', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.translate('xpack.lens.app.goBackModalMessage', { + defaultMessage: + 'The changes you have made here are not backwards compatible with your original {contextOriginatingApp} visualization. Are you sure you want to discard these unsaved changes and return to {contextOriginatingApp}?', + values: { contextOriginatingApp }, + })} + + )} ); } diff --git a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx index 90e924134d27b..8e8b7045fc253 100644 --- a/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx +++ b/x-pack/plugins/lens/public/app_plugin/lens_top_nav.tsx @@ -39,6 +39,7 @@ function getLensTopNavConfig(options: { tooltips: LensTopNavTooltips; savingToLibraryPermitted: boolean; savingToDashboardPermitted: boolean; + contextOriginatingApp?: string; }): TopNavMenuData[] { const { actions, @@ -49,6 +50,7 @@ function getLensTopNavConfig(options: { savingToLibraryPermitted, savingToDashboardPermitted, tooltips, + contextOriginatingApp, } = options; const topNavMenu: TopNavMenuData[] = []; @@ -71,6 +73,23 @@ function getLensTopNavConfig(options: { defaultMessage: 'Save', }); + if (contextOriginatingApp) { + topNavMenu.push({ + label: i18n.translate('xpack.lens.app.goBackLabel', { + defaultMessage: `Go back to {contextOriginatingApp}`, + values: { contextOriginatingApp }, + }), + run: actions.goBack, + className: 'lnsNavItem__goBack', + testId: 'lnsApp_goBackToAppButton', + description: i18n.translate('xpack.lens.app.goBackLabel', { + defaultMessage: `Go back to {contextOriginatingApp}`, + values: { contextOriginatingApp }, + }), + disableButton: false, + }); + } + topNavMenu.push({ label: i18n.translate('xpack.lens.app.inspect', { defaultMessage: 'Inspect', @@ -151,6 +170,9 @@ export const LensTopNavMenu = ({ redirectToOrigin, datasourceMap, title, + goBackToOriginatingApp, + contextOriginatingApp, + initialContextIsEmbedded, topNavMenuEntryGenerators, initialContext, }: LensTopNavMenuProps) => { @@ -270,17 +292,19 @@ export const LensTopNavMenu = ({ ]); const topNavConfig = useMemo(() => { const baseMenuEntries = getLensTopNavConfig({ - showSaveAndReturn: Boolean( - isLinkedToOriginatingApp && - // Temporarily required until the 'by value' paradigm is default. - (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) - ), + showSaveAndReturn: + Boolean( + isLinkedToOriginatingApp && + // Temporarily required until the 'by value' paradigm is default. + (dashboardFeatureFlag.allowByValueEmbeddables || Boolean(initialInput)) + ) || Boolean(initialContextIsEmbedded), enableExportToCSV: Boolean(isSaveable && activeData && Object.keys(activeData).length), isByValueMode: getIsByValueMode(), allowByValue: dashboardFeatureFlag.allowByValueEmbeddables, showCancel: Boolean(isLinkedToOriginatingApp), savingToLibraryPermitted, savingToDashboardPermitted, + contextOriginatingApp, tooltips: { showExportWarning: () => { if (activeData) { @@ -354,6 +378,11 @@ export const LensTopNavMenu = ({ setIsSaveModalVisible(true); } }, + goBack: () => { + if (contextOriginatingApp) { + goBackToOriginatingApp?.(); + } + }, cancel: () => { if (redirectToOrigin) { redirectToOrigin(); @@ -363,25 +392,28 @@ export const LensTopNavMenu = ({ }); return [...(additionalMenuEntries || []), ...baseMenuEntries]; }, [ - activeData, - attributeService, + isLinkedToOriginatingApp, dashboardFeatureFlag.allowByValueEmbeddables, - fieldFormats.deserialize, - getIsByValueMode, initialInput, - isLinkedToOriginatingApp, + initialContextIsEmbedded, isSaveable, + activeData, + getIsByValueMode, + savingToLibraryPermitted, + savingToDashboardPermitted, + contextOriginatingApp, + additionalMenuEntries, + lensInspector, title, + unsavedTitle, + uiSettings, + fieldFormats.deserialize, onAppLeave, - redirectToOrigin, runSave, - savingToDashboardPermitted, - savingToLibraryPermitted, + attributeService, setIsSaveModalVisible, - uiSettings, - unsavedTitle, - lensInspector, - additionalMenuEntries, + goBackToOriginatingApp, + redirectToOrigin, ]); const onQuerySubmitWrapped = useCallback( diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index e529c3ece055f..28db5e9f4c43a 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -29,6 +29,7 @@ import { LensByValueInput, } from '../embeddable/embeddable'; import { ACTION_VISUALIZE_LENS_FIELD } from '../../../../../src/plugins/ui_actions/public'; +import { ACTION_CONVERT_TO_LENS } from '../../../../../src/plugins/visualizations/public'; import { LensAttributeService } from '../lens_attribute_service'; import { LensAppServices, RedirectToOriginProps, HistoryLocationState } from './types'; import { @@ -155,28 +156,38 @@ export async function mountApp( }; const redirectToOrigin = (props?: RedirectToOriginProps) => { - if (!embeddableEditorIncomingState?.originatingApp) { + const contextOriginatingApp = + initialContext && 'originatingApp' in initialContext ? initialContext.originatingApp : null; + const originatingApp = embeddableEditorIncomingState?.originatingApp ?? contextOriginatingApp; + if (!originatingApp) { throw new Error('redirectToOrigin called without an originating app'); } + let embeddableId = embeddableEditorIncomingState?.embeddableId; + if (initialContext && 'embeddableId' in initialContext) { + embeddableId = initialContext.embeddableId; + } if (stateTransfer && props?.input) { const { input, isCopied } = props; - stateTransfer.navigateToWithEmbeddablePackage(embeddableEditorIncomingState?.originatingApp, { + stateTransfer.navigateToWithEmbeddablePackage(originatingApp, { path: embeddableEditorIncomingState?.originatingPath, state: { - embeddableId: isCopied ? undefined : embeddableEditorIncomingState.embeddableId, + embeddableId: isCopied ? undefined : embeddableId, type: LENS_EMBEDDABLE_TYPE, input, searchSessionId: data.search.session.getSessionId(), }, }); } else { - coreStart.application.navigateToApp(embeddableEditorIncomingState?.originatingApp, { + coreStart.application.navigateToApp(originatingApp, { path: embeddableEditorIncomingState?.originatingPath, }); } }; + // get state from location, used for nanigating from Visualize/Discover to Lens const initialContext = - historyLocationState && historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD + historyLocationState && + (historyLocationState.type === ACTION_VISUALIZE_LENS_FIELD || + historyLocationState.type === ACTION_CONVERT_TO_LENS) ? historyLocationState.payload : undefined; @@ -229,8 +240,9 @@ export async function mountApp( history={props.history} datasourceMap={datasourceMap} visualizationMap={visualizationMap} - topNavMenuEntryGenerators={topNavMenuEntryGenerators} initialContext={initialContext} + contextOriginatingApp={historyLocationState?.originatingApp} + topNavMenuEntryGenerators={topNavMenuEntryGenerators} /> ); diff --git a/x-pack/plugins/lens/public/app_plugin/types.ts b/x-pack/plugins/lens/public/app_plugin/types.ts index 3181df8b3256d..bdd7bebd991e7 100644 --- a/x-pack/plugins/lens/public/app_plugin/types.ts +++ b/x-pack/plugins/lens/public/app_plugin/types.ts @@ -31,6 +31,7 @@ import { VisualizeFieldContext, ACTION_VISUALIZE_LENS_FIELD, } from '../../../../../src/plugins/ui_actions/public'; +import { ACTION_CONVERT_TO_LENS } from '../../../../../src/plugins/visualizations/public'; import type { EmbeddableEditorState, EmbeddableStateTransfer, @@ -38,6 +39,7 @@ import type { import type { DatasourceMap, EditorFrameInstance, + VisualizeEditorContext, LensTopNavMenuEntryGenerator, VisualizationMap, } from '../types'; @@ -65,9 +67,9 @@ export interface LensAppProps { incomingState?: EmbeddableEditorState; datasourceMap: DatasourceMap; visualizationMap: VisualizationMap; - + initialContext?: VisualizeEditorContext | VisualizeFieldContext; + contextOriginatingApp?: string; topNavMenuEntryGenerators: LensTopNavMenuEntryGenerator[]; - initialContext?: VisualizeFieldContext; } export type RunSave = ( @@ -97,13 +99,17 @@ export interface LensTopNavMenuProps { datasourceMap: DatasourceMap; title?: string; lensInspector: LensInspector; + goBackToOriginatingApp?: () => void; + contextOriginatingApp?: string; + initialContextIsEmbedded?: boolean; topNavMenuEntryGenerators: LensTopNavMenuEntryGenerator[]; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; } export interface HistoryLocationState { - type: typeof ACTION_VISUALIZE_LENS_FIELD; - payload: VisualizeFieldContext; + type: typeof ACTION_VISUALIZE_LENS_FIELD | typeof ACTION_CONVERT_TO_LENS; + payload: VisualizeFieldContext | VisualizeEditorContext; + originatingApp?: string; } export interface LensAppServices { @@ -140,6 +146,7 @@ export interface LensTopNavActions { inspect: () => void; saveAndReturn: () => void; showSaveModal: () => void; + goBack: () => void; cancel: () => void; exportToCSV: () => void; } diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 6f5fb9c17a5aa..876d7f0522d9c 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -311,7 +311,7 @@ export const getDatatableVisualization = ({ { type: layerTypes.DATA, label: i18n.translate('xpack.lens.datatable.addLayer', { - defaultMessage: 'Add visualization layer', + defaultMessage: 'Visualization', }), }, ]; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx index b0c10abb75810..3bb47d0ccbb81 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/add_layer.tsx @@ -6,14 +6,7 @@ */ import React, { useState, useMemo } from 'react'; -import { - EuiToolTip, - EuiButton, - EuiPopover, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiIcon, -} from '@elastic/eui'; +import { EuiToolTip, EuiButton, EuiPopover, EuiIcon, EuiContextMenu } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { LayerType, layerTypes } from '../../../../common'; @@ -106,25 +99,29 @@ export function AddLayerButton({ closePopover={() => toggleLayersChoice(false)} panelPaddingSize="none" > - { - return ( - { - onAddLayerClick(type); - toggleLayersChoice(false); - }} - icon={icon && } - disabled={disabled} - toolTipContent={tooltipContent} - > - {label} - - ); - })} + { + return { + tooltipContent, + disabled, + name: label, + icon: icon && , + ['data-test-subj']: `lnsLayerAddButton-${type}`, + onClick: () => { + onAddLayerClick(type); + toggleLayersChoice(false); + }, + }; + }), + }, + ]} /> ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index 6879c35f30fe1..f2e4af61ddbdb 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useRef } from 'react'; import { CoreStart } from 'kibana/public'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; -import { DatasourceMap, FramePublicAPI, VisualizationMap } from '../../types'; +import { DatasourceMap, FramePublicAPI, VisualizationMap, Suggestion } from '../../types'; import { DataPanelWrapper } from './data_panel_wrapper'; import { ConfigPanelWrapper } from './config_panel'; import { FrameLayout } from './frame_layout'; @@ -16,7 +16,7 @@ import { SuggestionPanelWrapper } from './suggestion_panel'; import { WorkspacePanel } from './workspace_panel'; import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop'; import { EditorFrameStartPlugins } from '../service'; -import { getTopSuggestionForField, switchToSuggestion, Suggestion } from './suggestion_helpers'; +import { getTopSuggestionForField, switchToSuggestion } from './suggestion_helpers'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { useLensSelector, 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 0ea621997e859..40db06285d0b6 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 @@ -17,6 +17,7 @@ import { Visualization, VisualizationDimensionGroupConfig, VisualizationMap, + VisualizeEditorContext, } from '../../types'; import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; @@ -35,7 +36,7 @@ export async function initializeDatasources( datasourceMap: DatasourceMap, datasourceStates: DatasourceStates, references?: SavedObjectReference[], - initialContext?: VisualizeFieldContext, + initialContext?: VisualizeFieldContext | VisualizeEditorContext, options?: InitializationOptions ) { const states: DatasourceStates = {}; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts index 9d1e5910b468d..48536f8599060 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.test.ts @@ -7,7 +7,12 @@ import { getSuggestions, getTopSuggestionForField } from './suggestion_helpers'; import { createMockVisualization, createMockDatasource, DatasourceMock } from '../../mocks'; -import { TableSuggestion, DatasourceSuggestion, Visualization } from '../../types'; +import { + TableSuggestion, + DatasourceSuggestion, + Visualization, + VisualizeEditorContext, +} from '../../types'; import { PaletteOutput } from 'src/plugins/charts/public'; import { DatasourceStates } from '../../state_management'; @@ -251,6 +256,166 @@ describe('suggestion helpers', () => { ).not.toHaveBeenCalled(); }); + it('should call getDatasourceSuggestionsForVisualizeCharts when a visualizeChartTrigger is passed', () => { + datasourceMap.mock.getDatasourceSuggestionsForVisualizeCharts.mockReturnValue([ + generateSuggestion(), + ]); + + const visualizationMap = { + testVis: createMockVisualization(), + }; + const triggerContext = { + layers: [ + { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + timeFieldName: 'order_date', + chartType: 'area', + axisPosition: 'left', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + fill: '0.5', + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: true, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + isVisualizeAction: true, + } as VisualizeEditorContext; + + getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.testVis, + visualizationState: {}, + datasourceMap, + datasourceStates, + visualizeTriggerFieldContext: triggerContext, + }); + expect(datasourceMap.mock.getDatasourceSuggestionsForVisualizeCharts).toHaveBeenCalledWith( + datasourceStates.mock.state, + triggerContext.layers + ); + }); + + it('should call getDatasourceSuggestionsForVisualizeCharts from all datasources with a state', () => { + const multiDatasourceStates = { + mock: { + isLoading: false, + state: {}, + }, + mock2: { + isLoading: false, + state: {}, + }, + }; + const multiDatasourceMap = { + mock: createMockDatasource('a'), + mock2: createMockDatasource('a'), + mock3: createMockDatasource('a'), + }; + const triggerContext = { + layers: [ + { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + timeFieldName: 'order_date', + chartType: 'area', + axisPosition: 'left', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + fill: '0.5', + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: true, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + isVisualizeAction: true, + } as VisualizeEditorContext; + + const visualizationMap = { + testVis: createMockVisualization(), + }; + getSuggestions({ + visualizationMap, + activeVisualization: visualizationMap.testVis, + visualizationState: {}, + datasourceMap: multiDatasourceMap, + datasourceStates: multiDatasourceStates, + visualizeTriggerFieldContext: triggerContext, + }); + expect(multiDatasourceMap.mock.getDatasourceSuggestionsForVisualizeCharts).toHaveBeenCalledWith( + datasourceStates.mock.state, + triggerContext.layers + ); + + expect( + multiDatasourceMap.mock2.getDatasourceSuggestionsForVisualizeCharts + ).toHaveBeenCalledWith(multiDatasourceStates.mock2.state, triggerContext.layers); + expect( + multiDatasourceMap.mock3.getDatasourceSuggestionsForVisualizeCharts + ).not.toHaveBeenCalled(); + }); + it('should rank the visualizations by score', () => { const mockVisualization1 = createMockVisualization(); const mockVisualization2 = createMockVisualization(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts index ac55d966927bd..b8ce851f25349 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_helpers.ts @@ -5,20 +5,19 @@ * 2.0. */ -import { Ast } from '@kbn/interpreter'; -import { IconType } from '@elastic/eui/src/components/icon/icon'; import { Datatable } from 'src/plugins/expressions'; import { PaletteOutput } from 'src/plugins/charts/public'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; import { Visualization, Datasource, - TableChangeType, TableSuggestion, DatasourceSuggestion, DatasourcePublicAPI, DatasourceMap, VisualizationMap, + VisualizeEditorContext, + Suggestion, } from '../../types'; import { DragDropIdentifier } from '../../drag_drop'; import { LayerType, layerTypes } from '../../../common'; @@ -30,21 +29,6 @@ import { VisualizationState, } from '../../state_management'; -export interface Suggestion { - visualizationId: string; - datasourceState?: unknown; - datasourceId?: string; - columns: number; - score: number; - title: string; - visualizationState: unknown; - previewExpression?: Ast | string; - previewIcon: IconType; - hide?: boolean; - changeType: TableChangeType; - keptLayerIds: string[]; -} - /** * This function takes a list of available data tables and a list of visualization * extensions and creates a ranked list of suggestions which contain a pair of a data table @@ -72,7 +56,7 @@ export function getSuggestions({ subVisualizationId?: string; visualizationState: unknown; field?: unknown; - visualizeTriggerFieldContext?: VisualizeFieldContext; + visualizeTriggerFieldContext?: VisualizeFieldContext | VisualizeEditorContext; activeData?: Record; mainPalette?: PaletteOutput; }): Suggestion[] { @@ -100,12 +84,22 @@ export function getSuggestions({ const datasourceTableSuggestions = datasources.flatMap(([datasourceId, datasource]) => { const datasourceState = datasourceStates[datasourceId].state; let dataSourceSuggestions; + // context is used to pass the state from location to datasource if (visualizeTriggerFieldContext) { - dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( - datasourceState, - visualizeTriggerFieldContext.indexPatternId, - visualizeTriggerFieldContext.fieldName - ); + // used for navigating from VizEditor to Lens + if ('isVisualizeAction' in visualizeTriggerFieldContext) { + dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeCharts( + datasourceState, + visualizeTriggerFieldContext.layers + ); + } else { + // used for navigating from Discover to Lens + dataSourceSuggestions = datasource.getDatasourceSuggestionsForVisualizeField( + datasourceState, + visualizeTriggerFieldContext.indexPatternId, + visualizeTriggerFieldContext.fieldName + ); + } } else if (field) { dataSourceSuggestions = datasource.getDatasourceSuggestionsForField( datasourceState, @@ -170,7 +164,7 @@ export function getVisualizeFieldSuggestions({ datasourceStates: DatasourceStates; visualizationMap: VisualizationMap; subVisualizationId?: string; - visualizeTriggerFieldContext?: VisualizeFieldContext; + visualizeTriggerFieldContext?: VisualizeFieldContext | VisualizeEditorContext; }): Suggestion | undefined { const activeVisualization = visualizationMap?.[Object.keys(visualizationMap)[0]] || null; const suggestions = getSuggestions({ @@ -181,6 +175,17 @@ export function getVisualizeFieldSuggestions({ visualizationState: undefined, visualizeTriggerFieldContext, }); + + if (visualizeTriggerFieldContext && 'isVisualizeAction' in visualizeTriggerFieldContext) { + const allSuggestions = suggestions.filter( + (s) => s.visualizationId === visualizeTriggerFieldContext.type + ); + return activeVisualization?.getVisualizationSuggestionFromContext?.({ + suggestions: allSuggestions, + context: visualizeTriggerFieldContext, + }); + } + if (suggestions.length) { return suggestions.find((s) => s.visualizationId === activeVisualization?.id) || suggestions[0]; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx index 47070822a8080..c9ddc0ea6551c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { Visualization } from '../../types'; +import { Visualization, Suggestion } from '../../types'; import { createMockVisualization, createMockDatasource, @@ -17,7 +17,7 @@ import { import { act } from 'react-dom/test-utils'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; import { SuggestionPanel, SuggestionPanelProps, SuggestionPanelWrapper } from './suggestion_panel'; -import { getSuggestions, Suggestion } from './suggestion_helpers'; +import { getSuggestions } from './suggestion_helpers'; import { EuiIcon, EuiPanel, EuiToolTip, EuiAccordion } from '@elastic/eui'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { mountWithProvider } from '../../mocks'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 101f863d3227c..d24ed0a736ae2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -26,8 +26,9 @@ import { VisualizationType, VisualizationMap, DatasourceMap, + Suggestion, } from '../../../types'; -import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_helpers'; +import { getSuggestions, switchToSuggestion } from '../suggestion_helpers'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { ToolbarButton } from '../../../../../../../src/plugins/kibana_react/public'; import { 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 5f14e83bf41a1..3554f77047577 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 @@ -37,9 +37,10 @@ import { VisualizationMap, DatasourceMap, DatasourceFixAction, + Suggestion, } from '../../../types'; import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop'; -import { Suggestion, switchToSuggestion } from '../suggestion_helpers'; +import { switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; diff --git a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx index b8fd06a09ebcd..482a5b931ed78 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable_component.tsx @@ -23,7 +23,8 @@ import type { LensByReferenceInput, LensByValueInput } from './embeddable'; import type { Document } from '../persistence'; import type { IndexPatternPersistedState } from '../indexpattern_datasource/types'; import type { XYState } from '../xy_visualization/types'; -import type { PieVisualizationState, MetricState } from '../../common/expressions'; +import type { MetricState } from '../../common/expressions'; +import type { PieVisualizationState } from '../../common'; import type { DatatableVisualizationState } from '../datatable_visualization/visualization'; import type { HeatmapVisualizationState } from '../heatmap_visualization/types'; import type { GaugeVisualizationState } from '../visualizations/gauge/constants'; diff --git a/x-pack/plugins/lens/public/expressions.ts b/x-pack/plugins/lens/public/expressions.ts index 22e43addefcdd..2e5a30345633f 100644 --- a/x-pack/plugins/lens/public/expressions.ts +++ b/x-pack/plugins/lens/public/expressions.ts @@ -24,7 +24,6 @@ import { datatableColumn } from '../common/expressions/datatable/datatable_colum import { mergeTables } from '../common/expressions/merge_tables'; import { renameColumns } from '../common/expressions/rename_columns/rename_columns'; -import { pie } from '../common/expressions/pie_chart/pie_chart'; import { formatColumn } from '../common/expressions/format_column'; import { counterRate } from '../common/expressions/counter_rate'; import { getTimeScale } from '../common/expressions/time_scale/time_scale'; @@ -39,7 +38,6 @@ export const setupExpressions = ( [lensMultitable].forEach((expressionType) => expressions.registerType(expressionType)); [ - pie, xyChart, mergeTables, counterRate, diff --git a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx index 046e66e3f4708..5eebcc0fd6a94 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/visualization.tsx @@ -285,7 +285,7 @@ export const getHeatmapVisualization = ({ { type: layerTypes.DATA, label: i18n.translate('xpack.lens.heatmap.addLayer', { - defaultMessage: 'Add visualization layer', + defaultMessage: 'Visualization', }), }, ]; diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 1c045e63e9e26..f6ccb071075ac 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -14,9 +14,6 @@ export type { export type { XYState } from './xy_visualization/types'; export type { DataType, OperationMetadata, Visualization } from './types'; export type { - PieVisualizationState, - PieLayerState, - SharedPieLayerState, MetricState, AxesSettingsConfig, XYLayerConfig, @@ -26,7 +23,13 @@ export type { XYCurveType, YConfig, } from '../common/expressions'; -export type { ValueLabelConfig } from '../common/types'; +export type { + ValueLabelConfig, + PieVisualizationState, + PieLayerState, + SharedPieLayerState, +} from '../common/types'; + export type { DatatableVisualizationState } from './datatable_visualization/visualization'; export type { HeatmapVisualizationState } from './heatmap_visualization/types'; export type { GaugeVisualizationState } from './visualizations/gauge/constants'; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx index 087d2e51f6dd5..db3dfe8901fb9 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/advanced_options.tsx @@ -9,16 +9,16 @@ import { EuiLink, EuiText, EuiPopover, EuiButtonEmpty, EuiSpacer } from '@elasti import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -export function AdvancedOptions(props: { - options: Array<{ - title: string; - dataTestSubj: string; - onClick: () => void; - showInPopover: boolean; - inlineElement: React.ReactElement | null; - helpPopup?: string | null; - }>; -}) { +interface AdvancedOption { + title: string; + dataTestSubj: string; + onClick: () => void; + showInPopover: boolean; + inlineElement: React.ReactElement | null; + helpPopup?: string | null; +} + +export function AdvancedOptions(props: { options: AdvancedOption[] }) { const [popoverOpen, setPopoverOpen] = useState(false); const popoverOptions = props.options.filter((option) => option.showInPopover); const inlineOptions = props.options.filter((option) => option.inlineElement); 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 45bdfd2091a3b..6f991bb3f27c2 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 @@ -405,7 +405,20 @@ export function DimensionEditor(props: DimensionEditorProps) { !currentFieldIsInvalid && !incompleteInfo && selectedColumn && - isQuickFunction(selectedColumn.operationType); + isQuickFunction(selectedColumn.operationType) && + ParamEditor; + + const shouldDisplayReferenceEditor = + !incompleteInfo && + selectedColumn && + 'references' in selectedColumn && + selectedOperationDefinition?.input === 'fullReference'; + + const shouldDisplayFieldInput = + !selectedColumn || + selectedOperationDefinition?.input === 'field' || + (incompleteOperation && operationDefinitionMap[incompleteOperation]?.input === 'field') || + temporaryQuickFunction; const FieldInputComponent = selectedOperationDefinition?.renderFieldInput || FieldInput; @@ -431,10 +444,7 @@ export function DimensionEditor(props: DimensionEditorProps) {
- {!incompleteInfo && - selectedColumn && - 'references' in selectedColumn && - selectedOperationDefinition?.input === 'fullReference' ? ( + {shouldDisplayReferenceEditor ? ( <> {selectedColumn.references.map((referenceId, index) => { const validation = selectedOperationDefinition.requiredReferences[index]; @@ -478,10 +488,7 @@ export function DimensionEditor(props: DimensionEditorProps) { ) : null} - {!selectedColumn || - selectedOperationDefinition?.input === 'field' || - (incompleteOperation && operationDefinitionMap[incompleteOperation]?.input === 'field') || - temporaryQuickFunction ? ( + {shouldDisplayFieldInput ? ( ) : null} - {shouldDisplayExtraOptions && ParamEditor && ( + {shouldDisplayExtraOptions && ( {hasTabs ? : null} @@ -656,108 +674,103 @@ export function DimensionEditor(props: DimensionEditorProps) { /> {TabContent} - {!isFullscreen && - !currentFieldIsInvalid && - !incompleteInfo && - selectedColumn && - temporaryState === 'none' && - selectedOperationDefinition && ( -
- { - setStateWrapper( - setTimeScaling(columnId, state.layers[layerId], DEFAULT_TIME_SCALE) - ); - }, - showInPopover: Boolean( - selectedOperationDefinition.timeScalingMode && - selectedOperationDefinition.timeScalingMode !== 'disabled' && + {shouldDisplayAdvancedOptions && ( +
+ { + setStateWrapper( + setTimeScaling(columnId, state.layers[layerId], DEFAULT_TIME_SCALE) + ); + }, + showInPopover: Boolean( + selectedOperationDefinition.timeScalingMode && + selectedOperationDefinition.timeScalingMode !== 'disabled' && + Object.values(state.layers[layerId].columns).some( + (col) => col.operationType === 'date_histogram' + ) && + !selectedColumn.timeScale + ), + inlineElement: selectedOperationDefinition.timeScalingMode ? ( + + ) : null, + }, + { + title: i18n.translate('xpack.lens.indexPattern.filterBy.label', { + defaultMessage: 'Filter by', + }), + dataTestSubj: 'indexPattern-filter-by-enable', + onClick: () => { + setFilterByOpenInitally(true); + setStateWrapper(setFilter(columnId, state.layers[layerId], defaultFilter)); + }, + showInPopover: Boolean( + selectedOperationDefinition.filterable && !selectedColumn.filter + ), + inlineElement: + selectedOperationDefinition.filterable && selectedColumn.filter ? ( + + ) : null, + }, + { + title: i18n.translate('xpack.lens.indexPattern.timeShift.label', { + defaultMessage: 'Time shift', + }), + dataTestSubj: 'indexPattern-time-shift-enable', + onClick: () => { + setTimeShiftFocused(true); + setStateWrapper(setTimeShift(columnId, state.layers[layerId], '')); + }, + showInPopover: Boolean( + selectedOperationDefinition.shiftable && + selectedColumn.timeShift === undefined && + (currentIndexPattern.timeFieldName || Object.values(state.layers[layerId].columns).some( (col) => col.operationType === 'date_histogram' - ) && - !selectedColumn.timeScale - ), - inlineElement: ( - - ), - }, - { - title: i18n.translate('xpack.lens.indexPattern.filterBy.label', { - defaultMessage: 'Filter by', - }), - dataTestSubj: 'indexPattern-filter-by-enable', - onClick: () => { - setFilterByOpenInitally(true); - setStateWrapper(setFilter(columnId, state.layers[layerId], defaultFilter)); - }, - showInPopover: Boolean( - selectedOperationDefinition.filterable && !selectedColumn.filter - ), - inlineElement: - selectedOperationDefinition.filterable && selectedColumn.filter ? ( - - ) : null, - }, - { - title: i18n.translate('xpack.lens.indexPattern.timeShift.label', { - defaultMessage: 'Time shift', - }), - dataTestSubj: 'indexPattern-time-shift-enable', - onClick: () => { - setTimeShiftFocused(true); - setStateWrapper(setTimeShift(columnId, state.layers[layerId], '')); - }, - showInPopover: Boolean( - selectedOperationDefinition.shiftable && - selectedColumn.timeShift === undefined && - (currentIndexPattern.timeFieldName || - Object.values(state.layers[layerId].columns).some( - (col) => col.operationType === 'date_histogram' - )) - ), - inlineElement: - selectedOperationDefinition.shiftable && - selectedColumn.timeShift !== undefined ? ( - - ) : null, - }, - ]} - /> -
- )} + ) : null, + }, + ]} + /> +
+ )} {!isFullscreen && !currentFieldIsInvalid && (
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx index cbe158372e5a2..70475fba67cff 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimensions_editor_helpers.tsx @@ -48,8 +48,8 @@ export const LabelInput = ({ return ( { }); }); + describe('#getDatasourceSuggestionsForVisualizeCharts', () => { + const context = [ + { + indexPatternId: '1', + timeFieldName: 'timestamp', + chartType: 'area', + axisPosition: 'left', + palette: { + name: 'default', + type: 'palette', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ] as VisualizeEditorLayersContext[]; + function stateWithoutLayer() { + return { + ...testInitialState(), + layers: {}, + }; + } + + it('should return empty array if indexpattern id doesnt match the state', () => { + const updatedContext = [ + { + ...context[0], + indexPatternId: 'test', + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toStrictEqual([]); + }); + + it('should apply a count metric, with a timeseries bucket', () => { + const suggestions = getDatasourceSuggestionsForVisualizeCharts(stateWithoutLayer(), context); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + }), + id3: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a custom label if given', () => { + const updatedContext = [ + { + ...context[0], + label: 'testLabel', + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + label: 'testLabel', + }), + id3: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a custom format if given', () => { + const updatedContext = [ + { + ...context[0], + format: 'bytes', + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + label: 'Count of records', + params: expect.objectContaining({ + format: { + id: 'bytes', + params: { + decimals: 0, + }, + }, + }), + }), + id3: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a split by terms aggregation if it is provided', () => { + const updatedContext = [ + { + ...context[0], + splitField: 'source', + splitMode: 'terms', + termsParams: { + size: 10, + otherBucket: false, + orderBy: { + type: 'column', + }, + }, + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id4', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + }), + id3: expect.objectContaining({ + operationType: 'terms', + sourceField: 'source', + params: expect.objectContaining({ + size: 10, + otherBucket: false, + orderDirection: 'desc', + }), + }), + id4: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id4', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a split by filters aggregation if it is provided', () => { + const updatedContext = [ + { + ...context[0], + splitMode: 'filters', + splitFilters: [ + { + filter: { + query: 'category.keyword : "Men\'s Clothing" ', + language: 'kuery', + }, + label: '', + color: '#68BC00', + id: 'a8d92740-7de1-11ec-b443-27e8df79881f', + }, + { + filter: { + query: 'category.keyword : "Women\'s Accessories" ', + language: 'kuery', + }, + label: '', + color: '#68BC00', + id: 'ad5dc500-7de1-11ec-b443-27e8df79881f', + }, + ], + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id4', 'id3', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'count', + sourceField: '___records___', + }), + id3: expect.objectContaining({ + operationType: 'filters', + label: 'Filters', + params: expect.objectContaining({ + filters: [ + { + input: { + language: 'kuery', + query: 'category.keyword : "Men\'s Clothing" ', + }, + label: '', + }, + { + input: { + language: 'kuery', + query: 'category.keyword : "Women\'s Accessories" ', + }, + label: '', + }, + ], + }), + }), + id4: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id4', + }), + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + + it('should apply a formula layer if it is provided', () => { + const updatedContext = [ + { + ...context[0], + metrics: [ + { + agg: 'formula', + isFullReference: true, + fieldName: 'document', + params: { + formula: 'overall_sum(count())', + }, + color: '#68BC00', + }, + ], + }, + ]; + const suggestions = getDatasourceSuggestionsForVisualizeCharts( + stateWithoutLayer(), + updatedContext + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: { + id1: expect.objectContaining({ + columnOrder: ['id3', 'id2X0', 'id2X1', 'id2'], + columns: { + id2: expect.objectContaining({ + operationType: 'formula', + params: expect.objectContaining({ + formula: 'overall_sum(count())', + }), + }), + id2X0: expect.objectContaining({ + operationType: 'count', + label: 'Part of overall_sum(count())', + }), + id2X1: expect.objectContaining({ + operationType: 'overall_sum', + label: 'Part of overall_sum(count())', + }), + id3: expect.objectContaining({ + operationType: 'date_histogram', + sourceField: 'timestamp', + }), + }, + }), + }, + }), + table: { + changeType: 'initial', + label: undefined, + isMultiRow: true, + columns: [ + expect.objectContaining({ + columnId: 'id3', + }), + expect.objectContaining({ + columnId: 'id2', + }), + ], + layerId: 'id1', + }, + }) + ); + }); + }); + describe('#getDatasourceSuggestionsForVisualizeField', () => { describe('with no layer', () => { function stateWithoutLayer() { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index a96a43f74f0f4..0e6fbf02a491e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -7,6 +7,7 @@ import { flatten, minBy, pick, mapValues, partition } from 'lodash'; import { i18n } from '@kbn/i18n'; +import type { VisualizeEditorLayersContext } from '../../../../../src/plugins/visualizations/public'; import { generateId } from '../id_generator'; import type { DatasourceSuggestion, TableChangeType } from '../types'; import { columnToOperation } from './indexpattern'; @@ -21,6 +22,9 @@ import { getExistingColumnGroups, isReferenced, getReferencedColumnIds, + getSplitByTermsLayer, + getSplitByFiltersLayer, + computeLayerFromContext, hasTermsWithManyBuckets, } from './operations'; import { hasField } from './pure_utils'; @@ -31,7 +35,6 @@ import type { IndexPatternField, } from './types'; import { documentField } from './document_field'; - export type IndexPatternSuggestion = DatasourceSuggestion; function buildSuggestion({ @@ -129,6 +132,86 @@ export function getDatasourceSuggestionsForField( } } +// Called when the user navigates from Visualize editor to Lens +export function getDatasourceSuggestionsForVisualizeCharts( + state: IndexPatternPrivateState, + context: VisualizeEditorLayersContext[] +): IndexPatternSuggestion[] { + const layers = Object.keys(state.layers); + const layerIds = layers.filter( + (id) => state.layers[id].indexPatternId === context[0].indexPatternId + ); + if (layerIds.length !== 0) return []; + return getEmptyLayersSuggestionsForVisualizeCharts(state, context); +} + +function getEmptyLayersSuggestionsForVisualizeCharts( + state: IndexPatternPrivateState, + context: VisualizeEditorLayersContext[] +): IndexPatternSuggestion[] { + const suggestions: IndexPatternSuggestion[] = []; + for (let layerIdx = 0; layerIdx < context.length; layerIdx++) { + const layer = context[layerIdx]; + const indexPattern = state.indexPatterns[layer.indexPatternId]; + if (!indexPattern) return []; + + const newId = generateId(); + let newLayer: IndexPatternLayer | undefined; + if (indexPattern.timeFieldName) { + newLayer = createNewTimeseriesLayerWithMetricAggregationFromVizEditor(indexPattern, layer); + } + if (newLayer) { + const suggestion = buildSuggestion({ + state, + updatedLayer: newLayer, + layerId: newId, + changeType: 'initial', + }); + const layerId = Object.keys(suggestion.state.layers)[0]; + context[layerIdx].layerId = layerId; + suggestions.push(suggestion); + } + } + return suggestions; +} + +function createNewTimeseriesLayerWithMetricAggregationFromVizEditor( + indexPattern: IndexPattern, + layer: VisualizeEditorLayersContext +): IndexPatternLayer | undefined { + const { timeFieldName, splitMode, splitFilters, metrics, timeInterval } = layer; + const dateField = indexPattern.getFieldByName(timeFieldName!); + const splitField = layer.splitField ? indexPattern.getFieldByName(layer.splitField) : null; + // generate the layer for split by terms + if (splitMode === 'terms' && splitField) { + return getSplitByTermsLayer(indexPattern, splitField, dateField, layer); + // generate the layer for split by filters + } else if (splitMode?.includes('filter') && splitFilters && splitFilters.length) { + return getSplitByFiltersLayer(indexPattern, dateField, layer); + } else { + const copyMetricsArray = [...metrics]; + const computedLayer = computeLayerFromContext( + metrics.length === 1, + copyMetricsArray, + indexPattern, + layer.format, + layer.label + ); + + return insertNewColumn({ + op: 'date_histogram', + layer: computedLayer, + columnId: generateId(), + field: dateField, + indexPattern, + visualizationGroups: [], + columnParams: { + interval: timeInterval, + }, + }); + } +} + // Called when the user navigates from Discover to Lens (Visualize button) export function getDatasourceSuggestionsForVisualizeField( state: IndexPatternPrivateState, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index d2922ed86614a..9099b68cdaf0e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -506,6 +506,58 @@ describe('loader', () => { }); }); + it('should use the indexPatternId of the visualize trigger chart context, if provided', async () => { + const storage = createMockStorage(); + const state = await loadInitialState({ + indexPatternsService: mockIndexPatternsService(), + storage, + initialContext: { + layers: [ + { + indexPatternId: '1', + timeFieldName: 'timestamp', + chartType: 'area', + axisPosition: 'left', + metrics: [], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: true, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + }, + savedObjectId: '', + isVisualizeAction: true, + }, + options: { isFullEditor: true }, + }); + + expect(state).toMatchObject({ + currentIndexPatternId: '1', + indexPatternRefs: [ + { id: '1', title: sampleIndexPatterns['1'].title }, + { id: '2', title: sampleIndexPatterns['2'].title }, + ], + indexPatterns: { + '1': sampleIndexPatterns['1'], + }, + layers: {}, + }); + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: '1', + }); + }); + it('should initialize all the embeddable references without local storage', async () => { const savedState: IndexPatternPersistedState = { layers: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index c61569539bec8..8b3a0556b0320 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -9,8 +9,7 @@ import { uniq, mapValues, difference } from 'lodash'; import type { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import type { DataView } from 'src/plugins/data_views/public'; import type { HttpSetup, SavedObjectReference } from 'kibana/public'; -import type { InitializationOptions, StateSetter } from '../types'; - +import type { InitializationOptions, StateSetter, VisualizeEditorContext } from '../types'; import { IndexPattern, IndexPatternRef, @@ -226,7 +225,7 @@ export async function loadInitialState({ defaultIndexPatternId?: string; storage: IStorageWrapper; indexPatternsService: IndexPatternsService; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; options?: InitializationOptions; }): Promise { const { isFullEditor } = options ?? {}; @@ -237,12 +236,20 @@ export async function loadInitialState({ const lastUsedIndexPatternId = getLastUsedIndexPatternId(storage, indexPatternRefs); const fallbackId = lastUsedIndexPatternId || defaultIndexPatternId || indexPatternRefs[0]?.id; - + const indexPatternIds = []; + if (initialContext && 'isVisualizeAction' in initialContext) { + for (let layerIdx = 0; layerIdx < initialContext.layers.length; layerIdx++) { + const layerContext = initialContext.layers[layerIdx]; + indexPatternIds.push(layerContext.indexPatternId); + } + } else if (initialContext) { + indexPatternIds.push(initialContext.indexPatternId); + } const state = persistedState && references ? injectReferences(persistedState, references) : undefined; const usedPatterns = ( initialContext - ? [initialContext.indexPatternId] + ? indexPatternIds : uniq( state ? Object.values(state.layers) @@ -272,11 +279,9 @@ export async function loadInitialState({ // * start with the indexPattern in context // * then fallback to the used ones // * then as last resort use a first one from not used refs - const availableIndexPatternIds = [ - initialContext?.indexPatternId, - ...usedPatterns, - ...notUsedPatterns, - ].filter((id) => id != null && availableIndexPatterns.has(id) && indexPatterns[id]); + const availableIndexPatternIds = [...indexPatternIds, ...usedPatterns, ...notUsedPatterns].filter( + (id) => id != null && availableIndexPatterns.has(id) && indexPatterns[id] + ); const currentIndexPatternId = availableIndexPatternIds[0]; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx index 3f051286f3da9..674eac8194e41 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/counter_rate.tsx @@ -81,7 +81,9 @@ export const counterRateOperation: OperationDefinition< }, buildColumn: ({ referenceIds, previousColumn, layer, indexPattern }, columnParams) => { const metric = layer.columns[referenceIds[0]]; - const timeScale = previousColumn?.timeScale || DEFAULT_TIME_SCALE; + const counterRateColumnParams = columnParams as CounterRateIndexPatternColumn; + const timeScale = + previousColumn?.timeScale || counterRateColumnParams?.timeScale || DEFAULT_TIME_SCALE; return { label: ofName( metric && 'sourceField' in metric diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx index 31b21327958d7..2c4ab56d7e223 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/differences.tsx @@ -75,6 +75,8 @@ export const derivativeOperation: OperationDefinition< }, buildColumn: ({ referenceIds, previousColumn, layer }, columnParams) => { const ref = layer.columns[referenceIds[0]]; + const differencesColumnParams = columnParams as DerivativeIndexPatternColumn; + const timeScale = differencesColumnParams?.timeScale ?? previousColumn?.timeScale; return { label: ofName(ref?.label, previousColumn?.timeScale, previousColumn?.timeShift), dataType: 'number', @@ -82,7 +84,7 @@ export const derivativeOperation: OperationDefinition< isBucketed: false, scale: 'ratio', references: referenceIds, - timeScale: previousColumn?.timeScale, + timeScale, filter: getFilter(previousColumn, columnParams), timeShift: columnParams?.shift || previousColumn?.timeShift, params: getFormatFromPreviousColumn(previousColumn), diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx index 1a8519e6a60a1..aa68c8409ad80 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/calculations/moving_average.tsx @@ -92,12 +92,10 @@ export const movingAverageOperation: OperationDefinition< window: [(layer.columns[columnId] as MovingAverageIndexPatternColumn).params.window], }); }, - buildColumn: ( - { referenceIds, previousColumn, layer }, - columnParams = { window: WINDOW_DEFAULT_VALUE } - ) => { + buildColumn: ({ referenceIds, previousColumn, layer }, columnParams) => { const metric = layer.columns[referenceIds[0]]; - const { window = WINDOW_DEFAULT_VALUE } = columnParams; + const window = columnParams?.window ?? WINDOW_DEFAULT_VALUE; + return { label: ofName(metric?.label, previousColumn?.timeScale, previousColumn?.timeShift), dataType: 'number', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts index 3a1a53ba1a5f0..a048f2b559191 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/index.ts @@ -55,7 +55,7 @@ export type { } from './column_types'; export type { TermsIndexPatternColumn } from './terms'; -export type { FiltersIndexPatternColumn } from './filters'; +export type { FiltersIndexPatternColumn, Filter } from './filters'; export type { CardinalityIndexPatternColumn } from './cardinality'; export type { PercentileIndexPatternColumn } from './percentile'; export type { 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 4c656d15f197f..d574f9f6c5d35 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 @@ -18,6 +18,7 @@ import { htmlIdGenerator, EuiButtonGroup, } from '@elastic/eui'; +import { uniq } from 'lodash'; import { AggFunctionsMapping } from '../../../../../../../../src/plugins/data/public'; import { buildExpressionFunction } from '../../../../../../../../src/plugins/expressions/public'; import { updateColumnParam, updateDefaultLabels } from '../../layer_helpers'; @@ -367,12 +368,21 @@ export const termsOperation: OperationDefinition { const column = layer.columns[columnId] as TermsIndexPatternColumn; const secondaryFields = fields.length > 1 ? fields.slice(1) : undefined; + const dataTypes = uniq(fields.map((field) => indexPattern.getFieldByName(field)?.type)); + const newDataType = (dataTypes.length === 1 ? dataTypes[0] : 'string') || column.dataType; + const newParams = { + ...column.params, + }; + if ('format' in newParams && newDataType !== 'number') { + delete newParams.format; + } updateLayer({ ...layer, columns: { ...layer.columns, [columnId]: { ...column, + dataType: newDataType, sourceField: fields[0], label: ofName( indexPattern.getFieldByName(fields[0])?.displayName, @@ -380,10 +390,10 @@ export const termsOperation: OperationDefinition; initialParams?: { params: Record }; // TODO: bind this to the op parameter } @@ -190,6 +196,9 @@ export function insertNewColumn({ targetGroup, shouldResetLabel, incompleteParams, + incompleteFieldName, + incompleteFieldOperation, + columnParams, initialParams, }: ColumnChange): IndexPatternLayer { const operationDefinition = operationDefinitionMap[op]; @@ -218,6 +227,7 @@ export function insertNewColumn({ const possibleOperation = operationDefinition.getPossibleOperation(); const isBucketed = Boolean(possibleOperation?.isBucketed); const addOperationFn = isBucketed ? addBucket : addMetric; + return updateDefaultLabels( addOperationFn( layer, @@ -247,12 +257,30 @@ export function insertNewColumn({ } const newId = generateId(); + if (incompleteFieldOperation && incompleteFieldName) { + const validFields = indexPattern.fields.filter( + (validField) => validField.name === incompleteFieldName + ); + tempLayer = insertNewColumn({ + layer: tempLayer, + columnId: newId, + op: incompleteFieldOperation, + indexPattern, + field: validFields[0] ?? documentField, + visualizationGroups, + columnParams, + targetGroup, + }); + } if (validOperations.length === 1) { const def = validOperations[0]; - const validFields = + let validFields = def.input === 'field' ? indexPattern.fields.filter(def.getPossibleOperationForField) : []; + if (incompleteFieldName) { + validFields = validFields.filter((validField) => validField.name === incompleteFieldName); + } if (def.input === 'none') { tempLayer = insertNewColumn({ layer: tempLayer, @@ -293,14 +321,14 @@ export function insertNewColumn({ const isBucketed = Boolean(possibleOperation.isBucketed); const addOperationFn = isBucketed ? addBucket : addMetric; + const buildColumnFn = columnParams + ? operationDefinition.buildColumn( + { ...baseOptions, layer: tempLayer, referenceIds }, + columnParams + ) + : operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, referenceIds }); return updateDefaultLabels( - addOperationFn( - tempLayer, - operationDefinition.buildColumn({ ...baseOptions, layer: tempLayer, referenceIds }), - columnId, - visualizationGroups, - targetGroup - ), + addOperationFn(tempLayer, buildColumnFn, columnId, visualizationGroups, targetGroup), indexPattern ); } @@ -359,7 +387,7 @@ export function insertNewColumn({ }; } - const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer, field }); + const newColumn = operationDefinition.buildColumn({ ...baseOptions, layer, field }, columnParams); const isBucketed = Boolean(possibleOperation.isBucketed); const addOperationFn = isBucketed ? addBucket : addMetric; return updateDefaultLabels( @@ -1107,6 +1135,29 @@ export function getMetricOperationTypes(field: IndexPatternField) { }); } +export function updateColumnLabel({ + layer, + columnId, + customLabel, +}: { + layer: IndexPatternLayer; + columnId: string; + customLabel: string; +}): IndexPatternLayer { + const oldColumn = layer.columns[columnId]; + return { + ...layer, + columns: { + ...layer.columns, + [columnId]: { + ...oldColumn, + label: customLabel ? customLabel : oldColumn.label, + customLabel: Boolean(customLabel), + }, + } as Record, + }; +} + export function updateColumnParam({ layer, columnId, @@ -1507,3 +1558,234 @@ export function getManagedColumnsFrom( } return store.filter(([, column]) => column); } + +export function computeLayerFromContext( + isLast: boolean, + metricsArray: VisualizeEditorLayersContext['metrics'], + indexPattern: IndexPattern, + format?: string, + customLabel?: string +): IndexPatternLayer { + let layer: IndexPatternLayer = { + indexPatternId: indexPattern.id, + columns: {}, + columnOrder: [], + }; + if (isArray(metricsArray)) { + const metricContext = metricsArray.shift(); + const field = metricContext + ? indexPattern.getFieldByName(metricContext.fieldName) ?? documentField + : documentField; + + const operation = metricContext?.agg; + // Formula should be treated differently from other operations + if (operation === 'formula') { + const operationDefinition = operationDefinitionMap.formula as OperationDefinition< + FormulaIndexPatternColumn, + 'managedReference' + >; + const tempLayer = { indexPatternId: indexPattern.id, columns: {}, columnOrder: [] }; + let newColumn = operationDefinition.buildColumn({ + indexPattern, + layer: tempLayer, + }) as FormulaIndexPatternColumn; + let filterBy = metricContext?.params?.kql + ? { query: metricContext?.params?.kql, language: 'kuery' } + : undefined; + if (metricContext?.params?.lucene) { + filterBy = metricContext?.params?.lucene + ? { query: metricContext?.params?.lucene, language: 'lucene' } + : undefined; + } + newColumn = { + ...newColumn, + ...(filterBy && { filter: filterBy }), + params: { + ...newColumn.params, + ...metricContext?.params, + }, + } as FormulaIndexPatternColumn; + layer = metricContext?.params?.formula + ? insertOrReplaceFormulaColumn(generateId(), newColumn, tempLayer, { + indexPattern, + }).layer + : tempLayer; + } else { + const columnId = generateId(); + // recursive function to build the layer + layer = insertNewColumn({ + op: operation as OperationType, + layer: isLast + ? { indexPatternId: indexPattern.id, columns: {}, columnOrder: [] } + : computeLayerFromContext(metricsArray.length === 1, metricsArray, indexPattern), + columnId, + field: !metricContext?.isFullReference ? field ?? documentField : undefined, + columnParams: metricContext?.params ?? undefined, + incompleteFieldName: metricContext?.isFullReference ? field?.name : undefined, + incompleteFieldOperation: metricContext?.isFullReference + ? metricContext?.pipelineAggType + : undefined, + indexPattern, + visualizationGroups: [], + }); + if (metricContext) { + metricContext.accessor = columnId; + } + } + } + + // update the layer with the custom label and the format + let columnIdx = 0; + for (const [columnId, column] of Object.entries(layer.columns)) { + if (format) { + layer = updateColumnParam({ + layer, + columnId, + paramName: 'format', + value: { + id: format, + params: { + decimals: 0, + }, + }, + }); + } + + // for percentiles I want to update all columns with the custom label + if (customLabel && column.operationType === 'percentile') { + layer = updateColumnLabel({ + layer, + columnId, + customLabel, + }); + } else if (customLabel && columnIdx === Object.keys(layer.columns).length - 1) { + layer = updateColumnLabel({ + layer, + columnId, + customLabel, + }); + } + columnIdx++; + } + return layer; +} + +export function getSplitByTermsLayer( + indexPattern: IndexPattern, + splitField: IndexPatternField, + dateField: IndexPatternField | undefined, + layer: VisualizeEditorLayersContext +): IndexPatternLayer { + const { termsParams, metrics, timeInterval, splitWithDateHistogram } = layer; + const copyMetricsArray = [...metrics]; + const computedLayer = computeLayerFromContext( + metrics.length === 1, + copyMetricsArray, + indexPattern, + layer.format, + layer.label + ); + + const columnId = generateId(); + let termsLayer = insertNewColumn({ + op: splitWithDateHistogram ? 'date_histogram' : 'terms', + layer: insertNewColumn({ + op: 'date_histogram', + layer: computedLayer, + columnId: generateId(), + field: dateField, + indexPattern, + visualizationGroups: [], + columnParams: { + interval: timeInterval, + }, + }), + columnId, + field: splitField, + indexPattern, + visualizationGroups: [], + }); + const termsColumnParams = termsParams as TermsIndexPatternColumn['params']; + if (termsColumnParams) { + for (const [param, value] of Object.entries(termsColumnParams)) { + let paramValue = value; + if (param === 'orderBy') { + const [existingMetricColumn] = Object.keys(termsLayer.columns).filter((colId) => + isSortableByColumn(termsLayer, colId) + ); + + paramValue = ( + termsColumnParams.orderBy.type === 'column' && existingMetricColumn + ? { + type: 'column', + columnId: existingMetricColumn, + } + : { type: 'alphabetical', fallback: true } + ) as TermsIndexPatternColumn['params']['orderBy']; + } + termsLayer = updateColumnParam({ + layer: termsLayer, + columnId, + paramName: param, + value: paramValue, + }); + } + } + return termsLayer; +} + +export function getSplitByFiltersLayer( + indexPattern: IndexPattern, + dateField: IndexPatternField | undefined, + layer: VisualizeEditorLayersContext +): IndexPatternLayer { + const { splitFilters, metrics, timeInterval } = layer; + const filterParams = splitFilters?.map((param) => { + const query = param.filter ? param.filter.query : ''; + const language = param.filter ? param.filter.language : 'kuery'; + return { + input: { + query, + language, + }, + label: param.label ?? '', + }; + }); + const copyMetricsArray = [...metrics]; + const computedLayer = computeLayerFromContext( + metrics.length === 1, + copyMetricsArray, + indexPattern, + layer.format, + layer.label + ); + const columnId = generateId(); + let filtersLayer = insertNewColumn({ + op: 'filters', + layer: insertNewColumn({ + op: 'date_histogram', + layer: computedLayer, + columnId: generateId(), + field: dateField, + indexPattern, + visualizationGroups: [], + columnParams: { + interval: timeInterval, + }, + }), + columnId, + field: undefined, + indexPattern, + visualizationGroups: [], + }); + + if (filterParams) { + filtersLayer = updateColumnParam({ + layer: filtersLayer, + columnId, + paramName: 'filters', + value: filterParams, + }); + } + return filtersLayer; +} diff --git a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx index 19d5a9c7e340a..d760e489323c6 100644 --- a/x-pack/plugins/lens/public/metric_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/visualization.tsx @@ -164,7 +164,7 @@ export const getMetricVisualization = ({ { type: layerTypes.DATA, label: i18n.translate('xpack.lens.metric.addLayer', { - defaultMessage: 'Add visualization layer', + defaultMessage: 'Visualization', }), }, ]; diff --git a/x-pack/plugins/lens/public/mocks/datasource_mock.ts b/x-pack/plugins/lens/public/mocks/datasource_mock.ts index ce36b575b30e3..67b286b2ef8a2 100644 --- a/x-pack/plugins/lens/public/mocks/datasource_mock.ts +++ b/x-pack/plugins/lens/public/mocks/datasource_mock.ts @@ -24,6 +24,7 @@ export function createMockDatasource(id: string): DatasourceMock { clearLayer: jest.fn((state, _layerId) => state), getDatasourceSuggestionsForField: jest.fn((_state, _item, filterFn) => []), getDatasourceSuggestionsForVisualizeField: jest.fn((_state, _indexpatternId, _fieldName) => []), + getDatasourceSuggestionsForVisualizeCharts: jest.fn((_state, _context) => []), getDatasourceSuggestionsFromCurrentState: jest.fn((_state) => []), getPersistableState: jest.fn((x) => ({ state: x, diff --git a/x-pack/plugins/lens/public/pie_visualization/constants.ts b/x-pack/plugins/lens/public/pie_visualization/constants.ts index e32320bb75ff0..bfb263b415891 100644 --- a/x-pack/plugins/lens/public/pie_visualization/constants.ts +++ b/x-pack/plugins/lens/public/pie_visualization/constants.ts @@ -6,9 +6,3 @@ */ export const DEFAULT_PERCENT_DECIMALS = 2; - -export enum EMPTY_SIZE_RATIOS { - SMALL = 0.3, - MEDIUM = 0.54, - LARGE = 0.7, -} diff --git a/x-pack/plugins/lens/public/pie_visualization/expression.tsx b/x-pack/plugins/lens/public/pie_visualization/expression.tsx deleted file mode 100644 index bf52fb6ba5e5e..0000000000000 --- a/x-pack/plugins/lens/public/pie_visualization/expression.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import { i18n } from '@kbn/i18n'; -import { I18nProvider } from '@kbn/i18n-react'; -import type { - IInterpreterRenderHandlers, - ExpressionRenderDefinition, -} from 'src/plugins/expressions/public'; -import { ThemeServiceStart } from 'kibana/public'; -import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; -import type { LensFilterEvent } from '../types'; -import { PieComponent } from './render_function'; -import type { FormatFactory } from '../../common'; -import type { PieExpressionProps } from '../../common/expressions'; -import type { ChartsPluginSetup, PaletteRegistry } from '../../../../../src/plugins/charts/public'; - -export const getPieRenderer = (dependencies: { - formatFactory: FormatFactory; - chartsThemeService: ChartsPluginSetup['theme']; - paletteService: PaletteRegistry; - kibanaTheme: ThemeServiceStart; -}): ExpressionRenderDefinition => ({ - name: 'lens_pie_renderer', - displayName: i18n.translate('xpack.lens.pie.visualizationName', { - defaultMessage: 'Pie', - }), - help: '', - validate: () => undefined, - reuseDomNode: true, - render: (domNode: Element, config: PieExpressionProps, handlers: IInterpreterRenderHandlers) => { - const onClickValue = (data: LensFilterEvent['data']) => { - handlers.event({ name: 'filter', data }); - }; - - ReactDOM.render( - - - - - , - domNode, - () => { - handlers.done(); - } - ); - handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); - }, -}); - -const MemoizedChart = React.memo(PieComponent); diff --git a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx deleted file mode 100644 index df0648aa40d74..0000000000000 --- a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; -import { EuiPopover } from '@elastic/eui'; -import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { ComponentType, ReactWrapper } from 'enzyme'; -import type { Datatable } from 'src/plugins/expressions/public'; -import { getLegendAction } from './get_legend_action'; -import { LegendActionPopover } from '../shared_components'; - -const table: Datatable = { - type: 'datatable', - columns: [ - { id: 'a', name: 'A', meta: { type: 'string' } }, - { id: 'b', name: 'B', meta: { type: 'number' } }, - ], - rows: [ - { a: 'Hi', b: 2 }, - { a: 'Test', b: 4 }, - { a: 'Foo', b: 6 }, - ], -}; - -describe('getLegendAction', function () { - let wrapperProps: LegendActionProps; - const Component: ComponentType = getLegendAction(table, jest.fn()); - let wrapper: ReactWrapper; - - beforeAll(() => { - wrapperProps = { - color: 'rgb(109, 204, 177)', - label: 'Bar', - series: [ - { - specId: 'donut', - key: 'Bar', - }, - ] as unknown as SeriesIdentifier[], - }; - }); - - it('is not rendered if row does not exist', () => { - wrapper = mountWithIntl(); - expect(wrapper).toEqual({}); - expect(wrapper.find(EuiPopover).length).toBe(0); - }); - - it('is rendered if row is detected', () => { - const newProps = { - ...wrapperProps, - label: 'Hi', - series: [ - { - specId: 'donut', - key: 'Hi', - }, - ] as unknown as SeriesIdentifier[], - }; - wrapper = mountWithIntl(); - expect(wrapper.find(EuiPopover).length).toBe(1); - expect(wrapper.find(EuiPopover).prop('title')).toEqual('Hi, filter options'); - expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({ - data: [ - { - column: 0, - row: 0, - table, - value: 'Hi', - }, - ], - }); - }); -}); diff --git a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx b/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx deleted file mode 100644 index 9f16ad863a415..0000000000000 --- a/x-pack/plugins/lens/public/pie_visualization/get_legend_action.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import type { LegendAction } from '@elastic/charts'; -import type { Datatable } from 'src/plugins/expressions/public'; -import type { LensFilterEvent } from '../types'; -import { LegendActionPopover } from '../shared_components'; - -export const getLegendAction = ( - table: Datatable, - onFilter: (data: LensFilterEvent['data']) => void -): LegendAction => - React.memo(({ series: [pieSeries], label }) => { - const data = table.columns.reduce((acc, { id }, column) => { - const value = pieSeries.key; - const row = table.rows.findIndex((r) => r[id] === value); - if (row > -1) { - acc.push({ - table, - column, - row, - value, - }); - } - - return acc; - }, []); - - if (data.length === 0) { - return null; - } - - const context: LensFilterEvent['data'] = { - data, - }; - - return ; - }); diff --git a/x-pack/plugins/lens/public/pie_visualization/index.ts b/x-pack/plugins/lens/public/pie_visualization/index.ts index ce54f53c1cc93..b86c2fc90e4fa 100644 --- a/x-pack/plugins/lens/public/pie_visualization/index.ts +++ b/x-pack/plugins/lens/public/pie_visualization/index.ts @@ -6,16 +6,12 @@ */ import type { CoreSetup } from 'src/core/public'; -import type { ExpressionsSetup } from 'src/plugins/expressions/public'; import type { EditorFrameSetup } from '../types'; import type { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import type { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; -import type { FormatFactory } from '../../common'; export interface PieVisualizationPluginSetupPlugins { editorFrame: EditorFrameSetup; - expressions: ExpressionsSetup; - formatFactory: FormatFactory; charts: ChartsPluginSetup; } @@ -24,22 +20,11 @@ export interface PieVisualizationPluginStartPlugins { } export class PieVisualization { - setup( - core: CoreSetup, - { expressions, formatFactory, editorFrame, charts }: PieVisualizationPluginSetupPlugins - ) { + setup(core: CoreSetup, { editorFrame, charts }: PieVisualizationPluginSetupPlugins) { editorFrame.registerVisualization(async () => { - const { getPieVisualization, getPieRenderer } = await import('../async_services'); + const { getPieVisualization } = await import('../async_services'); const palettes = await charts.palettes.getPalettes(); - expressions.registerRenderer( - getPieRenderer({ - formatFactory, - chartsThemeService: charts.theme, - paletteService: palettes, - kibanaTheme: core.theme, - }) - ); return getPieVisualization({ paletteService: palettes, kibanaTheme: core.theme }); }); } diff --git a/x-pack/plugins/lens/public/pie_visualization/partition_charts_meta.ts b/x-pack/plugins/lens/public/pie_visualization/partition_charts_meta.ts index 3d02c0f6d513e..d77a09ae10689 100644 --- a/x-pack/plugins/lens/public/pie_visualization/partition_charts_meta.ts +++ b/x-pack/plugins/lens/public/pie_visualization/partition_charts_meta.ts @@ -6,24 +6,20 @@ */ import { i18n } from '@kbn/i18n'; -import { ArrayEntry, PartitionLayout } from '@elastic/charts'; import type { EuiIconProps } from '@elastic/eui'; +import type { DatatableColumn } from '../../../../../src/plugins/expressions'; import { LensIconChartDonut } from '../assets/chart_donut'; import { LensIconChartPie } from '../assets/chart_pie'; import { LensIconChartTreemap } from '../assets/chart_treemap'; import { LensIconChartMosaic } from '../assets/chart_mosaic'; import { LensIconChartWaffle } from '../assets/chart_waffle'; -import { EMPTY_SIZE_RATIOS } from './constants'; - -import type { SharedPieLayerState } from '../../common/expressions'; -import type { PieChartTypes } from '../../common/expressions/pie_chart/types'; -import type { DatatableColumn } from '../../../../../src/plugins/expressions'; +import { CategoryDisplay, NumberDisplay, SharedPieLayerState, EmptySizeRatios } from '../../common'; +import type { PieChartType } from '../../common/types'; interface PartitionChartMeta { icon: ({ title, titleId, ...props }: Omit) => JSX.Element; label: string; - partitionType: PartitionLayout; groupLabel: string; maxBuckets: number; isExperimental?: boolean; @@ -40,7 +36,7 @@ interface PartitionChartMeta { }>; emptySizeRatioOptions?: Array<{ id: string; - value: EMPTY_SIZE_RATIOS; + value: EmptySizeRatios; label: string; }>; }; @@ -50,10 +46,6 @@ interface PartitionChartMeta { hideNestedLegendSwitch?: boolean; getShowLegendDefault?: (bucketColumns: DatatableColumn[]) => boolean; }; - sortPredicate?: ( - bucketColumns: DatatableColumn[], - sortingMap: Record - ) => (node1: ArrayEntry, node2: ArrayEntry) => number; } const groupLabel = i18n.translate('xpack.lens.pie.groupLabel', { @@ -62,19 +54,19 @@ const groupLabel = i18n.translate('xpack.lens.pie.groupLabel', { const categoryOptions: PartitionChartMeta['toolbarPopover']['categoryOptions'] = [ { - value: 'default', + value: CategoryDisplay.DEFAULT, inputDisplay: i18n.translate('xpack.lens.pieChart.showCategoriesLabel', { defaultMessage: 'Inside or outside', }), }, { - value: 'inside', + value: CategoryDisplay.INSIDE, inputDisplay: i18n.translate('xpack.lens.pieChart.fitInsideOnlyLabel', { defaultMessage: 'Inside only', }), }, { - value: 'hide', + value: CategoryDisplay.HIDE, inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { defaultMessage: 'Hide labels', }), @@ -83,13 +75,13 @@ const categoryOptions: PartitionChartMeta['toolbarPopover']['categoryOptions'] = const categoryOptionsTreemap: PartitionChartMeta['toolbarPopover']['categoryOptions'] = [ { - value: 'default', + value: CategoryDisplay.DEFAULT, inputDisplay: i18n.translate('xpack.lens.pieChart.showTreemapCategoriesLabel', { defaultMessage: 'Show labels', }), }, { - value: 'hide', + value: CategoryDisplay.HIDE, inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { defaultMessage: 'Hide labels', }), @@ -98,19 +90,19 @@ const categoryOptionsTreemap: PartitionChartMeta['toolbarPopover']['categoryOpti const numberOptions: PartitionChartMeta['toolbarPopover']['numberOptions'] = [ { - value: 'hidden', + value: NumberDisplay.HIDDEN, inputDisplay: i18n.translate('xpack.lens.pieChart.hiddenNumbersLabel', { defaultMessage: 'Hide from chart', }), }, { - value: 'percent', + value: NumberDisplay.PERCENT, inputDisplay: i18n.translate('xpack.lens.pieChart.showPercentValuesLabel', { defaultMessage: 'Show percent', }), }, { - value: 'value', + value: NumberDisplay.VALUE, inputDisplay: i18n.translate('xpack.lens.pieChart.showFormatterValuesLabel', { defaultMessage: 'Show value', }), @@ -120,34 +112,33 @@ const numberOptions: PartitionChartMeta['toolbarPopover']['numberOptions'] = [ const emptySizeRatioOptions: PartitionChartMeta['toolbarPopover']['emptySizeRatioOptions'] = [ { id: 'emptySizeRatioOption-small', - value: EMPTY_SIZE_RATIOS.SMALL, + value: EmptySizeRatios.SMALL, label: i18n.translate('xpack.lens.pieChart.emptySizeRatioOptions.small', { defaultMessage: 'Small', }), }, { id: 'emptySizeRatioOption-medium', - value: EMPTY_SIZE_RATIOS.MEDIUM, + value: EmptySizeRatios.MEDIUM, label: i18n.translate('xpack.lens.pieChart.emptySizeRatioOptions.medium', { defaultMessage: 'Medium', }), }, { id: 'emptySizeRatioOption-large', - value: EMPTY_SIZE_RATIOS.LARGE, + value: EmptySizeRatios.LARGE, label: i18n.translate('xpack.lens.pieChart.emptySizeRatioOptions.large', { defaultMessage: 'Large', }), }, ]; -export const PartitionChartsMeta: Record = { +export const PartitionChartsMeta: Record = { donut: { icon: LensIconChartDonut, label: i18n.translate('xpack.lens.pie.donutLabel', { defaultMessage: 'Donut', }), - partitionType: PartitionLayout.sunburst, groupLabel, maxBuckets: 3, toolbarPopover: { @@ -164,7 +155,6 @@ export const PartitionChartsMeta: Record = { label: i18n.translate('xpack.lens.pie.pielabel', { defaultMessage: 'Pie', }), - partitionType: PartitionLayout.sunburst, groupLabel, maxBuckets: 3, toolbarPopover: { @@ -180,7 +170,6 @@ export const PartitionChartsMeta: Record = { label: i18n.translate('xpack.lens.pie.treemaplabel', { defaultMessage: 'Treemap', }), - partitionType: PartitionLayout.treemap, groupLabel, maxBuckets: 2, toolbarPopover: { @@ -196,7 +185,6 @@ export const PartitionChartsMeta: Record = { label: i18n.translate('xpack.lens.pie.mosaiclabel', { defaultMessage: 'Mosaic', }), - partitionType: PartitionLayout.mosaic, groupLabel, maxBuckets: 2, isExperimental: true, @@ -208,23 +196,12 @@ export const PartitionChartsMeta: Record = { getShowLegendDefault: () => false, }, requiredMinDimensionCount: 2, - sortPredicate: - (bucketColumns, sortingMap) => - ([name1, node1], [, node2]) => { - // Sorting for first group - if (bucketColumns.length === 1 || (node1.children.length && name1 in sortingMap)) { - return sortingMap[name1]; - } - // Sorting for second group - return node2.value - node1.value; - }, }, waffle: { icon: LensIconChartWaffle, label: i18n.translate('xpack.lens.pie.wafflelabel', { defaultMessage: 'Waffle', }), - partitionType: PartitionLayout.waffle, groupLabel, maxBuckets: 1, isExperimental: true, @@ -239,9 +216,5 @@ export const PartitionChartsMeta: Record = { hideNestedLegendSwitch: true, getShowLegendDefault: () => true, }, - sortPredicate: - () => - ([, node1], [, node2]) => - node2.value - node1.value, }, }; diff --git a/x-pack/plugins/lens/public/pie_visualization/pie_visualization.ts b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.ts index 231b6bacbbe20..78f082b8c0e29 100644 --- a/x-pack/plugins/lens/public/pie_visualization/pie_visualization.ts +++ b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.ts @@ -5,5 +5,4 @@ * 2.0. */ -export * from './expression'; export * from './visualization'; diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx deleted file mode 100644 index 8cd8e4f50d625..0000000000000 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ /dev/null @@ -1,430 +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 { - Partition, - SeriesIdentifier, - Settings, - NodeColorAccessor, - ShapeTreeNode, - HierarchyOfArrays, - Chart, - PartialTheme, -} from '@elastic/charts'; -import { shallow } from 'enzyme'; -import type { LensMultiTable } from '../../common'; -import type { PieExpressionArgs } from '../../common/expressions'; -import { PieComponent } from './render_function'; -import { VisualizationContainer } from '../visualization_container'; -import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; -import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; -import { LensIconChartDonut } from '../assets/chart_donut'; - -const chartsThemeService = chartPluginMock.createSetupContract().theme; - -describe('PieVisualization component', () => { - let getFormatSpy: jest.Mock; - let convertSpy: jest.Mock; - - beforeEach(() => { - convertSpy = jest.fn((x) => x); - getFormatSpy = jest.fn(); - getFormatSpy.mockReturnValue({ convert: convertSpy }); - }); - - describe('legend options', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: { - type: 'datatable', - columns: [ - { id: 'a', name: 'a', meta: { type: 'number' } }, - { id: 'b', name: 'b', meta: { type: 'string' } }, - { id: 'c', name: 'c', meta: { type: 'number' } }, - ], - rows: [ - { a: 6, b: 'I', c: 2, d: 'Row 1' }, - { a: 1, b: 'J', c: 5, d: 'Row 2' }, - ], - }, - }, - }; - - const args: PieExpressionArgs = { - shape: 'pie', - groups: ['a', 'b'], - metric: 'c', - numberDisplay: 'hidden', - categoryDisplay: 'default', - legendDisplay: 'default', - legendMaxLines: 1, - truncateLegend: true, - nestedLegend: false, - percentDecimals: 3, - hideLabels: false, - palette: { name: 'mock', type: 'palette' }, - }; - - function getDefaultArgs() { - return { - data, - formatFactory: getFormatSpy, - onClickValue: jest.fn(), - chartsThemeService, - paletteService: chartPluginMock.createPaletteRegistry(), - renderMode: 'view' as const, - syncColors: false, - }; - } - - test('it shows legend on correct side', () => { - const component = shallow( - - ); - expect(component.find(Settings).prop('legendPosition')).toEqual('top'); - }); - - test('it shows legend for 2 groups using default legendDisplay', () => { - const component = shallow(); - expect(component.find(Settings).prop('showLegend')).toEqual(true); - }); - - test('it hides legend for 1 group using default legendDisplay', () => { - const component = shallow( - - ); - expect(component.find(Settings).prop('showLegend')).toEqual(false); - }); - - test('it hides legend that would show otherwise in preview mode', () => { - const component = shallow( - - ); - expect(component.find(Settings).prop('showLegend')).toEqual(false); - }); - - test('it sets the correct lines per legend item', () => { - const component = shallow(); - expect(component.find(Settings).prop('theme')[0]).toMatchObject({ - background: { - color: undefined, - }, - legend: { - labelOptions: { - maxLines: 1, - }, - }, - }); - }); - - test('it calls the color function with the right series layers', () => { - const defaultArgs = getDefaultArgs(); - const component = shallow( - - ); - - (component.find(Partition).prop('layers')![1].shape!.fillColor as NodeColorAccessor)( - { - dataName: 'third', - depth: 2, - parent: { - children: [ - ['first', {}], - ['second', {}], - ['third', {}], - ], - depth: 1, - value: 200, - dataName: 'css', - parent: { - children: [ - ['empty', {}], - ['css', {}], - ['gz', {}], - ], - depth: 0, - sortIndex: 0, - value: 500, - }, - sortIndex: 1, - }, - value: 41, - sortIndex: 2, - } as unknown as ShapeTreeNode, - 0, - [] as HierarchyOfArrays - ); - - expect(defaultArgs.paletteService.get('mock').getCategoricalColor).toHaveBeenCalledWith( - [ - { - name: 'css', - rankAtDepth: 1, - totalSeriesAtDepth: 3, - }, - { - name: 'third', - rankAtDepth: 2, - totalSeriesAtDepth: 3, - }, - ], - { - maxDepth: 2, - totalSeries: 5, - syncColors: false, - behindText: true, - }, - undefined - ); - }); - - test('it hides legend with 2 groups for treemap', () => { - const component = shallow( - - ); - expect(component.find(Settings).prop('showLegend')).toEqual(false); - }); - - test('it shows treemap legend only when forced on', () => { - const component = shallow( - - ); - expect(component.find(Settings).prop('showLegend')).toEqual(true); - }); - - test('it defaults to 1-level legend depth', () => { - const component = shallow(); - expect(component.find(Settings).prop('legendMaxDepth')).toEqual(1); - }); - - test('it shows nested legend only when forced on', () => { - const component = shallow( - - ); - expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); - }); - - test('it calls filter callback with the given context', () => { - const defaultArgs = getDefaultArgs(); - const component = shallow(); - component.find(Settings).first().prop('onElementClick')!([ - [ - [ - { - groupByRollup: 6, - value: 6, - depth: 1, - path: [], - sortIndex: 1, - smAccessorValue: '', - }, - ], - {} as SeriesIdentifier, - ], - ]); - - expect(defaultArgs.onClickValue.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "data": Array [ - Object { - "column": 0, - "row": 0, - "table": Object { - "columns": Array [ - Object { - "id": "a", - "meta": Object { - "type": "number", - }, - "name": "a", - }, - Object { - "id": "b", - "meta": Object { - "type": "string", - }, - "name": "b", - }, - Object { - "id": "c", - "meta": Object { - "type": "number", - }, - "name": "c", - }, - ], - "rows": Array [ - Object { - "a": 6, - "b": "I", - "c": 2, - "d": "Row 1", - }, - Object { - "a": 1, - "b": "J", - "c": 5, - "d": "Row 2", - }, - ], - "type": "datatable", - }, - "value": 6, - }, - ], - } - `); - }); - - test('does not set click listener and legend actions on non-interactive mode', () => { - const defaultArgs = getDefaultArgs(); - const component = shallow( - - ); - expect(component.find(Settings).first().prop('onElementClick')).toBeUndefined(); - expect(component.find(Settings).first().prop('legendAction')).toBeUndefined(); - }); - - test('it renders the empty placeholder when metric contains only falsy data', () => { - const defaultData = getDefaultArgs().data; - const emptyData: LensMultiTable = { - ...defaultData, - tables: { - first: { - ...defaultData.tables.first, - rows: [ - { a: 0, b: 'I', c: 0, d: 'Row 1' }, - { a: 0, b: 'J', c: null, d: 'Row 2' }, - ], - }, - }, - }; - - const component = shallow( - - ); - expect(component.find(VisualizationContainer)).toHaveLength(1); - expect(component.find(EmptyPlaceholder)).toHaveLength(1); - }); - - test('it renders the chart when metric contains truthy data and buckets contain only falsy data', () => { - const defaultData = getDefaultArgs().data; - const emptyData: LensMultiTable = { - ...defaultData, - tables: { - first: { - ...defaultData.tables.first, - // a and b are buckets, c is a metric - rows: [{ a: 0, b: undefined, c: 12 }], - }, - }, - }; - - const component = shallow( - - ); - - expect(component.find(VisualizationContainer)).toHaveLength(1); - expect(component.find(EmptyPlaceholder)).toHaveLength(0); - expect(component.find(Chart)).toHaveLength(1); - }); - - test('it shows emptyPlaceholder for undefined grouped data', () => { - const defaultData = getDefaultArgs().data; - const emptyData: LensMultiTable = { - ...defaultData, - tables: { - first: { - ...defaultData.tables.first, - rows: [ - { a: undefined, b: 'I', c: undefined, d: 'Row 1' }, - { a: undefined, b: 'J', c: undefined, d: 'Row 2' }, - ], - }, - }, - }; - - const component = shallow( - - ); - expect(component.find(VisualizationContainer)).toHaveLength(1); - expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDonut); - }); - - test('it should dynamically shrink the chart area to when some small slices are detected', () => { - const defaultData = getDefaultArgs().data; - const emptyData: LensMultiTable = { - ...defaultData, - tables: { - first: { - ...defaultData.tables.first, - rows: [ - { a: 60, b: 'I', c: 200, d: 'Row 1' }, - { a: 1, b: 'J', c: 0.1, d: 'Row 2' }, - ], - }, - }, - }; - - const component = shallow( - - ); - expect( - component.find(Settings).prop('theme')[0].partition?.outerSizeRatio - ).toBeCloseTo(1 / 1.05); - }); - - test('it should bound the shrink the chart area to ~20% when some small slices are detected', () => { - const defaultData = getDefaultArgs().data; - const emptyData: LensMultiTable = { - ...defaultData, - tables: { - first: { - ...defaultData.tables.first, - rows: [ - { a: 60, b: 'I', c: 200, d: 'Row 1' }, - { a: 1, b: 'J', c: 0.1, d: 'Row 2' }, - { a: 1, b: 'K', c: 0.1, d: 'Row 3' }, - { a: 1, b: 'G', c: 0.1, d: 'Row 4' }, - { a: 1, b: 'H', c: 0.1, d: 'Row 5' }, - ], - }, - }, - }; - - const component = shallow( - - ); - expect( - component.find(Settings).prop('theme')[0].partition?.outerSizeRatio - ).toBeCloseTo(1 / 1.2); - }); - }); -}); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx deleted file mode 100644 index 15706e69d1e16..0000000000000 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ /dev/null @@ -1,354 +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 { uniq } from 'lodash'; -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { Required } from '@kbn/utility-types'; -import { EuiText } from '@elastic/eui'; -import { - Chart, - Datum, - LayerValue, - Partition, - PartitionLayer, - Position, - Settings, - ElementClickListener, - PartialTheme, -} from '@elastic/charts'; -import { RenderMode } from 'src/plugins/expressions'; -import type { LensFilterEvent } from '../types'; -import { VisualizationContainer } from '../visualization_container'; -import { DEFAULT_PERCENT_DECIMALS } from './constants'; -import { PartitionChartsMeta } from './partition_charts_meta'; -import type { FormatFactory } from '../../common'; -import type { PieExpressionProps } from '../../common/expressions'; -import { - getSliceValue, - getFilterContext, - isTreemapOrMosaicShape, - byDataColorPaletteMap, - extractUniqTermsMap, -} from './render_helpers'; -import { EmptyPlaceholder } from '../../../../../src/plugins/charts/public'; -import './visualization.scss'; -import { - ChartsPluginSetup, - PaletteRegistry, - SeriesLayer, -} from '../../../../../src/plugins/charts/public'; -import { LensIconChartDonut } from '../assets/chart_donut'; -import { getLegendAction } from './get_legend_action'; - -declare global { - interface Window { - /** - * Flag used to enable debugState on elastic charts - */ - _echDebugStateFlag?: boolean; - } -} - -const EMPTY_SLICE = Symbol('empty_slice'); - -export function PieComponent( - props: PieExpressionProps & { - formatFactory: FormatFactory; - chartsThemeService: ChartsPluginSetup['theme']; - interactive?: boolean; - paletteService: PaletteRegistry; - onClickValue: (data: LensFilterEvent['data']) => void; - renderMode: RenderMode; - syncColors: boolean; - } -) { - const [firstTable] = Object.values(props.data.tables); - const formatters: Record> = {}; - - const { chartsThemeService, paletteService, syncColors, onClickValue } = props; - const { - shape, - groups, - metric, - numberDisplay, - categoryDisplay, - legendDisplay, - legendPosition, - nestedLegend, - percentDecimals, - emptySizeRatio, - legendMaxLines, - truncateLegend, - hideLabels, - palette, - showValuesInLegend, - } = props.args; - const chartTheme = chartsThemeService.useChartsTheme(); - const chartBaseTheme = chartsThemeService.useChartsBaseTheme(); - const isDarkMode = chartsThemeService.useDarkMode(); - - if (!hideLabels) { - firstTable.columns.forEach((column) => { - formatters[column.id] = props.formatFactory(column.meta.params); - }); - } - - const fillLabel: PartitionLayer['fillLabel'] = { - valueFont: { - fontWeight: 700, - }, - }; - - if (numberDisplay === 'hidden') { - // Hides numbers from appearing inside chart, but they still appear in linkLabel - // and tooltips. - fillLabel.valueFormatter = () => ''; - } - - const bucketColumns = firstTable.columns.filter((col) => groups.includes(col.id)); - const totalSeriesCount = uniq( - firstTable.rows.map((row) => { - return bucketColumns.map(({ id: columnId }) => row[columnId]).join(','); - }) - ).length; - - const shouldUseByDataPalette = !syncColors && ['mosaic'].includes(shape) && bucketColumns[1]?.id; - let byDataPalette: ReturnType; - if (shouldUseByDataPalette) { - byDataPalette = byDataColorPaletteMap( - firstTable, - bucketColumns[1].id, - paletteService.get(palette.name), - palette - ); - } - - let sortingMap: Record = {}; - if (shape === 'mosaic') { - sortingMap = extractUniqTermsMap(firstTable, bucketColumns[0].id); - } - - const layers: PartitionLayer[] = bucketColumns.map((col, layerIndex) => { - return { - groupByRollup: (d: Datum) => d[col.id] ?? EMPTY_SLICE, - showAccessor: (d: Datum) => d !== EMPTY_SLICE, - nodeLabel: (d: unknown) => { - if (hideLabels || d === EMPTY_SLICE) { - return ''; - } - if (col.meta.params) { - return formatters[col.id].convert(d) ?? ''; - } - return String(d); - }, - fillLabel, - sortPredicate: PartitionChartsMeta[shape].sortPredicate?.(bucketColumns, sortingMap), - shape: { - fillColor: (d) => { - const seriesLayers: SeriesLayer[] = []; - - // Mind the difference here: the contrast computation for the text ignores the alpha/opacity - // therefore change it for dask mode - const defaultColor = isDarkMode ? 'rgba(0,0,0,0)' : 'rgba(255,255,255,0)'; - - // Color is determined by round-robin on the index of the innermost slice - // This has to be done recursively until we get to the slice index - let tempParent: typeof d | typeof d['parent'] = d; - - while (tempParent.parent && tempParent.depth > 0) { - seriesLayers.unshift({ - name: String(tempParent.parent.children[tempParent.sortIndex][0]), - rankAtDepth: tempParent.sortIndex, - totalSeriesAtDepth: tempParent.parent.children.length, - }); - tempParent = tempParent.parent; - } - - if (byDataPalette && seriesLayers[1]) { - return byDataPalette.getColor(seriesLayers[1].name) || defaultColor; - } - - if (isTreemapOrMosaicShape(shape)) { - // Only highlight the innermost color of the treemap, as it accurately represents area - if (layerIndex < bucketColumns.length - 1) { - return defaultColor; - } - // only use the top level series layer for coloring - if (seriesLayers.length > 1) { - seriesLayers.pop(); - } - } - - const outputColor = paletteService.get(palette.name).getCategoricalColor( - seriesLayers, - { - behindText: categoryDisplay !== 'hide' || isTreemapOrMosaicShape(shape), - maxDepth: bucketColumns.length, - totalSeries: totalSeriesCount, - syncColors, - }, - palette.params - ); - - return outputColor || defaultColor; - }, - }, - }; - }); - - const { legend, partitionType, label: chartType } = PartitionChartsMeta[shape]; - - const themeOverrides: Required = { - chartMargins: { top: 0, bottom: 0, left: 0, right: 0 }, - background: { - color: undefined, // removes background for embeddables - }, - legend: { - labelOptions: { maxLines: truncateLegend ? legendMaxLines ?? 1 : 0 }, - }, - partition: { - fontFamily: chartTheme.barSeriesStyle?.displayValue?.fontFamily, - outerSizeRatio: 1, - minFontSize: 10, - maxFontSize: 16, - // Labels are added outside the outer ring when the slice is too small - linkLabel: { - maxCount: 5, - fontSize: 11, - // Dashboard background color is affected by dark mode, which we need - // to account for in outer labels - // This does not handle non-dashboard embeddables, which are allowed to - // have different backgrounds. - textColor: chartTheme.axes?.axisTitle?.fill, - }, - sectorLineStroke: chartTheme.lineSeriesStyle?.point?.fill, - sectorLineWidth: 1.5, - circlePadding: 4, - }, - }; - if (isTreemapOrMosaicShape(shape)) { - if (hideLabels || categoryDisplay === 'hide') { - themeOverrides.partition.fillLabel = { textColor: 'rgba(0,0,0,0)' }; - } - } else { - themeOverrides.partition.emptySizeRatio = shape === 'donut' ? emptySizeRatio : 0; - - if (hideLabels || categoryDisplay === 'hide') { - // Force all labels to be linked, then prevent links from showing - themeOverrides.partition.linkLabel = { - maxCount: 0, - maximumSection: Number.POSITIVE_INFINITY, - }; - } else if (categoryDisplay === 'inside') { - // Prevent links from showing - themeOverrides.partition.linkLabel = { maxCount: 0 }; - } else { - // if it contains any slice below 2% reduce the ratio - // first step: sum it up the overall sum - const overallSum = firstTable.rows.reduce((sum, row) => sum + row[metric!], 0); - const slices = firstTable.rows.map((row) => row[metric!] / overallSum); - const smallSlices = slices.filter((value) => value < 0.02).length; - if (smallSlices) { - // shrink up to 20% to give some room for the linked values - themeOverrides.partition.outerSizeRatio = 1 / (1 + Math.min(smallSlices * 0.05, 0.2)); - } - } - } - const metricColumn = firstTable.columns.find((c) => c.id === metric)!; - const percentFormatter = props.formatFactory({ - id: 'percent', - params: { - pattern: `0,0.[${'0'.repeat(percentDecimals ?? DEFAULT_PERCENT_DECIMALS)}]%`, - }, - }); - - const hasNegative = firstTable.rows.some((row) => { - const value = row[metricColumn.id]; - return typeof value === 'number' && value < 0; - }); - - const isMetricEmpty = firstTable.rows.every((row) => { - return !row[metricColumn.id]; - }); - - const isEmpty = - firstTable.rows.length === 0 || - firstTable.rows.every((row) => groups.every((colId) => typeof row[colId] === 'undefined')) || - isMetricEmpty; - - if (isEmpty) { - return ( - - - - ); - } - - if (hasNegative) { - return ( - - - - ); - } - - const onElementClickHandler: ElementClickListener = (args) => { - const context = getFilterContext(args[0][0] as LayerValue[], groups, firstTable); - - onClickValue(context); - }; - - return ( - - - - - ); -} diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts index bcd9d79babbab..bf09b3f2706e5 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.test.ts @@ -6,321 +6,11 @@ */ import type { Datatable } from 'src/plugins/expressions/public'; -import type { PaletteDefinition, PaletteOutput } from 'src/plugins/charts/public'; -import { - getSliceValue, - getFilterContext, - byDataColorPaletteMap, - extractUniqTermsMap, - checkTableForContainsSmallValues, - shouldShowValuesInLegend, -} from './render_helpers'; -import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; -import type { PieLayerState } from '../../common/expressions'; +import { checkTableForContainsSmallValues, shouldShowValuesInLegend } from './render_helpers'; +import { PieLayerState, PieChartTypes } from '../../common'; describe('render helpers', () => { - describe('#getSliceValue', () => { - it('returns the metric when positive number', () => { - expect( - getSliceValue( - { a: 'Cat', b: 'Home', c: 5 }, - { - id: 'c', - name: 'C', - meta: { type: 'number' }, - } - ) - ).toEqual(5); - }); - - it('returns the metric when negative number', () => { - expect( - getSliceValue( - { a: 'Cat', b: 'Home', c: -100 }, - { - id: 'c', - name: 'C', - meta: { type: 'number' }, - } - ) - ).toEqual(0); - }); - - it('returns 0 when metric value is 0', () => { - expect( - getSliceValue( - { a: 'Cat', b: 'Home', c: 0 }, - { - id: 'c', - name: 'C', - meta: { type: 'number' }, - } - ) - ).toEqual(0); - }); - - it('returns 0 when metric value is infinite', () => { - expect( - getSliceValue( - { a: 'Cat', b: 'Home', c: Number.POSITIVE_INFINITY }, - { - id: 'c', - name: 'C', - meta: { type: 'number' }, - } - ) - ).toEqual(0); - }); - }); - - describe('#getFilterContext', () => { - it('handles single slice click for single ring', () => { - const table: Datatable = { - type: 'datatable', - columns: [ - { id: 'a', name: 'A', meta: { type: 'string' } }, - { id: 'b', name: 'B', meta: { type: 'number' } }, - ], - rows: [ - { a: 'Hi', b: 2 }, - { a: 'Test', b: 4 }, - { a: 'Foo', b: 6 }, - ], - }; - expect( - getFilterContext( - [ - { - groupByRollup: 'Test', - value: 100, - depth: 1, - path: [], - sortIndex: 1, - smAccessorValue: '', - }, - ], - ['a'], - table - ) - ).toEqual({ - data: [ - { - row: 1, - column: 0, - value: 'Test', - table, - }, - ], - }); - }); - - it('handles single slice click with 2 rings', () => { - const table: Datatable = { - type: 'datatable', - columns: [ - { id: 'a', name: 'A', meta: { type: 'string' } }, - { id: 'b', name: 'B', meta: { type: 'string' } }, - { id: 'c', name: 'C', meta: { type: 'number' } }, - ], - rows: [ - { a: 'Hi', b: 'Two', c: 2 }, - { a: 'Test', b: 'Two', c: 5 }, - { a: 'Foo', b: 'Three', c: 6 }, - ], - }; - expect( - getFilterContext( - [ - { - groupByRollup: 'Test', - value: 100, - depth: 1, - path: [], - sortIndex: 1, - smAccessorValue: '', - }, - ], - ['a', 'b'], - table - ) - ).toEqual({ - data: [ - { - row: 1, - column: 0, - value: 'Test', - table, - }, - ], - }); - }); - - it('finds right row for multi slice click', () => { - const table: Datatable = { - type: 'datatable', - columns: [ - { id: 'a', name: 'A', meta: { type: 'string' } }, - { id: 'b', name: 'B', meta: { type: 'string' } }, - { id: 'c', name: 'C', meta: { type: 'number' } }, - ], - rows: [ - { a: 'Hi', b: 'Two', c: 2 }, - { a: 'Test', b: 'Two', c: 5 }, - { a: 'Foo', b: 'Three', c: 6 }, - ], - }; - expect( - getFilterContext( - [ - { - groupByRollup: 'Test', - value: 100, - depth: 1, - path: [], - sortIndex: 1, - smAccessorValue: '', - }, - { - groupByRollup: 'Two', - value: 5, - depth: 1, - path: [], - sortIndex: 1, - smAccessorValue: '', - }, - ], - ['a', 'b'], - table - ) - ).toEqual({ - data: [ - { - row: 1, - column: 0, - value: 'Test', - table, - }, - { - row: 1, - column: 1, - value: 'Two', - table, - }, - ], - }); - }); - }); - - describe('#extractUniqTermsMap', () => { - it('should extract map', () => { - const table: Datatable = { - type: 'datatable', - columns: [ - { id: 'a', name: 'A', meta: { type: 'string' } }, - { id: 'b', name: 'B', meta: { type: 'string' } }, - { id: 'c', name: 'C', meta: { type: 'number' } }, - ], - rows: [ - { a: 'Hi', b: 'Two', c: 2 }, - { a: 'Test', b: 'Two', c: 5 }, - { a: 'Foo', b: 'Three', c: 6 }, - ], - }; - expect(extractUniqTermsMap(table, 'a')).toMatchInlineSnapshot(` - Object { - "Foo": 2, - "Hi": 0, - "Test": 1, - } - `); - expect(extractUniqTermsMap(table, 'b')).toMatchInlineSnapshot(` - Object { - "Three": 1, - "Two": 0, - } - `); - }); - }); - - describe('#byDataColorPaletteMap', () => { - let datatable: Datatable; - let paletteDefinition: PaletteDefinition; - let palette: PaletteOutput; - const columnId = 'foo'; - - beforeEach(() => { - datatable = { - rows: [ - { - [columnId]: '1', - }, - { - [columnId]: '2', - }, - ], - } as unknown as Datatable; - paletteDefinition = chartPluginMock.createPaletteRegistry().get('default'); - palette = { type: 'palette' } as PaletteOutput; - }); - - it('should create byDataColorPaletteMap', () => { - expect(byDataColorPaletteMap(datatable, columnId, paletteDefinition, palette)) - .toMatchInlineSnapshot(` - Object { - "getColor": [Function], - } - `); - }); - - it('should get color', () => { - const colorPaletteMap = byDataColorPaletteMap( - datatable, - columnId, - paletteDefinition, - palette - ); - - expect(colorPaletteMap.getColor('1')).toBe('black'); - }); - - it('should return undefined in case if values not in datatable', () => { - const colorPaletteMap = byDataColorPaletteMap( - datatable, - columnId, - paletteDefinition, - palette - ); - - expect(colorPaletteMap.getColor('wrong')).toBeUndefined(); - }); - - it('should increase rankAtDepth for each new value', () => { - const colorPaletteMap = byDataColorPaletteMap( - datatable, - columnId, - paletteDefinition, - palette - ); - colorPaletteMap.getColor('1'); - colorPaletteMap.getColor('2'); - - expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith( - 1, - [{ name: '1', rankAtDepth: 0, totalSeriesAtDepth: 2 }], - { behindText: false }, - undefined - ); - - expect(paletteDefinition.getCategoricalColor).toHaveBeenNthCalledWith( - 2, - [{ name: '2', rankAtDepth: 1, totalSeriesAtDepth: 2 }], - { behindText: false }, - undefined - ); - }); - }); - describe('#checkTableForContainsSmallValues', () => { let datatable: Datatable; const columnId = 'foo'; @@ -380,23 +70,35 @@ describe('render helpers', () => { describe('#shouldShowValuesInLegend', () => { it('should firstly read the state value', () => { expect( - shouldShowValuesInLegend({ showValuesInLegend: true } as PieLayerState, 'waffle') + shouldShowValuesInLegend( + { showValuesInLegend: true } as PieLayerState, + PieChartTypes.WAFFLE + ) ).toBeTruthy(); expect( - shouldShowValuesInLegend({ showValuesInLegend: false } as PieLayerState, 'waffle') + shouldShowValuesInLegend( + { showValuesInLegend: false } as PieLayerState, + PieChartTypes.WAFFLE + ) ).toBeFalsy(); }); it('should read value from meta in case of value in state is undefined', () => { expect( - shouldShowValuesInLegend({ showValuesInLegend: undefined } as PieLayerState, 'waffle') + shouldShowValuesInLegend( + { showValuesInLegend: undefined } as PieLayerState, + PieChartTypes.WAFFLE + ) ).toBeTruthy(); - expect(shouldShowValuesInLegend({} as PieLayerState, 'waffle')).toBeTruthy(); + expect(shouldShowValuesInLegend({} as PieLayerState, PieChartTypes.WAFFLE)).toBeTruthy(); expect( - shouldShowValuesInLegend({ showValuesInLegend: undefined } as PieLayerState, 'pie') + shouldShowValuesInLegend( + { showValuesInLegend: undefined } as PieLayerState, + PieChartTypes.PIE + ) ).toBeFalsy(); }); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts b/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts index a9685e13e1774..1f6d40abc32ec 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts +++ b/x-pack/plugins/lens/public/pie_visualization/render_helpers.ts @@ -5,47 +5,14 @@ * 2.0. */ -import type { Datum, LayerValue } from '@elastic/charts'; -import type { Datatable, DatatableColumn } from 'src/plugins/expressions/public'; -import type { LensFilterEvent } from '../types'; -import type { PieChartTypes, PieLayerState } from '../../common/expressions/pie_chart/types'; -import type { PaletteDefinition, PaletteOutput } from '../../../../../src/plugins/charts/public'; +import type { Datatable } from 'src/plugins/expressions/public'; +import type { PieChartType, PieLayerState } from '../../common/types'; import { PartitionChartsMeta } from './partition_charts_meta'; -export function getSliceValue(d: Datum, metricColumn: DatatableColumn) { - const value = d[metricColumn.id]; - return Number.isFinite(value) && value >= 0 ? value : 0; -} - -export function getFilterContext( - clickedLayers: LayerValue[], - layerColumnIds: string[], - table: Datatable -): LensFilterEvent['data'] { - const matchingIndex = table.rows.findIndex((row) => - clickedLayers.every((layer, index) => { - const columnId = layerColumnIds[index]; - return row[columnId] === layer.groupByRollup; - }) - ); - - return { - data: clickedLayers.map((clickedLayer, index) => ({ - column: table.columns.findIndex((col) => col.id === layerColumnIds[index]), - row: matchingIndex, - value: clickedLayer.groupByRollup, - table, - })), - }; -} - -export const isPartitionShape = (shape: PieChartTypes | string) => +export const isPartitionShape = (shape: PieChartType | string) => ['donut', 'pie', 'treemap', 'mosaic', 'waffle'].includes(shape); -export const isTreemapOrMosaicShape = (shape: PieChartTypes | string) => - ['treemap', 'mosaic'].includes(shape); - -export const shouldShowValuesInLegend = (layer: PieLayerState, shape: PieChartTypes) => { +export const shouldShowValuesInLegend = (layer: PieLayerState, shape: PieChartType) => { if ('showValues' in PartitionChartsMeta[shape]?.legend) { return layer.showValuesInLegend ?? PartitionChartsMeta[shape]?.legend?.showValues ?? true; } @@ -53,58 +20,6 @@ export const shouldShowValuesInLegend = (layer: PieLayerState, shape: PieChartTy return false; }; -export const extractUniqTermsMap = (dataTable: Datatable, columnId: string) => - [...new Set(dataTable.rows.map((item) => item[columnId]))].reduce( - (acc, item, index) => ({ - ...acc, - [item]: index, - }), - {} - ); - -export const byDataColorPaletteMap = ( - dataTable: Datatable, - columnId: string, - paletteDefinition: PaletteDefinition, - { params }: PaletteOutput -) => { - const colorMap = new Map( - dataTable.rows.map((item) => [String(item[columnId]), undefined]) - ); - let rankAtDepth = 0; - - return { - getColor: (item: unknown) => { - const key = String(item); - - if (colorMap.has(key)) { - let color = colorMap.get(key); - - if (color) { - return color; - } - color = - paletteDefinition.getCategoricalColor( - [ - { - name: key, - totalSeriesAtDepth: colorMap.size, - rankAtDepth: rankAtDepth++, - }, - ], - { - behindText: false, - }, - params - ) || undefined; - - colorMap.set(key, color); - return color; - } - }, - }; -}; - export const checkTableForContainsSmallValues = ( dataTable: Datatable, columnId: string, diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts index 229ef9b387ac0..f951d4f07e865 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.test.ts @@ -8,7 +8,14 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { suggestions } from './suggestions'; import type { DataType, SuggestionRequest } from '../types'; -import type { PieLayerState, PieVisualizationState } from '../../common/expressions'; +import { + CategoryDisplay, + LegendDisplay, + NumberDisplay, + PieChartTypes, + PieLayerState, + PieVisualizationState, +} from '../../common'; import { layerTypes } from '../../common'; describe('suggestions', () => { @@ -53,16 +60,16 @@ describe('suggestions', () => { changeType: 'unchanged', }, state: { - shape: 'pie', + shape: PieChartTypes.PIE, layers: [ { layerId: 'first', layerType: layerTypes.DATA, groups: [], metric: 'a', - numberDisplay: 'hidden', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, }, ], }, @@ -168,7 +175,7 @@ describe('suggestions', () => { changeType: 'initial', }, state: { - shape: 'mosaic', + shape: PieChartTypes.MOSAIC, layers: [{} as PieLayerState], }, keptLayerIds: ['first'], @@ -380,7 +387,7 @@ describe('suggestions', () => { expect(results).toContainEqual( expect.objectContaining({ - state: expect.objectContaining({ shape: 'donut' }), + state: expect.objectContaining({ shape: PieChartTypes.DONUT }), }) ); }); @@ -412,7 +419,7 @@ describe('suggestions', () => { expect(results).toContainEqual( expect.objectContaining({ - state: expect.objectContaining({ shape: 'pie' }), + state: expect.objectContaining({ shape: PieChartTypes.PIE }), }) ); }); @@ -542,7 +549,7 @@ describe('suggestions', () => { changeType: 'unchanged', }, state: { - shape: 'treemap', + shape: PieChartTypes.TREEMAP, palette, layers: [ { @@ -551,9 +558,9 @@ describe('suggestions', () => { groups: ['a'], metric: 'b', - numberDisplay: 'hidden', - categoryDisplay: 'inside', - legendDisplay: 'show', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.INSIDE, + legendDisplay: LegendDisplay.SHOW, percentDecimals: 0, legendMaxLines: 1, truncateLegend: true, @@ -566,7 +573,7 @@ describe('suggestions', () => { ).toContainEqual( expect.objectContaining({ state: { - shape: 'donut', + shape: PieChartTypes.DONUT, palette, layers: [ { @@ -575,8 +582,8 @@ describe('suggestions', () => { groups: ['a'], metric: 'b', - numberDisplay: 'hidden', - categoryDisplay: 'inside', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.INSIDE, legendDisplay: 'show', percentDecimals: 0, legendMaxLines: 1, @@ -601,7 +608,7 @@ describe('suggestions', () => { changeType: 'unchanged', }, state: { - shape: 'treemap', + shape: PieChartTypes.TREEMAP, layers: [ { layerId: 'first', @@ -609,9 +616,9 @@ describe('suggestions', () => { groups: [], metric: 'a', - numberDisplay: 'hidden', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, }, ], }, @@ -651,16 +658,16 @@ describe('suggestions', () => { changeType: 'extended', }, state: { - shape: 'treemap', + shape: PieChartTypes.TREEMAP, layers: [ { layerId: 'first', layerType: layerTypes.DATA, groups: ['a', 'b'], metric: 'e', - numberDisplay: 'value', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.VALUE, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, }, ], }, @@ -700,16 +707,16 @@ describe('suggestions', () => { changeType: 'initial', }, state: { - shape: 'treemap', + shape: PieChartTypes.TREEMAP, layers: [ { layerId: 'first', layerType: layerTypes.DATA, groups: ['a', 'b'], metric: 'e', - numberDisplay: 'percent', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, }, ], }, @@ -737,7 +744,7 @@ describe('suggestions', () => { changeType: 'unchanged', }, state: { - shape: 'pie', + shape: PieChartTypes.PIE, layers: [ { layerId: 'first', @@ -745,9 +752,9 @@ describe('suggestions', () => { groups: ['a'], metric: 'b', - numberDisplay: 'hidden', - categoryDisplay: 'inside', - legendDisplay: 'show', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.INSIDE, + legendDisplay: LegendDisplay.SHOW, percentDecimals: 0, legendMaxLines: 1, truncateLegend: true, @@ -760,7 +767,7 @@ describe('suggestions', () => { ).toContainEqual( expect.objectContaining({ state: { - shape: 'treemap', + shape: PieChartTypes.TREEMAP, layers: [ { layerId: 'first', @@ -768,8 +775,8 @@ describe('suggestions', () => { groups: ['a'], metric: 'b', - numberDisplay: 'hidden', - categoryDisplay: 'default', // This is changed + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.DEFAULT, // This is changed legendDisplay: 'show', percentDecimals: 0, legendMaxLines: 1, @@ -794,7 +801,7 @@ describe('suggestions', () => { changeType: 'unchanged', }, state: { - shape: 'mosaic', + shape: PieChartTypes.MOSAIC, layers: [ { layerId: 'first', @@ -802,9 +809,9 @@ describe('suggestions', () => { groups: [], metric: 'a', - numberDisplay: 'hidden', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, }, ], }, @@ -836,7 +843,7 @@ describe('suggestions', () => { changeType: 'unchanged', }, state: { - shape: 'treemap', + shape: PieChartTypes.TREEMAP, layers: [ { layerId: 'first', @@ -844,9 +851,9 @@ describe('suggestions', () => { groups: ['a', 'b'], metric: 'c', - numberDisplay: 'hidden', - categoryDisplay: 'inside', - legendDisplay: 'show', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.INSIDE, + legendDisplay: LegendDisplay.SHOW, percentDecimals: 0, legendMaxLines: 1, truncateLegend: true, @@ -871,7 +878,7 @@ describe('suggestions', () => { changeType: 'unchanged', }, state: { - shape: 'waffle', + shape: PieChartTypes.WAFFLE, layers: [ { layerId: 'first', @@ -879,9 +886,9 @@ describe('suggestions', () => { groups: [], metric: 'a', - numberDisplay: 'hidden', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, }, ], }, @@ -909,16 +916,16 @@ describe('suggestions', () => { changeType: 'unchanged', }, state: { - shape: 'pie', + shape: PieChartTypes.PIE, layers: [ { layerId: 'first', layerType: layerTypes.DATA, groups: ['a', 'b'], metric: 'c', - numberDisplay: 'hidden', - categoryDisplay: 'inside', - legendDisplay: 'show', + numberDisplay: NumberDisplay.HIDDEN, + categoryDisplay: CategoryDisplay.INSIDE, + legendDisplay: LegendDisplay.SHOW, percentDecimals: 0, legendMaxLines: 1, truncateLegend: true, diff --git a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts index dd42dd6474e0b..0ff75ee823d42 100644 --- a/x-pack/plugins/lens/public/pie_visualization/suggestions.ts +++ b/x-pack/plugins/lens/public/pie_visualization/suggestions.ts @@ -8,11 +8,17 @@ import { partition } from 'lodash'; import { i18n } from '@kbn/i18n'; import type { SuggestionRequest, TableSuggestionColumn, VisualizationSuggestion } from '../types'; -import { layerTypes } from '../../common'; -import type { PieVisualizationState } from '../../common/expressions'; +import { + CategoryDisplay, + layerTypes, + LegendDisplay, + NumberDisplay, + PieChartTypes, + PieVisualizationState, +} from '../../common'; +import type { PieChartType } from '../../common/types'; import { PartitionChartsMeta } from './partition_charts_meta'; import { isPartitionShape } from './render_helpers'; -import { PieChartTypes } from '../../common/expressions/pie_chart/types'; function hasIntervalScale(columns: TableSuggestionColumn[]) { return columns.some((col) => col.operation.scale === 'interval'); @@ -43,14 +49,19 @@ function getNewShape( let newShape: PieVisualizationState['shape'] | undefined; if (groups.length !== 1 && !subVisualizationId) { - newShape = 'pie'; + newShape = PieChartTypes.PIE; } - return newShape ?? 'donut'; + return newShape ?? PieChartTypes.DONUT; } -function hasCustomSuggestionsExists(shape: PieChartTypes | string | undefined) { - return shape ? ['treemap', 'waffle', 'mosaic'].includes(shape) : false; +function hasCustomSuggestionsExists(shape: PieChartType | string | undefined) { + const shapes: Array = [ + PieChartTypes.TREEMAP, + PieChartTypes.WAFFLE, + PieChartTypes.MOSAIC, + ]; + return shape ? shapes.includes(shape) : false; } const maximumGroupLength = Math.max( @@ -116,9 +127,9 @@ export function suggestions({ layerId: table.layerId, groups: groups.map((col) => col.columnId), metric: metricColumnId, - numberDisplay: 'percent', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, layerType: layerTypes.DATA, }, @@ -137,13 +148,18 @@ export function suggestions({ ...baseSuggestion, title: i18n.translate('xpack.lens.pie.suggestionLabel', { defaultMessage: 'As {chartName}', - values: { chartName: PartitionChartsMeta[newShape === 'pie' ? 'donut' : 'pie'].label }, + values: { + chartName: + PartitionChartsMeta[ + newShape === PieChartTypes.PIE ? PieChartTypes.DONUT : PieChartTypes.PIE + ].label, + }, description: 'chartName is already translated', }), score: 0.1, state: { ...baseSuggestion.state, - shape: newShape === 'pie' ? 'donut' : 'pie', + shape: newShape === PieChartTypes.PIE ? PieChartTypes.DONUT : PieChartTypes.PIE, }, hide: true, }); @@ -159,9 +175,9 @@ export function suggestions({ }), // Use a higher score when currently active, to prevent chart type switching // on the user unintentionally - score: state?.shape === 'treemap' ? 0.7 : 0.5, + score: state?.shape === PieChartTypes.TREEMAP ? 0.7 : 0.5, state: { - shape: 'treemap', + shape: PieChartTypes.TREEMAP, palette: mainPalette || state?.palette, layers: [ state?.layers[0] @@ -171,8 +187,8 @@ export function suggestions({ groups: groups.map((col) => col.columnId), metric: metricColumnId, categoryDisplay: - state.layers[0].categoryDisplay === 'inside' - ? 'default' + state.layers[0].categoryDisplay === CategoryDisplay.INSIDE + ? CategoryDisplay.DEFAULT : state.layers[0].categoryDisplay, layerType: layerTypes.DATA, } @@ -180,9 +196,9 @@ export function suggestions({ layerId: table.layerId, groups: groups.map((col) => col.columnId), metric: metricColumnId, - numberDisplay: 'percent', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, layerType: layerTypes.DATA, }, @@ -194,21 +210,21 @@ export function suggestions({ table.changeType === 'reduced' || !state || hasIntervalScale(groups) || - (state && state.shape === 'treemap'), + (state && state.shape === PieChartTypes.TREEMAP), }); } if ( groups.length <= PartitionChartsMeta.mosaic.maxBuckets && - (!subVisualizationId || subVisualizationId === 'mosaic') + (!subVisualizationId || subVisualizationId === PieChartTypes.MOSAIC) ) { results.push({ title: i18n.translate('xpack.lens.pie.mosaicSuggestionLabel', { defaultMessage: 'As Mosaic', }), - score: state?.shape === 'mosaic' ? 0.7 : 0.5, + score: state?.shape === PieChartTypes.MOSAIC ? 0.7 : 0.5, state: { - shape: 'mosaic', + shape: PieChartTypes.MOSAIC, palette: mainPalette || state?.palette, layers: [ state?.layers[0] @@ -217,16 +233,16 @@ export function suggestions({ layerId: table.layerId, groups: groups.map((col) => col.columnId), metric: metricColumnId, - categoryDisplay: 'default', + categoryDisplay: CategoryDisplay.DEFAULT, layerType: layerTypes.DATA, } : { layerId: table.layerId, groups: groups.map((col) => col.columnId), metric: metricColumnId, - numberDisplay: 'percent', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, layerType: layerTypes.DATA, }, @@ -239,15 +255,15 @@ export function suggestions({ if ( groups.length <= PartitionChartsMeta.waffle.maxBuckets && - (!subVisualizationId || subVisualizationId === 'waffle') + (!subVisualizationId || subVisualizationId === PieChartTypes.WAFFLE) ) { results.push({ title: i18n.translate('xpack.lens.pie.waffleSuggestionLabel', { defaultMessage: 'As Waffle', }), - score: state?.shape === 'waffle' ? 0.7 : 0.5, + score: state?.shape === PieChartTypes.WAFFLE ? 0.7 : 0.5, state: { - shape: 'waffle', + shape: PieChartTypes.WAFFLE, palette: mainPalette || state?.palette, layers: [ state?.layers[0] @@ -256,16 +272,16 @@ export function suggestions({ layerId: table.layerId, groups: groups.map((col) => col.columnId), metric: metricColumnId, - categoryDisplay: 'default', + categoryDisplay: CategoryDisplay.DEFAULT, layerType: layerTypes.DATA, } : { layerId: table.layerId, groups: groups.map((col) => col.columnId), metric: metricColumnId, - numberDisplay: 'percent', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, layerType: layerTypes.DATA, }, diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index f703b1b5f419b..9ae9f4ac0cae4 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -6,13 +6,62 @@ */ import type { Ast } from '@kbn/interpreter'; -import type { PaletteRegistry } from 'src/plugins/charts/public'; +import { Position } from '@elastic/charts'; + +import type { PaletteOutput, PaletteRegistry } from '../../../../../src/plugins/charts/public'; +import { + buildExpression, + buildExpressionFunction, +} from '../../../../../src/plugins/expressions/public'; import type { Operation, DatasourcePublicAPI } from '../types'; -import { DEFAULT_PERCENT_DECIMALS, EMPTY_SIZE_RATIOS } from './constants'; +import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { shouldShowValuesInLegend } from './render_helpers'; -import type { PieLayerState, PieVisualizationState } from '../../common/expressions'; +import { + CategoryDisplay, + NumberDisplay, + PieChartTypes, + PieLayerState, + PieVisualizationState, + EmptySizeRatios, + LegendDisplay, +} from '../../common'; import { getDefaultVisualValuesForLayer } from '../shared_components/datasource_default_values'; +interface Attributes { + isPreview: boolean; + title?: string; + description?: string; +} + +interface OperationColumnId { + columnId: string; + operation: Operation; +} + +type GenerateExpressionAstFunction = ( + state: PieVisualizationState, + attributes: Attributes, + operations: OperationColumnId[], + layer: PieLayerState, + datasourceLayers: Record, + paletteService: PaletteRegistry +) => Ast | null; + +type GenerateExpressionAstArguments = ( + state: PieVisualizationState, + attributes: Attributes, + operations: OperationColumnId[], + layer: PieLayerState, + datasourceLayers: Record, + paletteService: PaletteRegistry +) => Ast['chain'][number]['arguments']; + +type GenerateLabelsAstArguments = ( + state: PieVisualizationState, + attributes: Attributes, + layer: PieLayerState +) => [Ast]; + export const getSortedGroups = (datasource: DatasourcePublicAPI, layer: PieLayerState) => { const originalOrder = datasource .getTableSpec() @@ -22,23 +71,183 @@ export const getSortedGroups = (datasource: DatasourcePublicAPI, layer: PieLayer return Array.from(new Set(originalOrder.concat(layer.groups))); }; -export function toExpression( - state: PieVisualizationState, - datasourceLayers: Record, +const prepareDimension = (accessor: string) => { + const visdimension = buildExpressionFunction('visdimension', { accessor }); + return buildExpression([visdimension]).toAst(); +}; + +const generateCommonLabelsAstArgs: GenerateLabelsAstArguments = (state, attributes, layer) => { + const show = [!attributes.isPreview && layer.categoryDisplay !== CategoryDisplay.HIDE]; + const position = layer.categoryDisplay !== CategoryDisplay.HIDE ? [layer.categoryDisplay] : []; + const values = [layer.numberDisplay !== NumberDisplay.HIDDEN]; + const valuesFormat = layer.numberDisplay !== NumberDisplay.HIDDEN ? [layer.numberDisplay] : []; + const percentDecimals = [layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS]; + + return [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'partitionLabels', + arguments: { show, position, values, valuesFormat, percentDecimals }, + }, + ], + }, + ]; +}; + +const generateWaffleLabelsAstArguments: GenerateLabelsAstArguments = (...args) => { + const [labelsExpr] = generateCommonLabelsAstArgs(...args); + const [labels] = labelsExpr.chain; + return [ + { + ...labelsExpr, + chain: [{ ...labels, percentDecimals: DEFAULT_PERCENT_DECIMALS }], + }, + ]; +}; + +const generatePaletteAstArguments = ( paletteService: PaletteRegistry, - attributes: Partial<{ title: string; description: string }> = {} -) { - return expressionHelper(state, datasourceLayers, paletteService, { - ...attributes, - isPreview: false, - }); -} + palette?: PaletteOutput +): [Ast] => + palette + ? [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'theme', + arguments: { + variable: ['palette'], + default: [paletteService.get(palette.name).toExpression(palette.params)], + }, + }, + ], + }, + ] + : [paletteService.get('default').toExpression()]; + +const generateCommonArguments: GenerateExpressionAstArguments = ( + state, + attributes, + operations, + layer, + datasourceLayers, + paletteService +) => ({ + labels: generateCommonLabelsAstArgs(state, attributes, layer), + buckets: operations.map((o) => o.columnId).map(prepareDimension), + metric: layer.metric ? [prepareDimension(layer.metric)] : [], + legendDisplay: [attributes.isPreview ? LegendDisplay.HIDE : layer.legendDisplay], + legendPosition: [layer.legendPosition || Position.Right], + maxLegendLines: [layer.legendMaxLines ?? 1], + nestedLegend: [!!layer.nestedLegend], + truncateLegend: [ + layer.truncateLegend ?? getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, + ], + palette: generatePaletteAstArguments(paletteService, state.palette), +}); + +const generatePieVisAst: GenerateExpressionAstFunction = (...rest) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'pieVis', + arguments: { + ...generateCommonArguments(...rest), + respectSourceOrder: [false], + startFromSecondLargestSlice: [true], + }, + }, + ], +}); + +const generateDonutVisAst: GenerateExpressionAstFunction = (...rest) => { + const [, , , layer] = rest; + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'pieVis', + arguments: { + ...generateCommonArguments(...rest), + respectSourceOrder: [false], + isDonut: [true], + startFromSecondLargestSlice: [true], + emptySizeRatio: [layer.emptySizeRatio ?? EmptySizeRatios.SMALL], + }, + }, + ], + }; +}; + +const generateTreemapVisAst: GenerateExpressionAstFunction = (...rest) => { + const [, , , layer] = rest; + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'treemapVis', + arguments: { + ...generateCommonArguments(...rest), + nestedLegend: [!!layer.nestedLegend], + }, + }, + ], + }; +}; + +const generateMosaicVisAst: GenerateExpressionAstFunction = (...rest) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'mosaicVis', + arguments: generateCommonArguments(...rest), + }, + ], +}); + +const generateWaffleVisAst: GenerateExpressionAstFunction = (...rest) => { + const { buckets, nestedLegend, ...args } = generateCommonArguments(...rest); + const [state, attributes, , layer] = rest; + return { + type: 'expression', + chain: [ + { + type: 'function', + function: 'waffleVis', + arguments: { + ...args, + bucket: buckets, + labels: generateWaffleLabelsAstArguments(state, attributes, layer), + showValuesInLegend: [shouldShowValuesInLegend(layer, state.shape)], + }, + }, + ], + }; +}; + +const generateExprAst: GenerateExpressionAstFunction = (state, ...restArgs) => + ({ + [PieChartTypes.PIE]: () => generatePieVisAst(state, ...restArgs), + [PieChartTypes.DONUT]: () => generateDonutVisAst(state, ...restArgs), + [PieChartTypes.TREEMAP]: () => generateTreemapVisAst(state, ...restArgs), + [PieChartTypes.MOSAIC]: () => generateMosaicVisAst(state, ...restArgs), + [PieChartTypes.WAFFLE]: () => generateWaffleVisAst(state, ...restArgs), + }[state.shape]()); function expressionHelper( state: PieVisualizationState, datasourceLayers: Record, paletteService: PaletteRegistry, - attributes: { isPreview: boolean; title?: string; description?: string } = { isPreview: false } + attributes: Attributes = { isPreview: false } ): Ast | null { const layer = state.layers[0]; const datasource = datasourceLayers[layer.layerId]; @@ -51,63 +260,20 @@ function expressionHelper( if (!layer.metric || !operations.length) { return null; } - return { - type: 'expression', - chain: [ - { - type: 'function', - function: 'lens_pie', - arguments: { - title: [attributes.title || ''], - description: [attributes.description || ''], - shape: [state.shape], - hideLabels: [attributes.isPreview], - groups: operations.map((o) => o.columnId), - metric: [layer.metric], - numberDisplay: [layer.numberDisplay], - categoryDisplay: [layer.categoryDisplay], - legendDisplay: [layer.legendDisplay], - legendPosition: [layer.legendPosition || 'right'], - emptySizeRatio: [layer.emptySizeRatio ?? EMPTY_SIZE_RATIOS.SMALL], - showValuesInLegend: [shouldShowValuesInLegend(layer, state.shape)], - percentDecimals: [ - state.shape === 'waffle' - ? DEFAULT_PERCENT_DECIMALS - : layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS, - ], - legendMaxLines: [layer.legendMaxLines ?? 1], - truncateLegend: [ - layer.truncateLegend ?? - getDefaultVisualValuesForLayer(state, datasourceLayers).truncateText, - ], - nestedLegend: [!!layer.nestedLegend], - ...(state.palette - ? { - palette: [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'theme', - arguments: { - variable: ['palette'], - default: [ - paletteService - .get(state.palette.name) - .toExpression(state.palette.params), - ], - }, - }, - ], - }, - ], - } - : {}), - }, - }, - ], - }; + + return generateExprAst(state, attributes, operations, layer, datasourceLayers, paletteService); +} + +export function toExpression( + state: PieVisualizationState, + datasourceLayers: Record, + paletteService: PaletteRegistry, + attributes: Partial<{ title: string; description: string }> = {} +) { + return expressionHelper(state, datasourceLayers, paletteService, { + ...attributes, + isPreview: false, + }); } export function toPreviewExpression( diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx index cebacd5c95863..f188aa12069d7 100644 --- a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -20,7 +20,7 @@ import type { Position } from '@elastic/charts'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import { DEFAULT_PERCENT_DECIMALS } from './constants'; import { PartitionChartsMeta } from './partition_charts_meta'; -import type { PieVisualizationState, SharedPieLayerState } from '../../common/expressions'; +import { LegendDisplay, PieVisualizationState, SharedPieLayerState } from '../../common'; import { VisualizationDimensionEditorProps, VisualizationToolbarProps } from '../types'; import { ToolbarPopover, LegendSettingsPopover, useDebouncedValue } from '../shared_components'; import { PalettePicker } from '../shared_components'; @@ -34,21 +34,21 @@ const legendOptions: Array<{ }> = [ { id: 'pieLegendDisplay-default', - value: 'default', + value: LegendDisplay.DEFAULT, label: i18n.translate('xpack.lens.pieChart.legendVisibility.auto', { defaultMessage: 'Auto', }), }, { id: 'pieLegendDisplay-show', - value: 'show', + value: LegendDisplay.SHOW, label: i18n.translate('xpack.lens.pieChart.legendVisibility.show', { defaultMessage: 'Show', }), }, { id: 'pieLegendDisplay-hide', - value: 'hide', + value: LegendDisplay.HIDE, label: i18n.translate('xpack.lens.pieChart.legendVisibility.hide', { defaultMessage: 'Hide', }), diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.scss b/x-pack/plugins/lens/public/pie_visualization/visualization.scss deleted file mode 100644 index a8890208596b6..0000000000000 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.scss +++ /dev/null @@ -1,7 +0,0 @@ -.lnsPieExpression__container { - height: 100%; - width: 100%; - // the FocusTrap is adding extra divs which are making the visualization redraw twice - // with a visible glitch. This make the chart library resilient to this extra reflow - overflow-x: hidden; -} 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 86ac635e36068..c178613657947 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.test.ts @@ -6,7 +6,13 @@ */ import { getPieVisualization } from './visualization'; -import type { PieVisualizationState } from '../../common/expressions'; +import { + PieVisualizationState, + PieChartTypes, + CategoryDisplay, + NumberDisplay, + LegendDisplay, +} from '../../common'; import { layerTypes } from '../../common'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; @@ -24,16 +30,16 @@ const pieVisualization = getPieVisualization({ function getExampleState(): PieVisualizationState { return { - shape: 'pie', + shape: PieChartTypes.PIE, layers: [ { layerId: LAYER_ID, layerType: layerTypes.DATA, groups: [], metric: undefined, - numberDisplay: 'percent', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, }, ], @@ -81,14 +87,14 @@ describe('pie_visualization', () => { groups: ['a'], layerId: LAYER_ID, layerType: layerTypes.DATA, - numberDisplay: 'percent', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, metric: undefined, }, ], - shape: 'donut', + shape: PieChartTypes.DONUT, }; const setDimensionResult = pieVisualization.setDimension({ prevState, @@ -100,7 +106,7 @@ describe('pie_visualization', () => { expect(setDimensionResult).toEqual( expect.objectContaining({ - shape: 'donut', + shape: PieChartTypes.DONUT, }) ); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx index 5bf2ae8822583..0e8f05eff8920 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.tsx @@ -20,21 +20,21 @@ import type { VisualizationDimensionGroupConfig, } from '../types'; import { getSortedGroups, toExpression, toPreviewExpression } from './to_expression'; -import type { PieLayerState, PieVisualizationState } from '../../common/expressions'; -import { layerTypes } from '../../common'; +import { CategoryDisplay, layerTypes, LegendDisplay, NumberDisplay } from '../../common'; import { suggestions } from './suggestions'; import { PartitionChartsMeta } from './partition_charts_meta'; import { DimensionEditor, PieToolbar } from './toolbar'; import { checkTableForContainsSmallValues } from './render_helpers'; +import { PieChartTypes, PieLayerState, PieVisualizationState } from '../../common'; function newLayerState(layerId: string): PieLayerState { return { layerId, groups: [], metric: undefined, - numberDisplay: 'percent', - categoryDisplay: 'default', - legendDisplay: 'default', + numberDisplay: NumberDisplay.PERCENT, + categoryDisplay: CategoryDisplay.DEFAULT, + legendDisplay: LegendDisplay.DEFAULT, nestedLegend: false, layerType: layerTypes.DATA, }; @@ -108,7 +108,7 @@ export const getPieVisualization = ({ initialize(addNewLayer, state, mainPalette) { return ( state || { - shape: 'donut', + shape: PieChartTypes.DONUT, layers: [newLayerState(addNewLayer())], palette: mainPalette, } @@ -231,7 +231,7 @@ export const getPieVisualization = ({ { type: layerTypes.DATA, label: i18n.translate('xpack.lens.pie.addLayer', { - defaultMessage: 'Add visualization layer', + defaultMessage: 'Visualization', }), }, ]; diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index bba54c85a67c6..42e4a55167c8b 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -68,6 +68,7 @@ import { ACTION_VISUALIZE_FIELD, VISUALIZE_FIELD_TRIGGER, } from '../../../../src/plugins/ui_actions/public'; +import { VISUALIZE_EDITOR_TRIGGER } from '../../../../src/plugins/visualizations/public'; import { APP_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../common/constants'; import type { FormatFactory } from '../common/types'; import type { @@ -78,6 +79,7 @@ import type { } from './types'; import { getLensAliasConfig } from './vis_type_alias'; import { visualizeFieldAction } from './trigger_actions/visualize_field_actions'; +import { visualizeTSVBAction } from './trigger_actions/visualize_tsvb_actions'; import type { LensEmbeddableInput } from './embeddable'; import { EmbeddableFactory, LensEmbeddableStartServices } from './embeddable/embeddable_factory'; @@ -419,6 +421,11 @@ export class LensPlugin { visualizeFieldAction(core.application) ); + startDependencies.uiActions.addTriggerAction( + VISUALIZE_EDITOR_TRIGGER, + visualizeTSVBAction(core.application) + ); + return { EmbeddableComponent: getEmbeddableComponent(core, startDependencies), SaveModalComponent: getSaveModalComponent(core, startDependencies), diff --git a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx index c22ac8f1d860b..cc175b9f5ddab 100644 --- a/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx +++ b/x-pack/plugins/lens/public/shared_components/coloring/color_ranges/color_ranges.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React, { useState, useEffect, Dispatch } from 'react'; - +import { camelCase } from 'lodash'; import { EuiFlexGroup, EuiTextColor, EuiFlexItem } from '@elastic/eui'; import { ColorRangesExtraActions } from './color_ranges_extra_actions'; @@ -84,7 +84,9 @@ export function ColorRanges({ ) : null} {errors.map((error) => ( - {error} + + {error} + ))} {showExtraActions ? ( diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index 67b7ccac97478..099929cdf4796 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -12,16 +12,14 @@ import { History } from 'history'; import { LensEmbeddableInput } from '..'; import { getDatasourceLayers } from '../editor_frame_service/editor_frame'; import { TableInspectorAdapter } from '../editor_frame_service/types'; +import type { VisualizeEditorContext, Suggestion } from '../types'; import { getInitialDatasourceId, getResolvedDateRange, getRemoveOperation } from '../utils'; import { LensAppState, LensStoreDeps, VisualizationState } from './types'; import { Datasource, Visualization } from '../types'; import { generateId } from '../id_generator'; import type { LayerType } from '../../common/types'; import { getLayerType } from '../editor_frame_service/editor_frame/config_panel/add_layer'; -import { - getVisualizeFieldSuggestions, - Suggestion, -} from '../editor_frame_service/editor_frame/suggestion_helpers'; +import { getVisualizeFieldSuggestions } from '../editor_frame_service/editor_frame/suggestion_helpers'; import { FramePublicAPI, LensEditContextMapping, LensEditEvent } from '../types'; export const initialState: LensAppState = { @@ -131,7 +129,7 @@ export const initEmpty = createAction( initialContext, }: { newState: Partial; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; }) { return { payload: { layerId: generateId(), newState, initialContext } }; } @@ -411,7 +409,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => { }: { payload: { newState: Partial; - initialContext: VisualizeFieldContext | undefined; + initialContext: VisualizeFieldContext | VisualizeEditorContext | undefined; layerId: string; }; } diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts index 8c18a2a6082b5..b0ff49862d9b8 100644 --- a/x-pack/plugins/lens/public/state_management/types.ts +++ b/x-pack/plugins/lens/public/state_management/types.ts @@ -14,7 +14,12 @@ import { Document } from '../persistence'; import { TableInspectorAdapter } from '../editor_frame_service/types'; import { DateRange } from '../../common'; import { LensAppServices } from '../app_plugin/types'; -import { DatasourceMap, VisualizationMap, SharingSavedObjectProps } from '../types'; +import { + DatasourceMap, + VisualizationMap, + SharingSavedObjectProps, + VisualizeEditorContext, +} from '../types'; export interface VisualizationState { activeId: string | null; state: unknown; @@ -60,6 +65,6 @@ export interface LensStoreDeps { lensServices: LensAppServices; datasourceMap: DatasourceMap; visualizationMap: VisualizationMap; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; embeddableEditorIncomingState?: EmbeddableEditorState; } diff --git a/x-pack/plugins/lens/public/trigger_actions/visualize_tsvb_actions.ts b/x-pack/plugins/lens/public/trigger_actions/visualize_tsvb_actions.ts new file mode 100644 index 0000000000000..6694efac7bec7 --- /dev/null +++ b/x-pack/plugins/lens/public/trigger_actions/visualize_tsvb_actions.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { createAction } from '../../../../../src/plugins/ui_actions/public'; +import { ACTION_CONVERT_TO_LENS } from '../../../../../src/plugins/visualizations/public'; +import type { VisualizeEditorContext } from '../types'; +import type { ApplicationStart } from '../../../../../src/core/public'; + +export const visualizeTSVBAction = (application: ApplicationStart) => + createAction<{ [key: string]: VisualizeEditorContext }>({ + type: ACTION_CONVERT_TO_LENS, + id: ACTION_CONVERT_TO_LENS, + getDisplayName: () => + i18n.translate('xpack.lens.visualizeTSVBLegend', { + defaultMessage: 'Visualize TSVB chart', + }), + isCompatible: async () => !!application.capabilities.visualize.show, + execute: async (context: { [key: string]: VisualizeEditorContext }) => { + const table = Object.values(context.layers); + const payload = { + ...context, + layers: table, + isVisualizeAction: true, + }; + application.navigateToApp('lens', { + state: { + type: ACTION_CONVERT_TO_LENS, + payload, + originatingApp: i18n.translate('xpack.lens.TSVBLabel', { + defaultMessage: 'TSVB', + }), + }, + }); + }, + }); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 7cffd7bd88c17..483da14207516 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { Ast } from '@kbn/interpreter'; import type { IconType } from '@elastic/eui/src/components/icon/icon'; import type { CoreSetup, SavedObjectReference } from 'kibana/public'; import type { PaletteOutput } from 'src/plugins/charts/public'; @@ -17,6 +17,7 @@ import type { IInterpreterRenderHandlers, Datatable, } from '../../../../src/plugins/expressions/public'; +import type { VisualizeEditorLayersContext } from '../../../../src/plugins/visualizations/public'; import { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import type { DateRange, LayerType, SortingHint } from '../common'; import type { Query } from '../../../../src/plugins/data/public'; @@ -165,6 +166,33 @@ export interface InitializationOptions { isFullEditor?: boolean; } +interface AxisExtents { + mode: string; + lowerBound?: number; + upperBound?: number; +} + +export interface VisualizeEditorContext { + layers: VisualizeEditorLayersContext[]; + configuration: ChartSettings; + savedObjectId?: string; + embeddableId?: string; + vizEditorOriginatingAppUrl?: string; + originatingApp?: string; + isVisualizeAction: boolean; + type: string; +} + +interface ChartSettings { + fill?: string; + legend?: Record; + gridLinesVisibility?: Record; + extents?: { + yLeftExtent: AxisExtents; + yRightExtent: AxisExtents; + }; +} + /** * Interface for the datasource registry */ @@ -177,7 +205,7 @@ export interface Datasource { initialize: ( state?: P, savedObjectReferences?: SavedObjectReference[], - initialContext?: VisualizeFieldContext, + initialContext?: VisualizeFieldContext | VisualizeEditorContext, options?: InitializationOptions ) => Promise; @@ -247,6 +275,10 @@ export interface Datasource { field: unknown, filterFn: (layerId: string) => boolean ) => Array>; + getDatasourceSuggestionsForVisualizeCharts: ( + state: T, + context: VisualizeEditorLayersContext[] + ) => Array>; getDatasourceSuggestionsForVisualizeField: ( state: T, indexPatternId: string, @@ -529,6 +561,31 @@ interface VisualizationDimensionChangeProps { prevState: T; frame: Pick; } +export interface Suggestion { + visualizationId: string; + datasourceState?: unknown; + datasourceId?: string; + columns: number; + score: number; + title: string; + visualizationState: unknown; + previewExpression?: Ast | string; + previewIcon: IconType; + hide?: boolean; + changeType: TableChangeType; + keptLayerIds: string[]; +} + +interface VisualizationConfigurationFromContextChangeProps { + layerId: string; + prevState: T; + context: VisualizeEditorLayersContext; +} + +interface VisualizationStateFromContextChangeProps { + suggestions: Suggestion[]; + context: VisualizeEditorContext; +} /** * Object passed to `getSuggestions` of a visualization. @@ -745,6 +802,19 @@ export interface Visualization { */ removeDimension: (props: VisualizationDimensionChangeProps) => T; + /** + * Update the configuration for the visualization. This is used to update the state + */ + updateLayersConfigurationFromContext?: ( + props: VisualizationConfigurationFromContextChangeProps + ) => T; + + /** + * Update the visualization state from the context. + */ + getVisualizationSuggestionFromContext?: ( + props: VisualizationStateFromContextChangeProps + ) => Suggestion; /** * Additional editor that gets rendered inside the dimension popover. * This can be used to configure dimension-specific options @@ -892,5 +962,5 @@ export type LensTopNavMenuEntryGenerator = (props: { visualizationState: unknown; query: Query; filters: Filter[]; - initialContext?: VisualizeFieldContext; + initialContext?: VisualizeFieldContext | VisualizeEditorContext; }) => undefined | TopNavMenuData; diff --git a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx index b78fb4cd3dc17..61e540cd0defc 100644 --- a/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx +++ b/x-pack/plugins/lens/public/visualizations/gauge/visualization.tsx @@ -377,7 +377,7 @@ export const getGaugeVisualization = ({ { type: layerTypes.DATA, label: i18n.translate('xpack.lens.gauge.addLayer', { - defaultMessage: 'Add visualization layer', + defaultMessage: 'Visualization', }), initialDimensions: state ? [ diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 75e80782c5d38..b59d69bd8cbe6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -17,7 +17,7 @@ import { LensIconChartBarHorizontalStacked } from '../assets/chart_bar_horizonta import { LensIconChartBarHorizontalPercentage } from '../assets/chart_bar_horizontal_percentage'; import { LensIconChartLine } from '../assets/chart_line'; -import type { VisualizationType } from '../types'; +import type { VisualizationType, Suggestion } from '../types'; import type { SeriesType, LegendConfig, @@ -157,3 +157,12 @@ export const visualizationTypes: VisualizationType[] = [ sortPriority: 2, }, ]; + +interface XYStateWithLayers { + [prop: string]: unknown; + layers: XYLayerConfig[]; +} +export interface XYSuggestion extends Suggestion { + datasourceState: XYStateWithLayers; + visualizationState: XYStateWithLayers; +} 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 ff7ad2c0f2d85..51cf15c292647 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.test.ts @@ -7,12 +7,13 @@ import { getXyVisualization } from './visualization'; import { Position } from '@elastic/charts'; -import { Operation } from '../types'; -import type { State } from './types'; +import { Operation, VisualizeEditorContext, Suggestion } from '../types'; +import type { State, XYSuggestion } from './types'; import type { SeriesType, XYLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; import { createMockDatasource, createMockFramePublicAPI } from '../mocks'; import { LensIconChartBar } from '../assets/chart_bar'; +import type { VisualizeEditorLayersContext } from '../../../../../src/plugins/visualizations/public'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { fieldFormatsServiceMock } from '../../../../../src/plugins/field_formats/public/mocks'; import { Datatable } from 'src/plugins/expressions'; @@ -356,6 +357,243 @@ describe('xy_visualization', () => { }); }); + describe('#updateLayersConfigurationFromContext', () => { + let mockDatasource: ReturnType; + let frame: ReturnType; + let context: VisualizeEditorLayersContext; + + beforeEach(() => { + frame = createMockFramePublicAPI(); + mockDatasource = createMockDatasource('testDatasource'); + + mockDatasource.publicAPIMock.getTableSpec.mockReturnValue([ + { columnId: 'd' }, + { columnId: 'a' }, + { columnId: 'b' }, + { columnId: 'c' }, + ]); + + frame.datasourceLayers = { + first: mockDatasource.publicAPIMock, + }; + + frame.activeData = { + first: { + type: 'datatable', + rows: [], + columns: [], + }, + }; + + context = { + chartType: 'area', + axisPosition: 'right', + palette: { + name: 'temperature', + type: 'palette', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + format: 'bytes', + } as VisualizeEditorLayersContext; + }); + + it('sets the context configuration correctly', () => { + const state = xyVisualization?.updateLayersConfigurationFromContext?.({ + prevState: { + ...exampleState(), + layers: [ + { + layerId: 'first', + layerType: layerTypes.DATA, + seriesType: 'line', + xAccessor: undefined, + accessors: ['a'], + }, + ], + }, + layerId: 'first', + context, + }); + expect(state?.layers[0]).toHaveProperty('seriesType', 'area'); + expect(state?.layers[0].yConfig).toStrictEqual([ + { + axisMode: 'right', + color: '#68BC00', + forAccessor: 'a', + }, + ]); + + expect(state?.layers[0].palette).toStrictEqual({ + name: 'temperature', + type: 'palette', + }); + }); + }); + + describe('#getVisualizationSuggestionFromContext', () => { + let context: VisualizeEditorContext; + let suggestions: Suggestion[]; + + beforeEach(() => { + suggestions = [ + { + title: 'Average of AvgTicketPrice over timestamp', + score: 0.3333333333333333, + hide: true, + visualizationId: 'lnsXY', + visualizationState: { + legend: { + isVisible: true, + position: 'right', + }, + valueLabels: 'hide', + fittingFunction: 'None', + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + preferredSeriesType: 'bar_stacked', + layers: [ + { + layerId: 'e71c3459-ddcf-4a13-94a1-bf91f7b40175', + seriesType: 'bar_stacked', + xAccessor: '911abe51-36ca-42ba-ae4e-bcf3f941f3c1', + accessors: ['0ffeb3fb-86fd-42d1-ab62-5a00b7000a7b'], + layerType: 'data', + }, + ], + }, + keptLayerIds: [], + datasourceState: { + layers: { + 'e71c3459-ddcf-4a13-94a1-bf91f7b40175': { + indexPatternId: 'd3d7af60-4c81-11e8-b3d7-01146121b73d', + columns: { + '911abe51-36ca-42ba-ae4e-bcf3f941f3c1': { + label: 'timestamp', + dataType: 'date', + operationType: 'date_histogram', + sourceField: 'timestamp', + isBucketed: true, + scale: 'interval', + params: { + interval: 'auto', + }, + }, + '0ffeb3fb-86fd-42d1-ab62-5a00b7000a7b': { + label: 'Average of AvgTicketPrice', + dataType: 'number', + operationType: 'average', + sourceField: 'AvgTicketPrice', + isBucketed: false, + scale: 'ratio', + }, + }, + columnOrder: [ + '911abe51-36ca-42ba-ae4e-bcf3f941f3c1', + '0ffeb3fb-86fd-42d1-ab62-5a00b7000a7b', + ], + incompleteColumns: {}, + }, + }, + }, + datasourceId: 'indexpattern', + columns: 2, + changeType: 'initial', + }, + ] as unknown as Suggestion[]; + + context = { + layers: [ + { + indexPatternId: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + timeFieldName: 'order_date', + chartType: 'area', + axisPosition: 'left', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + agg: 'count', + isFullReference: false, + fieldName: 'document', + params: {}, + color: '#68BC00', + }, + ], + timeInterval: 'auto', + }, + ], + type: 'lnsXY', + configuration: { + fill: '0.5', + legend: { + isVisible: true, + position: 'right', + shouldTruncate: true, + maxLines: true, + }, + gridLinesVisibility: { + x: true, + yLeft: true, + yRight: true, + }, + extents: { + yLeftExtent: { + mode: 'full', + }, + yRightExtent: { + mode: 'full', + }, + }, + }, + isVisualizeAction: true, + } as VisualizeEditorContext; + }); + + it('updates the visualization state correctly based on the context', () => { + const suggestion = xyVisualization?.getVisualizationSuggestionFromContext?.({ + suggestions, + context, + }) as XYSuggestion; + expect(suggestion?.visualizationState?.fillOpacity).toEqual(0.5); + expect(suggestion?.visualizationState?.yRightExtent).toEqual({ mode: 'full' }); + expect(suggestion?.visualizationState?.legend).toEqual({ + isVisible: true, + maxLines: true, + position: 'right', + shouldTruncate: true, + }); + }); + }); + describe('#removeDimension', () => { let mockDatasource: ReturnType; let frame: ReturnType; diff --git a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx index 49c8705c5cb5e..9a84304bcfb34 100644 --- a/x-pack/plugins/lens/public/xy_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/visualization.tsx @@ -20,8 +20,8 @@ import { getSuggestions } from './xy_suggestions'; import { XyToolbar, DimensionEditor } from './xy_config_panel'; import { LayerHeader } from './xy_config_panel/layer_header'; import type { Visualization, OperationMetadata, VisualizationType, AccessorConfig } from '../types'; -import { State, visualizationTypes } from './types'; -import { SeriesType, XYLayerConfig } from '../../common/expressions'; +import { State, visualizationTypes, XYSuggestion } from './types'; +import { SeriesType, XYLayerConfig, YAxisMode } from '../../common/expressions'; import { LayerType, layerTypes } from '../../common'; import { isHorizontalChart } from './state_helpers'; import { toExpression, toPreviewExpression, getSortedAccessors } from './to_expression'; @@ -243,14 +243,14 @@ export const getXyVisualization = ({ { type: layerTypes.DATA, label: i18n.translate('xpack.lens.xyChart.addDataLayerLabel', { - defaultMessage: 'Add visualization layer', + defaultMessage: 'Visualization', }), icon: LensIconChartMixedXy, }, { type: layerTypes.REFERENCELINE, label: i18n.translate('xpack.lens.xyChart.addReferenceLineLayerLabel', { - defaultMessage: 'Add reference layer', + defaultMessage: 'Reference lines', }), icon: LensIconChartBarReferenceLine, disabled: @@ -527,6 +527,83 @@ export const getXyVisualization = ({ }; }, + updateLayersConfigurationFromContext({ prevState, layerId, context }) { + const { chartType, axisPosition, palette, metrics } = context; + const foundLayer = prevState?.layers.find((l) => l.layerId === layerId); + if (!foundLayer) { + return prevState; + } + const axisMode = axisPosition as YAxisMode; + const yConfig = metrics.map((metric, idx) => { + return { + color: metric.color, + forAccessor: metric.accessor ?? foundLayer.accessors[idx], + ...(axisMode && { axisMode }), + }; + }); + const newLayer = { + ...foundLayer, + ...(chartType && { seriesType: chartType as SeriesType }), + ...(palette && { palette }), + yConfig, + }; + + const newLayers = prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)); + + return { + ...prevState, + layers: newLayers, + }; + }, + + getVisualizationSuggestionFromContext({ suggestions, context }) { + const visualizationStateLayers = []; + let datasourceStateLayers = {}; + const fillOpacity = context.configuration.fill ? Number(context.configuration.fill) : undefined; + for (let suggestionIdx = 0; suggestionIdx < suggestions.length; suggestionIdx++) { + const currentSuggestion = suggestions[suggestionIdx] as XYSuggestion; + const currentSuggestionsLayers = currentSuggestion.visualizationState.layers; + const contextLayer = context.layers.find( + (layer) => layer.layerId === Object.keys(currentSuggestion.datasourceState.layers)[0] + ); + if (this.updateLayersConfigurationFromContext && contextLayer) { + const updatedSuggestionState = this.updateLayersConfigurationFromContext({ + prevState: currentSuggestion.visualizationState as unknown as State, + layerId: currentSuggestionsLayers[0].layerId as string, + context: contextLayer, + }); + + visualizationStateLayers.push(...updatedSuggestionState.layers); + datasourceStateLayers = { + ...datasourceStateLayers, + ...currentSuggestion.datasourceState.layers, + }; + } + } + let suggestion = suggestions[0] as XYSuggestion; + suggestion = { + ...suggestion, + datasourceState: { + ...suggestion.datasourceState, + layers: { + ...suggestion.datasourceState.layers, + ...datasourceStateLayers, + }, + }, + visualizationState: { + ...suggestion.visualizationState, + fillOpacity, + yRightExtent: context.configuration.extents?.yRightExtent, + yLeftExtent: context.configuration.extents?.yLeftExtent, + legend: context.configuration.legend, + gridlinesVisibilitySettings: context.configuration.gridLinesVisibility, + valuesInLegend: true, + layers: visualizationStateLayers, + }, + }; + return suggestion; + }, + removeDimension({ prevState, layerId, columnId, frame }) { const foundLayer = prevState.layers.find((l) => l.layerId === layerId); if (!foundLayer) { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx index 099ea258f0902..820a61c1ee37a 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/index.tsx @@ -621,13 +621,6 @@ export function DimensionEditor( name="axisSide" buttonSize="compressed" options={[ - { - id: `${idPrefix}auto`, - label: i18n.translate('xpack.lens.xyChart.axisSide.auto', { - defaultMessage: 'Auto', - }), - 'data-test-subj': 'lnsXY_axisSide_groups_auto', - }, { id: `${idPrefix}left`, label: isHorizontal @@ -639,6 +632,13 @@ export function DimensionEditor( }), 'data-test-subj': 'lnsXY_axisSide_groups_left', }, + { + id: `${idPrefix}auto`, + label: i18n.translate('xpack.lens.xyChart.axisSide.auto', { + defaultMessage: 'Auto', + }), + 'data-test-subj': 'lnsXY_axisSide_groups_auto', + }, { id: `${idPrefix}right`, label: isHorizontal diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx index f35bcae6ffb9f..d547471873e54 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/reference_line_panel.tsx @@ -7,14 +7,7 @@ import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { - EuiButtonGroup, - EuiComboBox, - EuiFormRow, - EuiIcon, - EuiRange, - EuiSwitch, -} from '@elastic/eui'; +import { EuiButtonGroup, EuiComboBox, EuiFormRow, EuiIcon, EuiRange } from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { VisualizationDimensionEditorProps } from '../../types'; import { State, XYState } from '../types'; @@ -28,7 +21,7 @@ import { TooltipWrapper, useDebouncedValue } from '../../shared_components'; const icons = [ { - value: 'none', + value: 'empty', label: i18n.translate('xpack.lens.xyChart.referenceLine.noIconLabel', { defaultMessage: 'None', }), @@ -113,6 +106,7 @@ const IconSelect = ({ singleSelection={{ asPlainText: true }} renderOption={IconView} compressed + prepend={hasIcon(selectedIcon.value) ? : undefined} /> ); }; @@ -161,15 +155,14 @@ function getFillPositionOptions({ isHorizontal, axisMode }: LabelConfigurationOp } function getIconPositionOptions({ isHorizontal, axisMode }: LabelConfigurationOptions) { - const options = [ - { - id: `${idPrefix}auto`, - label: i18n.translate('xpack.lens.xyChart.referenceLineMarker.auto', { - defaultMessage: 'Auto', - }), - 'data-test-subj': 'lnsXY_referenceLine_markerPosition_auto', - }, - ]; + const autoOption = { + id: `${idPrefix}auto`, + label: i18n.translate('xpack.lens.xyChart.referenceLineMarker.auto', { + defaultMessage: 'Auto', + }), + 'data-test-subj': 'lnsXY_referenceLine_markerPosition_auto', + }; + const topLabel = i18n.translate('xpack.lens.xyChart.markerPosition.above', { defaultMessage: 'Top', }); @@ -183,47 +176,37 @@ function getIconPositionOptions({ isHorizontal, axisMode }: LabelConfigurationOp defaultMessage: 'Right', }); if (axisMode === 'bottom') { - const bottomOptions = [ - { - id: `${idPrefix}above`, - label: isHorizontal ? rightLabel : topLabel, - 'data-test-subj': 'lnsXY_referenceLine_markerPosition_above', - }, + return [ { id: `${idPrefix}below`, label: isHorizontal ? leftLabel : bottomLabel, 'data-test-subj': 'lnsXY_referenceLine_markerPosition_below', }, + autoOption, + { + id: `${idPrefix}above`, + label: isHorizontal ? rightLabel : topLabel, + 'data-test-subj': 'lnsXY_referenceLine_markerPosition_above', + }, ]; - if (isHorizontal) { - // above -> below - // left -> right - bottomOptions.reverse(); - } - return [...options, ...bottomOptions]; } - const yOptions = [ + return [ { id: `${idPrefix}left`, label: isHorizontal ? bottomLabel : leftLabel, 'data-test-subj': 'lnsXY_referenceLine_markerPosition_left', }, + autoOption, { id: `${idPrefix}right`, label: isHorizontal ? topLabel : rightLabel, 'data-test-subj': 'lnsXY_referenceLine_markerPosition_right', }, ]; - if (isHorizontal) { - // left -> right - // above -> below - yOptions.reverse(); - } - return [...options, ...yOptions]; } export function hasIcon(icon: string | undefined): icon is string { - return icon != null && icon !== 'none'; + return icon != null && icon !== 'empty'; } export const ReferenceLinePanel = ( @@ -271,28 +254,46 @@ export const ReferenceLinePanel = ( <> - { - setYConfig({ forAccessor: accessor, textVisibility: !currentYConfig?.textVisibility }); + name="textVisibilityStyle" + buttonSize="compressed" + options={[ + { + id: `${idPrefix}none`, + label: i18n.translate('xpack.lens.xyChart.referenceLineMarker.textVisibility.none', { + defaultMessage: 'None', + }), + 'data-test-subj': 'lnsXY_reference_textVisibility_none', + }, + { + id: `${idPrefix}name`, + label: i18n.translate('xpack.lens.xyChart.referenceLineMarker.textVisibility.name', { + defaultMessage: 'Name', + }), + 'data-test-subj': 'lnsXY_reference_textVisibility_name', + }, + ]} + idSelected={`${idPrefix}${Boolean(currentYConfig?.textVisibility) ? 'name' : 'none'}`} + onChange={(id) => { + setYConfig({ forAccessor: accessor, textVisibility: id === `${idPrefix}name` }); }} + isFullWidth /> - - - { - const newMode = id.replace(idPrefix, '') as IconPosition; - setYConfig({ forAccessor: accessor, iconPosition: newMode }); - }} - /> - - - + + { + const newMode = id.replace(idPrefix, '') as IconPosition; + setYConfig({ forAccessor: accessor, iconPosition: newMode }); + }} + /> + + + ) : null} + + ); }; @@ -465,6 +469,7 @@ const LineThicknessSlider = ({ min={minRange} max={maxRange} step={1} + append="px" compressed onChange={({ currentTarget: { value: newValue } }) => { setUnsafeValue(newValue); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx index 0ffa2d394bcaf..7ddb770c1a176 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/xy_config_panel.test.tsx @@ -271,7 +271,7 @@ describe('XY Config panels', () => { .first() .prop('options') as EuiButtonGroupProps['options']; - expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Bottom', 'Top']); + expect(options!.map(({ label }) => label)).toEqual(['Bottom', 'Auto', 'Top']); }); test('shows the default axis side options when not in horizontal mode', () => { @@ -295,7 +295,7 @@ describe('XY Config panels', () => { .first() .prop('options') as EuiButtonGroupProps['options']; - expect(options!.map(({ label }) => label)).toEqual(['Auto', 'Left', 'Right']); + expect(options!.map(({ label }) => label)).toEqual(['Left', 'Auto', 'Right']); }); test('sets the color of a dimension to the color from palette service if not set explicitly', () => { diff --git a/x-pack/plugins/lens/server/expressions/expressions.ts b/x-pack/plugins/lens/server/expressions/expressions.ts index a04ad27d1a276..f258db7f9aede 100644 --- a/x-pack/plugins/lens/server/expressions/expressions.ts +++ b/x-pack/plugins/lens/server/expressions/expressions.ts @@ -7,7 +7,6 @@ import type { CoreSetup } from 'kibana/server'; import { - pie, xyChart, counterRate, metricChart, @@ -36,7 +35,6 @@ export const setupExpressions = ( [lensMultitable].forEach((expressionType) => expressions.registerType(expressionType)); [ - pie, xyChart, counterRate, metricChart, diff --git a/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx b/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx index 7ea7a5a21382b..328dee63365d4 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx @@ -56,7 +56,7 @@ function pageStateReducer(state: MlPageUIState, action: PageAction): MlPageUISta * Main page component of the ML App * @constructor */ -export const MlPage: FC<{ pageDeps: PageDependencies }> = React.memo(({ pageDeps, children }) => { +export const MlPage: FC<{ pageDeps: PageDependencies }> = React.memo(({ pageDeps }) => { const navigateToPath = useNavigateToPath(); const { services: { @@ -80,6 +80,10 @@ export const MlPage: FC<{ pageDeps: PageDependencies }> = React.memo(({ pageDeps const activeRoute = useActiveRoute(routeList); + const rightSideItems = useMemo(() => { + return [...(activeRoute.enableDatePicker ? [] : [])]; + }, [activeRoute.enableDatePicker]); + useDocTitle(activeRoute); return ( @@ -101,7 +105,7 @@ export const MlPage: FC<{ pageDeps: PageDependencies }> = React.memo(({ pageDeps }} pageHeader={{ pageTitle: pageState.pageHeader ?? , - rightSideItems: [...(activeRoute.enableDatePicker ? [] : [])], + rightSideItems, restrictWidth: false, }} pageBodyProps={{ diff --git a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx index 39672b14bf836..0ebf60c981689 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx @@ -45,7 +45,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { }, } : undefined; - }, [globalState]); + }, [globalState?.refreshInterval]); const redirectToTab = useCallback( async (defaultPathId: MlLocatorParams['page']) => { @@ -223,7 +223,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { forceOpen: true, }; }, - [activeRoute?.path] + [activeRoute?.path, redirectToTab] ); return useMemo(() => tabsDefinition.map(getTabItem), [tabsDefinition, getTabItem]); diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx index 501941ef83563..5ad3fc8a3a851 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.test.tsx @@ -13,6 +13,7 @@ import { EuiSuperDatePicker } from '@elastic/eui'; import { useUrlState } from '../../../util/url_state'; import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; +import { useToastNotificationService } from '../../../services/toast_notification_service'; import { DatePickerWrapper } from './date_picker_wrapper'; @@ -41,6 +42,10 @@ jest.mock('../../../util/url_state', () => { }; }); +jest.mock('../../../contexts/kibana/use_timefilter'); + +jest.mock('../../../services/toast_notification_service'); + jest.mock('../../../contexts/kibana', () => ({ useMlKibana: () => { // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -85,6 +90,9 @@ jest.mock('../../../contexts/kibana', () => ({ getLoadingCount$: of(0), }, }, + theme: { + theme$: of(), + }, }, }; }, @@ -117,11 +125,39 @@ describe('Navigation Menu: ', () => { // arrange (useUrlState as jest.Mock).mockReturnValue([{ refreshInterval: { pause: false, value: 0 } }]); + const displayWarningSpy = jest.fn(() => {}); + + (useToastNotificationService as jest.Mock).mockReturnValueOnce({ + displayWarningToast: displayWarningSpy, + }); + // act render(); // assert + expect(displayWarningSpy).not.toHaveBeenCalled(); const calledWith = MockedEuiSuperDatePicker.mock.calls[0][0]; expect(calledWith.isPaused).toBe(true); + expect(calledWith.refreshInterval).toBe(5000); + }); + + test('should show a warning when configured interval is too short', () => { + // arrange + (useUrlState as jest.Mock).mockReturnValue([{ refreshInterval: { pause: false, value: 10 } }]); + + const displayWarningSpy = jest.fn(() => {}); + + (useToastNotificationService as jest.Mock).mockReturnValueOnce({ + displayWarningToast: displayWarningSpy, + }); + + // act + render(); + + // assert + expect(displayWarningSpy).toHaveBeenCalled(); + const calledWith = MockedEuiSuperDatePicker.mock.calls[0][0]; + expect(calledWith.isPaused).toBe(false); + expect(calledWith.refreshInterval).toBe(10); }); }); diff --git a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx index f73d9736e3ad9..45ba8f5aa4e0f 100644 --- a/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/components/navigation_menu/date_picker_wrapper/date_picker_wrapper.tsx @@ -6,6 +6,7 @@ */ import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; import { Subscription } from 'rxjs'; import { debounce } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -16,6 +17,7 @@ import { EuiFlexItem, EuiSuperDatePicker, OnRefreshProps, + OnTimeChangeProps, } from '@elastic/eui'; import { TimeHistoryContract, TimeRange } from 'src/plugins/data/public'; import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/common'; @@ -23,6 +25,15 @@ import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/common'; import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; import { useUrlState } from '../../../util/url_state'; import { useMlKibana } from '../../../contexts/kibana'; +import { + useRefreshIntervalUpdates, + useTimeRangeUpdates, +} from '../../../contexts/kibana/use_timefilter'; +import { useToastNotificationService } from '../../../services/toast_notification_service'; +import { + wrapWithTheme, + toMountPoint, +} from '../../../../../../../../src/plugins/kibana_react/public'; interface TimePickerQuickRange { from: string; @@ -57,19 +68,46 @@ function updateLastRefresh(timeRange?: OnRefreshProps) { mlTimefilterRefresh$.next({ lastRefresh: Date.now(), ...(timeRange ? { timeRange } : {}) }); } +const DEFAULT_REFRESH_INTERVAL_MS = 5000; + export const DatePickerWrapper: FC = () => { const { services } = useMlKibana(); const config = services.uiSettings; + const theme$ = services.theme.theme$; const { httpService } = services.mlServices; - const { timefilter, history } = services.data.query.timefilter; + const { displayWarningToast } = useToastNotificationService(); const [globalState, setGlobalState] = useUrlState('_g'); const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(history); - const refreshInterval: RefreshInterval = - globalState?.refreshInterval ?? timefilter.getRefreshInterval(); + const timeFilterRefreshInterval = useRefreshIntervalUpdates(); + const time = useTimeRangeUpdates(); + + useEffect( + function syncTimRangeFromUrlState() { + if (globalState?.time !== undefined) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + } + }, + [globalState?.time?.from, globalState?.time?.to, globalState?.time?.ts] + ); + + useEffect( + function syncRefreshIntervalFromUrlState() { + if (globalState?.refreshInterval !== undefined) { + timefilter.setRefreshInterval({ + pause: !!globalState?.refreshInterval?.pause, + value: globalState?.refreshInterval?.value, + }); + } + }, + [globalState?.refreshInterval] + ); const setRefreshInterval = useCallback( debounce((refreshIntervalUpdate: RefreshInterval) => { @@ -79,7 +117,6 @@ export const DatePickerWrapper: FC = () => { ); const [isLoading, setIsLoading] = useState(false); - const [time, setTime] = useState(timefilter.getTime()); const [recentlyUsedRanges, setRecentlyUsedRanges] = useState(getRecentlyUsedRanges()); const [isAutoRefreshSelectorEnabled, setIsAutoRefreshSelectorEnabled] = useState( timefilter.isAutoRefreshSelectorEnabled() @@ -88,6 +125,67 @@ export const DatePickerWrapper: FC = () => { timefilter.isTimeRangeSelectorEnabled() ); + const refreshInterval = useMemo((): RefreshInterval => { + const resultInterval = globalState?.refreshInterval ?? timeFilterRefreshInterval; + + /** + * Enforce pause when it's set to false with 0 refresh interval. + */ + const pause = resultInterval.pause || (!resultInterval.pause && resultInterval.value <= 0); + const value = resultInterval.value; + + return { value, pause }; + }, [JSON.stringify(globalState?.refreshInterval), timeFilterRefreshInterval]); + + useEffect( + function warnAboutShortRefreshInterval() { + const isResolvedFromUrlState = !!globalState?.refreshInterval; + const isTooShort = refreshInterval.value < DEFAULT_REFRESH_INTERVAL_MS; + + // Only warn about short interval with enabled auto-refresh. + if (!isTooShort || refreshInterval.pause) return; + + displayWarningToast( + { + title: isResolvedFromUrlState + ? i18n.translate('xpack.ml.datePicker.shortRefreshIntervalURLWarningMessage', { + defaultMessage: + 'The refresh interval in the URL is shorter than the minimum supported by Machine Learning.', + }) + : i18n.translate('xpack.ml.datePicker.shortRefreshIntervalTimeFilterWarningMessage', { + defaultMessage: + 'The refresh interval in Advanced Settings is shorter than the minimum supported by Machine Learning.', + }), + text: toMountPoint( + wrapWithTheme( + + + , + theme$ + ) + ), + }, + { toastLifeTimeMs: 30000 } + ); + }, + [ + JSON.stringify(refreshInterval), + JSON.stringify(globalState?.refreshInterval), + setRefreshInterval, + ] + ); + const dateFormat = config.get('dateFormat'); const timePickerQuickRanges = config.get( UI_SETTINGS.TIMEPICKER_QUICK_RANGES @@ -105,7 +203,6 @@ export const DatePickerWrapper: FC = () => { useEffect(() => { const subscriptions = new Subscription(); - const refreshIntervalUpdate$ = timefilter.getRefreshIntervalUpdate$(); subscriptions.add( httpService.getLoadingCount$.subscribe((v) => { @@ -113,21 +210,6 @@ export const DatePickerWrapper: FC = () => { }) ); - if (refreshIntervalUpdate$ !== undefined) { - subscriptions.add( - refreshIntervalUpdate$.subscribe((r) => { - setRefreshInterval(timefilter.getRefreshInterval()); - }) - ); - } - const timeUpdate$ = timefilter.getTimeUpdate$(); - if (timeUpdate$ !== undefined) { - subscriptions.add( - timeUpdate$.subscribe((v) => { - setTime(timefilter.getTime()); - }) - ); - } const enabledUpdated$ = timefilter.getEnabledUpdated$(); if (enabledUpdated$ !== undefined) { subscriptions.add( @@ -143,13 +225,17 @@ export const DatePickerWrapper: FC = () => { }; }, []); - function updateFilter({ start, end }: Duration) { - const newTime = { from: start, to: end }; - // Update timefilter for controllers listening for changes - timefilter.setTime(newTime); - setTime(newTime); - setRecentlyUsedRanges(getRecentlyUsedRanges()); - } + const updateTimeFilter = useCallback( + ({ start, end }: OnTimeChangeProps) => { + setRecentlyUsedRanges(getRecentlyUsedRanges()); + setGlobalState('time', { + from: start, + to: end, + ...(start === 'now' || end === 'now' ? { ts: Date.now() } : {}), + }); + }, + [setGlobalState] + ); function updateInterval({ isPaused: pause, @@ -161,11 +247,6 @@ export const DatePickerWrapper: FC = () => { setRefreshInterval({ pause, value }); } - /** - * Enforce pause when it's set to false with 0 refresh interval. - */ - const isPaused = refreshInterval.pause || (!refreshInterval.pause && !refreshInterval.value); - return isAutoRefreshSelectorEnabled || isTimeRangeSelectorEnabled ? ( { isLoading={isLoading} start={time.from} end={time.to} - isPaused={isPaused} + isPaused={refreshInterval.pause} isAutoRefreshOnly={!isTimeRangeSelectorEnabled} - refreshInterval={refreshInterval.value} - onTimeChange={updateFilter} + refreshInterval={refreshInterval.value || DEFAULT_REFRESH_INTERVAL_MS} + onTimeChange={updateTimeFilter} onRefresh={updateLastRefresh} onRefreshChange={updateInterval} recentlyUsedRanges={recentlyUsedRanges} diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts index a9ee49fcbadd8..ef2988d8499d7 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/__mocks__/use_timefilter.ts @@ -12,3 +12,17 @@ export const timefilterMock = dataPluginMock.createStartContract().query.timefil export const useTimefilter = jest.fn(() => { return timefilterMock; }); + +export const useRefreshIntervalUpdates = jest.fn(() => { + return { + pause: false, + value: 0, + }; +}); + +export const useTimeRangeUpdates = jest.fn(() => { + return { + from: '', + to: '', + }; +}); diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/use_timefilter.ts b/x-pack/plugins/ml/public/application/contexts/kibana/use_timefilter.ts index 9008d82dd36b1..82ea7720ec743 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/use_timefilter.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/use_timefilter.ts @@ -6,7 +6,8 @@ */ import { useEffect } from 'react'; - +import useObservable from 'react-use/lib/useObservable'; +import { map } from 'rxjs/operators'; import { useMlKibana } from './kibana_context'; interface UseTimefilterOptions { @@ -37,3 +38,21 @@ export const useTimefilter = ({ return timefilter; }; + +export const useRefreshIntervalUpdates = () => { + const timefilter = useTimefilter(); + + return useObservable( + timefilter.getRefreshIntervalUpdate$().pipe(map(timefilter.getRefreshInterval)), + timefilter.getRefreshInterval() + ); +}; + +export const useTimeRangeUpdates = () => { + const timefilter = useTimefilter(); + + return useObservable( + timefilter.getTimeUpdate$().pipe(map(timefilter.getTime)), + timefilter.getTime() + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx index ca79ec89703f8..0b0c3228fd65f 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx @@ -72,7 +72,9 @@ export const JobFilterBar: FC = ({ queryText, setFilters }) = }; useEffect(() => { - setFilters(queryInstance); + if (queryText !== undefined) { + setFilters(queryInstance); + } }, [queryText]); const filters: SearchFilterConfig[] = useMemo( diff --git a/x-pack/plugins/ml/public/application/overview/overview_page.tsx b/x-pack/plugins/ml/public/application/overview/overview_page.tsx index 589425965966d..2e4d803db2c5f 100644 --- a/x-pack/plugins/ml/public/application/overview/overview_page.tsx +++ b/x-pack/plugins/ml/public/application/overview/overview_page.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useState } from 'react'; import { EuiPanel, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { checkPermission } from '../capabilities/check_capabilities'; @@ -19,8 +19,6 @@ import { UpgradeWarning } from '../components/upgrade'; import { HelpMenu } from '../components/help_menu'; import { useMlKibana, useTimefilter } from '../contexts/kibana'; import { NodesList } from '../trained_models/nodes_overview'; -import { useUrlState } from '../util/url_state'; -import { useRefresh } from '../routing/use_refresh'; import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; import { MlPageHeader } from '../components/page_header'; @@ -37,35 +35,7 @@ export const OverviewPage: FC = () => { } = useMlKibana(); const helpLink = docLinks.links.ml.guide; - const [globalState, setGlobalState] = useUrlState('_g'); - const [lastRefresh, setLastRefresh] = useState(0); - const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); - const refresh = useRefresh(); - - useEffect(() => { - if (refresh !== undefined && lastRefresh !== refresh.lastRefresh) { - setLastRefresh(refresh?.lastRefresh); - - if (refresh.timeRange !== undefined) { - const { start, end } = refresh.timeRange; - setGlobalState('time', { - from: start, - to: end, - ...(start === 'now' || end === 'now' ? { ts: Date.now() } : {}), - }); - } - } - }, [refresh?.lastRefresh, lastRefresh, setLastRefresh, setGlobalState]); - - useEffect(() => { - if (globalState?.time !== undefined) { - timefilter.setTime({ - from: globalState.time.from, - to: globalState.time.to, - }); - } - }, [globalState?.time?.from, globalState?.time?.to, globalState?.time?.ts]); const [adLazyJobCount, setAdLazyJobCount] = useState(0); const [dfaLazyJobCount, setDfaLazyJobCount] = useState(0); 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 d25d28248bd81..47e2b9babb4a2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -96,8 +96,7 @@ interface ExplorerUrlStateManagerProps { const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => { const [explorerUrlState, setExplorerUrlState] = useExplorerUrlState(); - const [globalState, setGlobalState] = useUrlState('_g'); - const [lastRefresh, setLastRefresh] = useState(0); + const [globalState] = useUrlState('_g'); const [stoppedPartitions, setStoppedPartitions] = useState(); const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); @@ -113,21 +112,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const explorerState = useObservable(explorerService.state$); const refresh = useRefresh(); - - useEffect(() => { - if (refresh !== undefined && lastRefresh !== refresh.lastRefresh) { - setLastRefresh(refresh?.lastRefresh); - - if (refresh.timeRange !== undefined) { - const { start, end } = refresh.timeRange; - setGlobalState('time', { - from: start, - to: end, - ...(start === 'now' || end === 'now' ? { ts: Date.now() } : {}), - }); - } - } - }, [refresh?.lastRefresh, lastRefresh, setLastRefresh, setGlobalState]); + const lastRefresh = refresh?.lastRefresh ?? 0; // We cannot simply infer bounds from the globalState's `time` attribute // with `moment` since it can contain custom strings such as `now-15m`. @@ -138,10 +123,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim if (globalState.time.mode === 'invalid') { setInValidTimeRangeError(true); } - timefilter.setTime({ - from: globalState.time.from, - to: globalState.time.to, - }); } }, [globalState?.time?.from, globalState?.time?.to, globalState?.time?.ts]); diff --git a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx index 397ce5bef4259..6ac1faf22bccc 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/jobs_list.tsx @@ -11,7 +11,6 @@ import { i18n } from '@kbn/i18n'; import { NavigateToPath } from '../../contexts/kibana'; import { DEFAULT_REFRESH_INTERVAL_MS } from '../../../../common/constants/jobs_list'; import { mlTimefilterRefresh$ } from '../../services/timefilter_refresh_service'; -import { useUrlState } from '../../util/url_state'; import { MlRoute, PageLoader, PageProps } from '../router'; import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; @@ -20,6 +19,7 @@ import { useTimefilter } from '../../contexts/kibana'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { AnnotationUpdatesService } from '../../services/annotations_service'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; +import { useRefreshIntervalUpdates } from '../../contexts/kibana/use_timefilter'; export const jobListRouteFactory = (navigateToPath: NavigateToPath, basePath: string): MlRoute => ({ id: 'anomaly_detection', @@ -51,24 +51,24 @@ const PageWrapper: FC = ({ deps }) => { ); const timefilter = useTimefilter({ timeRangeSelector: false, autoRefreshSelector: true }); - const [globalState, setGlobalState] = useUrlState('_g'); + const refresh = useRefreshIntervalUpdates(); const mlTimefilterRefresh = useObservable(mlTimefilterRefresh$); const lastRefresh = mlTimefilterRefresh?.lastRefresh ?? 0; - const refreshValue = globalState?.refreshInterval?.value ?? 0; - const refreshPause = globalState?.refreshInterval?.pause ?? true; + + const refreshValue = refresh.value ?? 0; + const refreshPause = refresh.pause ?? true; + const blockRefresh = refreshValue === 0 || refreshPause === true; useEffect(() => { - // If the refreshInterval defaults to 0s/pause=true, set it to 30s/pause=false, - // otherwise pass on the globalState's settings to the date picker. const refreshInterval = refreshValue === 0 && refreshPause === true ? { pause: false, value: DEFAULT_REFRESH_INTERVAL_MS } : { pause: refreshPause, value: refreshValue }; - setGlobalState({ refreshInterval }, undefined, true); timefilter.setRefreshInterval(refreshInterval); }, []); + const annotationUpdatesService = useMemo(() => new AnnotationUpdatesService(), []); return ( diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index ff7ee1c1a0933..036594322b2f2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -34,7 +34,6 @@ import { useTableInterval } from '../../components/controls/select_interval'; import { useTableSeverity } from '../../components/controls/select_severity'; import { MlRoute, PageLoader, PageProps } from '../router'; -import { useRefresh } from '../use_refresh'; import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; @@ -46,6 +45,7 @@ import { useTimeSeriesExplorerUrlState } from '../../timeseriesexplorer/hooks/us import type { TimeSeriesExplorerAppState } from '../../../../common/types/locator'; import type { TimeRangeBounds } from '../../util/time_buckets'; import { useJobSelectionFlyout } from '../../contexts/ml/use_job_selection_flyout'; +import { useRefresh } from '../use_refresh'; export const timeSeriesExplorerRouteFactory = ( navigateToPath: NavigateToPath, @@ -111,27 +111,12 @@ export const TimeSeriesExplorerUrlStateManager: FC(); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); const refresh = useRefresh(); - useEffect(() => { - if (refresh !== undefined && refresh.lastRefresh !== lastRefresh) { - setLastRefresh(refresh?.lastRefresh); - - if (refresh.timeRange !== undefined) { - const { start, end } = refresh.timeRange; - setGlobalState('time', { - from: start, - to: end, - ...(start === 'now' || end === 'now' ? { ts: Date.now() } : {}), - }); - } - } - }, [refresh?.lastRefresh, lastRefresh, setGlobalState]); + const previousRefresh = usePrevious(refresh?.lastRefresh ?? 0); // We cannot simply infer bounds from the globalState's `time` attribute // with `moment` since it can contain custom strings such as `now-15m`. @@ -143,10 +128,6 @@ export const TimeSeriesExplorerUrlStateManager: FC { if (selectedJobIds !== undefined && previousSelectedJobIds !== undefined) { - setLastRefresh(Date.now()); appStateHandler(APP_STATE_ACTION.CLEAR); } const validatedJobId = validateJobSelection( @@ -308,6 +288,8 @@ export const TimeSeriesExplorerUrlStateManager: FC { if (editFilterMatch) { return routesMap[editFilterMatch.path]; } - return routesMap[pathname]; + // Remove trailing slash from the pathname + const pathnameKey = pathname.replace(/\/$/, ''); + return routesMap[pathnameKey]; }, [pathname]); return activeRoute ?? routesMap['/overview']; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts index 381528e1055d6..8ea7ff07345ee 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts @@ -81,10 +81,7 @@ export function trainedModelsApiProvider(httpService: HttpService) { * @param params - Optional query params */ getTrainedModelStats(modelId?: string | string[], params?: InferenceStatsQueryParams) { - let model = modelId ?? '_all'; - if (Array.isArray(modelId)) { - model = modelId.join(','); - } + const model = (Array.isArray(modelId) ? modelId.join(',') : modelId) || '_all'; return httpService.http({ path: `${apiBasePath}/trained_models/${model}/_stats`, diff --git a/x-pack/plugins/ml/public/application/services/toast_notification_service/__mocks__/index.ts b/x-pack/plugins/ml/public/application/services/toast_notification_service/__mocks__/index.ts index 74895bfb47ae3..a6e7a8750058f 100644 --- a/x-pack/plugins/ml/public/application/services/toast_notification_service/__mocks__/index.ts +++ b/x-pack/plugins/ml/public/application/services/toast_notification_service/__mocks__/index.ts @@ -5,4 +5,8 @@ * 2.0. */ -export const useToastNotificationService = jest.fn(); +export const useToastNotificationService = jest.fn(() => { + return { + displayWarningToast: jest.fn(() => {}), + }; +}); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx index 8bafabf35e1d5..97a6fd3eb7b27 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx @@ -181,6 +181,7 @@ export const ModelsList: FC = () => { useEffect( function updateOnTimerRefresh() { + if (!refresh) return; fetchModelsData(); }, [refresh] diff --git a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_1.ndjson b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_1.ndjson new file mode 100644 index 0000000000000..00a841d173052 --- /dev/null +++ b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_1.ndjson @@ -0,0 +1,19 @@ +{ + "attributes": { + "created_at": "2022-02-03T07:43:10.311Z", + "created_by": "elastic", + "description": "fdsfsd", + "ecs_mapping": [], + "id": "NOMAPPING", + "interval": 3600, + "query": "select * from uptime;", + "updated_at": "2022-02-03T08:22:01.662Z", + "updated_by": "elastic" + }, + "coreMigrationVersion": "8.1.0", + "id": "ef31d680-84c4-11ec-991b-07bb2d53cda5", + "references": [], + "type": "osquery-saved-query", + "updated_at": "2022-02-03T08:22:01.668Z", + "version": "WzE3ODk5LDFd" +} diff --git a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_2.ndjson b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_2.ndjson new file mode 100644 index 0000000000000..da617a9dc863b --- /dev/null +++ b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_2.ndjson @@ -0,0 +1,26 @@ +{ + "attributes": { + "created_at": "2022-02-03T08:22:26.355Z", + "created_by": "elastic", + "description": "", + "ecs_mapping": [ + { + "key": "client.geo.continent_name", + "value": { + "field": "seconds" + } + } + ], + "id": "ONE_MAPPING_CHANGED", + "interval": 3600, + "query": "select * from uptime;", + "updated_at": "2022-02-03T08:24:52.429Z", + "updated_by": "elastic" + }, + "coreMigrationVersion": "8.1.0", + "id": "6b819f40-84ca-11ec-991b-07bb2d53cda5", + "references": [], + "type": "osquery-saved-query", + "updated_at": "2022-02-03T08:24:52.436Z", + "version": "WzE3OTAwLDFd" +} diff --git a/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_3.ndjson b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_3.ndjson new file mode 100644 index 0000000000000..64a7e01c5496a --- /dev/null +++ b/x-pack/plugins/osquery/cypress/fixtures/saved_objects/ecs_mapping_3.ndjson @@ -0,0 +1,37 @@ +{ + "attributes": { + "created_at": "2022-02-03T08:22:54.372Z", + "created_by": "elastic", + "ecs_mapping": [ + { + "key": "labels", + "value": { + "field": "days" + } + }, + { + "key": "tags", + "value": { + "field": "seconds" + } + }, + { + "key": "client.address", + "value": { + "field": "total_seconds" + } + } + ], + "id": "MULTIPLE_MAPPINGS", + "interval": "3600", + "query": "select * from uptime; ", + "updated_at": "2022-02-03T08:22:54.372Z", + "updated_by": "elastic" + }, + "coreMigrationVersion": "8.1.0", + "id": "7c348640-84ca-11ec-991b-07bb2d53cda5", + "references": [], + "type": "osquery-saved-query", + "updated_at": "2022-02-03T08:22:54.375Z", + "version": "WzE3OTAxLDFd" +} diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts b/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts index 689450d8838ee..5c21f29b650e7 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/superuser/delete_all_ecs_mappings.spec.ts @@ -28,20 +28,16 @@ describe('SuperUser - Delete ECS Mappings', () => { cy.react('CustomItemAction', { props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, }).click(); - cy.contains('Custom key/value pairs. e.g. {"application":"foo-bar","env":"production"}').should( - 'exist' - ); + cy.contains('Custom key/value pairs.').should('exist'); cy.contains('Hours of uptime').should('exist'); cy.react('EuiButtonIcon', { props: { id: 'labels-trash' } }).click(); cy.react('EuiButton').contains('Update query').click(); - cy.wait(1000); + cy.wait(5000); cy.react('CustomItemAction', { props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, }).click(); - cy.contains('Custom key/value pairs. e.g. {"application":"foo-bar","env":"production"}').should( - 'not.exist' - ); + cy.contains('Custom key/value pairs').should('not.exist'); cy.contains('Hours of uptime').should('not.exist'); }); }); diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts b/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts index 99f1dac6208ee..a674eb4d96829 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts @@ -26,6 +26,9 @@ describe('SuperUser - Packs', () => { describe('Create and edit a pack', () => { before(() => { runKbnArchiverScript(ArchiverMethod.LOAD, 'saved_query'); + runKbnArchiverScript(ArchiverMethod.LOAD, 'ecs_mapping_1'); + runKbnArchiverScript(ArchiverMethod.LOAD, 'ecs_mapping_2'); + runKbnArchiverScript(ArchiverMethod.LOAD, 'ecs_mapping_3'); }); beforeEach(() => { login(); @@ -34,6 +37,9 @@ describe('SuperUser - Packs', () => { after(() => { runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'ecs_mapping_1'); + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'ecs_mapping_2'); + runKbnArchiverScript(ArchiverMethod.UNLOAD, 'ecs_mapping_3'); }); it('should add a pack from a saved query', () => { @@ -146,6 +152,46 @@ describe('SuperUser - Packs', () => { cy.contains(/^No items found/); }); + it('enable changing saved queries and ecs_mappings', () => { + preparePack(PACK_NAME, SAVED_QUERY_ID); + cy.contains(/^Edit$/).click(); + + findAndClickButton('Add query'); + + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) + .click() + .type('Multiple {downArrow} {enter}'); + cy.contains('Custom key/value pairs'); + cy.contains('Days of uptime'); + cy.contains('List of keywords used to tag each'); + cy.contains('Seconds of uptime'); + cy.contains('Client network address.'); + cy.contains('Total uptime seconds'); + + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) + .click() + .type('NOMAPPING {downArrow} {enter}'); + cy.contains('Custom key/value pairs').should('not.exist'); + cy.contains('Days of uptime').should('not.exist'); + cy.contains('List of keywords used to tag each').should('not.exist'); + cy.contains('Seconds of uptime').should('not.exist'); + cy.contains('Client network address.').should('not.exist'); + cy.contains('Total uptime seconds').should('not.exist'); + + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) + .click() + .type('ONE_MAPPING {downArrow} {enter}'); + cy.contains('Name of the continent'); + cy.contains('Seconds of uptime'); + + findAndClickButton('Save'); + cy.react('CustomItemAction', { + props: { index: 0, item: { id: 'ONE_MAPPING_CHANGED' } }, + }).click(); + cy.contains('Name of the continent'); + cy.contains('Seconds of uptime'); + }); + it('to click delete button', () => { preparePack(PACK_NAME, SAVED_QUERY_ID); findAndClickButton('Edit'); @@ -156,7 +202,7 @@ describe('SuperUser - Packs', () => { beforeEach(() => { login(); }); - const AGENT_NAME = 'PackTest'; + const AGENT_NAME = 'PackTest2'; const REMOVING_PACK = 'removing-pack'; it('add integration', () => { cy.visit(FLEET_AGENT_POLICIES); @@ -165,7 +211,7 @@ describe('SuperUser - Packs', () => { cy.get('.euiFlyoutFooter').contains('Create agent policy').click(); cy.contains(`Agent policy '${AGENT_NAME}' created`); cy.visit(FLEET_AGENT_POLICIES); - cy.contains('Default Fleet Server policy').click(); + cy.contains(AGENT_NAME).click(); cy.contains('Add integration').click(); cy.contains(integration).click(); addIntegration(AGENT_NAME); @@ -194,25 +240,9 @@ describe('SuperUser - Packs', () => { navigateTo('app/osquery/packs'); cy.contains(REMOVING_PACK).click(); cy.contains(`${REMOVING_PACK} details`); + cy.wait(1000); findAndClickButton('Edit'); cy.react('EuiComboBoxInput', { props: { value: '' } }).should('exist'); }); }); - describe.skip('Remove queries from pack', () => { - const TEST_PACK = 'Test-pack'; - before(() => { - runKbnArchiverScript(ArchiverMethod.LOAD, 'hardware_monitoring'); - }); - beforeEach(() => { - login(); - navigateTo('/app/osquery'); - }); - after(() => { - runKbnArchiverScript(ArchiverMethod.UNLOAD, 'hardware_monitoring'); - }); - - it('should remove ALL queries', () => { - preparePack(TEST_PACK, SAVED_QUERY_ID); - }); - }); }); diff --git a/x-pack/plugins/osquery/kibana.json b/x-pack/plugins/osquery/kibana.json index 63f75b233f73b..619bcbeed14f4 100644 --- a/x-pack/plugins/osquery/kibana.json +++ b/x-pack/plugins/osquery/kibana.json @@ -7,7 +7,7 @@ "githubTeam": "security-asset-management" }, "kibanaVersion": "kibana", - "optionalPlugins": ["fleet", "home", "usageCollection", "lens"], + "optionalPlugins": ["fleet", "home", "usageCollection", "lens", "telemetry"], "requiredBundles": ["esUiShared", "fleet", "kibanaUtils", "kibanaReact", "lens"], "requiredPlugins": [ "actions", @@ -16,6 +16,7 @@ "discover", "features", "navigation", + "taskManager", "triggersActionsUi", "security" ], diff --git a/x-pack/plugins/osquery/public/live_queries/form/index.tsx b/x-pack/plugins/osquery/public/live_queries/form/index.tsx index 3b8cbe70610ef..bd8e2bf42129f 100644 --- a/x-pack/plugins/osquery/public/live_queries/form/index.tsx +++ b/x-pack/plugins/osquery/public/live_queries/form/index.tsx @@ -227,6 +227,8 @@ const LiveQueryFormComponent: React.FC = ({ if (!isEmpty(savedQuery.ecs_mapping)) { setFieldValue('ecs_mapping', savedQuery.ecs_mapping); setAdvancedContentState('open'); + } else { + setFieldValue('ecs_mapping', {}); } } else { setFieldValue('savedQueryId', null); diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index 8b8d361611a2d..c982cdd5604d1 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -24,7 +24,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedDate, FormattedTime, FormattedRelative } from '@kbn/i18n-react'; import moment from 'moment-timezone'; -import { +import type { TypedLensByValueInput, PersistedIndexPatternLayer, PieVisualizationState, diff --git a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx index 6cbf4dc84635e..bb63d733f36c8 100644 --- a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx @@ -586,33 +586,36 @@ export const ECSMappingEditorForm = forwardRef ({ + key: { type: FIELD_TYPES.COMBO_BOX, fieldsToValidateOnChange: ['result.value'], - }, - value: { - type: FIELD_TYPES.COMBO_BOX, - fieldsToValidateOnChange: ['key'], validations: [ { - validator: getOsqueryResultFieldValidator(osquerySchemaOptions, editForm), + validator: getEcsFieldValidator(editForm), }, ], }, - }, - }; + result: { + type: { + defaultValue: OSQUERY_COLUMN_VALUE_TYPE_OPTIONS[0].value, + type: FIELD_TYPES.COMBO_BOX, + fieldsToValidateOnChange: ['result.value'], + }, + value: { + type: FIELD_TYPES.COMBO_BOX, + fieldsToValidateOnChange: ['key'], + validations: [ + { + validator: getOsqueryResultFieldValidator(osquerySchemaOptions, editForm), + }, + ], + }, + }, + }), + [editForm, osquerySchemaOptions] + ); const { form } = useForm({ // @ts-expect-error update types @@ -1009,6 +1012,14 @@ export const ECSMappingEditorField = React.memo( }); }, [query]); + useEffect(() => { + Object.keys(formRefs.current).forEach((key) => { + if (!value[key]) { + delete formRefs.current[key]; + } + }); + }, [value]); + const handleAddRow = useCallback( (newRow) => { if (newRow?.key && newRow?.value) { diff --git a/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx b/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx index ab895c74b2a3e..c5000c1044588 100644 --- a/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx @@ -76,36 +76,35 @@ const QueryFlyoutComponent: React.FC = ({ const handleSetQueryValue = useCallback( (savedQuery) => { - if (!savedQuery) { - return reset(); - } + reset(); - setFieldValue('id', savedQuery.id); - setFieldValue('query', savedQuery.query); + if (savedQuery) { + setFieldValue('id', savedQuery.id); + setFieldValue('query', savedQuery.query); - if (savedQuery.description) { - setFieldValue('description', savedQuery.description); - } + if (savedQuery.description) { + setFieldValue('description', savedQuery.description); + } - if (savedQuery.interval) { - setFieldValue('interval', savedQuery.interval); - } + if (savedQuery.interval) { + setFieldValue('interval', savedQuery.interval); + } - if (savedQuery.platform) { - setFieldValue('platform', savedQuery.platform); - } + if (savedQuery.platform) { + setFieldValue('platform', savedQuery.platform); + } - if (savedQuery.version) { - setFieldValue('version', [savedQuery.version]); - } + if (savedQuery.version) { + setFieldValue('version', [savedQuery.version]); + } - if (savedQuery.ecs_mapping) { - setFieldValue('ecs_mapping', savedQuery.ecs_mapping); + if (savedQuery.ecs_mapping) { + setFieldValue('ecs_mapping', savedQuery.ecs_mapping); + } } }, [setFieldValue, reset] ); - /* Avoids accidental closing of the flyout when the user clicks outside of the flyout */ const maskProps = useMemo(() => ({ onClick: () => ({}) }), []); diff --git a/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts b/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts index ca4fd1ebeffd2..2816231cd094e 100644 --- a/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts +++ b/x-pack/plugins/osquery/server/lib/osquery_app_context_services.ts @@ -15,6 +15,7 @@ import { PackagePolicyServiceInterface, } from '../../../fleet/server'; import { ConfigType } from '../config'; +import { TelemetryEventsSender } from './telemetry/sender'; export type OsqueryAppContextServiceStartContract = Partial< Pick< @@ -72,6 +73,7 @@ export interface OsqueryAppContext { config(): ConfigType; security: SecurityPluginStart; getStartServices: CoreSetup['getStartServices']; + telemetryEventsSender: TelemetryEventsSender; /** * Object readiness is tied to plugin start method */ diff --git a/x-pack/plugins/osquery/server/lib/telemetry/__mocks__/index.ts b/x-pack/plugins/osquery/server/lib/telemetry/__mocks__/index.ts new file mode 100644 index 0000000000000..4b1186299be53 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/__mocks__/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConcreteTaskInstance, TaskStatus } from '../../../../../task_manager/server'; +import { TelemetryEventsSender } from '../sender'; +import { TelemetryReceiver } from '../receiver'; +import { OsqueryTelemetryTaskConfig } from '../task'; +import { PackagePolicy } from '../../../../../fleet/common/types/models/package_policy'; + +export const createMockTelemetryEventsSender = ( + enableTelemetry?: boolean +): jest.Mocked => + ({ + setup: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + getClusterID: jest.fn(), + fetchTelemetryUrl: jest.fn(), + queueTelemetryEvents: jest.fn(), + processEvents: jest.fn(), + isTelemetryOptedIn: jest.fn().mockReturnValue(enableTelemetry ?? jest.fn()), + sendIfDue: jest.fn(), + sendEvents: jest.fn(), + sendOnDemand: jest.fn(), + } as unknown as jest.Mocked); + +export const createMockTelemetryReceiver = ( + diagnosticsAlert?: unknown +): jest.Mocked => + ({ + start: jest.fn(), + fetchClusterInfo: jest.fn(), + fetchLicenseInfo: jest.fn(), + copyLicenseFields: jest.fn(), + fetchFleetAgents: jest.fn(), + fetchDiagnosticAlerts: jest.fn().mockReturnValue(diagnosticsAlert ?? jest.fn()), + fetchEndpointMetrics: jest.fn(), + fetchEndpointPolicyResponses: jest.fn(), + fetchTrustedApplications: jest.fn(), + fetchEndpointList: jest.fn(), + fetchDetectionRules: jest.fn().mockReturnValue({ body: null }), + } as unknown as jest.Mocked); + +export const createMockPackagePolicy = (): jest.Mocked => + ({ + id: jest.fn(), + inputs: jest.fn(), + version: jest.fn(), + revision: jest.fn(), + updated_at: jest.fn(), + updated_by: jest.fn(), + created_at: jest.fn(), + created_by: jest.fn(), + } as unknown as jest.Mocked); + +export const createMockOsqueryTelemetryTask = ( + testType?: string, + testLastTimestamp?: string +): jest.Mocked => + ({ + type: testType, + title: 'test title', + interval: '0m', + timeout: '0m', + version: '0.0.0', + getLastExecutionTime: jest.fn().mockReturnValue(testLastTimestamp ?? jest.fn()), + runTask: jest.fn(), + } as unknown as jest.Mocked); + +export const createMockTaskInstance = (testId: string, testType: string): ConcreteTaskInstance => + ({ + id: testId, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: testType, + } as ConcreteTaskInstance); diff --git a/x-pack/plugins/osquery/server/lib/telemetry/constants.ts b/x-pack/plugins/osquery/server/lib/telemetry/constants.ts new file mode 100644 index 0000000000000..69ef5cc601dbe --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const TELEMETRY_MAX_BUFFER_SIZE = 100; + +export const MAX_PACK_TELEMETRY_BATCH = 100; + +export const TELEMETRY_CHANNEL_PACKS = 'osquery-packs'; + +export const TELEMETRY_CHANNEL_SAVED_QUERIES = 'osquery-saved-queries'; diff --git a/x-pack/plugins/osquery/server/lib/telemetry/filters.test.ts b/x-pack/plugins/osquery/server/lib/telemetry/filters.test.ts new file mode 100644 index 0000000000000..926816149d25c --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/filters.test.ts @@ -0,0 +1,130 @@ +/* + * 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 { copyAllowlistedFields } from './filters'; + +describe('Security Telemetry filters', () => { + describe('allowlistEventFields', () => { + const allowlist = { + _id: true, + a: true, + b: true, + c: { + d: true, + }, + }; + + it('filters top level', () => { + const event = { + _id: 'id', + a: 'a', + a1: 'a1', + b: 'b', + b1: 'b1', + }; + expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ + _id: 'id', + a: 'a', + b: 'b', + }); + }); + + it('filters nested', () => { + const event = { + a: { + a1: 'a1', + }, + a1: 'a1', + b: { + b1: 'b1', + }, + b1: 'b1', + c: { + d: 'd', + e: 'e', + f: 'f', + }, + }; + expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ + a: { + a1: 'a1', + }, + b: { + b1: 'b1', + }, + c: { + d: 'd', + }, + }); + }); + + it('filters arrays of objects', () => { + const event = { + a: [ + { + a1: 'a1', + }, + ], + b: { + b1: 'b1', + }, + c: [ + { + d: 'd1', + e: 'e1', + f: 'f1', + }, + { + d: 'd2', + e: 'e2', + f: 'f2', + }, + { + d: 'd3', + e: 'e3', + f: 'f3', + }, + ], + }; + expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ + a: [ + { + a1: 'a1', + }, + ], + b: { + b1: 'b1', + }, + c: [ + { + d: 'd1', + }, + { + d: 'd2', + }, + { + d: 'd3', + }, + ], + }); + }); + + it("doesn't create empty objects", () => { + const event = { + a: 'a', + b: 'b', + c: { + e: 'e', + }, + }; + expect(copyAllowlistedFields(allowlist, event)).toStrictEqual({ + a: 'a', + b: 'b', + }); + }); + }); +}); diff --git a/x-pack/plugins/osquery/server/lib/telemetry/filters.ts b/x-pack/plugins/osquery/server/lib/telemetry/filters.ts new file mode 100644 index 0000000000000..9ef572e6879e2 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/filters.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TelemetryEvent } from './types'; + +export interface AllowlistFields { + [key: string]: boolean | AllowlistFields; +} + +export const savedQueryEventFields: AllowlistFields = { + id: true, + query: true, + created_at: true, + updated_at: true, + platform: true, + version: true, + interval: true, + ecs_mapping: true, +}; + +export const packEventFields: AllowlistFields = { + id: true, + name: true, + created_at: true, + updated_at: true, + enabled: true, + queries: true, +}; + +/** + * Filters out information not required for downstream analysis + * + * @param allowlist + * @param event + * @returns TelemetryEvent with explicitly required fields + */ +export function copyAllowlistedFields( + allowlist: AllowlistFields, + event: TelemetryEvent +): TelemetryEvent { + return Object.entries(allowlist).reduce((newEvent, [allowKey, allowValue]) => { + const eventValue = event[allowKey]; + if (eventValue !== null && eventValue !== undefined) { + if (allowValue === true) { + return { ...newEvent, [allowKey]: eventValue }; + } else if (typeof allowValue === 'object' && Array.isArray(eventValue)) { + const subValues = eventValue.filter((v) => typeof v === 'object'); + return { + ...newEvent, + [allowKey]: subValues.map((v) => copyAllowlistedFields(allowValue, v as TelemetryEvent)), + }; + } else if (typeof allowValue === 'object' && typeof eventValue === 'object') { + const values = copyAllowlistedFields(allowValue, eventValue as TelemetryEvent); + return { + ...newEvent, + ...(Object.keys(values).length > 0 ? { [allowKey]: values } : {}), + }; + } + } + return newEvent; + }, {}); +} diff --git a/x-pack/plugins/osquery/server/lib/telemetry/helpers.ts b/x-pack/plugins/osquery/server/lib/telemetry/helpers.ts new file mode 100644 index 0000000000000..061f3b9905f8b --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/helpers.ts @@ -0,0 +1,79 @@ +/* + * 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 { SavedObjectsFindResponse } from 'kibana/server'; +import { copyAllowlistedFields, packEventFields, savedQueryEventFields } from './filters'; +import type { ESClusterInfo, ESLicense, ListTemplate, TelemetryEvent } from './types'; + +/** + * Constructs the packs telemetry schema from a collection of packs saved objects + */ +export const templatePacks = ( + packsData: SavedObjectsFindResponse['saved_objects'], + clusterInfo: ESClusterInfo, + licenseInfo: ESLicense | undefined +) => + packsData.map((item) => { + const template: ListTemplate = { + '@timestamp': moment().toISOString(), + cluster_uuid: clusterInfo.cluster_uuid, + cluster_name: clusterInfo.cluster_name, + license_id: licenseInfo?.uid, + }; + + // cast exception list type to a TelemetryEvent for allowlist filtering + const filteredPackItem = copyAllowlistedFields( + packEventFields, + item.attributes as unknown as TelemetryEvent + ); + + return { + ...template, + id: item.id, + ...filteredPackItem, + }; + }); + +/** + * Constructs the packs telemetry schema from a collection of packs saved objects + */ +export const templateSavedQueries = ( + savedQueriesData: SavedObjectsFindResponse['saved_objects'], + clusterInfo: ESClusterInfo, + licenseInfo: ESLicense | undefined +) => + savedQueriesData.map((item) => { + const template: ListTemplate = { + '@timestamp': moment().toISOString(), + cluster_uuid: clusterInfo.cluster_uuid, + cluster_name: clusterInfo.cluster_name, + license_id: licenseInfo?.uid, + }; + + // cast exception list type to a TelemetryEvent for allowlist filtering + const filteredSavedQueryItem = copyAllowlistedFields( + savedQueryEventFields, + item.attributes as unknown as TelemetryEvent + ); + + return { + ...template, + id: item.id, + ...filteredSavedQueryItem, + }; + }); + +/** + * Convert counter label list to kebab case + * + * @param label_list the list of labels to create standardized UsageCounter from + * @returns a string label for usage in the UsageCounter + */ +export function createUsageCounterLabel(labelList: string[]): string { + return labelList.join('-'); +} diff --git a/x-pack/plugins/osquery/server/lib/telemetry/receiver.ts b/x-pack/plugins/osquery/server/lib/telemetry/receiver.ts new file mode 100644 index 0000000000000..59280ab342a97 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/receiver.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 { + Logger, + CoreStart, + ElasticsearchClient, + SavedObjectsClientContract, +} from 'src/core/server'; + +import { packSavedObjectType, savedQuerySavedObjectType } from '../../../common/types'; +import { AgentClient, AgentPolicyServiceInterface } from '../../../../fleet/server'; +import type { ESLicense, ESClusterInfo } from './types'; +import { OsqueryAppContextService } from '../osquery_app_context_services'; + +export class TelemetryReceiver { + private readonly logger: Logger; + private agentClient?: AgentClient; + private agentPolicyService?: AgentPolicyServiceInterface; + private esClient?: ElasticsearchClient; + private soClient?: SavedObjectsClientContract; + private clusterInfo?: ESClusterInfo; + private readonly max_records = 100; + + constructor(logger: Logger) { + this.logger = logger.get('telemetry_events'); + } + + public async start(core?: CoreStart, osqueryContextService?: OsqueryAppContextService) { + this.agentClient = osqueryContextService?.getAgentService()?.asInternalUser; + this.agentPolicyService = osqueryContextService?.getAgentPolicyService(); + this.esClient = core?.elasticsearch.client.asInternalUser; + this.soClient = + core?.savedObjects.createInternalRepository() as unknown as SavedObjectsClientContract; + this.clusterInfo = await this.fetchClusterInfo(); + } + + public getClusterInfo(): ESClusterInfo | undefined { + return this.clusterInfo; + } + + public async fetchPacks() { + return await this.soClient?.find({ + type: packSavedObjectType, + page: 1, + perPage: this.max_records, + sortField: 'updated_at', + sortOrder: 'desc', + }); + } + + public async fetchSavedQueries() { + return await this.soClient?.find({ + type: savedQuerySavedObjectType, + page: 1, + perPage: this.max_records, + sortField: 'updated_at', + sortOrder: 'desc', + }); + } + + public async fetchFleetAgents() { + if (this.esClient === undefined || this.esClient === null) { + throw Error('elasticsearch client is unavailable: cannot retrieve fleet policy responses'); + } + + return this.agentClient?.listAgents({ + perPage: this.max_records, + showInactive: true, + sortField: 'enrolled_at', + sortOrder: 'desc', + }); + } + + public async fetchPolicyConfigs(id: string) { + if (this.soClient === undefined || this.soClient === null) { + throw Error( + 'saved object client is unavailable: cannot retrieve endpoint policy configurations' + ); + } + + return this.agentPolicyService?.get(this.soClient, id); + } + + public async fetchClusterInfo(): Promise { + if (this.esClient === undefined || this.esClient === null) { + throw Error('elasticsearch client is unavailable: cannot retrieve cluster infomation'); + } + + return this.esClient.info(); + } + + public async fetchLicenseInfo(): Promise { + if (this.esClient === undefined || this.esClient === null) { + throw Error('elasticsearch client is unavailable: cannot retrieve license information'); + } + + try { + const ret = (await this.esClient.transport.request({ + method: 'GET', + path: '/_license', + querystring: { + local: true, + }, + })) as { license: ESLicense }; + + return (await ret).license; + } catch (err) { + this.logger.debug(`failed retrieving license: ${err}`); + return undefined; + } + } + + public copyLicenseFields(lic: ESLicense) { + return { + uid: lic.uid, + status: lic.status, + type: lic.type, + ...(lic.issued_to ? { issued_to: lic.issued_to } : {}), + ...(lic.issuer ? { issuer: lic.issuer } : {}), + }; + } +} diff --git a/x-pack/plugins/osquery/server/lib/telemetry/sender.test.ts b/x-pack/plugins/osquery/server/lib/telemetry/sender.test.ts new file mode 100644 index 0000000000000..c4e30f97fffcc --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/sender.test.ts @@ -0,0 +1,254 @@ +/* + * 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 dot-notation */ +import { TelemetryEventsSender } from './sender'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { usageCountersServiceMock } from 'src/plugins/usage_collection/server/usage_counters/usage_counters_service.mock'; +import { URL } from 'url'; + +describe('TelemetryEventsSender', () => { + let logger: ReturnType; + const usageCountersServiceSetup = usageCountersServiceMock.createSetupContract(); + const telemetryUsageCounter = usageCountersServiceSetup.createUsageCounter( + 'testTelemetryUsageCounter' + ); + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + }); + + describe('processEvents', () => { + it('returns empty array when empty array is passed', () => { + const sender = new TelemetryEventsSender(logger); + const result = sender.processEvents([]); + expect(result).toStrictEqual([]); + }); + + it('applies the allowlist', () => { + const sender = new TelemetryEventsSender(logger); + const input = [ + { + event: { + kind: 'alert', + }, + dns: { + question: { + name: 'test-dns', + }, + }, + agent: { + name: 'test', + }, + rule: { + id: 'X', + name: 'Y', + ruleset: 'Z', + version: '100', + }, + file: { + extension: '.exe', + size: 3, + created: 0, + path: 'X', + Ext: { + code_signature: { + key1: 'X', + key2: 'Y', + }, + malware_classification: { + key1: 'X', + }, + malware_signature: { + key1: 'X', + }, + header_bytes: 'data in here', + quarantine_result: true, + quarantine_message: 'this file is bad', + }, + }, + host: { + os: { + name: 'windows', + }, + }, + process: { + name: 'foo.exe', + working_directory: '/some/usr/dir', + entity_id: 'some_entity_id', + }, + Responses: '{ "result": 0 }', // >= 7.15 + Target: { + process: { + name: 'bar.exe', + thread: { + id: 1234, + }, + }, + }, + }, + ]; + + const result = sender.processEvents(input); + expect(result).toStrictEqual([ + { + event: { + kind: 'alert', + }, + dns: { + question: { + name: 'test-dns', + }, + }, + agent: { + name: 'test', + }, + rule: { + id: 'X', + name: 'Y', + ruleset: 'Z', + version: '100', + }, + file: { + extension: '.exe', + size: 3, + created: 0, + path: 'X', + Ext: { + code_signature: { + key1: 'X', + key2: 'Y', + }, + header_bytes: 'data in here', + malware_classification: { + key1: 'X', + }, + malware_signature: { + key1: 'X', + }, + quarantine_result: true, + quarantine_message: 'this file is bad', + }, + }, + host: { + os: { + name: 'windows', + }, + }, + process: { + name: 'foo.exe', + working_directory: '/some/usr/dir', + entity_id: 'some_entity_id', + }, + Responses: '{ "result": 0 }', + Target: { + process: { + name: 'bar.exe', + thread: { + id: 1234, + }, + }, + }, + }, + ]); + }); + }); + + describe('queueTelemetryEvents', () => { + it('queues two events', () => { + const sender = new TelemetryEventsSender(logger); + sender.queueTelemetryEvents([{ 'event.kind': '1' }, { 'event.kind': '2' }]); + expect(sender['queue'].length).toBe(2); + }); + + it('queues more than maxQueueSize events', () => { + const sender = new TelemetryEventsSender(logger); + sender['maxQueueSize'] = 5; + sender.queueTelemetryEvents([{ 'event.kind': '1' }, { 'event.kind': '2' }]); + sender.queueTelemetryEvents([{ 'event.kind': '3' }, { 'event.kind': '4' }]); + sender.queueTelemetryEvents([{ 'event.kind': '5' }, { 'event.kind': '6' }]); + sender.queueTelemetryEvents([{ 'event.kind': '7' }, { 'event.kind': '8' }]); + expect(sender['queue'].length).toBe(5); + }); + + it('empties the queue when sending', async () => { + const sender = new TelemetryEventsSender(logger); + sender['telemetryStart'] = { + getIsOptedIn: jest.fn(async () => true), + }; + sender['telemetrySetup'] = { + getTelemetryUrl: jest.fn(async () => new URL('https://telemetry.elastic.co')), + }; + sender['telemetryUsageCounter'] = telemetryUsageCounter; + sender['sendEvents'] = jest.fn(async () => { + sender['telemetryUsageCounter']?.incrementCounter({ + counterName: 'test_counter', + counterType: 'invoked', + incrementBy: 1, + }); + }); + + sender.queueTelemetryEvents([{ 'event.kind': '1' }, { 'event.kind': '2' }]); + expect(sender['queue'].length).toBe(2); + await sender['sendIfDue'](); + expect(sender['queue'].length).toBe(0); + expect(sender['sendEvents']).toBeCalledTimes(1); + sender.queueTelemetryEvents([{ 'event.kind': '3' }, { 'event.kind': '4' }]); + sender.queueTelemetryEvents([{ 'event.kind': '5' }, { 'event.kind': '6' }]); + expect(sender['queue'].length).toBe(4); + await sender['sendIfDue'](); + expect(sender['queue'].length).toBe(0); + expect(sender['sendEvents']).toBeCalledTimes(2); + expect(sender['telemetryUsageCounter'].incrementCounter).toBeCalledTimes(2); + }); + + it("shouldn't send when telemetry is disabled", async () => { + const sender = new TelemetryEventsSender(logger); + sender['sendEvents'] = jest.fn(); + const telemetryStart = { + getIsOptedIn: jest.fn(async () => false), + }; + sender['telemetryStart'] = telemetryStart; + + sender.queueTelemetryEvents([{ 'event.kind': '1' }, { 'event.kind': '2' }]); + expect(sender['queue'].length).toBe(2); + await sender['sendIfDue'](); + + expect(sender['queue'].length).toBe(0); + expect(sender['sendEvents']).toBeCalledTimes(0); + }); + }); +}); + +describe('getV3UrlFromV2', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + }); + + it('should return prod url', () => { + const sender = new TelemetryEventsSender(logger); + expect( + sender.getV3UrlFromV2('https://telemetry.elastic.co/xpack/v2/send', 'alerts-endpoint') + ).toBe('https://telemetry.elastic.co/v3/send/alerts-endpoint'); + }); + + it('should return staging url', () => { + const sender = new TelemetryEventsSender(logger); + expect( + sender.getV3UrlFromV2('https://telemetry-staging.elastic.co/xpack/v2/send', 'alerts-endpoint') + ).toBe('https://telemetry-staging.elastic.co/v3-dev/send/alerts-endpoint'); + }); + + it('should support ports and auth', () => { + const sender = new TelemetryEventsSender(logger); + expect( + sender.getV3UrlFromV2('http://user:pass@myproxy.local:1337/xpack/v2/send', 'alerts-endpoint') + ).toBe('http://user:pass@myproxy.local:1337/v3/send/alerts-endpoint'); + }); +}); diff --git a/x-pack/plugins/osquery/server/lib/telemetry/sender.ts b/x-pack/plugins/osquery/server/lib/telemetry/sender.ts new file mode 100644 index 0000000000000..84fd4d3004c92 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/sender.ts @@ -0,0 +1,295 @@ +/* + * 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 axios from 'axios'; +import { URL } from 'url'; +import { transformDataToNdjson } from '@kbn/securitysolution-utils'; + +import { Logger } from 'src/core/server'; +import { TelemetryPluginStart, TelemetryPluginSetup } from 'src/plugins/telemetry/server'; +import { UsageCounter } from 'src/plugins/usage_collection/server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../task_manager/server'; +import { TelemetryReceiver } from './receiver'; +import { createTelemetryTaskConfigs } from './tasks'; +import { createUsageCounterLabel } from './helpers'; +import type { TelemetryEvent } from './types'; +import { TELEMETRY_MAX_BUFFER_SIZE } from './constants'; +import { OsqueryTelemetryTask, OsqueryTelemetryTaskConfig } from './task'; + +const usageLabelPrefix: string[] = ['osquery_telemetry', 'sender']; + +export class TelemetryEventsSender { + private readonly initialCheckDelayMs = 10 * 1000; + private readonly checkIntervalMs = 60 * 1000; + private readonly logger: Logger; + private maxQueueSize = TELEMETRY_MAX_BUFFER_SIZE; + private telemetryStart?: TelemetryPluginStart; + private telemetrySetup?: TelemetryPluginSetup; + private intervalId?: NodeJS.Timeout; + private isSending = false; + private receiver: TelemetryReceiver | undefined; + private queue: TelemetryEvent[] = []; + private isOptedIn?: boolean = true; // Assume true until the first check + + private telemetryUsageCounter?: UsageCounter; + private telemetryTasks?: OsqueryTelemetryTask[]; + + constructor(logger: Logger) { + this.logger = logger.get('telemetry_events'); + } + + public setup( + telemetryReceiver: TelemetryReceiver, + telemetrySetup?: TelemetryPluginSetup, + taskManager?: TaskManagerSetupContract, + telemetryUsageCounter?: UsageCounter + ) { + this.telemetrySetup = telemetrySetup; + this.telemetryUsageCounter = telemetryUsageCounter; + + if (taskManager) { + this.telemetryTasks = createTelemetryTaskConfigs().map( + (config: OsqueryTelemetryTaskConfig) => { + const task = new OsqueryTelemetryTask(config, this.logger, this, telemetryReceiver); + task.register(taskManager); + return task; + } + ); + } + } + + public getClusterID(): string | undefined { + return this.receiver?.getClusterInfo()?.cluster_uuid; + } + + public start( + telemetryStart?: TelemetryPluginStart, + taskManager?: TaskManagerStartContract, + receiver?: TelemetryReceiver + ) { + this.telemetryStart = telemetryStart; + this.receiver = receiver; + + if (taskManager && this.telemetryTasks) { + this.logger.debug(`Starting osquery telemetry tasks`); + this.telemetryTasks.forEach((task) => task.start(taskManager)); + } + + this.logger.debug(`Starting local task`); + setTimeout(() => { + this.sendIfDue(); + this.intervalId = setInterval(() => this.sendIfDue(), this.checkIntervalMs); + }, this.initialCheckDelayMs); + } + + public stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + } + } + + public queueTelemetryEvents(events: TelemetryEvent[]) { + const qlength = this.queue.length; + + if (events.length === 0) { + return; + } + + this.logger.debug(`Queue events`); + + if (qlength >= this.maxQueueSize) { + // we're full already + return; + } + + if (events.length > this.maxQueueSize - qlength) { + this.telemetryUsageCounter?.incrementCounter({ + counterName: createUsageCounterLabel(usageLabelPrefix.concat(['queue_stats'])), + counterType: 'docs_lost', + incrementBy: events.length, + }); + this.telemetryUsageCounter?.incrementCounter({ + counterName: createUsageCounterLabel(usageLabelPrefix.concat(['queue_stats'])), + counterType: 'num_capacity_exceeded', + incrementBy: 1, + }); + this.queue.push(...this.processEvents(events.slice(0, this.maxQueueSize - qlength))); + } else { + this.queue.push(...this.processEvents(events)); + } + } + + public async isTelemetryOptedIn() { + this.isOptedIn = await this.telemetryStart?.getIsOptedIn(); + return this.isOptedIn === true; + } + + private async sendIfDue() { + if (this.isSending) { + return; + } + + if (this.queue.length === 0) { + return; + } + + try { + this.isSending = true; + + this.isOptedIn = await this.isTelemetryOptedIn(); + if (!this.isOptedIn) { + this.logger.debug(`Telemetry is not opted-in.`); + this.queue = []; + this.isSending = false; + return; + } + + const clusterInfo = this.receiver?.getClusterInfo(); + + const [telemetryUrl, licenseInfo] = await Promise.all([ + this.fetchTelemetryUrl('alerts-endpoint'), + this.receiver?.fetchLicenseInfo(), + ]); + + this.logger.debug(`Telemetry URL: ${telemetryUrl}`); + this.logger.debug( + `cluster_uuid: ${clusterInfo?.cluster_uuid} cluster_name: ${clusterInfo?.cluster_name}` + ); + + const toSend: TelemetryEvent[] = this.queue.slice().map((event) => ({ + ...event, + ...(licenseInfo ? { license: this.receiver?.copyLicenseFields(licenseInfo) } : {}), + cluster_uuid: clusterInfo?.cluster_uuid, + cluster_name: clusterInfo?.cluster_name, + })); + this.queue = []; + + await this.sendEvents( + toSend, + telemetryUrl, + 'alerts-endpoint', + clusterInfo?.cluster_uuid, + clusterInfo?.cluster_name, + clusterInfo?.version?.number, + licenseInfo?.uid + ); + } catch (err) { + this.queue = []; + } + this.isSending = false; + } + + public processEvents(events: TelemetryEvent[]): TelemetryEvent[] { + return events; + } + + /** + * This function sends events to the elastic telemetry channel. Caution is required + * because it does no allowlist filtering at send time. The function call site is + * responsible for ensuring sure no sensitive material is in telemetry events. + * + * @param channel the elastic telemetry channel + * @param toSend telemetry events + */ + public async sendOnDemand(channel: string, toSend: unknown[]) { + const clusterInfo = this.receiver?.getClusterInfo(); + try { + const [telemetryUrl, licenseInfo] = await Promise.all([ + this.fetchTelemetryUrl(channel), + this.receiver?.fetchLicenseInfo(), + ]); + + this.logger.debug(`Telemetry URL: ${telemetryUrl}`); + this.logger.debug( + `cluster_uuid: ${clusterInfo?.cluster_uuid} cluster_name: ${clusterInfo?.cluster_name}` + ); + + await this.sendEvents( + toSend, + telemetryUrl, + channel, + clusterInfo?.cluster_uuid, + clusterInfo?.cluster_name, + clusterInfo?.version?.number, + licenseInfo?.uid + ); + // eslint-disable-next-line no-empty + } catch (err) {} + } + + private async fetchTelemetryUrl(channel: string): Promise { + const telemetryUrl = await this.telemetrySetup?.getTelemetryUrl(); + if (!telemetryUrl) { + throw Error("Couldn't get telemetry URL"); + } + return this.getV3UrlFromV2(telemetryUrl.toString(), channel); + } + + // Forms URLs like: + // https://telemetry.elastic.co/v3/send/my-channel-name or + // https://telemetry-staging.elastic.co/v3-dev/send/my-channel-name + public getV3UrlFromV2(v2url: string, channel: string): string { + const url = new URL(v2url); + if (!url.hostname.includes('staging')) { + url.pathname = `/v3/send/${channel}`; + } else { + url.pathname = `/v3-dev/send/${channel}`; + } + return url.toString(); + } + + private async sendEvents( + events: unknown[], + telemetryUrl: string, + channel: string, + clusterUuid: string | undefined, + clusterName: string | undefined, + clusterVersionNumber: string | undefined, + licenseId: string | undefined + ) { + const ndjson = transformDataToNdjson(events); + + try { + this.logger.debug(`Sending ${events.length} telemetry events to ${channel}`); + const resp = await axios.post(telemetryUrl, ndjson, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'X-Elastic-Cluster-ID': clusterUuid, + 'X-Elastic-Cluster-Name': clusterName, + 'X-Elastic-Stack-Version': clusterVersionNumber ? clusterVersionNumber : '8.0.0', + ...(licenseId ? { 'X-Elastic-License-ID': licenseId } : {}), + }, + }); + this.telemetryUsageCounter?.incrementCounter({ + counterName: createUsageCounterLabel(usageLabelPrefix.concat(['payloads', channel])), + counterType: resp.status.toString(), + incrementBy: 1, + }); + this.telemetryUsageCounter?.incrementCounter({ + counterName: createUsageCounterLabel(usageLabelPrefix.concat(['payloads', channel])), + counterType: 'docs_sent', + incrementBy: events.length, + }); + this.logger.debug(`Events sent!. Response: ${resp.status} ${JSON.stringify(resp.data)}`); + } catch (err) { + this.logger.debug(`Error sending events: ${err}`); + this.telemetryUsageCounter?.incrementCounter({ + counterName: createUsageCounterLabel(usageLabelPrefix.concat(['payloads', channel])), + counterType: 'docs_lost', + incrementBy: events.length, + }); + this.telemetryUsageCounter?.incrementCounter({ + counterName: createUsageCounterLabel(usageLabelPrefix.concat(['payloads', channel])), + counterType: 'num_exceptions', + incrementBy: 1, + }); + } + } +} diff --git a/x-pack/plugins/osquery/server/lib/telemetry/task.test.ts b/x-pack/plugins/osquery/server/lib/telemetry/task.test.ts new file mode 100644 index 0000000000000..eaa0aa4f20464 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/task.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from 'src/core/server/mocks'; +import { taskManagerMock } from '../../../../task_manager/server/mocks'; +import { SuccessfulRunResult } from '../../../../task_manager/server/task'; +import { OsqueryTelemetryTask } from './task'; +import { + createMockTaskInstance, + createMockTelemetryEventsSender, + createMockTelemetryReceiver, + createMockOsqueryTelemetryTask, +} from './__mocks__'; + +describe('test osquery telemetry task', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + }); + + test('telemetry task should be constructed', () => { + const telemetryTask = new OsqueryTelemetryTask( + createMockOsqueryTelemetryTask(), + logger, + createMockTelemetryEventsSender(true), + createMockTelemetryReceiver() + ); + + expect(telemetryTask).toBeInstanceOf(OsqueryTelemetryTask); + }); + + test('telemetry task should be registered and scheduled', async () => { + const mockTaskManagerSetup = taskManagerMock.createSetup(); + const mockTaskManagerStart = taskManagerMock.createStart(); + const telemetryTask = new OsqueryTelemetryTask( + createMockOsqueryTelemetryTask(), + logger, + createMockTelemetryEventsSender(true), + createMockTelemetryReceiver() + ); + telemetryTask.register(mockTaskManagerSetup); + await telemetryTask.start(mockTaskManagerStart); + + expect(mockTaskManagerSetup.registerTaskDefinitions).toHaveBeenCalled(); + expect(mockTaskManagerStart.ensureScheduled).toHaveBeenCalled(); + }); + + test('telemetry task should run if opted in', async () => { + const { + testLastTimestamp, + testResult, + telemetryTask, + mockTelemetryTaskConfig, + mockTelemetryEventsSender, + mockTelemetryReceiver, + } = await testTelemetryTaskRun(true); + + expect(mockTelemetryTaskConfig.runTask).toHaveBeenCalledWith( + telemetryTask.getTaskId(), + logger, + mockTelemetryReceiver, + mockTelemetryEventsSender, + { + last: testLastTimestamp, + current: testResult.state.lastExecutionTimestamp, + } + ); + }); + + test('telemetry task should not run if opted out', async () => { + const { mockTelemetryTaskConfig } = await testTelemetryTaskRun(false); + + expect(mockTelemetryTaskConfig.runTask).not.toHaveBeenCalled(); + }); + + async function testTelemetryTaskRun(optedIn: boolean) { + const now = new Date(); + const testType = 'security:test-task'; + const testLastTimestamp = now.toISOString(); + const mockTaskManagerSetup = taskManagerMock.createSetup(); + const mockTelemetryTaskConfig = createMockOsqueryTelemetryTask(testType, testLastTimestamp); + const mockTelemetryEventsSender = createMockTelemetryEventsSender(optedIn); + const mockTelemetryReceiver = createMockTelemetryReceiver(); + const telemetryTask = new OsqueryTelemetryTask( + mockTelemetryTaskConfig, + logger, + mockTelemetryEventsSender, + mockTelemetryReceiver + ); + const mockTaskInstance = createMockTaskInstance(telemetryTask.getTaskId(), testType); + + telemetryTask.register(mockTaskManagerSetup); + expect(mockTaskManagerSetup.registerTaskDefinitions).toHaveBeenCalled(); + + const createTaskRunner = + mockTaskManagerSetup.registerTaskDefinitions.mock.calls[0][0][testType].createTaskRunner; + + const taskRunner = createTaskRunner({ taskInstance: mockTaskInstance }); + const testResult = (await taskRunner.run()) as SuccessfulRunResult; + + expect(mockTelemetryTaskConfig.getLastExecutionTime).toHaveBeenCalled(); + expect(mockTelemetryEventsSender.isTelemetryOptedIn).toHaveBeenCalled(); + + expect(testResult).not.toBeNull(); + expect(testResult).toHaveProperty('state.lastExecutionTimestamp'); + + return { + testLastTimestamp, + testResult, + telemetryTask, + mockTelemetryTaskConfig, + mockTelemetryEventsSender, + mockTelemetryReceiver, + }; + } +}); diff --git a/x-pack/plugins/osquery/server/lib/telemetry/task.ts b/x-pack/plugins/osquery/server/lib/telemetry/task.ts new file mode 100644 index 0000000000000..aff75ef28a41b --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/task.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { Logger } from 'src/core/server'; +import { + ConcreteTaskInstance, + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../task_manager/server'; +import { TelemetryReceiver } from './receiver'; +import { TelemetryEventsSender } from './sender'; + +export interface OsqueryTelemetryTaskConfig { + type: string; + title: string; + interval: string; + timeout: string; + version: string; + getLastExecutionTime?: LastExecutionTimestampCalculator; + runTask: OsqueryTelemetryTaskRunner; +} + +export type OsqueryTelemetryTaskRunner = ( + taskId: string, + logger: Logger, + receiver: TelemetryReceiver, + sender: TelemetryEventsSender, + taskExecutionPeriod: TaskExecutionPeriod +) => Promise; + +export interface TaskExecutionPeriod { + last?: string; + current: string; +} + +export type LastExecutionTimestampCalculator = ( + executeTo: string, + lastExecutionTimestamp?: string +) => string; + +export class OsqueryTelemetryTask { + private readonly config: OsqueryTelemetryTaskConfig; + private readonly logger: Logger; + private readonly sender: TelemetryEventsSender; + private readonly receiver: TelemetryReceiver; + + constructor( + config: OsqueryTelemetryTaskConfig, + logger: Logger, + sender: TelemetryEventsSender, + receiver: TelemetryReceiver + ) { + this.config = config; + this.logger = logger; + this.sender = sender; + this.receiver = receiver; + } + + public getLastExecutionTime = ( + taskExecutionTime: string, + taskInstance: ConcreteTaskInstance + ): string | undefined => + this.config.getLastExecutionTime + ? this.config.getLastExecutionTime( + taskExecutionTime, + taskInstance.state?.lastExecutionTimestamp + ) + : undefined; + + public getTaskId = (): string => `${this.config.type}:${this.config.version}`; + + public register = (taskManager: TaskManagerSetupContract) => { + taskManager.registerTaskDefinitions({ + [this.config.type]: { + title: this.config.title, + timeout: this.config.timeout, + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + const { state } = taskInstance; + + return { + run: async () => { + const taskExecutionTime = moment().utc().toISOString(); + const executionPeriod = { + last: this.getLastExecutionTime(taskExecutionTime, taskInstance), + current: taskExecutionTime, + }; + + const hits = await this.runTask(taskInstance.id, executionPeriod); + + return { + state: { + lastExecutionTimestamp: taskExecutionTime, + runs: (state.runs || 0) + 1, + hits, + }, + }; + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + cancel: async () => {}, + }; + }, + }, + }); + }; + + public start = async (taskManager: TaskManagerStartContract) => { + const taskId = this.getTaskId(); + this.logger.debug(`[task ${taskId}]: attempting to schedule`); + try { + await taskManager.ensureScheduled({ + id: taskId, + taskType: this.config.type, + scope: ['osquery'], + schedule: { + interval: this.config.interval, + }, + state: { runs: 0 }, + params: { version: this.config.version }, + }); + } catch (e) { + this.logger.error(`[task ${taskId}]: error scheduling task, received ${e.message}`); + } + }; + + public runTask = async (taskId: string, executionPeriod: TaskExecutionPeriod) => { + this.logger.debug(`[task ${taskId}]: attempting to run`); + if (taskId !== this.getTaskId()) { + this.logger.debug(`[task ${taskId}]: outdated task`); + return 0; + } + + const isOptedIn = await this.sender.isTelemetryOptedIn(); + if (!isOptedIn) { + this.logger.debug(`[task ${taskId}]: telemetry is not opted-in`); + return 0; + } + + this.logger.debug(`[task ${taskId}]: running task`); + return this.config.runTask(taskId, this.logger, this.receiver, this.sender, executionPeriod); + }; +} diff --git a/x-pack/plugins/osquery/server/lib/telemetry/tasks/index.ts b/x-pack/plugins/osquery/server/lib/telemetry/tasks/index.ts new file mode 100644 index 0000000000000..eb0d166c19d41 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/tasks/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { OsqueryTelemetryTaskConfig } from '../task'; +import { createTelemetryPacksTaskConfig } from './packs'; +import { createTelemetrySavedQueriesTaskConfig } from './saved_queries'; + +export function createTelemetryTaskConfigs(): OsqueryTelemetryTaskConfig[] { + return [createTelemetryPacksTaskConfig(), createTelemetrySavedQueriesTaskConfig()]; +} diff --git a/x-pack/plugins/osquery/server/lib/telemetry/tasks/packs.ts b/x-pack/plugins/osquery/server/lib/telemetry/tasks/packs.ts new file mode 100644 index 0000000000000..e863481a725f1 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/tasks/packs.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from 'src/core/server'; +import { TELEMETRY_CHANNEL_PACKS } from '../constants'; +import { templatePacks } from '../helpers'; +import { TelemetryEventsSender } from '../sender'; +import { TelemetryReceiver } from '../receiver'; +import type { ESClusterInfo, ESLicense } from '../types'; + +export function createTelemetryPacksTaskConfig() { + return { + type: 'osquery:telemetry-packs', + title: 'Osquery Packs Telemetry', + interval: '24h', + timeout: '10m', + version: '1.0.0', + runTask: async ( + taskId: string, + logger: Logger, + receiver: TelemetryReceiver, + sender: TelemetryEventsSender + ) => { + const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ + receiver.fetchClusterInfo(), + receiver.fetchLicenseInfo(), + ]); + + const clusterInfo = + clusterInfoPromise.status === 'fulfilled' + ? clusterInfoPromise.value + : ({} as ESClusterInfo); + const licenseInfo = + licenseInfoPromise.status === 'fulfilled' + ? licenseInfoPromise.value + : ({} as ESLicense | undefined); + + const packsResponse = await receiver.fetchPacks(); + + if (!packsResponse?.total) { + logger.debug('no packs found'); + return 0; + } + + const packsJson = templatePacks(packsResponse?.saved_objects, clusterInfo, licenseInfo); + + sender.sendOnDemand(TELEMETRY_CHANNEL_PACKS, packsJson); + + return packsResponse.total; + }, + }; +} diff --git a/x-pack/plugins/osquery/server/lib/telemetry/tasks/saved_queries.ts b/x-pack/plugins/osquery/server/lib/telemetry/tasks/saved_queries.ts new file mode 100644 index 0000000000000..962e0d2379696 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/tasks/saved_queries.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from 'src/core/server'; +import { TELEMETRY_CHANNEL_SAVED_QUERIES } from '../constants'; +import { templateSavedQueries } from '../helpers'; +import { TelemetryEventsSender } from '../sender'; +import { TelemetryReceiver } from '../receiver'; +import type { ESClusterInfo, ESLicense } from '../types'; + +export function createTelemetrySavedQueriesTaskConfig() { + return { + type: 'osquery:telemetry-saved-queries', + title: 'Osquery Saved Queries Telemetry', + interval: '24h', + timeout: '10m', + version: '1.0.0', + runTask: async ( + taskId: string, + logger: Logger, + receiver: TelemetryReceiver, + sender: TelemetryEventsSender + ) => { + const [clusterInfoPromise, licenseInfoPromise] = await Promise.allSettled([ + receiver.fetchClusterInfo(), + receiver.fetchLicenseInfo(), + ]); + + const clusterInfo = + clusterInfoPromise.status === 'fulfilled' + ? clusterInfoPromise.value + : ({} as ESClusterInfo); + const licenseInfo = + licenseInfoPromise.status === 'fulfilled' + ? licenseInfoPromise.value + : ({} as ESLicense | undefined); + + const savedQueriesResponse = await receiver.fetchSavedQueries(); + + if (!savedQueriesResponse?.total) { + logger.debug('no saved queries found'); + return 0; + } + + const savedQueriesJson = templateSavedQueries( + savedQueriesResponse?.saved_objects, + clusterInfo, + licenseInfo + ); + + sender.sendOnDemand(TELEMETRY_CHANNEL_SAVED_QUERIES, savedQueriesJson); + + return savedQueriesResponse.total; + }, + }; +} diff --git a/x-pack/plugins/osquery/server/lib/telemetry/types.ts b/x-pack/plugins/osquery/server/lib/telemetry/types.ts new file mode 100644 index 0000000000000..d7ba5fa34cf09 --- /dev/null +++ b/x-pack/plugins/osquery/server/lib/telemetry/types.ts @@ -0,0 +1,264 @@ +/* + * 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, TypeOf } from '@kbn/config-schema'; + +type BaseSearchTypes = string | number | boolean | object; +export type SearchTypes = BaseSearchTypes | BaseSearchTypes[] | undefined; + +// For getting cluster info. Copied from telemetry_collection/get_cluster_info.ts +export interface ESClusterInfo { + cluster_uuid: string; + cluster_name: string; + version?: { + number: string; + build_flavor: string; + build_type: string; + build_hash: string; + build_date: string; + build_snapshot?: boolean; + lucene_version: string; + minimum_wire_compatibility_version: string; + minimum_index_compatibility_version: string; + }; +} + +// From https://www.elastic.co/guide/en/elasticsearch/reference/current/get-license.html +export interface ESLicense { + status: string; + uid: string; + type: string; + issue_date?: string; + issue_date_in_millis?: number; + expiry_date?: string; + expirty_date_in_millis?: number; + max_nodes?: number; + issued_to?: string; + issuer?: string; + start_date_in_millis?: number; +} + +export interface TelemetryEvent { + [key: string]: SearchTypes; + '@timestamp'?: string; + data_stream?: { + [key: string]: SearchTypes; + dataset?: string; + }; + cluster_name?: string; + cluster_uuid?: string; + file?: { + [key: string]: SearchTypes; + Ext?: { + [key: string]: SearchTypes; + }; + }; + license?: ESLicense; +} + +// EP Policy Response + +export interface EndpointPolicyResponseAggregation { + hits: { + total: { value: number }; + }; + aggregations: { + policy_responses: { + buckets: Array<{ + key: string; + doc_count: number; + latest_response: EndpointPolicyResponseHits; + }>; + }; + }; +} + +interface EndpointPolicyResponseHits { + hits: { + total: { value: number }; + hits: EndpointPolicyResponseDocument[]; + }; +} + +export interface EndpointPolicyResponseDocument { + _source: { + '@timestamp': string; + agent: { + id: string; + }; + event: { + agent_id_status: string; + }; + Endpoint: { + policy: { + applied: { + actions: Array<{ + name: string; + message: string; + status: string; + }>; + artifacts: { + global: { + version: string; + }; + }; + status: string; + }; + }; + }; + }; +} + +// EP Metrics + +export interface EndpointMetricsAggregation { + hits: { + total: { value: number }; + }; + aggregations: { + endpoint_agents: { + buckets: Array<{ key: string; doc_count: number; latest_metrics: EndpointMetricHits }>; + }; + }; +} + +interface EndpointMetricHits { + hits: { + total: { value: number }; + hits: EndpointMetricDocument[]; + }; +} + +interface EndpointMetricDocument { + _source: { + '@timestamp': string; + agent: { + id: string; + version: string; + }; + Endpoint: { + metrics: EndpointMetrics; + }; + elastic: { + agent: { + id: string; + }; + }; + host: { + os: EndpointMetricOS; + }; + event: { + agent_id_status: string; + }; + }; +} + +export interface EndpointMetrics { + memory: { + endpoint: { + private: { + mean: number; + latest: number; + }; + }; + }; + cpu: { + endpoint: { + histogram: { + counts: number[]; + values: number[]; + }; + mean: number; + latest: number; + }; + }; + uptime: { + endpoint: number; + system: number; + }; +} + +interface EndpointMetricOS { + Ext: { + variant: string; + }; + kernel: string; + name: string; + family: string; + version: string; + platform: string; + full: string; +} + +// List HTTP Types + +export const GetTrustedAppsRequestSchema = { + query: schema.object({ + page: schema.maybe(schema.number({ defaultValue: 1, min: 1 })), + per_page: schema.maybe(schema.number({ defaultValue: 20, min: 1 })), + kuery: schema.maybe(schema.string()), + }), +}; + +export type GetEndpointListRequest = TypeOf; + +export interface GetEndpointListResponse { + per_page: number; + page: number; + total: number; + data: ExceptionListItem[]; +} + +// Telemetry List types + +export interface ExceptionListItem { + id: string; + rule_version?: number; + name: string; + created_at: string; + updated_at: string; + entries: object; + os_types: object; +} + +export interface ListTemplate { + '@timestamp': string; + cluster_uuid: string; + cluster_name: string; + license_id: string | undefined; + detection_rule?: TelemetryEvent; + endpoint_exception?: TelemetryEvent; + endpoint_event_filter?: TelemetryEvent; + trusted_application?: TelemetryEvent; +} + +// Detection Rule types + +interface ExceptionListEntry { + id: string; + list_id: string; + type: string; + namespace_type: string; +} + +interface DetectionRuleParms { + ruleId: string; + version: number; + type: string; + exceptionsList: ExceptionListEntry[]; +} + +export interface RuleSearchResult { + alert: { + name: string; + enabled: boolean; + tags: string[]; + createdAt: string; + updatedAt: string; + params: DetectionRuleParms; + }; +} diff --git a/x-pack/plugins/osquery/server/plugin.ts b/x-pack/plugins/osquery/server/plugin.ts index 8e887a012dab3..e0daae4c9687f 100644 --- a/x-pack/plugins/osquery/server/plugin.ts +++ b/x-pack/plugins/osquery/server/plugin.ts @@ -6,12 +6,6 @@ */ import { i18n } from '@kbn/i18n'; -import { - ASSETS_SAVED_OBJECT_TYPE, - PACKAGE_POLICY_SAVED_OBJECT_TYPE, - AGENT_POLICY_SAVED_OBJECT_TYPE, - PACKAGES_SAVED_OBJECT_TYPE, -} from '../../fleet/common'; import { PluginInitializerContext, CoreSetup, @@ -21,6 +15,7 @@ import { SavedObjectsClient, DEFAULT_APP_CATEGORIES, } from '../../../../src/core/server'; +import { UsageCounter } from '../../../../src/plugins/usage_collection/server'; import { createConfig } from './create_config'; import { OsqueryPluginSetup, OsqueryPluginStart, SetupPlugins, StartPlugins } from './types'; @@ -33,6 +28,8 @@ import { ConfigType } from './config'; import { packSavedObjectType, savedQuerySavedObjectType } from '../common/types'; import { PLUGIN_ID } from '../common'; import { getPackagePolicyDeleteCallback } from './lib/fleet_integration'; +import { TelemetryEventsSender } from './lib/telemetry/sender'; +import { TelemetryReceiver } from './lib/telemetry/receiver'; const registerFeatures = (features: SetupPlugins['features']) => { features.registerKibanaFeature({ @@ -51,12 +48,8 @@ const registerFeatures = (features: SetupPlugins['features']) => { app: [PLUGIN_ID, 'kibana'], catalogue: [PLUGIN_ID], savedObject: { - all: [ - PACKAGE_POLICY_SAVED_OBJECT_TYPE, - ASSETS_SAVED_OBJECT_TYPE, - AGENT_POLICY_SAVED_OBJECT_TYPE, - ], - read: [PACKAGES_SAVED_OBJECT_TYPE], + all: [], + read: [], }, ui: ['write'], }, @@ -66,11 +59,7 @@ const registerFeatures = (features: SetupPlugins['features']) => { catalogue: [PLUGIN_ID], savedObject: { all: [], - read: [ - PACKAGE_POLICY_SAVED_OBJECT_TYPE, - PACKAGES_SAVED_OBJECT_TYPE, - AGENT_POLICY_SAVED_OBJECT_TYPE, - ], + read: [], }, ui: ['read'], }, @@ -176,11 +165,7 @@ const registerFeatures = (features: SetupPlugins['features']) => { includeIn: 'all', name: 'All', savedObject: { - all: [ - PACKAGE_POLICY_SAVED_OBJECT_TYPE, - ASSETS_SAVED_OBJECT_TYPE, - packSavedObjectType, - ], + all: [packSavedObjectType], read: [], }, ui: ['writePacks', 'readPacks'], @@ -208,10 +193,16 @@ export class OsqueryPlugin implements Plugin, plugins: SetupPlugins) { @@ -228,6 +219,7 @@ export class OsqueryPlugin implements Plugin config, security: plugins.security, + telemetryEventsSender: this.telemetryEventsSender, }; initSavedObjects(core.savedObjects); @@ -236,6 +228,9 @@ export class OsqueryPlugin implements Plugin { @@ -244,6 +239,13 @@ export class OsqueryPlugin implements Plugin { router.get( @@ -29,21 +30,29 @@ export const getAgentPoliciesRoute = (router: IRouter, osqueryContext: OsqueryAp options: { tags: [`access:${PLUGIN_ID}-read`] }, }, async (context, request, response) => { - const soClient = context.core.savedObjects.client; + const internalSavedObjectsClient = await getInternalSavedObjectsClient( + osqueryContext.getStartServices + ); const agentService = osqueryContext.service.getAgentService(); const agentPolicyService = osqueryContext.service.getAgentPolicyService(); const packagePolicyService = osqueryContext.service.getPackagePolicyService(); - const { items: packagePolicies } = (await packagePolicyService?.list(soClient, { - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, - perPage: 1000, - page: 1, - })) ?? { items: [] as PackagePolicy[] }; + const { items: packagePolicies } = (await packagePolicyService?.list( + internalSavedObjectsClient, + { + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, + perPage: 1000, + page: 1, + } + )) ?? { items: [] as PackagePolicy[] }; const supportedPackagePolicyIds = filter(packagePolicies, (packagePolicy) => satisfies(packagePolicy.package?.version ?? '', '>=0.6.0') ); const agentPolicyIds = uniq(map(supportedPackagePolicyIds, 'policy_id')); - const agentPolicies = await agentPolicyService?.getByIds(soClient, agentPolicyIds); + const agentPolicies = await agentPolicyService?.getByIds( + internalSavedObjectsClient, + agentPolicyIds + ); if (agentPolicies?.length) { await pMap( diff --git a/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_policy.ts b/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_policy.ts index f845b04e99c93..9f2e523941bc2 100644 --- a/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_policy.ts +++ b/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_policy.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { PLUGIN_ID } from '../../../common'; import { IRouter } from '../../../../../../src/core/server'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; +import { getInternalSavedObjectsClient } from '../../usage/collector'; export const getAgentPolicyRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.get( @@ -22,11 +23,12 @@ export const getAgentPolicyRoute = (router: IRouter, osqueryContext: OsqueryAppC options: { tags: [`access:${PLUGIN_ID}-read`] }, }, async (context, request, response) => { - const soClient = context.core.savedObjects.client; - + const internalSavedObjectsClient = await getInternalSavedObjectsClient( + osqueryContext.getStartServices + ); const packageInfo = await osqueryContext.service .getAgentPolicyService() - ?.get(soClient, request.params.id); + ?.get(internalSavedObjectsClient, request.params.id); return response.ok({ body: { item: packageInfo } }); } diff --git a/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_package_policies.ts b/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_package_policies.ts index b95dfbdfb9cb4..36d22abc1fd05 100644 --- a/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_package_policies.ts +++ b/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_package_policies.ts @@ -10,6 +10,7 @@ import { PLUGIN_ID, OSQUERY_INTEGRATION_NAME } from '../../../common'; import { IRouter } from '../../../../../../src/core/server'; import { PACKAGE_POLICY_SAVED_OBJECT_TYPE } from '../../../../fleet/common'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; +import { getInternalSavedObjectsClient } from '../../usage/collector'; export const getPackagePoliciesRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.get( @@ -21,9 +22,12 @@ export const getPackagePoliciesRoute = (router: IRouter, osqueryContext: Osquery options: { tags: [`access:${PLUGIN_ID}-read`] }, }, async (context, request, response) => { + const internalSavedObjectsClient = await getInternalSavedObjectsClient( + osqueryContext.getStartServices + ); const kuery = `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.attributes.package.name: ${OSQUERY_INTEGRATION_NAME}`; const packagePolicyService = osqueryContext.service.getPackagePolicyService(); - const policies = await packagePolicyService?.list(context.core.savedObjects.client, { + const policies = await packagePolicyService?.list(internalSavedObjectsClient, { kuery, }); diff --git a/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts index bdc307e36619f..69384619596a2 100644 --- a/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts +++ b/x-pack/plugins/osquery/server/routes/pack/create_pack_route.ts @@ -20,6 +20,7 @@ import { OSQUERY_INTEGRATION_NAME } from '../../../common'; import { PLUGIN_ID } from '../../../common'; import { packSavedObjectType } from '../../../common/types'; import { convertPackQueriesToSO } from './utils'; +import { getInternalSavedObjectsClient } from '../../usage/collector'; export const createPackRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.post( @@ -61,6 +62,9 @@ export const createPackRoute = (router: IRouter, osqueryContext: OsqueryAppConte async (context, request, response) => { const esClient = context.core.elasticsearch.client.asCurrentUser; const savedObjectsClient = context.core.savedObjects.client; + const internalSavedObjectsClient = await getInternalSavedObjectsClient( + osqueryContext.getStartServices + ); const agentPolicyService = osqueryContext.service.getAgentPolicyService(); const packagePolicyService = osqueryContext.service.getPackagePolicyService(); @@ -78,14 +82,17 @@ export const createPackRoute = (router: IRouter, osqueryContext: OsqueryAppConte return response.conflict({ body: `Pack with name "${name}" already exists.` }); } - const { items: packagePolicies } = (await packagePolicyService?.list(savedObjectsClient, { - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, - perPage: 1000, - page: 1, - })) ?? { items: [] }; + const { items: packagePolicies } = (await packagePolicyService?.list( + internalSavedObjectsClient, + { + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, + perPage: 1000, + page: 1, + } + )) ?? { items: [] }; const agentPolicies = policy_ids - ? mapKeys(await agentPolicyService?.getByIds(savedObjectsClient, policy_ids), 'id') + ? mapKeys(await agentPolicyService?.getByIds(internalSavedObjectsClient, policy_ids), 'id') : {}; const references = policy_ids @@ -120,7 +127,7 @@ export const createPackRoute = (router: IRouter, osqueryContext: OsqueryAppConte const packagePolicy = find(packagePolicies, ['policy_id', agentPolicyId]); if (packagePolicy) { return packagePolicyService?.update( - savedObjectsClient, + internalSavedObjectsClient, esClient, packagePolicy.id, produce(packagePolicy, (draft) => { diff --git a/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts b/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts index 8cf891bff8b99..b2cff1b769d1c 100644 --- a/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts +++ b/x-pack/plugins/osquery/server/routes/pack/update_pack_route.ts @@ -21,6 +21,7 @@ import { packSavedObjectType } from '../../../common/types'; import { OsqueryAppContext } from '../../lib/osquery_app_context_services'; import { PLUGIN_ID } from '../../../common'; import { convertSOQueriesToPack, convertPackQueriesToSO } from './utils'; +import { getInternalSavedObjectsClient } from '../../usage/collector'; export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { router.put( @@ -70,6 +71,9 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte async (context, request, response) => { const esClient = context.core.elasticsearch.client.asCurrentUser; const savedObjectsClient = context.core.savedObjects.client; + const internalSavedObjectsClient = await getInternalSavedObjectsClient( + osqueryContext.getStartServices + ); const agentPolicyService = osqueryContext.service.getAgentPolicyService(); const packagePolicyService = osqueryContext.service.getPackagePolicyService(); const currentUser = await osqueryContext.security.authc.getCurrentUser(request)?.username; @@ -96,16 +100,19 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte } } - const { items: packagePolicies } = (await packagePolicyService?.list(savedObjectsClient, { - kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, - perPage: 1000, - page: 1, - })) ?? { items: [] }; + const { items: packagePolicies } = (await packagePolicyService?.list( + internalSavedObjectsClient, + { + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${OSQUERY_INTEGRATION_NAME}`, + perPage: 1000, + page: 1, + } + )) ?? { items: [] }; const currentPackagePolicies = filter(packagePolicies, (packagePolicy) => has(packagePolicy, `inputs[0].config.osquery.value.packs.${currentPackSO.attributes.name}`) ); const agentPolicies = policy_ids - ? mapKeys(await agentPolicyService?.getByIds(savedObjectsClient, policy_ids), 'id') + ? mapKeys(await agentPolicyService?.getByIds(internalSavedObjectsClient, policy_ids), 'id') : {}; const agentPolicyIds = Object.keys(agentPolicies); @@ -161,7 +168,7 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte if (packagePolicy) { return packagePolicyService?.update( - savedObjectsClient, + internalSavedObjectsClient, esClient, packagePolicy.id, produce(packagePolicy, (draft) => { @@ -189,7 +196,7 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte if (!packagePolicy) return; return packagePolicyService?.update( - savedObjectsClient, + internalSavedObjectsClient, esClient, packagePolicy.id, produce(packagePolicy, (draft) => { @@ -216,7 +223,7 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte const packagePolicy = find(currentPackagePolicies, ['policy_id', agentPolicyId]); if (packagePolicy) { return packagePolicyService?.update( - savedObjectsClient, + internalSavedObjectsClient, esClient, packagePolicy.id, produce(packagePolicy, (draft) => { @@ -238,7 +245,7 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte if (packagePolicy) { return packagePolicyService?.update( - savedObjectsClient, + internalSavedObjectsClient, esClient, packagePolicy.id, produce(packagePolicy, (draft) => { @@ -270,7 +277,7 @@ export const updatePackRoute = (router: IRouter, osqueryContext: OsqueryAppConte if (packagePolicy) { return packagePolicyService?.update( - savedObjectsClient, + internalSavedObjectsClient, esClient, packagePolicy.id, produce(packagePolicy, (draft) => { diff --git a/x-pack/plugins/osquery/server/types.ts b/x-pack/plugins/osquery/server/types.ts index 84b2ff41dc1cf..b07958eaa0d6a 100644 --- a/x-pack/plugins/osquery/server/types.ts +++ b/x-pack/plugins/osquery/server/types.ts @@ -5,15 +5,20 @@ * 2.0. */ -import { ActionsPlugin } from '../../actions/server'; -import { +import type { TelemetryPluginSetup, TelemetryPluginStart } from 'src/plugins/telemetry/server'; +import type { ActionsPlugin } from '../../actions/server'; +import type { PluginSetup as DataPluginSetup, PluginStart as DataPluginStart, } from '../../../../src/plugins/data/server'; -import { FleetStartContract } from '../../fleet/server'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; -import { PluginSetupContract } from '../../features/server'; -import { SecurityPluginStart } from '../../security/server'; +import type { FleetStartContract } from '../../fleet/server'; +import type { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import type { PluginSetupContract } from '../../features/server'; +import type { SecurityPluginStart } from '../../security/server'; +import { + TaskManagerSetupContract as TaskManagerPluginSetup, + TaskManagerStartContract as TaskManagerPluginStart, +} from '../../task_manager/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface OsqueryPluginSetup {} @@ -26,10 +31,14 @@ export interface SetupPlugins { data: DataPluginSetup; features: PluginSetupContract; security: SecurityPluginStart; + taskManager?: TaskManagerPluginSetup; + telemetry?: TelemetryPluginSetup; } export interface StartPlugins { actions: ActionsPlugin['start']; data: DataPluginStart; fleet?: FleetStartContract; + taskManager?: TaskManagerPluginStart; + telemetry?: TelemetryPluginStart; } diff --git a/x-pack/plugins/reporting/jest.integration.config.js b/x-pack/plugins/reporting/jest.integration.config.js new file mode 100644 index 0000000000000..7f43fa6b4464a --- /dev/null +++ b/x-pack/plugins/reporting/jest.integration.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../../..', + roots: ['/x-pack/plugins/reporting'], +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 386e00fc28d8b..c336b588f12b8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -400,7 +400,7 @@ export const BULK_EDIT_FLYOUT_FORM_ADD_TAGS_HELP_TEXT = i18n.translate( 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.addTagsComboboxHelpText', { defaultMessage: - 'Add one or more custom identifying tags for selected rules. Press enter after each tag to begin a new one.', + 'Add one or more tags for selected rules from the dropdown. You can also enter custom identifying tags and press Enter to begin a new one.', } ); @@ -408,7 +408,7 @@ export const BULK_EDIT_FLYOUT_FORM_DELETE_TAGS_HELP_TEXT = i18n.translate( 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteTagsComboboxHelpText', { defaultMessage: - 'Delete one or more custom identifying tags for selected rules. Press enter after each tag to begin a new one.', + 'Delete one or more tags for selected rules from the dropdown. You can also enter custom identifying tags and press Enter to begin a new one.', } ); diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/snapshot_list.test.tsx b/x-pack/plugins/snapshot_restore/__jest__/client_integration/snapshot_list.test.tsx index fc00953724428..bc6eae3fcd573 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/snapshot_list.test.tsx +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/snapshot_list.test.tsx @@ -155,6 +155,17 @@ describe('', () => { }); }); + test('term search with a date is parsed', async () => { + await setSearchText('2022.02.10'); + expect(useLoadSnapshots).lastCalledWith({ + ...DEFAULT_SNAPSHOT_LIST_PARAMS, + searchField: 'snapshot', + searchValue: '2022.02.10', + searchMatch: 'must', + searchOperator: 'eq', + }); + }); + test('excluding term search is converted to partial excluding snapshot search', async () => { await setSearchText('-test_snapshot'); expect(useLoadSnapshots).lastCalledWith({ diff --git a/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts b/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts index b75a3e01fb617..20276ae58b8e4 100644 --- a/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts +++ b/x-pack/plugins/snapshot_restore/public/application/lib/snapshot_list_params.ts @@ -6,6 +6,7 @@ */ import { Direction, Query } from '@elastic/eui'; +import { SchemaType } from '@elastic/eui/src/components/search_bar/search_box'; export type SortField = | 'snapshot' @@ -49,12 +50,15 @@ const resetSearchOptions = (listParams: SnapshotListParams): SnapshotListParams }); // to init the query for repository and policyName search passed via url -export const getQueryFromListParams = (listParams: SnapshotListParams): Query => { +export const getQueryFromListParams = ( + listParams: SnapshotListParams, + schema: SchemaType +): Query => { const { searchField, searchValue } = listParams; if (!searchField || !searchValue) { return Query.MATCH_ALL; } - return Query.parse(`${searchField}=${searchValue}`); + return Query.parse(`${searchField}=${searchValue}`, { schema }); }; export const getListParams = (listParams: SnapshotListParams, query: Query): SnapshotListParams => { diff --git a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx index 6f873eacceb51..99a160d54d23e 100644 --- a/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/sections/home/snapshot_list/components/snapshot_search_bar.tsx @@ -126,7 +126,7 @@ export const SnapshotSearchBar: React.FunctionComponent = ({ ); - const [query, setQuery] = useState(getQueryFromListParams(listParams)); + const [query, setQuery] = useState(getQueryFromListParams(listParams, searchSchema)); const [error, setError] = useState(null); const onSearchBarChange = (args: EuiSearchBarOnChangeArgs) => { diff --git a/x-pack/plugins/task_manager/jest.integration.config.js b/x-pack/plugins/task_manager/jest.integration.config.js new file mode 100644 index 0000000000000..e46b3f1bdf136 --- /dev/null +++ b/x-pack/plugins/task_manager/jest.integration.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../../..', + roots: ['/x-pack/plugins/task_manager'], +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a5020c1122651..125c9ff096507 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -631,17 +631,14 @@ "xpack.lens.pie.addLayer": "ビジュアライゼーションレイヤーを追加", "xpack.lens.pie.arrayValues": "{label}には配列値が含まれます。可視化が想定通りに表示されない場合があります。", "xpack.lens.pie.donutLabel": "ドーナッツ", - "xpack.lens.pie.expressionHelpLabel": "円表示", "xpack.lens.pie.groupLabel": "比率", "xpack.lens.pie.groupsizeLabel": "サイズ単位", "xpack.lens.pie.pielabel": "円", - "xpack.lens.pie.pieWithNegativeWarningLabel": "{chartType}グラフは負の値では表示できません。", "xpack.lens.pie.sliceGroupLabel": "スライス", "xpack.lens.pie.suggestionLabel": "{chartName}として", "xpack.lens.pie.treemapGroupLabel": "グループ分けの条件", "xpack.lens.pie.treemaplabel": "ツリーマップ", "xpack.lens.pie.treemapSuggestionLabel": "ツリーマップとして", - "xpack.lens.pie.visualizationName": "円", "xpack.lens.pieChart.categoriesInLegendLabel": "ラベルを非表示", "xpack.lens.pieChart.fitInsideOnlyLabel": "内部のみ", "xpack.lens.pieChart.hiddenNumbersLabel": "グラフから非表示", @@ -2983,8 +2980,6 @@ "expressionPartitionVis.legend.filterForValueButtonAriaLabel": "値でフィルター", "expressionPartitionVis.legend.filterOptionsLegend": "{legendDataLabel}、フィルターオプション", "expressionPartitionVis.legend.filterOutValueButtonAriaLabel": "値を除外", - "expressionPartitionVis.negativeValuesFound": "円/ドーナツグラフは負の値では表示できません。", - "expressionPartitionVis.noResultsFoundTitle": "結果が見つかりませんでした", "fieldFormats.advancedSettings.format.bytesFormat.numeralFormatLinkText": "数字フォーマット", "fieldFormats.advancedSettings.format.bytesFormatText": "「バイト」フォーマットのデフォルト{numeralFormatLink}です", "fieldFormats.advancedSettings.format.bytesFormatTitle": "バイトフォーマット", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ab50cb20956a8..69e9f293f845d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -643,17 +643,14 @@ "xpack.lens.pie.addLayer": "添加可视化图层", "xpack.lens.pie.arrayValues": "{label} 包含数组值。您的可视化可能无法正常渲染。", "xpack.lens.pie.donutLabel": "圆环图", - "xpack.lens.pie.expressionHelpLabel": "饼图呈现器", "xpack.lens.pie.groupLabel": "比例", "xpack.lens.pie.groupsizeLabel": "大小调整依据", "xpack.lens.pie.pielabel": "饼图", - "xpack.lens.pie.pieWithNegativeWarningLabel": "{chartType} 图表无法使用负值进行呈现。", "xpack.lens.pie.sliceGroupLabel": "切片依据", "xpack.lens.pie.suggestionLabel": "为 {chartName}", "xpack.lens.pie.treemapGroupLabel": "分组依据", "xpack.lens.pie.treemaplabel": "树状图", "xpack.lens.pie.treemapSuggestionLabel": "为树状图", - "xpack.lens.pie.visualizationName": "饼图", "xpack.lens.pieChart.categoriesInLegendLabel": "隐藏标签", "xpack.lens.pieChart.fitInsideOnlyLabel": "仅内部", "xpack.lens.pieChart.hiddenNumbersLabel": "在图表中隐藏", @@ -2767,8 +2764,6 @@ "expressionPartitionVis.legend.filterForValueButtonAriaLabel": "筛留值", "expressionPartitionVis.legend.filterOptionsLegend": "{legendDataLabel}, 筛选选项", "expressionPartitionVis.legend.filterOutValueButtonAriaLabel": "筛除值", - "expressionPartitionVis.negativeValuesFound": "饼图/圆环图无法使用负值进行呈现。", - "expressionPartitionVis.noResultsFoundTitle": "找不到结果", "fieldFormats.advancedSettings.format.bytesFormat.numeralFormatLinkText": "数值格式", "fieldFormats.advancedSettings.format.bytesFormatText": "“字节”格式的默认{numeralFormatLink}", "fieldFormats.advancedSettings.format.bytesFormatTitle": "字节格式", diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index 989d6d8ef941a..e78f026277d3a 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -90,14 +90,14 @@ export type Tls = t.TypeOf; export const MonitorType = t.intersection([ t.type({ - duration: t.type({ - us: t.number, - }), id: t.string, status: t.string, type: t.string, }), t.partial({ + duration: t.type({ + us: t.number, + }), check_group: t.string, ip: t.string, name: t.string, diff --git a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts index d2decba3e9a99..92bc5ea8ee704 100644 --- a/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts +++ b/x-pack/plugins/uptime/e2e/journeys/monitor_management.journey.ts @@ -10,8 +10,10 @@ import { monitorManagementPageProvider } from '../page_objects/monitor_managemen import { DataStream } from '../../common/runtime_types/monitor_management'; import { byTestId } from './utils'; +const customLocation = process.env.SYNTHETICS_TEST_LOCATION; + const basicMonitorDetails = { - location: 'US Central', + location: customLocation || 'US Central', schedule: '@every 3m', }; const httpName = 'http monitor'; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx index c185303447854..bfcf359ac0525 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.test.tsx @@ -42,7 +42,6 @@ describe('PingList component', () => { type: 'io', }, monitor: { - duration: { us: 1370 }, id: 'auto-tcp-0X81440A68E839814D', ip: '255.255.255.0', name: '', @@ -161,9 +160,6 @@ describe('PingList component', () => { "type": "io", }, "monitor": Object { - "duration": Object { - "us": 1370, - }, "id": "auto-tcp-0X81440A68E839814D", "ip": "255.255.255.0", "name": "", @@ -186,6 +182,13 @@ describe('PingList component', () => { }); }); + describe('duration column', () => { + it('shows -- when duration is null', () => { + const { getByTestId } = render(); + expect(getByTestId('ping-list-duration-unavailable-tool-tip')).toBeInTheDocument(); + }); + }); + describe('formatDuration', () => { it('returns zero for < 1 millisecond', () => { expect(formatDuration(984)).toBe('0 ms'); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_table.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_table.tsx index 84a2d6a5d6a31..5e2737684b333 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_table.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_table.tsx @@ -140,7 +140,12 @@ export function PingListTable({ loading, error, pings, pagination, onChange, fai name: i18n.translate('xpack.uptime.pingList.durationMsColumnLabel', { defaultMessage: 'Duration', }), - render: (duration: number) => formatDuration(duration), + render: (duration: number | null) => + duration ? ( + formatDuration(duration) + ) : ( + {'--'} + ), }, ...(hasError ? [ diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index 2dd4ed7bed481..a2d823cd90af1 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -135,7 +135,7 @@ export const MonitorListComponent: ({ timestamp={timestamp} summaryPings={summaryPings ?? []} monitorType={type} - duration={duration!.us} + duration={duration?.us} monitorId={monitorId} /> ); diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts index 450ab324e7e48..12e13cd22f748 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts @@ -280,6 +280,7 @@ export class SyntheticsService { const findResult = await savedObjectsClient.find({ type: syntheticsMonitorType, namespaces: ['*'], + perPage: 10000, }); hydrateSavedObjects({ diff --git a/x-pack/test/apm_api_integration/tests/service_overview/get_service_node_ids.ts b/x-pack/test/apm_api_integration/tests/service_overview/get_service_node_ids.ts index 019c41f9292ba..3d07f3feacdd2 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/get_service_node_ids.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/get_service_node_ids.ts @@ -6,22 +6,22 @@ */ import { take } from 'lodash'; import { LatencyAggregationType } from '../../../../plugins/apm/common/latency_aggregation_types'; -import { ApmApiSupertest } from '../../common/apm_api_supertest'; +import { ApmServices } from '../../common/config'; export async function getServiceNodeIds({ - apmApiSupertest, + apmApiClient, start, end, serviceName = 'opbeans-java', count = 1, }: { - apmApiSupertest: ApmApiSupertest; + apmApiClient: Awaited>; start: string; end: string; serviceName?: string; count?: number; }) { - const { body } = await apmApiSupertest({ + const { body } = await apmApiClient.readUser({ endpoint: `GET /internal/apm/services/{serviceName}/service_overview_instances/main_statistics`, params: { path: { serviceName }, diff --git a/x-pack/test/apm_api_integration/tests/service_overview/instance_details.spec.ts b/x-pack/test/apm_api_integration/tests/service_overview/instance_details.spec.ts index d0f60ded4c444..99c2585162fdf 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/instance_details.spec.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/instance_details.spec.ts @@ -4,22 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import url from 'url'; import expect from '@kbn/expect'; import { omit } from 'lodash'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import archives from '../../common/fixtures/es_archiver/archives_metadata'; import { APIReturnType } from '../../../../plugins/apm/public/services/rest/create_call_apm_api'; import { getServiceNodeIds } from './get_service_node_ids'; -import { createApmApiClient } from '../../common/apm_api_supertest'; type ServiceOverviewInstanceDetails = APIReturnType<'GET /internal/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}'>; export default function ApiTest({ getService }: FtrProviderContext) { const registry = getService('registry'); - const supertest = getService('legacySupertestAsApmReadUser'); - const apmApiSupertest = createApmApiClient(supertest); + const apmApiClient = getService('apmApiClient'); const archiveName = 'apm_8.0.0'; const { start, end } = archives[archiveName]; @@ -30,16 +27,17 @@ export default function ApiTest({ getService }: FtrProviderContext) { () => { describe('when data is not loaded', () => { it('handles empty state', async () => { - const response = await supertest.get( - url.format({ - pathname: - '/internal/apm/services/opbeans-java/service_overview_instances/details/foo', + const response = await apmApiClient.readUser({ + endpoint: + 'GET /internal/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}', + params: { + path: { serviceName: 'opbeans-java', serviceNodeName: 'foo' }, query: { start, end, }, - }) - ); + }, + }); expect(response.status).to.be(200); expect(response.body).to.eql({}); @@ -62,16 +60,23 @@ export default function ApiTest({ getService }: FtrProviderContext) { let serviceNodeIds: string[]; before(async () => { - serviceNodeIds = await getServiceNodeIds({ apmApiSupertest, start, end }); - response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/service_overview_instances/details/${serviceNodeIds[0]}`, + serviceNodeIds = await getServiceNodeIds({ + apmApiClient, + start, + end, + }); + + response = await apmApiClient.readUser({ + endpoint: + 'GET /internal/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}', + params: { + path: { serviceName: 'opbeans-node', serviceNodeName: serviceNodeIds[0] }, query: { start, end, }, - }) - ); + }, + }); }); it('returns the instance details', () => { @@ -90,15 +95,17 @@ export default function ApiTest({ getService }: FtrProviderContext) { { config: 'basic', archives: [archiveName] }, () => { it('handles empty state when instance id not found', async () => { - const response = await supertest.get( - url.format({ - pathname: '/internal/apm/services/opbeans-java/service_overview_instances/details/foo', + const response = await apmApiClient.readUser({ + endpoint: + 'GET /internal/apm/services/{serviceName}/service_overview_instances/details/{serviceNodeName}', + params: { + path: { serviceName: 'opbeans-java', serviceNodeName: 'foo' }, query: { start, end, }, - }) - ); + }, + }); expect(response.status).to.be(200); expect(response.body).to.eql({}); }); diff --git a/x-pack/test/apm_api_integration/tests/service_overview/instances_detailed_statistics.spec.ts b/x-pack/test/apm_api_integration/tests/service_overview/instances_detailed_statistics.spec.ts index b344639920615..e8c4b73ac2970 100644 --- a/x-pack/test/apm_api_integration/tests/service_overview/instances_detailed_statistics.spec.ts +++ b/x-pack/test/apm_api_integration/tests/service_overview/instances_detailed_statistics.spec.ts @@ -6,21 +6,20 @@ */ import expect from '@kbn/expect'; -import url from 'url'; import moment from 'moment'; import { Coordinate } from '../../../../plugins/apm/typings/timeseries'; +import { LatencyAggregationType } from '../../../../plugins/apm/common/latency_aggregation_types'; import { isFiniteNumber } from '../../../../plugins/apm/common/utils/is_finite_number'; import { APIReturnType } from '../../../../plugins/apm/public/services/rest/create_call_apm_api'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import archives from '../../common/fixtures/es_archiver/archives_metadata'; -import { createApmApiClient } from '../../common/apm_api_supertest'; import { getServiceNodeIds } from './get_service_node_ids'; export default function ApiTest({ getService }: FtrProviderContext) { const registry = getService('registry'); - const supertest = getService('legacySupertestAsApmReadUser'); - const apmApiSupertest = createApmApiClient(supertest); + const apmApiClient = getService('apmApiClient'); + const serviceName = 'opbeans-java'; const archiveName = 'apm_8.0.0'; const { start, end } = archives[archiveName]; @@ -35,23 +34,25 @@ export default function ApiTest({ getService }: FtrProviderContext) { () => { describe('when data is not loaded', () => { it('handles the empty state', async () => { - const response: Response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/service_overview_instances/detailed_statistics`, + const response = await apmApiClient.readUser({ + endpoint: + 'GET /internal/apm/services/{serviceName}/service_overview_instances/detailed_statistics', + params: { + path: { serviceName }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, start, end, numBuckets: 20, transactionType: 'request', serviceNodeIds: JSON.stringify( - await getServiceNodeIds({ apmApiSupertest, start, end }) + await getServiceNodeIds({ apmApiClient, start, end }) ), environment: 'ENVIRONMENT_ALL', kuery: '', }, - }) - ); + }, + }); expect(response.status).to.be(200); expect(response.body).to.be.eql({ currentPeriod: {}, previousPeriod: {} }); @@ -69,15 +70,21 @@ export default function ApiTest({ getService }: FtrProviderContext) { let serviceNodeIds: string[]; beforeEach(async () => { - serviceNodeIds = await getServiceNodeIds({ apmApiSupertest, start, end }); + serviceNodeIds = await getServiceNodeIds({ + apmApiClient, + start, + end, + }); }); beforeEach(async () => { - response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/service_overview_instances/detailed_statistics`, + response = await apmApiClient.readUser({ + endpoint: + 'GET /internal/apm/services/{serviceName}/service_overview_instances/detailed_statistics', + params: { + path: { serviceName }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, start, end, numBuckets: 20, @@ -86,8 +93,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { environment: 'ENVIRONMENT_ALL', kuery: '', }, - }) - ); + }, + }); }); it('returns a service node item', () => { @@ -123,15 +130,21 @@ export default function ApiTest({ getService }: FtrProviderContext) { let serviceNodeIds: string[]; beforeEach(async () => { - serviceNodeIds = await getServiceNodeIds({ apmApiSupertest, start, end }); + serviceNodeIds = await getServiceNodeIds({ + apmApiClient, + start, + end, + }); }); beforeEach(async () => { - response = await supertest.get( - url.format({ - pathname: `/internal/apm/services/opbeans-java/service_overview_instances/detailed_statistics`, + response = await apmApiClient.readUser({ + endpoint: + 'GET /internal/apm/services/{serviceName}/service_overview_instances/detailed_statistics', + params: { + path: { serviceName }, query: { - latencyAggregationType: 'avg', + latencyAggregationType: LatencyAggregationType.avg, numBuckets: 20, transactionType: 'request', serviceNodeIds: JSON.stringify(serviceNodeIds), @@ -142,8 +155,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { environment: 'ENVIRONMENT_ALL', kuery: '', }, - }) - ); + }, + }); }); it('returns a service node item for current and previous periods', () => { diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts index 75d5c58d8e375..a803b7224d0b4 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/create.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/create.ts @@ -12,6 +12,10 @@ export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); + const getPackagePolicyById = async (id: string) => { + const { body } = await supertest.get(`/api/fleet/package_policies/${id}`); + return body; + }; // use function () {} and not () => {} here // because `this` has to point to the Mocha context // see https://mochajs.org/#arrow-functions @@ -399,5 +403,51 @@ export default function (providerContext: FtrProviderContext) { }) .expect(200); }); + + it('should trim whitespace from policy name', async function () { + const nameWithWhitespace = ' package-policy-with-whitespace-prefix-and-suffix '; + const { body } = await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: nameWithWhitespace, + description: '', + namespace: 'default', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [ + { + enabled: true, + streams: [ + { + data_stream: { + dataset: 'with_required_variables.log', + type: 'logs', + }, + enabled: true, + vars: { + test_var_required: { + value: 'I am required', + }, + }, + }, + ], + type: 'test_input', + }, + ], + package: { + name: 'with_required_variables', + version: '0.1.0', + }, + }) + .expect(200); + + const policyId = body.item.id; + + const { item: policy } = await getPackagePolicyById(policyId); + + expect(policy.name).to.equal(nameWithWhitespace.trim()); + }); }); } diff --git a/x-pack/test/fleet_api_integration/apis/package_policy/update.ts b/x-pack/test/fleet_api_integration/apis/package_policy/update.ts index 7d62ea3bf7ec3..d1fa97b715b76 100644 --- a/x-pack/test/fleet_api_integration/apis/package_policy/update.ts +++ b/x-pack/test/fleet_api_integration/apis/package_policy/update.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { skipIfNoDockerRegistry } from '../../helpers'; @@ -12,6 +13,11 @@ export default function (providerContext: FtrProviderContext) { const supertest = getService('supertest'); const dockerServers = getService('dockerServers'); + const getPackagePolicyById = async (id: string) => { + const { body } = await supertest.get(`/api/fleet/package_policies/${id}`); + return body; + }; + const server = dockerServers.get('registry'); // use function () {} and not () => {} here // because `this` has to point to the Mocha context @@ -138,6 +144,30 @@ export default function (providerContext: FtrProviderContext) { }); }); + it('should trim whitespace from name on update', async function () { + await supertest + .put(`/api/fleet/package_policies/${packagePolicyId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: ' filetest-1 ', + description: '', + namespace: 'updated_namespace', + policy_id: agentPolicyId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }); + + const { item: policy } = await getPackagePolicyById(packagePolicyId); + + expect(policy.name).to.equal('filetest-1'); + }); + it('should work with valid values on hosted policies', async function () { await supertest .put(`/api/fleet/package_policies/${packagePolicyId}`) diff --git a/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts b/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts index 26efa4248850b..382449e5e2586 100644 --- a/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts +++ b/x-pack/test/functional/apps/dashboard/dashboard_lens_by_value.ts @@ -11,7 +11,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getPageObjects, getService }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens', 'timePicker']); - const find = getService('find'); const esArchiver = getService('esArchiver'); const testSubjects = getService('testSubjects'); const dashboardPanelActions = getService('dashboardPanelActions'); @@ -49,8 +48,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.lens.saveAndReturn(); await PageObjects.dashboard.waitForRenderComplete(); - const pieExists = await find.existsByCssSelector('.lnsPieExpression__container'); - expect(pieExists).to.be(true); + const partitionVisExists = await testSubjects.exists('partitionVisChart'); + expect(partitionVisExists).to.be(true); }); it('editing and saving a lens by value panel retains number of panels', async () => { diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts index f6692a2edb3bf..1d2d3f6862e43 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/time_to_visualize_security.ts @@ -103,8 +103,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await PageObjects.lens.saveAndReturn(); await PageObjects.dashboard.waitForRenderComplete(); - const pieExists = await find.existsByCssSelector('.lnsPieExpression__container'); - expect(pieExists).to.be(true); + const partitionVisExists = await testSubjects.exists('partitionVisChart'); + expect(partitionVisExists).to.be(true); }); it('disables save to library button without visualize save permissions', async () => { diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 19bd3510c9527..45c53ea18a601 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -76,6 +76,7 @@ export default function ({ getService, loadTestFile, getPageObjects }: FtrProvid loadTestFile(require.resolve('./error_handling')); loadTestFile(require.resolve('./lens_tagging')); loadTestFile(require.resolve('./lens_reporting')); + loadTestFile(require.resolve('./tsvb_open_in_lens')); // has to be last one in the suite because it overrides saved objects loadTestFile(require.resolve('./rollup')); }); diff --git a/x-pack/test/functional/apps/lens/tsvb_open_in_lens.ts b/x-pack/test/functional/apps/lens/tsvb_open_in_lens.ts new file mode 100644 index 0000000000000..0856fbb4ff1ec --- /dev/null +++ b/x-pack/test/functional/apps/lens/tsvb_open_in_lens.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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const { visualize, visualBuilder, header, lens, timeToVisualize, dashboard, canvas } = + getPageObjects([ + 'visualBuilder', + 'visualize', + 'header', + 'lens', + 'timeToVisualize', + 'dashboard', + 'canvas', + ]); + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const panelActions = getService('dashboardPanelActions'); + const retry = getService('retry'); + const filterBar = getService('filterBar'); + const queryBar = getService('queryBar'); + + describe('TSVB to Lens', function describeIndexTests() { + before(async () => { + await visualize.initTests(); + }); + + describe('Time Series', () => { + it('should show the "Edit Visualization in Lens" menu item for a count aggregation', async () => { + await visualize.navigateToNewVisualization(); + await visualize.clickVisualBuilder(); + await visualBuilder.checkVisualBuilderIsPresent(); + await visualBuilder.resetPage(); + const isMenuItemVisible = await find.existsByCssSelector( + '[data-test-subj="visualizeEditInLensButton"]' + ); + expect(isMenuItemVisible).to.be(true); + }); + + it('visualizes field to Lens and loads fields to the dimesion editor', async () => { + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + await retry.try(async () => { + const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); + expect(dimensions).to.have.length(2); + expect(await dimensions[0].getVisibleText()).to.be('@timestamp'); + expect(await dimensions[1].getVisibleText()).to.be('Count of records'); + }); + }); + + it('navigates back to TSVB when the Back button is clicked', async () => { + const goBackBtn = await testSubjects.find('lnsApp_goBackToAppButton'); + goBackBtn.click(); + await visualBuilder.checkVisualBuilderIsPresent(); + await retry.try(async () => { + const actualCount = await visualBuilder.getRhythmChartLegendValue(); + expect(actualCount).to.be('56'); + }); + }); + + it('should preserve app filters in lens', async () => { + await filterBar.addFilter('extension', 'is', 'css'); + await header.waitUntilLoadingHasFinished(); + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + + expect(await filterBar.hasFilter('extension', 'css')).to.be(true); + }); + + it('should preserve query in lens', async () => { + const goBackBtn = await testSubjects.find('lnsApp_goBackToAppButton'); + goBackBtn.click(); + await visualBuilder.checkVisualBuilderIsPresent(); + await queryBar.setQuery('machine.os : ios'); + await queryBar.submitQuery(); + await header.waitUntilLoadingHasFinished(); + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + + expect(await queryBar.getQueryString()).to.equal('machine.os : ios'); + }); + }); + + describe('Metric', () => { + beforeEach(async () => { + await visualize.navigateToNewVisualization(); + await visualize.clickVisualBuilder(); + await visualBuilder.checkVisualBuilderIsPresent(); + await visualBuilder.resetPage(); + await visualBuilder.clickMetric(); + await visualBuilder.clickDataTab('metric'); + }); + + it('should hide the "Edit Visualization in Lens" menu item', async () => { + const button = await testSubjects.exists('visualizeEditInLensButton'); + expect(button).to.eql(false); + }); + }); + + describe('Dashboard to TSVB to Lens', () => { + it('should convert a by value TSVB viz to a Lens viz', async () => { + await visualize.navigateToNewVisualization(); + await visualize.clickVisualBuilder(); + await visualBuilder.checkVisualBuilderIsPresent(); + await visualBuilder.resetPage(); + await testSubjects.click('visualizeSaveButton'); + + await timeToVisualize.saveFromModal('My TSVB to Lens viz 1', { + addToDashboard: 'new', + saveToLibrary: false, + }); + + await dashboard.waitForRenderComplete(); + const originalEmbeddableCount = await canvas.getEmbeddableCount(); + await panelActions.openContextMenu(); + await panelActions.clickEdit(); + + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + await retry.try(async () => { + const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); + expect(await dimensions[1].getVisibleText()).to.be('Count of records'); + }); + + await lens.saveAndReturn(); + await retry.try(async () => { + const embeddableCount = await canvas.getEmbeddableCount(); + expect(embeddableCount).to.eql(originalEmbeddableCount); + }); + await panelActions.removePanel(); + }); + + it('should convert a by reference TSVB viz to a Lens viz', async () => { + await dashboardAddPanel.clickEditorMenuButton(); + await dashboardAddPanel.clickVisType('metrics'); + await testSubjects.click('visualizesaveAndReturnButton'); + // save it to library + const originalPanel = await testSubjects.find('embeddablePanelHeading-'); + await panelActions.saveToLibrary('My TSVB to Lens viz 2', originalPanel); + + await dashboard.waitForRenderComplete(); + const originalEmbeddableCount = await canvas.getEmbeddableCount(); + await panelActions.openContextMenu(); + await panelActions.clickEdit(); + + const button = await testSubjects.find('visualizeEditInLensButton'); + await button.click(); + await lens.waitForVisualization(); + await retry.try(async () => { + const dimensions = await testSubjects.findAll('lns-dimensionTrigger'); + expect(await dimensions[1].getVisibleText()).to.be('Count of records'); + }); + + await lens.saveAndReturn(); + await retry.try(async () => { + const embeddableCount = await canvas.getEmbeddableCount(); + expect(embeddableCount).to.eql(originalEmbeddableCount); + }); + + const panel = await testSubjects.find(`embeddablePanelHeading-`); + const descendants = await testSubjects.findAllDescendant( + 'embeddablePanelNotification-ACTION_LIBRARY_NOTIFICATION', + panel + ); + expect(descendants.length).to.equal(0); + + await panelActions.removePanel(); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/model_management/model_list.ts b/x-pack/test/functional/apps/ml/model_management/model_list.ts index aef8f1d95302d..08fb3b7124aec 100644 --- a/x-pack/test/functional/apps/ml/model_management/model_list.ts +++ b/x-pack/test/functional/apps/ml/model_management/model_list.ts @@ -14,8 +14,6 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await ml.trainedModels.createTestTrainedModels('classification', 15, true); await ml.trainedModels.createTestTrainedModels('regression', 15); - await ml.securityUI.loginAsMlPowerUser(); - await ml.navigation.navigateToTrainedModels(); }); after(async () => { @@ -46,6 +44,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await ml.securityUI.loginAsMlPowerUser(); await ml.navigation.navigateToTrainedModels(); + await ml.commonUI.waitForRefreshButtonEnabled(); }); after(async () => { @@ -173,6 +172,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await ml.securityUI.loginAsMlViewer(); await ml.navigation.navigateToTrainedModels(); + await ml.commonUI.waitForRefreshButtonEnabled(); }); after(async () => { diff --git a/x-pack/test/functional/services/ml/common_ui.ts b/x-pack/test/functional/services/ml/common_ui.ts index d6b75f53578a8..a97c25b2fcbbf 100644 --- a/x-pack/test/functional/services/ml/common_ui.ts +++ b/x-pack/test/functional/services/ml/common_ui.ts @@ -338,5 +338,9 @@ export function MachineLearningCommonUIProvider({ async waitForDatePickerIndicatorLoaded() { await testSubjects.waitForEnabled('superDatePickerApplyTimeButton'); }, + + async waitForRefreshButtonEnabled() { + await testSubjects.waitForEnabled('~mlRefreshPageButton'); + }, }; } diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index f7fd5efefda33..f0cb2da9efdde 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -124,7 +124,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { const alerting = MachineLearningAlertingProvider(context, commonUI); const swimLane = SwimLaneProvider(context); const trainedModels = TrainedModelsProvider(context, api, commonUI); - const trainedModelsTable = TrainedModelsTableProvider(context); + const trainedModelsTable = TrainedModelsTableProvider(context, commonUI); const mlNodesPanel = MlNodesPanelProvider(context); return { diff --git a/x-pack/test/functional/services/ml/trained_models_table.ts b/x-pack/test/functional/services/ml/trained_models_table.ts index 1f35d7d1f6d39..3eed354aca4c1 100644 --- a/x-pack/test/functional/services/ml/trained_models_table.ts +++ b/x-pack/test/functional/services/ml/trained_models_table.ts @@ -10,7 +10,8 @@ import { ProvidedType } from '@kbn/test'; import { upperFirst } from 'lodash'; import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import type { FtrProviderContext } from '../../ftr_provider_context'; +import type { MlCommonUI } from './common_ui'; export interface TrainedModelRowData { id: string; @@ -20,7 +21,10 @@ export interface TrainedModelRowData { export type MlTrainedModelsTable = ProvidedType; -export function TrainedModelsTableProvider({ getService }: FtrProviderContext) { +export function TrainedModelsTableProvider( + { getService }: FtrProviderContext, + mlCommonUI: MlCommonUI +) { const testSubjects = getService('testSubjects'); const retry = getService('retry'); @@ -218,6 +222,7 @@ export function TrainedModelsTableProvider({ getService }: FtrProviderContext) { await testSubjects.existOrFail('mlTrainedModelRowDetails', { timeout: 1000 }); } }); + await mlCommonUI.waitForRefreshButtonEnabled(); } public async assertTabContent( diff --git a/x-pack/test/osquery_cypress/artifact_manager.ts b/x-pack/test/osquery_cypress/artifact_manager.ts index 498d873747185..4eaf16a33b629 100644 --- a/x-pack/test/osquery_cypress/artifact_manager.ts +++ b/x-pack/test/osquery_cypress/artifact_manager.ts @@ -5,11 +5,10 @@ * 2.0. */ -// import axios from 'axios'; -// import { last } from 'lodash'; +import axios from 'axios'; +import { last } from 'lodash'; export async function getLatestVersion(): Promise { - return '8.0.0-SNAPSHOT'; - // const response: any = await axios('https://artifacts-api.elastic.co/v1/versions'); - // return last(response.data.versions as string[]) || '8.1.0-SNAPSHOT'; + const response: any = await axios('https://artifacts-api.elastic.co/v1/versions'); + return last(response.data.versions as string[]) || '8.2.0-SNAPSHOT'; } diff --git a/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/geographic_data.ts b/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/geographic_data.ts index 868e649950ba5..7f9da26466414 100644 --- a/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/geographic_data.ts +++ b/x-pack/test/screenshot_creation/apps/ml_docs/anomaly_detection/geographic_data.ts @@ -266,7 +266,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { await ml.testExecution.logTestStep('select swim lane tile'); const cells = await ml.swimLane.getCells(overallSwimLaneTestSubj); const sampleCell1 = cells[11]; - const sampleCell2 = cells[12]; + const sampleCell2 = cells[cells.length - 1]; await ml.swimLane.selectCells(overallSwimLaneTestSubj, { x1: sampleCell1.x + cellSize, y1: sampleCell1.y + cellSize, @@ -281,6 +281,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) { // clickFitToData only works with displayed legend await maps.openLegend(); await maps.clickFitToData(); + await ml.anomalyExplorer.scrollChartsContainerIntoView(); await maps.closeLegend(); await mlScreenshots.takeScreenshot( diff --git a/yarn.lock b/yarn.lock index 296142627216f..cb5038e3f3d72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@ampproject/remapping@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.1.1.tgz#7922fb0817bf3166d8d9e258c57477e3fd1c3610" + integrity sha512-Aolwjd7HSC2PyY0fDj/wA/EimQT4HfEnFYNp5s9CQlrdhyvWTtvZ5YzrUPu6R6/1jKiUlxu8bUhkdSnKHNAHMA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.0" + "@apidevtools/json-schema-ref-parser@^9.0.6": version "9.0.9" resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#d720f9256e3609621280584f2b47ae165359268b" @@ -34,10 +41,10 @@ call-me-maybe "^1.0.1" z-schema "^5.0.1" -"@babel/cli@^7.16.8": - version "7.16.8" - resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.16.8.tgz#44b9be7706762bfa3bff8adbf746da336eb0ab7c" - integrity sha512-FTKBbxyk5TclXOGmwYyqelqP5IF6hMxaeJskd85jbR5jBfYlwqgwAbJwnixi1ZBbTqKfFuAA95mdmUFeSRwyJA== +"@babel/cli@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.17.0.tgz#9b932d8f08a2e218fcdd9bba456044eb0a2e0b2c" + integrity sha512-es10YH/ejXbg551vtnmEzIPe3MQRNOS644o3pf8vUr1tIeNzVNlP8BBvs1Eh7roh5A+k2fEHUas+ZptOWHA1fQ== dependencies: commander "^4.0.1" convert-source-map "^1.1.0" @@ -136,31 +143,31 @@ semver "^6.3.0" source-map "^0.5.0" -"@babel/core@^7.16.12": - version "7.16.12" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.16.12.tgz#5edc53c1b71e54881315923ae2aedea2522bb784" - integrity sha512-dK5PtG1uiN2ikk++5OzSYsitZKny4wOCD0nrO4TqnW4BVBTQ2NGS3NgilvT/TEyxTST7LNyWV/T4tXDoD3fOgg== +"@babel/core@^7.17.2": + version "7.17.2" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.17.2.tgz#2c77fc430e95139d816d39b113b31bf40fb22337" + integrity sha512-R3VH5G42VSDolRHyUO4V2cfag8WHcZyxdq5Z/m8Xyb92lW/Erm/6kM+XtRFGf3Mulre3mveni2NHfEUws8wSvw== dependencies: + "@ampproject/remapping" "^2.0.0" "@babel/code-frame" "^7.16.7" - "@babel/generator" "^7.16.8" + "@babel/generator" "^7.17.0" "@babel/helper-compilation-targets" "^7.16.7" "@babel/helper-module-transforms" "^7.16.7" - "@babel/helpers" "^7.16.7" - "@babel/parser" "^7.16.12" + "@babel/helpers" "^7.17.2" + "@babel/parser" "^7.17.0" "@babel/template" "^7.16.7" - "@babel/traverse" "^7.16.10" - "@babel/types" "^7.16.8" + "@babel/traverse" "^7.17.0" + "@babel/types" "^7.17.0" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" json5 "^2.1.2" semver "^6.3.0" - source-map "^0.5.0" -"@babel/eslint-parser@^7.16.5": - version "7.16.5" - resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.16.5.tgz#48d3485091d6e36915358e4c0d0b2ebe6da90462" - integrity sha512-mUqYa46lgWqHKQ33Q6LNCGp/wPR3eqOYTUixHFsfrSQqRxH0+WOzca75iEjFr5RDGH1dDz622LaHhLOzOuQRUA== +"@babel/eslint-parser@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.17.0.tgz#eabb24ad9f0afa80e5849f8240d0e5facc2d90d6" + integrity sha512-PUEJ7ZBXbRkbq3qqM/jZ2nIuakUBqCYc7Qf52Lj7dlZ6zERnqisdHioL0l4wwQZnmskMeasqUNzLBFKs3nylXA== dependencies: eslint-scope "^5.1.1" eslint-visitor-keys "^2.1.0" @@ -191,6 +198,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.17.0.tgz#7bd890ba706cd86d3e2f727322346ffdbf98f65e" + integrity sha512-I3Omiv6FGOC29dtlZhkfXO6pgkmukJSlT26QjVvS1DGZe/NzSVCPG41X0tS21oZkJYlovfj9qDWgKP+Cn4bXxw== + dependencies: + "@babel/types" "^7.17.0" + jsesc "^2.5.1" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.0.0", "@babel/helper-annotate-as-pure@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.0.tgz#9a1f0ebcda53d9a2d00108c4ceace6a5d5f1f08d" @@ -608,14 +624,14 @@ "@babel/traverse" "^7.16.3" "@babel/types" "^7.16.0" -"@babel/helpers@^7.16.7": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.16.7.tgz#7e3504d708d50344112767c3542fc5e357fffefc" - integrity sha512-9ZDoqtfY7AuEOt3cxchfii6C7GDyyMBffktR5B2jvWv8u2+efwvpnVKXMWzNehqy68tKgAfSwfdw/lWpthS2bw== +"@babel/helpers@^7.17.2": + version "7.17.2" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.17.2.tgz#23f0a0746c8e287773ccd27c14be428891f63417" + integrity sha512-0Qu7RLR1dILozr/6M0xgj+DFPmi6Bnulgm9M8BVa9ZCWxDqlSnqt3cf8IDPB5m45sVXUZ0kuQAgUrdSFFH79fQ== dependencies: "@babel/template" "^7.16.7" - "@babel/traverse" "^7.16.7" - "@babel/types" "^7.16.7" + "@babel/traverse" "^7.17.0" + "@babel/types" "^7.17.0" "@babel/highlight@^7.10.4", "@babel/highlight@^7.16.0": version "7.16.0" @@ -640,11 +656,16 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.16.4.tgz#d5f92f57cf2c74ffe9b37981c0e72fee7311372e" integrity sha512-6V0qdPUaiVHH3RtZeLIsc+6pDhbYzHR8ogA8w+f+Wc77DuXto19g2QUwveINoS34Uw+W8/hQDGJCx+i4n7xcng== -"@babel/parser@^7.16.10", "@babel/parser@^7.16.12", "@babel/parser@^7.16.7": +"@babel/parser@^7.16.10", "@babel/parser@^7.16.7": version "7.16.12" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.16.12.tgz#9474794f9a650cf5e2f892444227f98e28cdf8b6" integrity sha512-VfaV15po8RiZssrkPweyvbGVSe4x2y+aciFCgn0n0/SJMR22cwofRV1mtnJQYcSB1wUTaA/X1LnA3es66MCO5A== +"@babel/parser@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.17.0.tgz#f0ac33eddbe214e4105363bb17c3341c5ffcc43c" + integrity sha512-VKXSCQx5D8S04ej+Dqsr1CzYvvWgf20jIw2D+YhQCrIlr2UZGaDds23Y0xg75/skOxpLCRpUZvk/1EAVkGoDOw== + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.16.2": version "7.16.2" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.16.2.tgz#2977fca9b212db153c195674e57cfab807733183" @@ -1609,10 +1630,10 @@ babel-plugin-polyfill-regenerator "^0.3.0" semver "^6.3.0" -"@babel/plugin-transform-runtime@^7.16.10": - version "7.16.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.16.10.tgz#53d9fd3496daedce1dd99639097fa5d14f4c7c2c" - integrity sha512-9nwTiqETv2G7xI4RvXHNfpGdr8pAA+Q/YtN3yLK7OoK7n9OibVm/xymJ838a9A6E/IciOLPj82lZk0fW6O4O7w== +"@babel/plugin-transform-runtime@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.17.0.tgz#0a2e08b5e2b2d95c4b1d3b3371a2180617455b70" + integrity sha512-fr7zPWnKXNc1xoHfrIU9mN/4XKX4VLZ45Q+oMhfsYIaHvg7mHgmhfOy/ckRWqDK7XF3QDigRpkh5DKq6+clE8A== dependencies: "@babel/helper-module-imports" "^7.16.7" "@babel/helper-plugin-utils" "^7.16.7" @@ -1973,15 +1994,15 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/register@^7.16.9": - version "7.16.9" - resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.16.9.tgz#fcfb23cfdd9ad95c9771e58183de83b513857806" - integrity sha512-jJ72wcghdRIlENfvALcyODhNoGE5j75cYHdC+aQMh6cU/P86tiiXTp9XYZct1UxUMo/4+BgQRyNZEGx0KWGS+g== +"@babel/register@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/register/-/register-7.17.0.tgz#8051e0b7cb71385be4909324f072599723a1f084" + integrity sha512-UNZsMAZ7uKoGHo1HlEXfteEOYssf64n/PNLHGqOKq/bgYcu/4LrQWAHJwSCb3BRZK8Hi5gkJdRcwrGTO2wtRCg== dependencies: clone-deep "^4.0.1" find-cache-dir "^2.0.0" make-dir "^2.1.0" - pirates "^4.0.0" + pirates "^4.0.5" source-map-support "^0.5.16" "@babel/runtime-corejs3@^7.10.2": @@ -1992,10 +2013,10 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.0", "@babel/runtime@^7.16.0", "@babel/runtime@^7.16.7", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.16.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" - integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.0", "@babel/runtime@^7.16.0", "@babel/runtime@^7.17.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + version "7.17.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.2.tgz#66f68591605e59da47523c631416b18508779941" + integrity sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw== dependencies: regenerator-runtime "^0.13.4" @@ -2032,7 +2053,7 @@ debug "^4.1.0" globals "^11.1.0" -"@babel/traverse@^7.16.10", "@babel/traverse@^7.16.7", "@babel/traverse@^7.16.8": +"@babel/traverse@^7.16.7", "@babel/traverse@^7.16.8": version "7.16.10" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.16.10.tgz#448f940defbe95b5a8029975b051f75993e8239f" integrity sha512-yzuaYXoRJBGMlBhsMJoUW7G1UmSb/eXr/JHYM/MsOJgavJibLwASijW7oXBdw3NQ6T0bW7Ty5P/VarOs9cHmqw== @@ -2048,6 +2069,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.17.0.tgz#3143e5066796408ccc880a33ecd3184f3e75cd30" + integrity sha512-fpFIXvqD6kC7c7PUNnZ0Z8cQXlarCLtCUpt2S1Dx7PjoRtCFffvOkHHSom+m5HIxMZn5bIBVb71lhabcmjEsqg== + dependencies: + "@babel/code-frame" "^7.16.7" + "@babel/generator" "^7.17.0" + "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-function-name" "^7.16.7" + "@babel/helper-hoist-variables" "^7.16.7" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/parser" "^7.17.0" + "@babel/types" "^7.17.0" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.16.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.16.0.tgz#db3b313804f96aadd0b776c4823e127ad67289ba" @@ -2064,6 +2101,14 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" +"@babel/types@^7.17.0": + version "7.17.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.17.0.tgz#a826e368bccb6b3d84acd76acad5c0d87342390b" + integrity sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw== + dependencies: + "@babel/helper-validator-identifier" "^7.16.7" + to-fast-properties "^2.0.0" + "@base2/pretty-print-object@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" @@ -3724,6 +3769,24 @@ "@babel/runtime" "^7.7.2" regenerator-runtime "^0.13.3" +"@jridgewell/resolve-uri@^3.0.3": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz#68eb521368db76d040a6315cdb24bf2483037b9c" + integrity sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.11" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz#771a1d8d744eeb71b6adb35808e1a6c7b9b8c8ec" + integrity sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg== + +"@jridgewell/trace-mapping@^0.3.0": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.4.tgz#f6a0832dffd5b8a6aaa633b7d9f8e8e94c83a0c3" + integrity sha512-vFv9ttIedivx0ux3QSjhgtCVjPZd5l46ZOMDSCwnH1yUO2e964gO8LZGyv2QkqcgR6TnBU1v+1IFqmeoG+0UJQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jsdevtools/ono@^7.1.3": version "7.1.3" resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" @@ -13579,10 +13642,10 @@ elastic-apm-http-client@^10.4.0: semver "^6.3.0" stream-chopper "^3.0.1" -elastic-apm-node@^3.28.0: - version "3.28.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.28.0.tgz#74bb0278a711549a45bd8c8b561c23e02e5865e3" - integrity sha512-6P6IAiozEIUDCZGMQ/Lul1c7a10p5uSIIYISxXd7ms+470fvTOL2pKDfE8ygeXyCvvzwjbvuTQC4y4PWKhj8rg== +elastic-apm-node@^3.29.0: + version "3.29.0" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.29.0.tgz#3e828405adb9e91ed66bb30780268cc30703f46a" + integrity sha512-tPZKoeIJus8mCYXbIcr+jtsU56EQmmUJ+FvcCopp1zB9mCBLrsqdnJ1oXApLmwMAdWn3IpClO1DZi4gmuRNrEA== dependencies: "@elastic/ecs-pino-format" "^1.2.0" after-all-results "^2.0.0" @@ -22967,6 +23030,11 @@ pirates@^4.0.0, pirates@^4.0.1: dependencies: node-modules-regexp "^1.0.0" +pirates@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" + integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== + pixelmatch@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-4.0.2.tgz#8f47dcec5011b477b67db03c243bc1f3085e8854"