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 5df9e9132dd4b..0f4081405e175 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", @@ -424,25 +424,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/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/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/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/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/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/jest.integration.config.js b/x-pack/plugins/fleet/jest.integration.config.js new file mode 100644 index 0000000000000..f1b9ee2f5f7e0 --- /dev/null +++ b/x-pack/plugins/fleet/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/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/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/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/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/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 438d728b7df1f..ab7ee8992f2fe 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { partition, mapValues, pickBy } from 'lodash'; +import { partition, mapValues, pickBy, isArray } from 'lodash'; import { CoreStart } from 'kibana/public'; import { Query } from 'src/plugins/data/common'; +import type { VisualizeEditorLayersContext } from '../../../../../../src/plugins/visualizations/public'; import type { DatasourceFixAction, FrameDatasourceAPI, @@ -38,7 +39,9 @@ import { } from './definitions/column_types'; import { FormulaIndexPatternColumn, insertOrReplaceFormulaColumn } from './definitions/formula'; import type { TimeScaleUnit } from '../../../common/expressions'; +import { documentField } from '../document_field'; import { isColumnOfType } from './definitions/helpers'; +import { isSortableByColumn } from './definitions/terms/helpers'; interface ColumnAdvancedParams { filter?: Query | undefined; @@ -57,6 +60,9 @@ interface ColumnChange { shouldResetLabel?: boolean; shouldCombineField?: boolean; incompleteParams?: ColumnAdvancedParams; + incompleteFieldName?: string; + incompleteFieldOperation?: OperationType; + columnParams?: Record; 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/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/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/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/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 afad91faa4bde..4258349726ccf 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 @@ -32,4 +32,29 @@ describe('PdfMaker', () => { await expect(pdf.getBuffer()).resolves.toBeInstanceOf(Buffer); }); }); + + describe('getPageCount', () => { + it('should return zero pages on no content', () => { + expect(pdf.getPageCount()).toBe(0); + }); + + it('should return a number of generated pages', () => { + for (let i = 0; i < 100; i++) { + pdf.addImage(imageBase64, { title: `${i} viz`, description: '☃️' }); + } + 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: '☃️' }); + } + pdf.generate(); + await pdf.getBuffer(); + + 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 96dcd480a454c..4d462a429607a 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 @@ -157,4 +157,14 @@ export class PdfMaker { this._pdfDoc.end(); }); } + + getPageCount(): number { + const pageRange = this._pdfDoc?.bufferedPageRange(); + if (!pageRange) { + return 0; + } + const { count, start } = pageRange; + + return start + count; + } } 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 032552be3978f..149f4fc3aee52 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,8 +7,9 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; +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'; @@ -25,25 +26,32 @@ const getTimeRange = (urlScreenshots: ScreenshotResult['results']) => { return null; }; +interface PdfResult { + buffer: Buffer | null; + metrics?: PdfMetrics; + warnings: string[]; +} + export function generatePdfObservable( reporting: ReportingCore, logger: LevelLogger, title: string, options: ScreenshotOptions, logo?: string -): Rx.Observable<{ buffer: Buffer | 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); @@ -89,6 +97,10 @@ export function generatePdfObservable( return { buffer, + metrics: { + ...metrics, + pages: pdfOutput.getPageCount(), + }, warnings: results.reduce((found, current) => { if (current.error) { found.push(current.error.message); 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 890c0c9cde731..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 @@ -57,15 +57,16 @@ export const runTaskFnFactory: RunTaskFnFactory> = logo ); }), - tap(({ buffer, warnings }) => { + tap(({ buffer }) => { apmGeneratePdf?.end(); if (buffer) { 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 424715f10fb79..08e73371f74b7 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,9 +7,9 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; +import { mergeMap, tap } from 'rxjs/operators'; import { ReportingCore } from '../../../'; -import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../../common/types'; +import { LocatorParams, PdfMetrics, UrlOrUrlLocatorTuple } from '../../../../common/types'; import { LevelLogger } from '../../../lib'; import { ScreenshotResult } from '../../../../../screenshotting/server'; import { ScreenshotOptions } from '../../../types'; @@ -28,6 +28,12 @@ const getTimeRange = (urlScreenshots: ScreenshotResult['results']) => { return null; }; +interface PdfResult { + buffer: Buffer | null; + metrics?: PdfMetrics; + warnings: string[]; +} + export function generatePdfObservable( reporting: ReportingCore, logger: LevelLogger, @@ -36,7 +42,7 @@ export function generatePdfObservable( locatorParams: LocatorParams[], options: Omit, logo?: string -): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { +): Rx.Observable { const tracker = getTracker(); tracker.startScreenshots(); @@ -49,14 +55,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); @@ -102,6 +109,10 @@ export function generatePdfObservable( return { buffer, + metrics: { + ...metrics, + pages: pdfOutput.getPageCount(), + }, warnings: results.reduce((found, current) => { if (current.error) { found.push(current.error.message); 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/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 125c9ff096507..9c38543d56fbc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4662,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": "インポートするファイルを選択してください", @@ -13777,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}が存在する必要があります。", @@ -13786,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 69e9f293f845d..d94df37a7c260 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4459,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": "选择要导入的文件", @@ -13728,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": "内容配置不一致", @@ -13738,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/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 cf20efbce8e6f..5e0f0d8b54ba7 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" @@ -23378,6 +23441,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"