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/.eslintrc.js b/.eslintrc.js index ce7e2dea0a14f..6c98a016469f7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -886,6 +886,18 @@ module.exports = { ], }, }, + { + // require explicit return types in route handlers for performance reasons + files: ['x-pack/plugins/apm/server/**/route.ts'], + rules: { + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { + allowTypedFunctionExpressions: false, + }, + ], + }, + }, /** * Fleet overrides 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/package.json b/package.json index c36cafc1d0527..38272ae2ab010 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "yarn": "^1.21.1" }, "resolutions": { - "**/@babel/runtime": "^7.16.7", + "**/@babel/runtime": "^7.17.2", "**/@types/node": "16.10.2", "**/chokidar": "^3.4.3", "**/deepmerge": "^4.2.2", @@ -96,7 +96,7 @@ "globby/fast-glob": "3.2.7" }, "dependencies": { - "@babel/runtime": "^7.16.7", + "@babel/runtime": "^7.17.2", "@dnd-kit/core": "^3.1.1", "@dnd-kit/sortable": "^4.0.0", "@dnd-kit/utilities": "^2.0.0", @@ -231,7 +231,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.28.0", + "elastic-apm-node": "^3.29.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "expiry-js": "0.1.7", @@ -423,25 +423,25 @@ }, "devDependencies": { "@apidevtools/swagger-parser": "^10.0.3", - "@babel/cli": "^7.16.8", - "@babel/core": "^7.16.12", - "@babel/eslint-parser": "^7.16.5", + "@babel/cli": "^7.17.0", + "@babel/core": "^7.17.2", + "@babel/eslint-parser": "^7.17.0", "@babel/eslint-plugin": "^7.16.5", - "@babel/generator": "^7.16.8", - "@babel/parser": "^7.16.12", + "@babel/generator": "^7.17.0", + "@babel/parser": "^7.17.0", "@babel/plugin-proposal-class-properties": "^7.16.7", "@babel/plugin-proposal-export-namespace-from": "^7.16.7", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.7", "@babel/plugin-proposal-object-rest-spread": "^7.16.7", "@babel/plugin-proposal-optional-chaining": "^7.16.7", "@babel/plugin-proposal-private-methods": "^7.16.11", - "@babel/plugin-transform-runtime": "^7.16.10", + "@babel/plugin-transform-runtime": "^7.17.0", "@babel/preset-env": "^7.16.11", "@babel/preset-react": "^7.16.7", "@babel/preset-typescript": "^7.16.7", - "@babel/register": "^7.16.9", - "@babel/traverse": "^7.16.10", - "@babel/types": "^7.16.8", + "@babel/register": "^7.17.0", + "@babel/traverse": "^7.17.0", + "@babel/types": "^7.17.0", "@bazel/ibazel": "^0.15.10", "@bazel/typescript": "4.0.0", "@cypress/code-coverage": "^3.9.12", diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_client.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_client.ts new file mode 100644 index 0000000000000..77b3769fe62c1 --- /dev/null +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_client.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 Axios from 'axios'; +import { ToolingLog } from '../tooling_log'; + +import { parseConfig, Config } from './ci_stats_config'; +import { CiStatsMetadata } from './ci_stats_metadata'; + +interface LatestTestGroupStatsOptions { + /** The Kibana branch to get stats for, eg "main" */ + branch: string; + /** The CI job names to filter builds by, eg "kibana-hourly" */ + ciJobNames: string[]; + /** Filter test groups by group type */ + testGroupType?: string; +} + +interface CompleteSuccessBuildSource { + jobName: string; + jobRunner: string; + completedAt: string; + commit: string; + startedAt: string; + branch: string; + result: 'SUCCESS'; + jobId: string; + targetBranch: string | null; + fromKibanaCiProduction: boolean; + requiresValidMetrics: boolean | null; + jobUrl: string; + mergeBase: string | null; +} + +interface TestGroupSource { + '@timestamp': string; + buildId: string; + name: string; + type: string; + startTime: string; + durationMs: number; + meta: CiStatsMetadata; +} + +interface LatestTestGroupStatsResp { + build: CompleteSuccessBuildSource & { id: string }; + testGroups: Array; +} + +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/console/public/lib/autocomplete/components/component_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js new file mode 100644 index 0000000000000..ca59e077116e4 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getComponentTemplates } from '../../mappings/mappings'; +import { ListComponent } from './list_component'; + +export class ComponentTemplateAutocompleteComponent extends ListComponent { + constructor(name, parent) { + super(name, getComponentTemplates, parent, true, true); + } + + getContextKey() { + return 'component_template'; + } +} diff --git a/src/plugins/console/public/lib/autocomplete/components/index.js b/src/plugins/console/public/lib/autocomplete/components/index.js index 0e651aefa1678..32078ee2c1519 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index.js +++ b/src/plugins/console/public/lib/autocomplete/components/index.js @@ -20,5 +20,7 @@ export { IndexAutocompleteComponent } from './index_autocomplete_component'; export { FieldAutocompleteComponent } from './field_autocomplete_component'; export { TypeAutocompleteComponent } from './type_autocomplete_component'; export { IdAutocompleteComponent } from './id_autocomplete_component'; -export { TemplateAutocompleteComponent } from './template_autocomplete_component'; export { UsernameAutocompleteComponent } from './username_autocomplete_component'; +export { IndexTemplateAutocompleteComponent } from './index_template_autocomplete_component'; +export { ComponentTemplateAutocompleteComponent } from './component_template_autocomplete_component'; +export * from './legacy'; diff --git a/src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js similarity index 68% rename from src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js rename to src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js index 40ebd6b4c55fb..444e40e756f7b 100644 --- a/src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js @@ -6,14 +6,15 @@ * Side Public License, v 1. */ -import { getTemplates } from '../../mappings/mappings'; +import { getIndexTemplates } from '../../mappings/mappings'; import { ListComponent } from './list_component'; -export class TemplateAutocompleteComponent extends ListComponent { +export class IndexTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getTemplates, parent, true, true); + super(name, getIndexTemplates, parent, true, true); } + getContextKey() { - return 'template'; + return 'index_template'; } } diff --git a/src/plugins/console/public/lib/autocomplete/components/legacy/index.js b/src/plugins/console/public/lib/autocomplete/components/legacy/index.js new file mode 100644 index 0000000000000..1e84cb05f5b80 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/components/legacy/index.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 { LegacyTemplateAutocompleteComponent } from './legacy_template_autocomplete_component'; diff --git a/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js new file mode 100644 index 0000000000000..b68ae952702f5 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js @@ -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 { getLegacyTemplates } from '../../../mappings/mappings'; +import { ListComponent } from '../list_component'; + +export class LegacyTemplateAutocompleteComponent extends ListComponent { + constructor(name, parent) { + super(name, getLegacyTemplates, parent, true, true); + } + getContextKey() { + return 'template'; + } +} diff --git a/src/plugins/console/public/lib/kb/kb.js b/src/plugins/console/public/lib/kb/kb.js index 199440bf6197a..5f02365a48fdf 100644 --- a/src/plugins/console/public/lib/kb/kb.js +++ b/src/plugins/console/public/lib/kb/kb.js @@ -12,8 +12,10 @@ import { IndexAutocompleteComponent, FieldAutocompleteComponent, ListComponent, - TemplateAutocompleteComponent, + LegacyTemplateAutocompleteComponent, UsernameAutocompleteComponent, + IndexTemplateAutocompleteComponent, + ComponentTemplateAutocompleteComponent, } from '../autocomplete/components'; import $ from 'jquery'; @@ -62,7 +64,7 @@ const parametrizedComponentFactories = { return new UsernameAutocompleteComponent(name, parent); }, template: function (name, parent) { - return new TemplateAutocompleteComponent(name, parent); + return new LegacyTemplateAutocompleteComponent(name, parent); }, task_id: function (name, parent) { return idAutocompleteComponentFactory(name, parent); @@ -86,6 +88,12 @@ const parametrizedComponentFactories = { node: function (name, parent) { return new ListComponent(name, [], parent, false); }, + index_template: function (name, parent) { + return new IndexTemplateAutocompleteComponent(name, parent); + }, + component_template: function (name, parent) { + return new ComponentTemplateAutocompleteComponent(name, parent); + }, }; export function getUnmatchedEndpointComponents() { diff --git a/src/plugins/console/public/lib/mappings/mapping.test.js b/src/plugins/console/public/lib/mappings/mapping.test.js index b694b8c3936fc..9191eb736be3c 100644 --- a/src/plugins/console/public/lib/mappings/mapping.test.js +++ b/src/plugins/console/public/lib/mappings/mapping.test.js @@ -240,4 +240,30 @@ describe('Mappings', () => { ]); expect(mappings.expandAliases('alias2')).toEqual('test_index2'); }); + + test('Templates', function () { + mappings.loadLegacyTemplates({ + test_index1: { order: 0 }, + test_index2: { order: 0 }, + test_index3: { order: 0 }, + }); + + mappings.loadIndexTemplates({ + index_templates: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], + }); + + mappings.loadComponentTemplates({ + component_templates: [ + { name: 'test_index1' }, + { name: 'test_index2' }, + { name: 'test_index3' }, + ], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + + expect(mappings.getLegacyTemplates()).toEqual(expectedResult); + expect(mappings.getIndexTemplates()).toEqual(expectedResult); + expect(mappings.getComponentTemplates()).toEqual(expectedResult); + }); }); diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js index 84e818f177d63..75b8a263e8690 100644 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ b/src/plugins/console/public/lib/mappings/mappings.js @@ -14,7 +14,9 @@ let pollTimeoutId; let perIndexTypes = {}; let perAliasIndexes = []; -let templates = []; +let legacyTemplates = []; +let indexTemplates = []; +let componentTemplates = []; const mappingObj = {}; @@ -46,8 +48,16 @@ export function expandAliases(indicesOrAliases) { return ret.length > 1 ? ret : ret[0]; } -export function getTemplates() { - return [...templates]; +export function getLegacyTemplates() { + return [...legacyTemplates]; +} + +export function getIndexTemplates() { + return [...indexTemplates]; +} + +export function getComponentTemplates() { + return [...componentTemplates]; } export function getFields(indices, types) { @@ -182,8 +192,16 @@ function getFieldNamesFromProperties(properties = {}) { }); } -function loadTemplates(templatesObject = {}) { - templates = Object.keys(templatesObject); +export function loadLegacyTemplates(templatesObject = {}) { + legacyTemplates = Object.keys(templatesObject); +} + +export function loadIndexTemplates(data) { + indexTemplates = (data.index_templates ?? []).map(({ name }) => name); +} + +export function loadComponentTemplates(data) { + componentTemplates = (data.component_templates ?? []).map(({ name }) => name); } export function loadMappings(mappings) { @@ -235,14 +253,18 @@ export function loadAliases(aliases) { export function clear() { perIndexTypes = {}; perAliasIndexes = {}; - templates = []; + legacyTemplates = []; + indexTemplates = []; + componentTemplates = []; } function retrieveSettings(settingsKey, settingsToRetrieve) { const settingKeyToPathMap = { fields: '_mapping', indices: '_aliases', - templates: '_template', + legacyTemplates: '_template', + indexTemplates: '_index_template', + componentTemplates: '_component_template', }; // Fetch autocomplete info if setting is set to true, and if user has made changes. @@ -289,36 +311,66 @@ export function clearSubscriptions() { export function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { clearSubscriptions(); + const templatesSettingToRetrieve = { + ...settingsToRetrieve, + legacyTemplates: settingsToRetrieve.templates, + indexTemplates: settingsToRetrieve.templates, + componentTemplates: settingsToRetrieve.templates, + }; + const mappingPromise = retrieveSettings('fields', settingsToRetrieve); const aliasesPromise = retrieveSettings('indices', settingsToRetrieve); - const templatesPromise = retrieveSettings('templates', settingsToRetrieve); - - $.when(mappingPromise, aliasesPromise, templatesPromise).done((mappings, aliases, templates) => { + const legacyTemplatesPromise = retrieveSettings('legacyTemplates', templatesSettingToRetrieve); + const indexTemplatesPromise = retrieveSettings('indexTemplates', templatesSettingToRetrieve); + const componentTemplatesPromise = retrieveSettings( + 'componentTemplates', + templatesSettingToRetrieve + ); + + $.when( + mappingPromise, + aliasesPromise, + legacyTemplatesPromise, + indexTemplatesPromise, + componentTemplatesPromise + ).done((mappings, aliases, legacyTemplates, indexTemplates, componentTemplates) => { let mappingsResponse; - if (mappings) { - const maxMappingSize = mappings[0].length > 10 * 1024 * 1024; - if (maxMappingSize) { - console.warn( - `Mapping size is larger than 10MB (${mappings[0].length / 1024 / 1024} MB). Ignoring...` - ); - mappingsResponse = '[{}]'; - } else { - mappingsResponse = mappings[0]; + try { + if (mappings && mappings.length) { + const maxMappingSize = mappings[0].length > 10 * 1024 * 1024; + if (maxMappingSize) { + console.warn( + `Mapping size is larger than 10MB (${mappings[0].length / 1024 / 1024} MB). Ignoring...` + ); + mappingsResponse = '[{}]'; + } else { + mappingsResponse = mappings[0]; + } + loadMappings(JSON.parse(mappingsResponse)); } - loadMappings(JSON.parse(mappingsResponse)); - } - if (aliases) { - loadAliases(JSON.parse(aliases[0])); - } + if (aliases) { + loadAliases(JSON.parse(aliases[0])); + } - if (templates) { - loadTemplates(JSON.parse(templates[0])); - } + if (legacyTemplates) { + loadLegacyTemplates(JSON.parse(legacyTemplates[0])); + } - if (mappings && aliases) { - // Trigger an update event with the mappings, aliases - $(mappingObj).trigger('update', [mappingsResponse, aliases[0]]); + if (indexTemplates) { + loadIndexTemplates(JSON.parse(indexTemplates[0])); + } + + if (componentTemplates) { + loadComponentTemplates(JSON.parse(componentTemplates[0])); + } + + if (mappings && aliases) { + // Trigger an update event with the mappings, aliases + $(mappingObj).trigger('update', [mappingsResponse, aliases[0]]); + } + } catch (error) { + console.error(error); } // Schedule next request. diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json index 24255f7231892..400e064c3de9c 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json @@ -8,7 +8,7 @@ "DELETE" ], "patterns": [ - "_component_template/{name}" + "_component_template/{component_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-template.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.exists_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.exists_component_template.json index 24dcbeb006e6f..3157e1b8ccc7a 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.exists_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.exists_component_template.json @@ -8,7 +8,7 @@ "HEAD" ], "patterns": [ - "_component_template/{name}" + "_component_template/{component_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-template.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json index cbfed6741f8a4..e491ccf94bb64 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json @@ -10,7 +10,7 @@ ], "patterns": [ "_component_template", - "_component_template/{name}" + "_component_template/{component_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/getting-component-templates.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json index 999ff0c149fe8..31a94d098f604 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json @@ -9,7 +9,7 @@ "POST" ], "patterns": [ - "_component_template/{name}" + "_component_template/{component_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-template.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json index ef3f836207f17..5d6d53e1d1d6f 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json @@ -8,7 +8,7 @@ "DELETE" ], "patterns": [ - "_index_template/{name}" + "_index_template/{index_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json index 97fa8cf55576f..d1c5d9d617f8f 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json @@ -9,7 +9,7 @@ "HEAD" ], "patterns": [ - "_index_template/{name}" + "_index_template/{index_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json index 142b75f22c2a6..3d91424f4ce3b 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json @@ -10,7 +10,7 @@ ], "patterns": [ "_index_template", - "_index_template/{name}" + "_index_template/{index_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json index 0ce27c1d9d21e..fcae7af55b4ee 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json @@ -10,7 +10,7 @@ "POST" ], "patterns": [ - "_index_template/{name}" + "_index_template/{index_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html" } 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/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap index c55583679f264..42fa495dfb4cc 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap @@ -62,11 +62,11 @@ exports[`NotFoundErrors component renders correctly for index-pattern type 1`] = >
- The index pattern associated with this object no longer exists. + The data view associated with this object no longer exists.
@@ -199,11 +199,11 @@ exports[`NotFoundErrors component renders correctly for index-pattern-field type >
- A field associated with this object no longer exists in the index pattern. + A field associated with this object no longer exists in the data view.
diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx index 9ef69b5cef2d2..dd3d29ead6438 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx @@ -34,7 +34,7 @@ describe('NotFoundErrors component', () => { const callOut = mounted.find('EuiCallOut'); expect(callOut).toMatchSnapshot(); expect(mounted.text()).toMatchInlineSnapshot( - `"There is a problem with this saved objectThe index pattern associated with this object no longer exists.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` + `"There is a problem with this saved objectThe data view associated with this object no longer exists.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` ); }); @@ -43,7 +43,7 @@ describe('NotFoundErrors component', () => { const callOut = mounted.find('EuiCallOut'); expect(callOut).toMatchSnapshot(); expect(mounted.text()).toMatchInlineSnapshot( - `"There is a problem with this saved objectA field associated with this object no longer exists in the index pattern.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` + `"There is a problem with this saved objectA field associated with this object no longer exists in the data view.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` ); }); diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx index ec2f345056d29..56a317b54c4fd 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx @@ -38,14 +38,14 @@ export const NotFoundErrors = ({ type, docLinks }: NotFoundErrors) => { return ( ); case 'index-pattern-field': return ( ); default: diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index d71c79398acae..dabcee37a8959 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -32,7 +32,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` iconType="help" title={ @@ -40,7 +40,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` >

@@ -63,7 +63,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` columns={ Array [ Object { - "description": "ID of the index pattern", + "description": "ID of the data view", "field": "existingIndexPatternId", "name": "ID", "sortable": true, @@ -82,7 +82,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` }, Object { "field": "existingIndexPatternId", - "name": "New index pattern", + "name": "New data view", "render": [Function], }, ] diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index 7b363109a6f3b..5b0408110fd85 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -287,7 +287,7 @@ export class Flyout extends Component { ), description: i18n.translate( 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription', - { defaultMessage: 'ID of the index pattern' } + { defaultMessage: 'ID of the data view' } ), sortable: true, }, @@ -329,7 +329,7 @@ export class Flyout extends Component { field: 'existingIndexPatternId', name: i18n.translate( 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName', - { defaultMessage: 'New index pattern' } + { defaultMessage: 'New data view' } ), render: (id: string) => { const options = [ @@ -573,7 +573,7 @@ export class Flyout extends Component { title={ } color="warning" @@ -582,15 +582,15 @@ export class Flyout extends Component {

), 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/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/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/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts index 5601ade671908..31586651cbb84 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts @@ -18,6 +18,71 @@ const baseUrl = url.format({ query: { rangeFrom: start, rangeTo: end }, }); +const apiRequestsToIntercept = [ + { + endpoint: + '/internal/apm/services/opbeans-node/transactions/groups/main_statistics?*', + aliasName: 'transactionsGroupsMainStadisticsRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/errors/groups/main_statistics?*', + aliasName: 'errorsGroupsMainStadisticsRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/transaction/charts/breakdown?*', + aliasName: 'transactionsBreakdownRequest', + }, + { + endpoint: '/internal/apm/services/opbeans-node/dependencies?*', + aliasName: 'dependenciesRequest', + }, +]; + +const apiRequestsToInterceptWithComparison = [ + { + endpoint: + '/internal/apm/services/opbeans-node/transactions/charts/latency?*', + aliasName: 'latencyRequest', + }, + { + endpoint: '/internal/apm/services/opbeans-node/throughput?*', + aliasName: 'throughputRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/transactions/charts/error_rate?*', + aliasName: 'errorRateRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/transactions/groups/detailed_statistics?*', + aliasName: 'transactionsGroupsDetailedStadisticsRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/service_overview_instances/main_statistics?*', + aliasName: 'instancesMainStadisticsRequest', + }, + + { + endpoint: + '/internal/apm/services/opbeans-node/service_overview_instances/detailed_statistics?*', + aliasName: 'instancesDetailedStadisticsRequest', + }, +]; + +const aliasNamesNoComparison = apiRequestsToIntercept.map( + ({ aliasName }) => `@${aliasName}` +); + +const aliasNamesWithComparison = apiRequestsToInterceptWithComparison.map( + ({ aliasName }) => `@${aliasName}` +); + +const aliasNames = [...aliasNamesNoComparison, ...aliasNamesWithComparison]; + describe('Service Overview', () => { before(async () => { await synthtrace.index( @@ -32,66 +97,167 @@ describe('Service Overview', () => { await synthtrace.clean(); }); - beforeEach(() => { - cy.loginAsReadOnlyUser(); + describe('renders', () => { + before(() => { + cy.loginAsReadOnlyUser(); + cy.visit(baseUrl); + }); + it('transaction latency chart', () => { + cy.get('[data-test-subj="latencyChart"]'); + }); + + it('throughput chart', () => { + cy.get('[data-test-subj="throughput"]'); + }); + + it('transactions group table', () => { + cy.get('[data-test-subj="transactionsGroupTable"]'); + }); + + it('error table', () => { + cy.get('[data-test-subj="serviceOverviewErrorsTable"]'); + }); + + it('dependencies table', () => { + cy.get('[data-test-subj="dependenciesTable"]'); + }); + + it('instances latency distribution chart', () => { + cy.get('[data-test-subj="instancesLatencyDistribution"]'); + }); + + it('instances table', () => { + cy.get('[data-test-subj="serviceOverviewInstancesTable"]'); + }); }); - it('persists transaction type selected when clicking on Transactions tab', () => { - cy.visit(baseUrl); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'request' - ); - cy.get('[data-test-subj="headerFilterTransactionType"]').select('Worker'); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'Worker' - ); - cy.contains('Transactions').click(); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'Worker' - ); + describe('transactions', () => { + beforeEach(() => { + cy.loginAsReadOnlyUser(); + cy.visit(baseUrl); + }); + + it('persists transaction type selected when clicking on Transactions tab', () => { + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'request' + ); + cy.get('[data-test-subj="headerFilterTransactionType"]').select('Worker'); + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'Worker' + ); + cy.contains('Transactions').click(); + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'Worker' + ); + }); + + it('persists transaction type selected when clicking on View Transactions link', () => { + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'request' + ); + cy.get('[data-test-subj="headerFilterTransactionType"]').select('Worker'); + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'Worker' + ); + + cy.contains('View transactions').click(); + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'Worker' + ); + }); }); - it('persists transaction type selected when clicking on View Transactions link', () => { - cy.visit(baseUrl); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'request' - ); - cy.get('[data-test-subj="headerFilterTransactionType"]').select('Worker'); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'Worker' - ); + describe('when RUM service', () => { + before(() => { + cy.loginAsReadOnlyUser(); + cy.visit( + url.format({ + pathname: '/app/apm/services/opbeans-rum/overview', + query: { rangeFrom: start, rangeTo: end }, + }) + ); + }); - cy.contains('View transactions').click(); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'Worker' - ); + it('hides dependency tab when RUM service', () => { + cy.intercept('GET', '/internal/apm/services/opbeans-rum/agent?*').as( + 'agentRequest' + ); + cy.contains('Overview'); + cy.contains('Transactions'); + cy.contains('Error'); + cy.contains('Service Map'); + // Waits until the agent request is finished to check the tab. + cy.wait('@agentRequest'); + cy.get('.euiTabs .euiTab__content').then((elements) => { + elements.map((index, element) => { + expect(element.innerText).to.not.equal('Dependencies'); + }); + }); + }); }); - it('hides dependency tab when RUM service', () => { - cy.intercept('GET', '/internal/apm/services/opbeans-rum/agent?*').as( - 'agentRequest' - ); - cy.visit( - url.format({ - pathname: '/app/apm/services/opbeans-rum/overview', - query: { rangeFrom: start, rangeTo: end }, - }) - ); - cy.contains('Overview'); - cy.contains('Transactions'); - cy.contains('Error'); - cy.contains('Service Map'); - // Waits until the agent request is finished to check the tab. - cy.wait('@agentRequest'); - cy.get('.euiTabs .euiTab__content').then((elements) => { - elements.map((index, element) => { - expect(element.innerText).to.not.equal('Dependencies'); + describe('Calls APIs', () => { + beforeEach(() => { + cy.loginAsReadOnlyUser(); + cy.visit(baseUrl); + apiRequestsToIntercept.map(({ endpoint, aliasName }) => { + cy.intercept('GET', endpoint).as(aliasName); + }); + apiRequestsToInterceptWithComparison.map(({ endpoint, aliasName }) => { + cy.intercept('GET', endpoint).as(aliasName); + }); + }); + + it('with the correct environment when changing the environment', () => { + cy.wait(aliasNames, { requestTimeout: 10000 }); + + cy.get('[data-test-subj="environmentFilter"]').select('production'); + + cy.expectAPIsToHaveBeenCalledWith({ + apisIntercepted: aliasNames, + value: 'environment=production', + }); + }); + + it('when clicking the refresh button', () => { + cy.contains('Refresh').click(); + cy.wait(aliasNames, { requestTimeout: 10000 }); + }); + + it('when selecting a different time range and clicking the update button', () => { + cy.wait(aliasNames, { requestTimeout: 10000 }); + + cy.selectAbsoluteTimeRange( + 'Oct 10, 2021 @ 01:00:00.000', + 'Oct 10, 2021 @ 01:30:00.000' + ); + cy.contains('Update').click(); + + cy.expectAPIsToHaveBeenCalledWith({ + apisIntercepted: aliasNames, + value: + 'start=2021-10-10T00%3A00%3A00.000Z&end=2021-10-10T00%3A30%3A00.000Z', + }); + }); + + it('when selecting a different comparison window', () => { + cy.get('[data-test-subj="comparisonSelect"]').should('have.value', 'day'); + + // selects another comparison type + cy.get('[data-test-subj="comparisonSelect"]').select('week'); + cy.get('[data-test-subj="comparisonSelect"]').should( + 'have.value', + 'week' + ); + cy.expectAPIsToHaveBeenCalledWith({ + apisIntercepted: aliasNamesWithComparison, + value: 'comparisonStart', }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index cffc5563d75cd..0b7d3c32957e2 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -180,7 +180,11 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { }); return ( - + diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx index b698a0672213d..c41ad329ea863 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx @@ -145,7 +145,11 @@ export function ServiceOverviewInstancesTable({ }; return ( - +

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/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx index 0146b9e8dd44d..4c93d2b513818 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx @@ -114,8 +114,13 @@ export function InstancesLatencyDistributionChart({ })}

- - + + + diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 856fa4139963e..4c1063173d929 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -229,7 +229,11 @@ export function TransactionsTable({ const isNotInitiated = status === FETCH_STATUS.NOT_INITIATED; return ( - + diff --git a/x-pack/plugins/apm/server/routes/backends/route.ts b/x-pack/plugins/apm/server/routes/backends/route.ts index 02dac877715a9..730ad672a26b7 100644 --- a/x-pack/plugins/apm/server/routes/backends/route.ts +++ b/x-pack/plugins/apm/server/routes/backends/route.ts @@ -21,6 +21,7 @@ import { getTopBackends } from './get_top_backends'; import { getUpstreamServicesForBackend } from './get_upstream_services_for_backend'; import { getThroughputChartsForBackend } from './get_throughput_charts_for_backend'; import { getErrorRateChartsForBackend } from './get_error_rate_charts_for_backend'; +import { ConnectionStatsItemWithImpact } from './../../../common/connections'; const topBackendsRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/backends/top_backends', @@ -105,10 +106,11 @@ const topBackendsRoute = createApmServerRoute({ ]); return { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type backends: currentBackends.map((backend) => { const { stats, ...rest } = backend; const prev = previousBackends.find( - (item) => item.location.id === backend.location.id + (item): boolean => item.location.id === backend.location.id ); return { ...rest, @@ -221,17 +223,24 @@ const upstreamServicesForBackendRoute = createApmServerRoute({ ]); return { - services: currentServices.map((service) => { - const { stats, ...rest } = service; - const prev = previousServices.find( - (item) => item.location.id === service.location.id - ); - return { - ...rest, - currentStats: stats, - previousStats: prev?.stats ?? null, - }; - }), + services: currentServices.map( + ( + service + ): Omit & { + currentStats: ConnectionStatsItemWithImpact['stats']; + previousStats: ConnectionStatsItemWithImpact['stats'] | null; + } => { + const { stats, ...rest } = service; + const prev = previousServices.find( + (item): boolean => item.location.id === service.location.id + ); + return { + ...rest, + currentStats: stats, + previousStats: prev?.stats ?? null, + }; + } + ), }; }, }); diff --git a/x-pack/plugins/apm/server/routes/correlations/route.ts b/x-pack/plugins/apm/server/routes/correlations/route.ts index 0e1707cc55222..fd0bce7a62ff8 100644 --- a/x-pack/plugins/apm/server/routes/correlations/route.ts +++ b/x-pack/plugins/apm/server/routes/correlations/route.ts @@ -27,6 +27,13 @@ import { withApmSpan } from '../../utils/with_apm_span'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from '../default_api_types'; +import { LatencyCorrelation } from './../../../common/correlations/latency_correlations/types'; +import { + FieldStats, + TopValuesStats, +} from './../../../common/correlations/field_stats_types'; +import { FieldValuePair } from './../../../common/correlations/types'; +import { FailedTransactionsCorrelation } from './../../../common/correlations/failed_transactions_correlations/types'; const INVALID_LICENSE = i18n.translate('xpack.apm.correlations.license.text', { defaultMessage: @@ -59,7 +66,7 @@ const fieldCandidatesRoute = createApmServerRoute({ return withApmSpan( 'get_correlations_field_candidates', - async () => + async (): Promise<{ fieldCandidates: string[] }> => await fetchTransactionDurationFieldCandidates(esClient, { ...resources.params.query, index: indices.transaction, @@ -106,7 +113,7 @@ const fieldStatsRoute = createApmServerRoute({ return withApmSpan( 'get_correlations_field_stats', - async () => + async (): Promise<{ stats: FieldStats[]; errors: any[] }> => await fetchFieldsStats( esClient, { @@ -155,7 +162,7 @@ const fieldValueStatsRoute = createApmServerRoute({ return withApmSpan( 'get_correlations_field_value_stats', - async () => + async (): Promise => await fetchFieldValueFieldStats( esClient, { @@ -206,7 +213,7 @@ const fieldValuePairsRoute = createApmServerRoute({ return withApmSpan( 'get_correlations_field_value_pairs', - async () => + async (): Promise<{ errors: any[]; fieldValuePairs: FieldValuePair[] }> => await fetchTransactionDurationFieldValuePairs( esClient, { @@ -268,7 +275,11 @@ const significantCorrelationsRoute = createApmServerRoute({ return withApmSpan( 'get_significant_correlations', - async () => + async (): Promise<{ + latencyCorrelations: LatencyCorrelation[]; + ccsWarning: boolean; + totalDocCount: number; + }> => await fetchSignificantCorrelations( esClient, paramsWithIndex, @@ -321,7 +332,10 @@ const pValuesRoute = createApmServerRoute({ return withApmSpan( 'get_p_values', - async () => await fetchPValues(esClient, paramsWithIndex, fieldCandidates) + async (): Promise<{ + failedTransactionsCorrelations: FailedTransactionsCorrelation[]; + ccsWarning: boolean; + }> => await fetchPValues(esClient, paramsWithIndex, fieldCandidates) ); }, }); diff --git a/x-pack/plugins/apm/server/routes/data_view/route.ts b/x-pack/plugins/apm/server/routes/data_view/route.ts index b918e687bd7cd..01d835b149d2e 100644 --- a/x-pack/plugins/apm/server/routes/data_view/route.ts +++ b/x-pack/plugins/apm/server/routes/data_view/route.ts @@ -9,6 +9,7 @@ import { createStaticDataView } from './create_static_data_view'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getDynamicDataView } from './get_dynamic_data_view'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; +import { ISavedObjectsRepository } from '../../../../../../src/core/server'; const staticDataViewRoute = createApmServerRoute({ endpoint: 'POST /internal/apm/data_view/static', @@ -24,7 +25,10 @@ const staticDataViewRoute = createApmServerRoute({ const setupPromise = setupRequest(resources); const clientPromise = core .start() - .then((coreStart) => coreStart.savedObjects.createInternalRepository()); + .then( + (coreStart): ISavedObjectsRepository => + coreStart.savedObjects.createInternalRepository() + ); const setup = await setupPromise; const savedObjectsClient = await clientPromise; diff --git a/x-pack/plugins/apm/server/routes/fleet/route.ts b/x-pack/plugins/apm/server/routes/fleet/route.ts index 668d4e207208c..11753ab3ef12c 100644 --- a/x-pack/plugins/apm/server/routes/fleet/route.ts +++ b/x-pack/plugins/apm/server/routes/fleet/route.ts @@ -105,16 +105,25 @@ const fleetAgentsRoute = createApmServerRoute({ return { cloudStandaloneSetup, isFleetEnabled: true, - fleetAgents: fleetAgents.map((agent) => { - const packagePolicy = policiesGroupedById[agent.id]; - const packagePolicyVars = packagePolicy.inputs[0]?.vars; - return { - id: agent.id, - name: agent.name, - apmServerUrl: packagePolicyVars?.url?.value, - secretToken: packagePolicyVars?.secret_token?.value, - }; - }), + fleetAgents: fleetAgents.map( + ( + agent + ): { + id: string; + name: string; + apmServerUrl: string | undefined; + secretToken: string | undefined; + } => { + const packagePolicy = policiesGroupedById[agent.id]; + const packagePolicyVars = packagePolicy.inputs[0]?.vars; + return { + id: agent.id, + name: agent.name, + apmServerUrl: packagePolicyVars?.url?.value, + secretToken: packagePolicyVars?.secret_token?.value, + }; + } + ), }; }, }); diff --git a/x-pack/plugins/apm/server/routes/observability_overview/route.ts b/x-pack/plugins/apm/server/routes/observability_overview/route.ts index faccd5eb29602..e32c04b849664 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview/route.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview/route.ts @@ -58,25 +58,36 @@ const observabilityOverviewRoute = createApmServerRoute({ kuery: '', }); - return withApmSpan('observability_overview', async () => { - const [serviceCount, transactionPerMinute] = await Promise.all([ - getServiceCount({ - setup, - searchAggregatedTransactions, - start, - end, - }), - getTransactionsPerMinute({ - setup, - bucketSize, - searchAggregatedTransactions, - start, - end, - intervalString, - }), - ]); - return { serviceCount, transactionPerMinute }; - }); + return withApmSpan( + 'observability_overview', + async (): Promise<{ + serviceCount: number; + transactionPerMinute: + | { value: undefined; timeseries: never[] } + | { + value: number; + timeseries: Array<{ x: number; y: number | null }>; + }; + }> => { + const [serviceCount, transactionPerMinute] = await Promise.all([ + getServiceCount({ + setup, + searchAggregatedTransactions, + start, + end, + }), + getTransactionsPerMinute({ + setup, + bucketSize, + searchAggregatedTransactions, + start, + end, + intervalString, + }), + ]); + return { serviceCount, transactionPerMinute }; + } + ); }, }); diff --git a/x-pack/plugins/apm/server/routes/rum_client/route.ts b/x-pack/plugins/apm/server/routes/rum_client/route.ts index e3bee6da6722c..d7d1e2837c53e 100644 --- a/x-pack/plugins/apm/server/routes/rum_client/route.ts +++ b/x-pack/plugins/apm/server/routes/rum_client/route.ts @@ -407,6 +407,7 @@ function decodeUiFilters( } } +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type async function setupUXRequest( resources: APMRouteHandlerResources & { params: TParams } ) { diff --git a/x-pack/plugins/apm/server/routes/services/route.ts b/x-pack/plugins/apm/server/routes/services/route.ts index db7793568676b..949105807b0f2 100644 --- a/x-pack/plugins/apm/server/routes/services/route.ts +++ b/x-pack/plugins/apm/server/routes/services/route.ts @@ -49,6 +49,9 @@ import { } from '../../../../ml/server'; import { getServiceInstancesDetailedStatisticsPeriods } from './get_service_instances/detailed_statistics'; import { ML_ERRORS } from '../../../common/anomaly_detection'; +import { ScopedAnnotationsClient } from '../../../../observability/server'; +import { Annotation } from './../../../../observability/common/annotations'; +import { ConnectionStatsItemWithImpact } from './../../../common/connections'; const servicesRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/services', @@ -373,8 +376,10 @@ const serviceAnnotationsRoute = createApmServerRoute({ const [annotationsClient, searchAggregatedTransactions] = await Promise.all( [ observability - ? withApmSpan('get_scoped_annotations_client', () => - observability.setup.getScopedAnnotationsClient(context, request) + ? withApmSpan( + 'get_scoped_annotations_client', + (): Promise => + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined, getSearchAggregatedTransactions({ @@ -443,8 +448,10 @@ const serviceAnnotationsCreateRoute = createApmServerRoute({ } = resources; const annotationsClient = observability - ? await withApmSpan('get_scoped_annotations_client', () => - observability.setup.getScopedAnnotationsClient(context, request) + ? await withApmSpan( + 'get_scoped_annotations_client', + (): Promise => + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined; @@ -454,20 +461,22 @@ const serviceAnnotationsCreateRoute = createApmServerRoute({ const { body, path } = params; - return withApmSpan('create_annotation', () => - annotationsClient.create({ - message: body.service.version, - ...body, - '@timestamp': new Date(body['@timestamp']).toISOString(), - annotation: { - type: 'deployment', - }, - service: { - ...body.service, - name: path.serviceName, - }, - tags: uniq(['apm'].concat(body.tags ?? [])), - }) + return withApmSpan( + 'create_annotation', + (): Promise<{ _id: string; _index: string; _source: Annotation }> => + annotationsClient.create({ + message: body.service.version, + ...body, + '@timestamp': new Date(body['@timestamp']).toISOString(), + annotation: { + type: 'deployment', + }, + service: { + ...body.service, + name: path.serviceName, + }, + tags: uniq(['apm'].concat(body.tags ?? [])), + }) ); }, }); @@ -925,18 +934,25 @@ export const serviceDependenciesRoute = createApmServerRoute({ ]); return { - serviceDependencies: currentPeriod.map((item) => { - const { stats, ...rest } = item; - const previousPeriodItem = previousPeriod.find( - (prevItem) => item.location.id === prevItem.location.id - ); - - return { - ...rest, - currentStats: stats, - previousStats: previousPeriodItem?.stats || null, - }; - }), + serviceDependencies: currentPeriod.map( + ( + item + ): Omit & { + currentStats: ConnectionStatsItemWithImpact['stats']; + previousStats: ConnectionStatsItemWithImpact['stats'] | null; + } => { + const { stats, ...rest } = item; + const previousPeriodItem = previousPeriod.find( + (prevItem): boolean => item.location.id === prevItem.location.id + ); + + return { + ...rest, + currentStats: stats, + previousStats: previousPeriodItem?.stats || null, + }; + } + ), }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts index 44dac0d9bc4a0..974b7f57289db 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts @@ -19,6 +19,7 @@ import { notifyFeatureUsage } from '../../../feature'; import { updateToV3 } from './update_to_v3'; import { environmentStringRt } from '../../../../common/environment_rt'; import { getMlJobsWithAPMGroup } from '../../../lib/anomaly_detection/get_ml_jobs_with_apm_group'; +import { ElasticsearchClient } from '../../../../../../../src/core/server'; // get ML anomaly detection jobs for each environment const anomalyDetectionJobsRoute = createApmServerRoute({ @@ -49,7 +50,7 @@ const anomalyDetectionJobsRoute = createApmServerRoute({ return { jobs, - hasLegacyJobs: jobs.some((job) => job.version === 1), + hasLegacyJobs: jobs.some((job): boolean => job.version === 1), }; }, }); @@ -128,7 +129,10 @@ const anomalyDetectionUpdateToV3Route = createApmServerRoute({ setupRequest(resources), resources.core .start() - .then((start) => start.elasticsearch.client.asInternalUser), + .then( + (start): ElasticsearchClient => + start.elasticsearch.client.asInternalUser + ), ]); const { logger } = resources; 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/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap b/x-pack/plugins/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap index 0339bfc8a9be5..a66ebc7bc1f1e 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap +++ b/x-pack/plugins/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap @@ -11,36 +11,68 @@ exports[`graph_visualization should render to svg elements 1`] = ` > - + - + + + + + x1={7} + x2={12} + y1={9} + y2={2} + /> + + { /> ); - instance.find('.gphEdge').first().simulate('click'); + instance.find('.gphEdge').at(1).simulate('click'); expect(workspace.getAllIntersections).toHaveBeenCalled(); expect(edges[0].topSrc).toEqual(workspace.getAllIntersections.mock.calls[0][1][0]); diff --git a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx index 26359101a9a5b..4859daa16488e 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx +++ b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx @@ -90,24 +90,39 @@ export function GraphVisualization({ {workspace.edges && workspace.edges.map((edge) => ( - { - edgeClick(edge); - }} - className={classNames('gphEdge', { - 'gphEdge--selected': edge.isSelected, - })} - style={{ strokeWidth: edge.width }} - strokeLinecap="round" - /> + className="gphEdge--wrapper" + > + {/* Draw two edges: a thicker one for better click handling and the one to show the user */} + + { + edgeClick(edge); + }} + className="gphEdge gphEdge--clickable" + style={{ + strokeWidth: Math.max(edge.width, 15), + }} + /> + ))} {workspace.nodes && diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx index 49e847e944694..5da03d9cb22c1 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx @@ -5,17 +5,7 @@ * 2.0. */ -import { - EuiButton, - EuiCallOut, - EuiCode, - EuiDescribedFormGroup, - EuiFieldText, - EuiFormRow, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiCode, EuiDescribedFormGroup, EuiFieldText, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; @@ -28,8 +18,7 @@ export const IndexNamesConfigurationPanel: React.FC<{ isLoading: boolean; isReadOnly: boolean; indexNamesFormElement: FormElement; - onSwitchToIndexPatternReference: () => void; -}> = ({ isLoading, isReadOnly, indexNamesFormElement, onSwitchToIndexPatternReference }) => { +}> = ({ isLoading, isReadOnly, indexNamesFormElement }) => { useTrackPageview({ app: 'infra_logs', path: 'log_source_configuration_index_name' }); useTrackPageview({ app: 'infra_logs', @@ -39,29 +28,6 @@ export const IndexNamesConfigurationPanel: React.FC<{ return ( <> - -

- -

-
- - - - - - - - @@ -118,10 +84,3 @@ const getIndexNamesInputFieldProps = getInputFieldProps( }), ({ indexName }) => indexName ); - -const indexPatternInformationCalloutTitle = i18n.translate( - 'xpack.infra.logSourceConfiguration.indexPatternInformationCalloutTitle', - { - defaultMessage: 'New configuration option', - } -); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx index 17d537101e5d2..2d1c407595f61 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiDescribedFormGroup, EuiFormRow, EuiLink, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiFormRow, EuiLink, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback, useMemo } from 'react'; import { useTrackPageview } from '../../../../../observability/public'; @@ -44,15 +44,6 @@ export const IndexPatternConfigurationPanel: React.FC<{ return ( <> - -

- -

-
- { return ( diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx index cbc9bc477829d..c63b27f6d0ce1 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx @@ -76,7 +76,7 @@ export const IndexPatternSelector: React.FC<{ options={availableOptions} placeholder={indexPatternSelectorPlaceholder} selectedOptions={selectedOptions} - singleSelection={true} + singleSelection={{ asPlainText: true }} onChange={changeSelectedIndexPatterns} /> ); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx index 064d5f7907037..46af94989f259 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import { EuiCheckableCard, EuiFormFieldset, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback } from 'react'; import { useUiTracker } from '../../../../../observability/public'; import { @@ -23,37 +25,106 @@ export const IndicesConfigurationPanel = React.memo<{ isReadOnly: boolean; indicesFormElement: FormElement; }>(({ isLoading, isReadOnly, indicesFormElement }) => { - const trackSwitchToIndexPatternReference = useUiTracker({ app: 'infra_logs' }); + const trackChangeIndexSourceType = useUiTracker({ app: 'infra_logs' }); - const switchToIndexPatternReference = useCallback(() => { - indicesFormElement.updateValue(() => undefined); - trackSwitchToIndexPatternReference({ + const changeToIndexPatternType = useCallback(() => { + if (indicesFormElement.initialValue?.type === 'index_pattern') { + indicesFormElement.updateValue(() => indicesFormElement.initialValue); + } else { + indicesFormElement.updateValue(() => undefined); + } + + trackChangeIndexSourceType({ metric: 'configuration_switch_to_index_pattern_reference', }); - }, [indicesFormElement, trackSwitchToIndexPatternReference]); + }, [indicesFormElement, trackChangeIndexSourceType]); + + const changeToIndexNameType = useCallback(() => { + if (indicesFormElement.initialValue?.type === 'index_name') { + indicesFormElement.updateValue(() => indicesFormElement.initialValue); + } else { + indicesFormElement.updateValue(() => ({ + type: 'index_name', + indexName: '', + })); + } + + trackChangeIndexSourceType({ + metric: 'configuration_switch_to_index_names_reference', + }); + }, [indicesFormElement, trackChangeIndexSourceType]); + + return ( + +

+ +

+ + ), + }} + > + +

+ +

+ + } + name="dataView" + value="dataView" + checked={isIndexPatternFormElement(indicesFormElement)} + onChange={changeToIndexPatternType} + disabled={isReadOnly} + > + {isIndexPatternFormElement(indicesFormElement) && ( + + )} +
+ - if (isIndexPatternFormElement(indicesFormElement)) { - return ( - - ); - } else if (isIndexNamesFormElement(indicesFormElement)) { - return ( - <> - - - ); - } else { - return null; - } + +

+ +

+ + } + name="indexNames" + value="indexNames" + checked={isIndexNamesFormElement(indicesFormElement)} + onChange={changeToIndexNameType} + disabled={isReadOnly} + > + {isIndexNamesFormElement(indicesFormElement) && ( + + )} +
+
+ ); }); const isIndexPatternFormElement = isFormElementForType( 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/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/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/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 8efb667120f77..2a44550af2b58 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -42,6 +42,7 @@ import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, + getDatasourceSuggestionsForVisualizeCharts, } from './indexpattern_suggestions'; import { getVisualDefaultsForLayer, isColumnInvalid } from './utils'; @@ -61,7 +62,7 @@ import { import { DataPublicPluginStart, ES_FIELD_TYPES } from '../../../../../src/plugins/data/public'; import { VisualizeFieldContext } from '../../../../../src/plugins/ui_actions/public'; import { mergeLayer } from './state_helpers'; -import { Datasource, StateSetter } from '../types'; +import { Datasource, StateSetter, VisualizeEditorContext } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { deleteColumn, isReferenced } from './operations'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; @@ -150,7 +151,7 @@ export function getIndexPatternDatasource({ async initialize( persistedState?: IndexPatternPersistedState, references?: SavedObjectReference[], - initialContext?: VisualizeFieldContext, + initialContext?: VisualizeFieldContext | VisualizeEditorContext, options?: InitializationOptions ) { return loadInitialState({ @@ -485,6 +486,7 @@ export function getIndexPatternDatasource({ }, getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, + getDatasourceSuggestionsForVisualizeCharts, getErrorMessages(state) { if (!state) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 5a0eb1a73e075..c25b8b7264077 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import type { VisualizeEditorLayersContext } from '../../../../../src/plugins/visualizations/public'; import { DatasourceSuggestion } from '../types'; import { generateId } from '../id_generator'; import type { IndexPatternPrivateState } from './types'; @@ -12,6 +12,7 @@ import { getDatasourceSuggestionsForField, getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, + getDatasourceSuggestionsForVisualizeCharts, IndexPatternSuggestion, } from './indexpattern_suggestions'; import { documentField } from './document_field'; @@ -1406,6 +1407,432 @@ describe('IndexPattern Data Source suggestions', () => { }); }); + 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/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 8c52fc5a52fd8..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, } 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/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/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 47a0f43538eb2..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'; @@ -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/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/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 5918cce7d67c6..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) { - 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/plugin.ts b/x-pack/plugins/osquery/server/plugin.ts index aa0578d1cac14..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, @@ -54,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'], }, @@ -69,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'], }, @@ -179,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'], diff --git a/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_policies.ts b/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_policies.ts index 8593d498514c2..f190f9abfaf78 100644 --- a/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_policies.ts +++ b/x-pack/plugins/osquery/server/routes/fleet_wrapper/get_agent_policies.ts @@ -17,6 +17,7 @@ import { import { OSQUERY_INTEGRATION_NAME, 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 getAgentPoliciesRoute = (router: IRouter, osqueryContext: OsqueryAppContext) => { 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/reporting/common/types/index.ts b/x-pack/plugins/reporting/common/types/index.ts index 55da2cc366390..37c33dd0ecc7c 100644 --- a/x-pack/plugins/reporting/common/types/index.ts +++ b/x-pack/plugins/reporting/common/types/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { ScreenshotResult } from '../../../screenshotting/server'; import type { BaseParams, BaseParamsV2, BasePayload, BasePayloadV2, JobId } from './base'; export type { JobParamsPNGDeprecated } from './export_types/png'; @@ -33,12 +35,33 @@ export interface ReportOutput extends TaskRunResult { size: number; } +type ScreenshotMetrics = Required['metrics']; + +export interface CsvMetrics { + rows: number; +} + +export type PngMetrics = ScreenshotMetrics; + +export interface PdfMetrics extends Partial { + /** + * A number of emitted pages in the generated PDF report. + */ + pages: number; +} + +export interface TaskRunMetrics { + csv?: CsvMetrics; + png?: PngMetrics; + pdf?: PdfMetrics; +} + export interface TaskRunResult { content_type: string | null; csv_contains_formulas?: boolean; - csv_rows?: number; max_size_reached?: boolean; warnings?: string[]; + metrics?: TaskRunMetrics; } export interface ReportSource { @@ -76,6 +99,7 @@ export interface ReportSource { started_at?: string; // timestamp in UTC completed_at?: string; // timestamp in UTC process_expiration?: string | null; // timestamp in UTC - is overwritten with `null` when the job needs a retry + metrics?: TaskRunMetrics; } /* @@ -131,7 +155,6 @@ export interface JobSummary { title: ReportSource['payload']['title']; maxSizeReached: TaskRunResult['max_size_reached']; csvContainsFormulas: TaskRunResult['csv_contains_formulas']; - csvRows: TaskRunResult['csv_rows']; } export interface JobSummarySet { 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/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap index 3d326844fedf7..50c8672733168 100644 --- a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap +++ b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap @@ -5,7 +5,6 @@ Object { "completed": Array [ Object { "csvContainsFormulas": false, - "csvRows": undefined, "id": "job-source-mock1", "jobtype": undefined, "maxSizeReached": false, @@ -14,7 +13,6 @@ Object { }, Object { "csvContainsFormulas": true, - "csvRows": 42000000, "id": "job-source-mock4", "jobtype": undefined, "maxSizeReached": false, @@ -25,7 +23,6 @@ Object { "failed": Array [ Object { "csvContainsFormulas": false, - "csvRows": undefined, "id": "job-source-mock2", "jobtype": undefined, "maxSizeReached": false, diff --git a/x-pack/plugins/reporting/public/lib/job.tsx b/x-pack/plugins/reporting/public/lib/job.tsx index d9f501ecd1418..e875d00cabab8 100644 --- a/x-pack/plugins/reporting/public/lib/job.tsx +++ b/x-pack/plugins/reporting/public/lib/job.tsx @@ -55,8 +55,8 @@ export class Job { public size?: ReportOutput['size']; public content_type?: TaskRunResult['content_type']; public csv_contains_formulas?: TaskRunResult['csv_contains_formulas']; - public csv_rows?: TaskRunResult['csv_rows']; public max_size_reached?: TaskRunResult['max_size_reached']; + public metrics?: ReportSource['metrics']; public warnings?: TaskRunResult['warnings']; public locatorParams?: BaseParamsV2['locatorParams']; @@ -88,10 +88,10 @@ export class Job { this.isDeprecated = report.payload.isDeprecated || false; this.spaceId = report.payload.spaceId; this.csv_contains_formulas = report.output?.csv_contains_formulas; - this.csv_rows = report.output?.csv_rows; this.max_size_reached = report.output?.max_size_reached; this.warnings = report.output?.warnings; this.locatorParams = (report.payload as BaseParamsV2).locatorParams; + this.metrics = report.metrics; } getStatusMessage() { diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 2caa1b70fe162..4863a9f7e1e36 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -24,7 +24,7 @@ const mockJobsFound: Job[] = [ { id: 'job-source-mock1', status: 'completed', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, { id: 'job-source-mock2', status: 'failed', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, { id: 'job-source-mock3', status: 'pending', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, - { id: 'job-source-mock4', status: 'completed', output: { csv_contains_formulas: true, csv_rows: 42000000, max_size_reached: false }, payload: { title: 'specimen' } }, + { id: 'job-source-mock4', status: 'completed', output: { csv_contains_formulas: true, max_size_reached: false }, payload: { title: 'specimen' } }, ].map((j) => new Job(j as ReportApiJSON)); // prettier-ignore const coreSetup = coreMock.createSetup(); diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.ts b/x-pack/plugins/reporting/public/lib/stream_handler.ts index 03f4fcd30a618..e9645f3bb8735 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.ts @@ -33,7 +33,6 @@ function getReportStatus(src: Job): JobSummary { jobtype: src.prettyJobTypeName ?? src.jobtype, maxSizeReached: src.max_size_reached, csvContainsFormulas: src.csv_contains_formulas, - csvRows: src.csv_rows, }; } diff --git a/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx b/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx index e73fc5ab54e33..92b38d99cedd1 100644 --- a/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx +++ b/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx @@ -50,8 +50,12 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { const formatDate = createDateFormatter(uiSettings.get('dateFormat'), timezone); - const hasCsvRows = info.csv_rows != null; + const cpuInPercentage = info.metrics?.pdf?.cpuInPercentage ?? info.metrics?.png?.cpuInPercentage; + const memoryInMegabytes = + info.metrics?.pdf?.memoryInMegabytes ?? info.metrics?.png?.memoryInMegabytes; + const hasCsvRows = info.metrics?.csv?.rows != null; const hasScreenshot = USES_HEADLESS_JOB_TYPES.includes(info.jobtype); + const hasPdfPagesMetric = info.metrics?.pdf?.pages != null; const outputInfo = [ { @@ -99,7 +103,7 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { title: i18n.translate('xpack.reporting.listing.infoPanel.csvRows', { defaultMessage: 'CSV rows', }), - description: info.csv_rows?.toString() || NA, + description: info.metrics?.csv?.rows?.toString() || NA, }, hasScreenshot && { @@ -118,6 +122,12 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { description: info.layout?.dimensions?.width != null ? Math.ceil(info.layout.dimensions.width) : UNKNOWN, }, + hasPdfPagesMetric && { + title: i18n.translate('xpack.reporting.listing.infoPanel.pdfPagesInfo', { + defaultMessage: 'Pages count', + }), + description: info.metrics?.pdf?.pages, + }, { title: i18n.translate('xpack.reporting.listing.infoPanel.processedByInfo', { @@ -132,6 +142,20 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { }), description: info.prettyTimeout, }, + + cpuInPercentage != null && { + title: i18n.translate('xpack.reporting.listing.infoPanel.cpuInfo', { + defaultMessage: 'CPU usage', + }), + description: `${cpuInPercentage}%`, + }, + + memoryInMegabytes != null && { + title: i18n.translate('xpack.reporting.listing.infoPanel.memoryInfo', { + defaultMessage: 'RAM usage', + }), + description: `${memoryInMegabytes}MB`, + }, ].filter(Boolean) as EuiDescriptionListProps['listItems']; const timestampsInfo = [ diff --git a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts index 8c83e0ae73527..caa0b7fb91b3f 100644 --- a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts @@ -10,15 +10,22 @@ import * as Rx from 'rxjs'; import { finalize, map, tap } from 'rxjs/operators'; import { LayoutTypes } from '../../../../screenshotting/common'; import { REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; +import type { PngMetrics } from '../../../common/types'; import { ReportingCore } from '../../'; import { ScreenshotOptions } from '../../types'; import { LevelLogger } from '../../lib'; +interface PngResult { + buffer: Buffer; + metrics?: PngMetrics; + warnings: string[]; +} + export function generatePngObservable( reporting: ReportingCore, logger: LevelLogger, options: ScreenshotOptions -): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { +): Rx.Observable { const apmTrans = apm.startTransaction('generate-png', REPORTING_TRANSACTION_TYPE); const apmLayout = apmTrans?.startSpan('create-layout', 'setup'); if (!options.layout.dimensions) { @@ -35,15 +42,16 @@ export function generatePngObservable( let apmBuffer: typeof apm.currentSpan; return reporting.getScreenshots({ ...options, layout }).pipe( - tap(({ metrics$ }) => { - metrics$.subscribe(({ cpu, memory }) => { - apmTrans?.setLabel('cpu', cpu, false); - apmTrans?.setLabel('memory', memory, false); - }); + tap(({ metrics }) => { + if (metrics) { + apmTrans?.setLabel('cpu', metrics.cpu, false); + apmTrans?.setLabel('memory', metrics.memory, false); + } apmScreenshots?.end(); apmBuffer = apmTrans?.startSpan('get-buffer', 'output') ?? null; }), - map(({ results }) => ({ + map(({ metrics, results }) => ({ + metrics, buffer: results[0].screenshots[0].data, warnings: results.reduce((found, current) => { if (current.error) { diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/integration_tests/pdfmaker.test.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/integration_tests/pdfmaker.test.ts index d2596c7e24547..967f227e21f1c 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/integration_tests/pdfmaker.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/integration_tests/pdfmaker.test.ts @@ -60,4 +60,28 @@ describe('PdfMaker', () => { await expect(buggyMaker.generate()).rejects.toEqual(new Error('This is a bug')); }); }); + + describe('getPageCount', () => { + it('should return zero pages on no content', () => { + expect(pdf.getPageCount()).toBe(0); + }); + + it('should return a number of generated pages', async () => { + for (let i = 0; i < 100; i++) { + pdf.addImage(imageBase64, { title: `${i} viz`, description: '☃️' }); + } + await pdf.generate(); + + expect(pdf.getPageCount()).toBe(100); + }); + + it('should return a number of already flushed pages', async () => { + for (let i = 0; i < 100; i++) { + pdf.addImage(imageBase64, { title: `${i} viz`, description: '☃️' }); + } + await pdf.generate(); + + expect(pdf.getPageCount()).toBe(100); + }); + }); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/pdfmaker.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/pdfmaker.ts index 14cfd0082a82b..1681037477f84 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/pdfmaker.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/pdfmaker.ts @@ -35,6 +35,7 @@ export class PdfMaker { private _content: Content[]; private worker?: Worker; + private pageCount: number = 0; protected workerModulePath = path.resolve(__dirname, './worker.js'); @@ -215,11 +216,16 @@ export class PdfMaker { reject(new Error(`Worker did not generate a PDF!`)); return; } - resolve(data); + this.pageCount = data.metrics.pages; + resolve(data.buffer); }); }); } finally { await this.cleanupWorker(); } } + + getPageCount(): number { + return this.pageCount; + } } diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/worker.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/worker.ts index 5b6620fb0d904..e17f896616dd6 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/worker.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/worker.ts @@ -36,7 +36,12 @@ export type GeneratePdfResponse = SuccessResponse | ErrorResponse; export interface SuccessResponse { error?: undefined; - data: Uint8Array; + data: { + buffer: Uint8Array; + metrics: { + pages: number; + }; + }; } export interface ErrorResponse { @@ -48,6 +53,16 @@ if (!isMainThread) { parentPort!.on('message', execute); } +const getPageCount = (pdfDoc: PDFKit.PDFDocument): number => { + const pageRange = pdfDoc.bufferedPageRange(); + if (!pageRange) { + return 0; + } + const { count, start } = pageRange; + + return start + count; +}; + async function execute({ data: { layout, logo, title, content }, port }: GeneratePdfRequest) { try { const tableBorderWidth = 1; @@ -107,7 +122,12 @@ async function execute({ data: { layout, logo, title, content }, port }: Generat }); const successResponse: SuccessResponse = { - data: buffer, + data: { + buffer, + metrics: { + pages: getPageCount(pdfDoc), + }, + }, }; port.postMessage(successResponse, [buffer.buffer /* Transfer buffer instead of copying */]); } catch (error) { diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 7b1f82f226e5e..0feaab90975d8 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -395,8 +395,10 @@ export class CsvGenerator { return { content_type: CONTENT_TYPE_CSV, csv_contains_formulas: this.csvContainsFormulas && !escapeFormulaValues, - csv_rows: this.csvRowCount, max_size_reached: this.maxSizeReached, + metrics: { + csv: { rows: this.csvRowCount }, + }, warnings, }; } diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts index e6cbfb45eb095..20a2ea98e06d4 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts @@ -49,8 +49,9 @@ export const runTaskFnFactory: RunTaskFnFactory> = }); }), tap(({ buffer }) => stream.write(buffer)), - map(({ warnings }) => ({ + map(({ metrics, warnings }) => ({ content_type: 'image/png', + metrics: { png: metrics }, warnings, })), tap({ error: (error) => jobLogger.error(error) }), diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts index a8ab6c4355000..1acce6e475630 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -50,8 +50,9 @@ export const runTaskFnFactory: RunTaskFnFactory> = }); }), tap(({ buffer }) => stream.write(buffer)), - map(({ warnings }) => ({ + map(({ metrics, warnings }) => ({ content_type: 'image/png', + metrics: { png: metrics }, warnings, })), tap({ error: (error) => jobLogger.error(error) }), diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index f301b3e1e6ef2..02ba917ce329d 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -65,8 +65,9 @@ export const runTaskFnFactory: RunTaskFnFactory> = stream.write(buffer); } }), - map(({ warnings }) => ({ + map(({ metrics, warnings }) => ({ content_type: 'application/pdf', + metrics: { pdf: metrics }, warnings, })), catchError((err) => { diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index 3881fe4502ba8..459887ebb8118 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -7,9 +7,10 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; -import { ReportingCore } from '../../../'; +import { mergeMap, tap } from 'rxjs/operators'; import { ScreenshotResult } from '../../../../../screenshotting/server'; +import type { PdfMetrics } from '../../../../common/types'; +import { ReportingCore } from '../../../'; import { LevelLogger } from '../../../lib'; import { ScreenshotOptions } from '../../../types'; import { PdfMaker, PdfWorkerOutOfMemoryError } from '../../common/pdf'; @@ -25,25 +26,32 @@ const getTimeRange = (urlScreenshots: ScreenshotResult['results']) => { return null; }; +interface PdfResult { + buffer: Uint8Array | null; + metrics?: PdfMetrics; + warnings: string[]; +} + export function generatePdfObservable( reporting: ReportingCore, logger: LevelLogger, title: string, options: ScreenshotOptions, logo?: string -): Rx.Observable<{ buffer: Uint8Array | null; warnings: string[] }> { +): Rx.Observable { const tracker = getTracker(); tracker.startScreenshots(); return reporting.getScreenshots(options).pipe( - mergeMap(async ({ layout, metrics$, results }) => { - metrics$.subscribe(({ cpu, memory }) => { - tracker.setCpuUsage(cpu); - tracker.setMemoryUsage(memory); - }); + tap(({ metrics }) => { + if (metrics) { + tracker.setCpuUsage(metrics.cpu); + tracker.setMemoryUsage(metrics.memory); + } tracker.endScreenshots(); tracker.startSetup(); - + }), + mergeMap(async ({ layout, metrics, results }) => { const pdfOutput = new PdfMaker(layout, logo); if (title) { const timeRange = getTimeRange(results); @@ -103,6 +111,10 @@ export function generatePdfObservable( return { buffer, warnings, + metrics: { + ...metrics, + pages: pdfOutput.getPageCount(), + }, }; }) ); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts index e606b49b8ce01..de6f2ae70a756 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -64,8 +64,9 @@ export const runTaskFnFactory: RunTaskFnFactory> = stream.write(buffer); } }), - map(({ warnings }) => ({ + map(({ metrics, warnings }) => ({ content_type: 'application/pdf', + metrics: { pdf: metrics }, warnings, })), catchError((err) => { diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts index 554571478d798..b0ac1a59010a6 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -7,10 +7,10 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; +import { mergeMap, tap } from 'rxjs/operators'; import { ReportingCore } from '../../../'; import { ScreenshotResult } from '../../../../../screenshotting/server'; -import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../../common/types'; +import { LocatorParams, PdfMetrics, UrlOrUrlLocatorTuple } from '../../../../common/types'; import { LevelLogger } from '../../../lib'; import { ScreenshotOptions } from '../../../types'; import { PdfMaker } from '../../common/pdf'; @@ -29,6 +29,12 @@ const getTimeRange = (urlScreenshots: ScreenshotResult['results']) => { return null; }; +interface PdfResult { + buffer: Uint8Array | null; + metrics?: PdfMetrics; + warnings: string[]; +} + export function generatePdfObservable( reporting: ReportingCore, logger: LevelLogger, @@ -37,7 +43,7 @@ export function generatePdfObservable( locatorParams: LocatorParams[], options: Omit, logo?: string -): Rx.Observable<{ buffer: Uint8Array | null; warnings: string[] }> { +): Rx.Observable { const tracker = getTracker(); tracker.startScreenshots(); @@ -50,14 +56,15 @@ export function generatePdfObservable( ]) as UrlOrUrlLocatorTuple[]; const screenshots$ = reporting.getScreenshots({ ...options, urls }).pipe( - mergeMap(async ({ layout, metrics$, results }) => { - metrics$.subscribe(({ cpu, memory }) => { - tracker.setCpuUsage(cpu); - tracker.setMemoryUsage(memory); - }); + tap(({ metrics }) => { + if (metrics) { + tracker.setCpuUsage(metrics.cpu); + tracker.setMemoryUsage(metrics.memory); + } tracker.endScreenshots(); tracker.startSetup(); - + }), + mergeMap(async ({ layout, metrics, results }) => { const pdfOutput = new PdfMaker(layout, logo); if (title) { const timeRange = getTimeRange(results); @@ -115,6 +122,10 @@ export function generatePdfObservable( return { buffer, warnings, + metrics: { + ...metrics, + pages: pdfOutput.getPageCount(), + }, }; }) ); diff --git a/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts b/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts index 10b7d1278183f..fa45a8d04176c 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts @@ -116,7 +116,14 @@ describe('Event Logger', () => { jest.spyOn(logger.completionLogger, 'stopTiming'); logger.logExecutionStart(); - const result = logger.logExecutionComplete({ byteSize: 444, csvRows: 440000 }); + const result = logger.logExecutionComplete({ + byteSize: 444, + pdf: { + cpu: 0.1, + memory: 1024, + pages: 5, + }, + }); expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` Array [ Object { @@ -125,9 +132,15 @@ describe('Event Logger', () => { Object { "actionType": "execute-complete", "byteSize": 444, - "csvRows": 440000, + "csv": undefined, "id": "12348", "jobType": "csv", + "pdf": Object { + "cpu": 0.1, + "memory": 1024, + "pages": 5, + }, + "png": undefined, }, "completed csv execution", ] diff --git a/x-pack/plugins/reporting/server/lib/event_logger/logger.ts b/x-pack/plugins/reporting/server/lib/event_logger/logger.ts index a54f69eff3582..6a7feea0c335d 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/logger.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/logger.ts @@ -9,6 +9,7 @@ import deepMerge from 'deepmerge'; import { LogMeta } from 'src/core/server'; import { LevelLogger } from '../'; import { PLUGIN_ID } from '../../../common/constants'; +import type { TaskRunMetrics } from '../../../common/types'; import { IReport } from '../store'; import { ActionType } from './'; import { EcsLogAdapter } from './adapter'; @@ -25,9 +26,8 @@ import { } from './types'; /** @internal */ -export interface ExecutionCompleteMetrics { +export interface ExecutionCompleteMetrics extends TaskRunMetrics { byteSize: number; - csvRows?: number; } export interface IReportingEventLogger { @@ -102,13 +102,26 @@ export function reportingEventLoggerFactory(logger: LevelLogger) { return event; } - logExecutionComplete({ byteSize, csvRows }: ExecutionCompleteMetrics): CompletedExecution { + logExecutionComplete({ + byteSize, + csv, + pdf, + png, + }: ExecutionCompleteMetrics): CompletedExecution { const message = `completed ${this.report.jobtype} execution`; this.completionLogger.stopTiming(); const event = deepMerge( { message, - kibana: { reporting: { actionType: ActionType.EXECUTE_COMPLETE, byteSize, csvRows } }, + kibana: { + reporting: { + actionType: ActionType.EXECUTE_COMPLETE, + byteSize, + csv, + pdf, + png, + }, + }, } as Partial, this.eventObj ); diff --git a/x-pack/plugins/reporting/server/lib/event_logger/types.ts b/x-pack/plugins/reporting/server/lib/event_logger/types.ts index cc3ee25813128..3094919da278d 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/types.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/types.ts @@ -6,6 +6,7 @@ */ import { LogMeta } from 'src/core/server'; +import type { TaskRunMetrics } from '../../../common/types'; import { ActionType } from './'; export interface ReportingAction extends LogMeta { @@ -19,8 +20,7 @@ export interface ReportingAction extends LogMeta { id?: string; // "immediate download" exports have no ID jobType: string; byteSize?: number; - csvRows?: number; - }; + } & TaskRunMetrics; task?: { id?: string }; }; user?: { name: string }; diff --git a/x-pack/plugins/reporting/server/lib/store/mapping.ts b/x-pack/plugins/reporting/server/lib/store/mapping.ts index 667648d3372c5..f860493dfc3fa 100644 --- a/x-pack/plugins/reporting/server/lib/store/mapping.ts +++ b/x-pack/plugins/reporting/server/lib/store/mapping.ts @@ -59,4 +59,34 @@ export const mapping = { content: { type: 'object', enabled: false }, }, }, + metrics: { + type: 'object', + properties: { + csv: { + type: 'object', + properties: { + rows: { type: 'long' }, + }, + }, + pdf: { + type: 'object', + properties: { + pages: { type: 'long' }, + cpu: { type: 'double' }, + cpuInPercentage: { type: 'double' }, + memory: { type: 'long' }, + memoryInMegabytes: { type: 'double' }, + }, + }, + png: { + type: 'object', + properties: { + cpu: { type: 'double' }, + cpuInPercentage: { type: 'double' }, + memory: { type: 'long' }, + memoryInMegabytes: { type: 'double' }, + }, + }, + }, + }, } as const; diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index 67f1ccdea5db8..6b2b6f997233b 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -50,6 +50,7 @@ export class Report implements Partial { public readonly completed_at: ReportSource['completed_at']; public readonly timeout: ReportSource['timeout']; public readonly max_attempts: ReportSource['max_attempts']; + public readonly metrics?: ReportSource['metrics']; public process_expiration?: ReportSource['process_expiration']; public migration_version: string; @@ -88,6 +89,7 @@ export class Report implements Partial { this.created_at = opts.created_at || moment.utc().toISOString(); this.created_by = opts.created_by || false; this.meta = opts.meta || { objectType: 'unknown' }; + this.metrics = opts.metrics; this.status = opts.status || JOB_STATUSES.PENDING; this.output = opts.output || null; @@ -129,6 +131,7 @@ export class Report implements Partial { completed_at: this.completed_at, process_expiration: this.process_expiration, output: this.output || null, + metrics: this.metrics, }; } @@ -174,6 +177,7 @@ export class Report implements Partial { migration_version: this.migration_version, payload: omit(this.payload, 'headers'), output: omit(this.output, 'content'), + metrics: this.metrics, }; } } diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 81ba2454124c0..3e8942be1ffa0 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -193,6 +193,14 @@ describe('ReportingStore', () => { max_attempts: 1, timeout: 30000, output: null, + metrics: { + png: { + cpu: 0.02, + cpuInPercentage: 2, + memory: 1024 * 1024, + memoryInMegabytes: 1, + }, + }, }, }; mockEsClient.get.mockResponse(mockReport as any); @@ -219,6 +227,14 @@ describe('ReportingStore', () => { "meta": Object { "testMeta": "meta", }, + "metrics": Object { + "png": Object { + "cpu": 0.02, + "cpuInPercentage": 2, + "memory": 1048576, + "memoryInMegabytes": 1, + }, + }, "migration_version": "7.14.0", "output": null, "payload": Object { diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 492838d61ca74..41fdd9580c996 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -253,6 +253,7 @@ export class ReportingStore { created_by: document._source?.created_by, max_attempts: document._source?.max_attempts, meta: document._source?.meta, + metrics: document._source?.metrics, payload: document._source?.payload, process_expiration: document._source?.process_expiration, status: document._source?.status, diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index c566a07c3e6b2..8cc4139da3f1f 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -221,7 +221,6 @@ export class ExecuteReportTask implements ReportingTask { docOutput.content_type = output.content_type || unknownMime; docOutput.max_size_reached = output.max_size_reached; docOutput.csv_contains_formulas = output.csv_contains_formulas; - docOutput.csv_rows = output.csv_rows; docOutput.size = output.size; docOutput.warnings = output.warnings && output.warnings.length > 0 ? output.warnings : undefined; @@ -271,6 +270,7 @@ export class ExecuteReportTask implements ReportingTask { const store = await this.getStore(); const doc = { completed_at: completedTime, + metrics: output.metrics, output: docOutput, }; docId = `/${report._index}/_doc/${report._id}`; @@ -365,8 +365,8 @@ export class ExecuteReportTask implements ReportingTask { report._primary_term = stream.getPrimaryTerm()!; eventLog.logExecutionComplete({ + ...(report.metrics ?? {}), byteSize: stream.bytesWritten, - csvRows: output?.csv_rows, }); if (output) { diff --git a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts index 364ceea3fa001..b6ada00ba55ab 100644 --- a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts @@ -89,8 +89,8 @@ export function registerGenerateCsvFromSavedObjectImmediate( } eventLog.logExecutionComplete({ + ...(output.metrics ?? {}), byteSize: stream.bytesWritten, - csvRows: output.csv_rows, }); }) .finally(() => stream.end()); diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.test.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.test.ts index 7904946892905..d2ed0b86e2cce 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.test.ts @@ -76,7 +76,6 @@ describe('getDocumentPayload', () => { output: { content_type: 'text/csv', csv_contains_formulas: true, - csv_rows: 42000000, max_size_reached: true, size: 1024, }, diff --git a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts index 0028073290f20..d1c1dddb3c302 100644 --- a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts @@ -114,6 +114,7 @@ describe('Handle request to generate', () => { "layout": "preserve_layout", "objectType": "cool_object_type", }, + "metrics": undefined, "migration_version": "7.14.0", "output": null, "process_expiration": undefined, @@ -195,6 +196,7 @@ describe('Handle request to generate', () => { "layout": "preserve_layout", "objectType": "cool_object_type", }, + "metrics": undefined, "migration_version": "7.14.0", "output": Object {}, "payload": Object { diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts index d26d948beee16..2f4c59707430e 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts @@ -49,7 +49,6 @@ interface CreatePageOptions { interface CreatePageResult { driver: HeadlessChromiumDriver; unexpectedExit$: Rx.Observable; - metrics$: Rx.Observable; /** * Close the page and the browser. * @@ -57,7 +56,11 @@ interface CreatePageResult { * have concluded. This ensures the browser is closed and gives the OS a chance * to reclaim resources like memory. */ - close: () => Rx.Observable; + close: () => Rx.Observable; +} + +interface ClosePageResult { + metrics?: PerformanceMetrics; } export const DEFAULT_VIEWPORT = { @@ -167,7 +170,6 @@ export class HeadlessChromiumDriverFactory { await devTools.send('Performance.enable', { timeDomain: 'timeTicks' }); const startMetrics = await devTools.send('Performance.getMetrics'); - const metrics$ = new Rx.Subject(); // Log version info for debugging / maintenance const versionInfo = await devTools.send('Browser.getVersion'); @@ -182,23 +184,25 @@ export class HeadlessChromiumDriverFactory { logger.debug(`Browser page driver created`); const childProcess = { - async kill(): Promise { - if (page.isClosed()) return; + async kill(): Promise { + if (page.isClosed()) { + return {}; + } + + let metrics: PerformanceMetrics | undefined; + try { if (devTools && startMetrics) { const endMetrics = await devTools.send('Performance.getMetrics'); - const metrics = getMetrics(startMetrics, endMetrics); + metrics = getMetrics(startMetrics, endMetrics); const { cpuInPercentage, memoryInMegabytes } = metrics; - metrics$.next(metrics); logger.debug( `Chromium consumed CPU ${cpuInPercentage}% Memory ${memoryInMegabytes}MB` ); } } catch (error) { logger.error(error); - } finally { - metrics$.complete(); } try { @@ -209,6 +213,8 @@ export class HeadlessChromiumDriverFactory { // do not throw logger.error(err); } + + return { metrics }; }, }; const { terminate$ } = safeChildProcess(logger, childProcess); @@ -245,7 +251,6 @@ export class HeadlessChromiumDriverFactory { observer.next({ driver, unexpectedExit$, - metrics$: metrics$.asObservable(), close: () => Rx.from(childProcess.kill()), }); diff --git a/x-pack/plugins/screenshotting/server/browsers/mock.ts b/x-pack/plugins/screenshotting/server/browsers/mock.ts index 1958f5e6b0396..9904f3e396830 100644 --- a/x-pack/plugins/screenshotting/server/browsers/mock.ts +++ b/x-pack/plugins/screenshotting/server/browsers/mock.ts @@ -91,8 +91,7 @@ export function createMockBrowserDriverFactory( of({ driver: driver ?? createMockBrowserDriver(), unexpectedExit$: NEVER, - metrics$: NEVER, - close: () => of(undefined), + close: () => of({}), }) ), diagnose: jest.fn(() => of('message')), diff --git a/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts b/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts index 4bc378a4c8c86..8447e56324a25 100644 --- a/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts +++ b/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts @@ -10,7 +10,7 @@ import { take, share, mapTo, delay, tap } from 'rxjs/operators'; import type { Logger } from 'src/core/server'; interface IChild { - kill: (signal: string) => Promise; + kill(signal: string): Promise; } // Our process can get sent various signals, and when these occur we wish to diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.test.ts b/x-pack/plugins/screenshotting/server/screenshots/index.test.ts index eae7a6a5bc031..ff5c910e9cc3e 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { of, throwError, NEVER } from 'rxjs'; +import { of, throwError } from 'rxjs'; import type { Logger } from 'src/core/server'; import type { ConfigType } from '../config'; import { createMockBrowserDriver, createMockBrowserDriverFactory } from '../browsers/mock'; @@ -356,8 +356,7 @@ describe('Screenshot Observable Pipeline', () => { of({ driver, unexpectedExit$: throwError('Instant timeout has fired!'), - metrics$: NEVER, - close: () => of(undefined), + close: () => of({}), }) ); diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.ts b/x-pack/plugins/screenshotting/server/screenshots/index.ts index a43fd4549e482..e8a90145f77e6 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.ts @@ -11,10 +11,11 @@ import { catchError, concatMap, first, - mapTo, + map, mergeMap, take, takeUntil, + tap, toArray, } from 'rxjs/operators'; import type { Logger } from 'src/core/server'; @@ -40,7 +41,7 @@ export interface ScreenshotResult { /** * Collected performance metrics during the screenshotting session. */ - metrics$: Observable; + metrics?: PerformanceMetrics; /** * Screenshotting results. @@ -88,12 +89,8 @@ export class Screenshots { ) .pipe( this.semaphore.acquire(), - mergeMap(({ driver, unexpectedExit$, metrics$, close }) => { + mergeMap(({ driver, unexpectedExit$, close }) => { apmCreatePage?.end(); - metrics$.subscribe(({ cpu, memory }) => { - apmTrans?.setLabel('cpu', cpu, false); - apmTrans?.setLabel('memory', memory, false); - }); unexpectedExit$.subscribe({ error: () => apmTrans?.end() }); const screen = new ScreenshotObservableHandler(driver, this.logger, layout, options); @@ -113,10 +110,18 @@ export class Screenshots { ), take(options.urls.length), toArray(), - mergeMap((results) => { + mergeMap((results) => // At this point we no longer need the page, close it. - return close().pipe(mapTo({ layout, metrics$, results })); - }) + close().pipe( + tap(({ metrics }) => { + if (metrics) { + apmTrans?.setLabel('cpu', metrics.cpu, false); + apmTrans?.setLabel('memory', metrics.memory, false); + } + }), + map(({ metrics }) => ({ layout, metrics, results })) + ) + ) ); }), first() diff --git a/x-pack/plugins/screenshotting/server/screenshots/mock.ts b/x-pack/plugins/screenshotting/server/screenshots/mock.ts index c4b5707243136..302407864ffbe 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/mock.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { of, NEVER } from 'rxjs'; +import { of } from 'rxjs'; import { createMockLayout } from '../layouts/mock'; import type { Screenshots, ScreenshotResult } from '.'; @@ -14,7 +14,6 @@ export function createMockScreenshots(): jest.Mocked { getScreenshots: jest.fn((options) => of({ layout: createMockLayout(), - metrics$: NEVER, results: options.urls.map(() => ({ timeRange: null, screenshots: [ diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index 76ca459fbbe1d..25de792731d44 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -254,9 +254,27 @@ describe('AlertSummaryView', () => { }, { category: 'kibana', - field: 'kibana.alert.threshold_result.terms', - values: ['{"field":"host.name","value":"Host-i120rdnmnw"}'], - originalValue: ['{"field":"host.name","value":"Host-i120rdnmnw"}'], + field: 'kibana.alert.threshold_result.terms.value', + values: ['host-23084y2', '3084hf3n84p8934r8h'], + originalValue: ['host-23084y2', '3084hf3n84p8934r8h'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.terms.field', + values: ['host.name', 'host.id'], + originalValue: ['host.name', 'host.id'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.field', + values: ['host.name'], + originalValue: ['host.name'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.value', + values: [9001], + originalValue: [9001], }, ] as TimelineEventsDetailsItem[]; const renderProps = { @@ -269,11 +287,130 @@ describe('AlertSummaryView', () => { ); - ['Threshold Count', 'host.name [threshold]'].forEach((fieldId) => { + [ + 'Threshold Count', + 'host.name [threshold]', + 'host.id [threshold]', + 'Threshold Cardinality', + 'count(host.name) >= 9001', + ].forEach((fieldId) => { expect(getByText(fieldId)); }); }); + test('Threshold fields are not shown when data is malformated', () => { + const enhancedData = [ + ...mockAlertDetailsData.map((item) => { + if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') { + return { + ...item, + values: ['threshold'], + originalValue: ['threshold'], + }; + } + return item; + }), + { + category: 'kibana', + field: 'kibana.alert.threshold_result.count', + values: [9001], + originalValue: [9001], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.terms.field', + // This would be expected to have two entries + values: ['host.id'], + originalValue: ['host.id'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.terms.value', + values: ['host-23084y2', '3084hf3n84p8934r8h'], + originalValue: ['host-23084y2', '3084hf3n84p8934r8h'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.field', + values: ['host.name'], + originalValue: ['host.name'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.value', + // This would be expected to have one entry + values: [], + originalValue: [], + }, + ] as TimelineEventsDetailsItem[]; + const renderProps = { + ...props, + data: enhancedData, + }; + const { getByText } = render( + + + + ); + + ['Threshold Count'].forEach((fieldId) => { + expect(getByText(fieldId)); + }); + + [ + 'host.name [threshold]', + 'host.id [threshold]', + 'Threshold Cardinality', + 'count(host.name) >= 9001', + ].forEach((fieldText) => { + expect(() => getByText(fieldText)).toThrow(); + }); + }); + + test('Threshold fields are not shown when data is partially missing', () => { + const enhancedData = [ + ...mockAlertDetailsData.map((item) => { + if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') { + return { + ...item, + values: ['threshold'], + originalValue: ['threshold'], + }; + } + return item; + }), + { + category: 'kibana', + field: 'kibana.alert.threshold_result.terms.field', + // This would be expected to have two entries + values: ['host.id'], + originalValue: ['host.id'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.field', + values: ['host.name'], + originalValue: ['host.name'], + }, + ] as TimelineEventsDetailsItem[]; + const renderProps = { + ...props, + data: enhancedData, + }; + const { getByText } = render( + + + + ); + + // The `value` fields are missing here, so the enriched field info cannot be calculated correctly + ['host.id [threshold]', 'Threshold Cardinality', 'count(host.name) >= 9001'].forEach( + (fieldText) => { + expect(() => getByText(fieldText)).toThrow(); + } + ); + }); + test("doesn't render empty fields", () => { const renderProps = { ...props, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 441bd5028cb95..3da4ecab77992 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { getOr, find, isEmpty, uniqBy } from 'lodash/fp'; +import { find, isEmpty, uniqBy } from 'lodash/fp'; import { ALERT_RULE_NAMESPACE, ALERT_RULE_TYPE, @@ -24,12 +24,18 @@ import { import { ALERT_THRESHOLD_RESULT } from '../../../../common/field_maps/field_names'; import { AGENT_STATUS_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; import { getEnrichedFieldInfo, SummaryRow } from './helpers'; -import { EventSummaryField } from './types'; +import { EventSummaryField, EnrichedFieldInfo } from './types'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { isAlertFromEndpointEvent } from '../../utils/endpoint_alert_check'; import { EventCode, EventCategory } from '../../../../common/ecs/event'; +const THRESHOLD_TERMS_FIELD = `${ALERT_THRESHOLD_RESULT}.terms.field`; +const THRESHOLD_TERMS_VALUE = `${ALERT_THRESHOLD_RESULT}.terms.value`; +const THRESHOLD_CARDINALITY_FIELD = `${ALERT_THRESHOLD_RESULT}.cardinality.field`; +const THRESHOLD_CARDINALITY_VALUE = `${ALERT_THRESHOLD_RESULT}.cardinality.value`; +const THRESHOLD_COUNT = `${ALERT_THRESHOLD_RESULT}.count`; + /** Always show these fields */ const alwaysDisplayedFields: EventSummaryField[] = [ { id: 'host.name' }, @@ -132,10 +138,10 @@ function getFieldsByRuleType(ruleType?: string): EventSummaryField[] { switch (ruleType) { case 'threshold': return [ - { id: `${ALERT_THRESHOLD_RESULT}.count`, label: ALERTS_HEADERS_THRESHOLD_COUNT }, - { id: `${ALERT_THRESHOLD_RESULT}.terms`, label: ALERTS_HEADERS_THRESHOLD_TERMS }, + { id: THRESHOLD_COUNT, label: ALERTS_HEADERS_THRESHOLD_COUNT }, + { id: THRESHOLD_TERMS_FIELD, label: ALERTS_HEADERS_THRESHOLD_TERMS }, { - id: `${ALERT_THRESHOLD_RESULT}.cardinality`, + id: THRESHOLD_CARDINALITY_FIELD, label: ALERTS_HEADERS_THRESHOLD_CARDINALITY, }, ]; @@ -272,42 +278,20 @@ export const getSummaryRows = ({ return acc; } - if (field.id === `${ALERT_THRESHOLD_RESULT}.terms`) { - try { - const terms = getOr(null, 'originalValue', item); - const parsedValue = terms.map((term: string) => JSON.parse(term)); - const thresholdTerms = (parsedValue ?? []).map( - (entry: { field: string; value: string }) => { - return { - title: `${entry.field} [threshold]`, - description: { - ...description, - values: [entry.value], - }, - }; - } - ); - return [...acc, ...thresholdTerms]; - } catch (err) { - return [...acc]; + if (field.id === THRESHOLD_TERMS_FIELD) { + const enrichedInfo = enrichThresholdTerms(item, data, description); + if (enrichedInfo) { + return [...acc, ...enrichedInfo]; + } else { + return acc; } } - if (field.id === `${ALERT_THRESHOLD_RESULT}.cardinality`) { - try { - const value = getOr(null, 'originalValue.0', field); - const parsedValue = JSON.parse(value); - return [ - ...acc, - { - title: ALERTS_HEADERS_THRESHOLD_CARDINALITY, - description: { - ...description, - values: [`count(${parsedValue.field}) == ${parsedValue.value}`], - }, - }, - ]; - } catch (err) { + if (field.id === THRESHOLD_CARDINALITY_FIELD) { + const enrichedInfo = enrichThresholdCardinality(item, data, description); + if (enrichedInfo) { + return [...acc, enrichedInfo]; + } else { return acc; } } @@ -322,3 +306,63 @@ export const getSummaryRows = ({ }, []) : []; }; + +/** + * Enriches the summary data for threshold terms. + * For any given threshold term, it generates a row with the term's name and the associated value. + */ +function enrichThresholdTerms( + { values: termsFieldArr }: TimelineEventsDetailsItem, + data: TimelineEventsDetailsItem[], + description: EnrichedFieldInfo +) { + const termsValueItem = data.find((d) => d.field === THRESHOLD_TERMS_VALUE); + const termsValueArray = termsValueItem && termsValueItem.values; + + // Make sure both `fields` and `values` are an array and that they have the same length + if ( + Array.isArray(termsFieldArr) && + termsFieldArr.length > 0 && + Array.isArray(termsValueArray) && + termsFieldArr.length === termsValueArray.length + ) { + return termsFieldArr.map((field, index) => { + return { + title: `${field} [threshold]`, + description: { + ...description, + values: [termsValueArray[index]], + }, + }; + }); + } +} + +/** + * Enriches the summary data for threshold cardinality. + * Reads out the cardinality field and the value and interpolates them into a combined string value. + */ +function enrichThresholdCardinality( + { values: cardinalityFieldArr }: TimelineEventsDetailsItem, + data: TimelineEventsDetailsItem[], + description: EnrichedFieldInfo +) { + const cardinalityValueItem = data.find((d) => d.field === THRESHOLD_CARDINALITY_VALUE); + const cardinalityValueArray = cardinalityValueItem && cardinalityValueItem.values; + + // Only return a summary row if we actually have the correct field and value + if ( + Array.isArray(cardinalityFieldArr) && + cardinalityFieldArr.length === 1 && + Array.isArray(cardinalityValueArray) && + cardinalityFieldArr.length === cardinalityValueArray.length + ) { + return { + title: ALERTS_HEADERS_THRESHOLD_CARDINALITY, + description: { + ...description, + values: [`count(${cardinalityFieldArr[0]}) >= ${cardinalityValueArray[0]}`], + }, + }; + } +} 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 a04c403d504dd..cb30a9e28d0b1 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": "バイトフォーマット", @@ -4667,14 +4662,9 @@ "savedObjectsManagement.objectsTable.flyout.importSavedObjectTitle": "保存されたオブジェクトのインポート", "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "すべての変更を確定", "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmButtonLabel": "完了", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "新規インデックスパターンを作成", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsDescription": "次の保存されたオブジェクトは、存在しないインデックスパターンを使用しています。関連付け直す別のインデックスパターンを選択してください。必要に応じて、{indexPatternLink}できます。", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsTitle": "インデックスパターンの矛盾", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "影響されるオブジェクトの数です", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "カウント", - "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "インデックスパターンのIDです", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdName": "ID", - "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "新規インデックスパターン", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "影響されるオブジェクトのサンプル", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "影響されるオブジェクトのサンプル", "savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel": "インポートするファイルを選択してください", @@ -13782,8 +13772,6 @@ "xpack.infra.logSourceConfiguration.dataViewTitle": "ログデータビュー", "xpack.infra.logSourceConfiguration.emptyColumnListErrorMessage": "列リストは未入力のままにできません。", "xpack.infra.logSourceConfiguration.emptyFieldErrorMessage": "フィールド'{fieldName}'は未入力のままにできません。", - "xpack.infra.logSourceConfiguration.indexPatternInformationCalloutDescription": "ログUIはデータビューと統合し、使用されているインデックスを構成します。", - "xpack.infra.logSourceConfiguration.indexPatternInformationCalloutTitle": "新しい構成オプション", "xpack.infra.logSourceConfiguration.invalidMessageFieldTypeErrorMessage": "{messageField}フィールドはテキストフィールドでなければなりません。", "xpack.infra.logSourceConfiguration.logSourceConfigurationFormErrorsCalloutTitle": "一貫しないソース構成", "xpack.infra.logSourceConfiguration.missingDataViewErrorMessage": "データビュー{dataViewId}が存在する必要があります。", @@ -13791,7 +13779,6 @@ "xpack.infra.logSourceConfiguration.missingMessageFieldErrorMessage": "データビューには{messageField}フィールドが必要です。", "xpack.infra.logSourceConfiguration.missingTimestampFieldErrorMessage": "データビューは時間に基づく必要があります。", "xpack.infra.logSourceConfiguration.rollupIndexPatternErrorMessage": "データビューがロールアップインデックスパターンであってはなりません。", - "xpack.infra.logSourceConfiguration.switchToDataViewReferenceButtonLabel": "データビューを使用", "xpack.infra.logSourceConfiguration.unsavedFormPromptMessage": "終了してよろしいですか?変更内容は失われます", "xpack.infra.logSourceErrorPage.failedToLoadSourceMessage": "構成の読み込み試行中にエラーが発生しました。再試行するか、構成を変更して問題を修正してください。", "xpack.infra.logSourceErrorPage.failedToLoadSourceTitle": "構成を読み込めませんでした", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7033e4e4485ef..6c9008c56a807 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": "字节格式", @@ -4464,14 +4459,9 @@ "savedObjectsManagement.objectsTable.flyout.importSavedObjectTitle": "导入已保存对象", "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "确认所有更改", "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmButtonLabel": "完成", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "创建新的索引模式", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsDescription": "以下已保存对象使用不存在的索引模式。请选择要重新关联的索引模式。必要时可以{indexPatternLink}。", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsTitle": "索引模式冲突", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "受影响对象数目", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "计数", - "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "索引模式的 ID", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdName": "ID", - "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "新建索引模式", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "受影响对象样例", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "受影响对象样例", "savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel": "选择要导入的文件", @@ -13733,8 +13723,6 @@ "xpack.infra.logSourceConfiguration.dataViewTitle": "日志数据视图", "xpack.infra.logSourceConfiguration.emptyColumnListErrorMessage": "列列表不得为空。", "xpack.infra.logSourceConfiguration.emptyFieldErrorMessage": "字段“{fieldName}”不得为空。", - "xpack.infra.logSourceConfiguration.indexPatternInformationCalloutDescription": "现在,Logs UI 可以与数据视图集成以配置使用的索引。", - "xpack.infra.logSourceConfiguration.indexPatternInformationCalloutTitle": "新配置选项", "xpack.infra.logSourceConfiguration.invalidMessageFieldTypeErrorMessage": "{messageField} 字段必须是文本字段。", "xpack.infra.logSourceConfiguration.logDataViewHelpText": "数据视图在 Kibana 工作区中的应用间共享,并可以通过 {dataViewsManagementLink} 进行管理。", "xpack.infra.logSourceConfiguration.logSourceConfigurationFormErrorsCalloutTitle": "内容配置不一致", @@ -13743,7 +13731,6 @@ "xpack.infra.logSourceConfiguration.missingMessageFieldErrorMessage": "数据视图必须包含 {messageField} 字段。", "xpack.infra.logSourceConfiguration.missingTimestampFieldErrorMessage": "数据视图必须基于时间。", "xpack.infra.logSourceConfiguration.rollupIndexPatternErrorMessage": "数据视图不得为汇总/打包索引模式。", - "xpack.infra.logSourceConfiguration.switchToDataViewReferenceButtonLabel": "使用数据视图", "xpack.infra.logSourceConfiguration.unsavedFormPromptMessage": "是否确定要离开?更改将丢失", "xpack.infra.logSourceErrorPage.failedToLoadSourceMessage": "尝试加载配置时出错。请重试或更改配置以解决问题。", "xpack.infra.logSourceErrorPage.failedToLoadSourceTitle": "无法加载配置", 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/hydrate_saved_object.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts index 2e98b62ddee66..08e2934a4ac08 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts @@ -18,39 +18,43 @@ export const hydrateSavedObjects = async ({ monitors: SyntheticsMonitorSavedObject[]; server: UptimeServerSetup; }) => { - const missingUrlInfoIds: string[] = []; + try { + const missingUrlInfoIds: string[] = []; - monitors - .filter((monitor) => monitor.attributes.type === 'browser') - .forEach(({ attributes, id }) => { - const monitor = attributes as MonitorFields; - if (!monitor || !monitor.urls) { - missingUrlInfoIds.push(id); - } - }); + monitors + .filter((monitor) => monitor.attributes.type === 'browser') + .forEach(({ attributes, id }) => { + const monitor = attributes as MonitorFields; + if (!monitor || !monitor.urls) { + missingUrlInfoIds.push(id); + } + }); - if (missingUrlInfoIds.length > 0 && server.uptimeEsClient) { - const esDocs: Ping[] = await fetchSampleMonitorDocuments( - server.uptimeEsClient, - missingUrlInfoIds - ); - const updatedObjects = monitors - .filter((monitor) => missingUrlInfoIds.includes(monitor.id)) - .map((monitor) => { - let url = ''; - esDocs.forEach((doc) => { - // to make sure the document is ingested after the latest update of the monitor - const diff = moment(monitor.updated_at).diff(moment(doc.timestamp), 'minutes'); - if (doc.config_id === monitor.id && doc.url?.full && diff > 1) { - url = doc.url?.full; + if (missingUrlInfoIds.length > 0 && server.uptimeEsClient) { + const esDocs: Ping[] = await fetchSampleMonitorDocuments( + server.uptimeEsClient, + missingUrlInfoIds + ); + const updatedObjects = monitors + .filter((monitor) => missingUrlInfoIds.includes(monitor.id)) + .map((monitor) => { + let url = ''; + esDocs.forEach((doc) => { + // to make sure the document is ingested after the latest update of the monitor + const diff = moment(monitor.updated_at).diff(moment(doc.timestamp), 'minutes'); + if (doc.config_id === monitor.id && doc.url?.full && diff > 1) { + url = doc.url?.full; + } + }); + if (url) { + return { ...monitor, attributes: { ...monitor.attributes, urls: url } }; } + return monitor; }); - if (url) { - return { ...monitor, attributes: { ...monitor.attributes, urls: url } }; - } - return monitor; - }); - await server.authSavedObjectsClient?.bulkUpdate(updatedObjects); + await server.authSavedObjectsClient?.bulkUpdate(updatedObjects); + } + } catch (e) { + server.logger.error(e); } }; 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..acf3c0df49164 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 @@ -51,6 +51,9 @@ export class SyntheticsService { public locations: ServiceLocations; + private indexTemplateExists?: boolean; + private indexTemplateInstalling?: boolean; + constructor(logger: Logger, server: UptimeServerSetup, config: ServiceConfig) { this.logger = logger; this.server = server; @@ -70,23 +73,34 @@ export class SyntheticsService { // this.apiKey = apiKey; // } // }); - this.setupIndexTemplates(); } private setupIndexTemplates() { - installSyntheticsIndexTemplates(this.server).then( - (result) => { - if (result.name === 'synthetics' && result.install_status === 'installed') { - this.logger.info('Installed synthetics index templates'); - } else if (result.name === 'synthetics' && result.install_status === 'install_failed') { + if (this.indexTemplateExists) { + // if already installed, don't need to reinstall + return; + } + + if (!this.indexTemplateInstalling) { + installSyntheticsIndexTemplates(this.server).then( + (result) => { + this.indexTemplateInstalling = false; + if (result.name === 'synthetics' && result.install_status === 'installed') { + this.logger.info('Installed synthetics index templates'); + this.indexTemplateExists = true; + } else if (result.name === 'synthetics' && result.install_status === 'install_failed') { + this.logger.warn(new IndexTemplateInstallationError()); + this.indexTemplateExists = false; + } + }, + () => { + this.indexTemplateInstalling = false; this.logger.warn(new IndexTemplateInstallationError()); } - }, - () => { - this.logger.warn(new IndexTemplateInstallationError()); - } - ); + ); + this.indexTemplateInstalling = true; + } } public registerSyncTask(taskManager: TaskManagerSetupContract) { @@ -106,6 +120,8 @@ export class SyntheticsService { async run() { const { state } = taskInstance; + service.setupIndexTemplates(); + getServiceLocations(service.server).then((result) => { service.locations = result.locations; service.apiClient.locations = result.locations; @@ -280,12 +296,16 @@ export class SyntheticsService { const findResult = await savedObjectsClient.find({ type: syntheticsMonitorType, namespaces: ['*'], + perPage: 10000, }); - hydrateSavedObjects({ - monitors: findResult.saved_objects as unknown as SyntheticsMonitorSavedObject[], - server: this.server, - }); + if (this.indexTemplateExists) { + // without mapping, querying won't make sense + hydrateSavedObjects({ + monitors: findResult.saved_objects as unknown as SyntheticsMonitorSavedObject[], + server: this.server, + }); + } return (findResult.saved_objects ?? []).map(({ attributes, id }) => ({ ...attributes, 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/graph/graph.ts b/x-pack/test/functional/apps/graph/graph.ts index 172686692110e..6410a0b0272f8 100644 --- a/x-pack/test/functional/apps/graph/graph.ts +++ b/x-pack/test/functional/apps/graph/graph.ts @@ -138,7 +138,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.execute(() => { const event = document.createEvent('SVGEvents'); event.initEvent('click', true, true); - return document.getElementsByClassName('gphEdge')[0].dispatchEvent(event); + return document.getElementsByClassName('gphEdge--clickable')[0].dispatchEvent(event); }); await PageObjects.common.sleep(1000); await PageObjects.graph.startLayout(); 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/page_objects/graph_page.ts b/x-pack/test/functional/page_objects/graph_page.ts index b0389510e5ef5..bc6890246f444 100644 --- a/x-pack/test/functional/page_objects/graph_page.ts +++ b/x-pack/test/functional/page_objects/graph_page.ts @@ -131,7 +131,9 @@ export class GraphPageObject extends FtrService { const elements = document.querySelectorAll('#graphSvg text.gphNode__label'); return [...elements].map(element => element.innerHTML); `); - const graphElements = await this.find.allByCssSelector('#graphSvg line, #graphSvg circle'); + const graphElements = await this.find.allByCssSelector( + '#graphSvg line:not(.gphEdge--clickable), #graphSvg circle' + ); const nodes: Node[] = []; const nodePositionMap: Record = {}; const edges: Edge[] = []; 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 ae2fb1bf3d84a..3155053fb8b40 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"