diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 0000000000000..b71007690f1cf --- /dev/null +++ b/.bazelignore @@ -0,0 +1,15 @@ +# Bazel does not support wildcards like .gitignore +# Issues are opened for to include that feature but not available yet +# https://github.com/bazelbuild/bazel/issues/7093 +# https://github.com/bazelbuild/bazel/issues/8106 +.ci +.git +.github +.idea +.teamcity +.yarn-local-mirror +bazel-cache +bazel-dist +build +node_modules +target diff --git a/.bazeliskversion b/.bazeliskversion new file mode 100644 index 0000000000000..661e7aeadf36f --- /dev/null +++ b/.bazeliskversion @@ -0,0 +1 @@ +1.7.3 diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000000000..fd469d1203a82 --- /dev/null +++ b/.bazelrc @@ -0,0 +1,9 @@ +# Inspired on from https://raw.githubusercontent.com/bazelbuild/rules_nodejs/master/.bazelrc +# Import shared settings first so we can override below +import %workspace%/.bazelrc.common + +# Remote cache settings for local env +# build --remote_cache=https://storage.googleapis.com/kibana-bazel-cache +# build --incompatible_remote_results_ignore_disk=true +# build --remote_accept_cached=true +# build --remote_upload_local_results=false diff --git a/.bazelrc.common b/.bazelrc.common new file mode 100644 index 0000000000000..a53d1b8072483 --- /dev/null +++ b/.bazelrc.common @@ -0,0 +1,118 @@ +# Inspired on from https://raw.githubusercontent.com/bazelbuild/rules_nodejs/master/common.bazelrc +# Common Bazel settings for JavaScript/NodeJS workspaces +# This rc file is automatically discovered when Bazel is run in this workspace, +# see https://docs.bazel.build/versions/master/guide.html#bazelrc +# +# The full list of Bazel options: https://docs.bazel.build/versions/master/command-line-reference.html + +# Cache action outputs on disk so they persist across output_base and bazel shutdown (eg. changing branches) +build --disk_cache=bazel-cache/disk-cache + +# Bazel repo cache settings +build --repository_cache=bazel-cache/repository-cache + +# Bazel will create symlinks from the workspace directory to output artifacts. +# Build results will be placed in a directory called "bazel-dist/bin" +# This will still create a bazel-out symlink in +# the project directory, which must be excluded from the +# editor's search path. +build --symlink_prefix=bazel-dist/ +# To disable the symlinks altogether (including bazel-out) we can use +# build --symlink_prefix=/ +# however this makes it harder to find outputs. + +# Prevents the creation of bazel-out dir +build --experimental_no_product_name_out_symlink + +# Make direct file system calls to create symlink trees +build --experimental_inprocess_symlink_creation + +# Incompatible flags to run with +build --incompatible_no_implicit_file_export +build --incompatible_restrict_string_escapes + +# Log configs +## different from default +common --color=yes +build --show_task_finish +build --noshow_progress +build --noshow_loading_progress + +## enforced default values +build --show_result=1 + +# Specifies desired output mode for running tests. +# Valid values are +# 'summary' to output only test status summary +# 'errors' to also print test logs for failed tests +# 'all' to print logs for all tests +# 'streamed' to output logs for all tests in real time +# (this will force tests to be executed locally one at a time regardless of --test_strategy value). +test --test_output=errors + +# Support for debugging NodeJS tests +# Add the Bazel option `--config=debug` to enable this +# --test_output=streamed +# Stream stdout/stderr output from each test in real-time. +# See https://docs.bazel.build/versions/master/user-manual.html#flag--test_output for more details. +# --test_strategy=exclusive +# Run one test at a time. +# --test_timeout=9999 +# Prevent long running tests from timing out +# See https://docs.bazel.build/versions/master/user-manual.html#flag--test_timeout for more details. +# --nocache_test_results +# Always run tests +# --node_options=--inspect-brk +# Pass the --inspect-brk option to all tests which enables the node inspector agent. +# See https://nodejs.org/de/docs/guides/debugging-getting-started/#command-line-options for more details. +# --define=VERBOSE_LOGS=1 +# Rules will output verbose logs if the VERBOSE_LOGS environment variable is set. `VERBOSE_LOGS` will be passed to +# `nodejs_binary` and `nodejs_test` via the default value of the `default_env_vars` attribute of those rules. +# --compilation_mode=dbg +# Rules may change their build outputs if the compilation mode is set to dbg. For example, +# mininfiers such as terser may make their output more human readable when this is set. `COMPILATION_MODE` will be passed to +# `nodejs_binary` and `nodejs_test` via the default value of the `default_env_vars` attribute of those rules. +# See https://docs.bazel.build/versions/master/user-manual.html#flag--compilation_mode for more details. +test:debug --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results --define=VERBOSE_LOGS=1 +# Use bazel run with `--config=debug` to turn on the NodeJS inspector agent. +# The node process will break before user code starts and wait for the debugger to connect. +run:debug --define=VERBOSE_LOGS=1 -- --node_options=--inspect-brk +# The following option will change the build output of certain rules such as terser and may not be desirable in all cases +build:debug --compilation_mode=dbg + +# Turn off legacy external runfiles +# This prevents accidentally depending on this feature, which Bazel will remove. +build --nolegacy_external_runfiles +run --nolegacy_external_runfiles +test --nolegacy_external_runfiles + +# Turn on --incompatible_strict_action_env which was on by default +# in Bazel 0.21.0 but turned off again in 0.22.0. Follow +# https://github.com/bazelbuild/bazel/issues/7026 for more details. +# This flag is needed to so that the bazel cache is not invalidated +# when running bazel via `yarn bazel`. +# See https://github.com/angular/angular/issues/27514. +build --incompatible_strict_action_env +run --incompatible_strict_action_env +test --incompatible_strict_action_env + +# Do not build runfile trees by default. If an execution strategy relies on runfile +# symlink tree, the tree is created on-demand. See: https://github.com/bazelbuild/bazel/issues/6627 +# and https://github.com/bazelbuild/bazel/commit/03246077f948f2790a83520e7dccc2625650e6df +build --nobuild_runfile_links + +# When running `bazel coverage` --instrument_test_targets needs to be set in order to +# collect coverage information from test targets +coverage --instrument_test_targets + +# Settings for CI +# Bazel flags for CI are in /src/dev/ci_setup/.bazelrc-ci + +# Load any settings specific to the current user. +# .bazelrc.user should appear in .gitignore so that settings are not shared with team members +# This needs to be last statement in this +# config, as the user configuration should be able to overwrite flags from this file. +# See https://docs.bazel.build/versions/master/best-practices.html#bazelrc +# (Note that we use .bazelrc.user so the file appears next to .bazelrc in directory listing, +# rather than user.bazelrc as suggested in the Bazel docs) +try-import %workspace%/.bazelrc.user diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000000000..fcdb2e109f68c --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +4.0.0 diff --git a/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index b40cd91a45c57..11a39faa9aed0 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -29,6 +29,7 @@ kibanaPipeline(timeoutMinutes: 150) { withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { parallel([ 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh'), 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), diff --git a/.ci/jobs.yml b/.ci/jobs.yml index 6aa93d4a1056a..b05e834f5a459 100644 --- a/.ci/jobs.yml +++ b/.ci/jobs.yml @@ -2,6 +2,7 @@ JOB: - kibana-intake + - x-pack-intake - kibana-firefoxSmoke - kibana-ciGroup1 - kibana-ciGroup2 diff --git a/.ci/teamcity/default/jest.sh b/.ci/teamcity/default/jest.sh index dac1cc8986a1c..b900d1b6d6b4e 100755 --- a/.ci/teamcity/default/jest.sh +++ b/.ci/teamcity/default/jest.sh @@ -1,4 +1,10 @@ #!/bin/bash -# This file is temporary and can be removed once #85850 has been -# merged and the changes included in open PR's (~3 days after merging) +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-default-jest + +checks-reporter-with-killswitch "Jest Unit Tests" \ + node scripts/jest x-pack --ci --verbose --maxWorkers=5 diff --git a/.ci/teamcity/oss/jest.sh b/.ci/teamcity/oss/jest.sh index b323a88ef06bc..0dee07d00d2be 100755 --- a/.ci/teamcity/oss/jest.sh +++ b/.ci/teamcity/oss/jest.sh @@ -1,13 +1,10 @@ #!/bin/bash -# This file is temporary and can be removed once #85850 has been -# merged and the changes included in open PR's (~3 days after merging) - set -euo pipefail source "$(dirname "${0}")/../util.sh" export JOB=kibana-oss-jest -checks-reporter-with-killswitch "Jest Unit Tests" \ - node scripts/jest --ci --verbose +checks-reporter-with-killswitch "OSS Jest Unit Tests" \ + node scripts/jest --config jest.config.oss.js --ci --verbose --maxWorkers=5 diff --git a/.ci/teamcity/tests/jest.sh b/.ci/teamcity/tests/jest.sh deleted file mode 100755 index c8b9b075e0e61..0000000000000 --- a/.ci/teamcity/tests/jest.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "${0}")/../util.sh" - -export JOB=kibana-jest - -checks-reporter-with-killswitch "Jest Unit Tests" \ - node scripts/jest --ci --verbose --coverage diff --git a/.ci/teamcity/tests/test_projects.sh b/.ci/teamcity/tests/test_projects.sh index 2553650930392..06dd3607a6799 100755 --- a/.ci/teamcity/tests/test_projects.sh +++ b/.ci/teamcity/tests/test_projects.sh @@ -5,4 +5,4 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" checks-reporter-with-killswitch "Test Projects" \ - yarn kbn run test --exclude kibana --oss --skip-kibana-plugins + yarn kbn run test --exclude kibana --oss --skip-kibana-plugins --skip-missing diff --git a/.eslintignore b/.eslintignore index 4ef96ebab062a..5d25f3a78c1ec 100644 --- a/.eslintignore +++ b/.eslintignore @@ -36,6 +36,7 @@ snapshots.js # package overrides /packages/elastic-eslint-config-kibana /packages/kbn-interpreter/src/common/lib/grammar.js +/packages/kbn-tinymath/src/grammar.js /packages/kbn-plugin-generator/template /packages/kbn-pm/dist /packages/kbn-test/src/functional_test_runner/__tests__/fixtures/ @@ -43,3 +44,6 @@ snapshots.js /packages/kbn-ui-framework/dist /packages/kbn-ui-shared-deps/flot_charts /packages/kbn-monaco/src/painless/antlr + +# Bazel +/bazel-* diff --git a/.eslintrc.js b/.eslintrc.js index 29528c249d279..d8b9a9d7cdd99 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1192,6 +1192,32 @@ module.exports = { }, }, + /** + * Osquery overrides + */ + { + extends: ['eslint:recommended', 'plugin:react/recommended'], + plugins: ['react'], + files: ['x-pack/plugins/osquery/**/*.{js,mjs,ts,tsx}'], + rules: { + 'arrow-body-style': ['error', 'as-needed'], + 'prefer-arrow-callback': 'error', + 'no-unused-vars': 'off', + 'react/prop-types': 'off', + }, + }, + { + // typescript and javascript for front end react performance + files: ['x-pack/plugins/osquery/public/**/!(*.test).{js,mjs,ts,tsx}'], + plugins: ['react', 'react-perf'], + rules: { + 'react-perf/jsx-no-new-object-as-prop': 'error', + 'react-perf/jsx-no-new-array-as-prop': 'error', + 'react-perf/jsx-no-new-function-as-prop': 'error', + 'react/jsx-no-bind': 'error', + }, + }, + /** * Prettier disables all conflicting rules, listing as last override so it takes precedence */ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0630937d5ac4b..9e31bd31b4037 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -9,7 +9,6 @@ /x-pack/plugins/discover_enhanced/ @elastic/kibana-app /x-pack/plugins/lens/ @elastic/kibana-app /x-pack/plugins/graph/ @elastic/kibana-app -/x-pack/plugins/vis_type_timeseries_enhanced/ @elastic/kibana-app /src/plugins/advanced_settings/ @elastic/kibana-app /src/plugins/charts/ @elastic/kibana-app /src/plugins/discover/ @elastic/kibana-app @@ -67,6 +66,7 @@ # APM /x-pack/plugins/apm/ @elastic/apm-ui /x-pack/test/functional/apps/apm/ @elastic/apm-ui +/x-pack/test/apm_api_integration/ @elastic/apm-ui /src/plugins/apm_oss/ @elastic/apm-ui /src/apm.js @elastic/kibana-core @vigneshshanmugam /packages/kbn-apm-config-loader/ @elastic/kibana-core @vigneshshanmugam @@ -81,6 +81,7 @@ /x-pack/plugins/apm/server/lib/rum_client @elastic/uptime /x-pack/plugins/apm/server/routes/rum_client.ts @elastic/uptime /x-pack/plugins/apm/server/projections/rum_page_load_transactions.ts @elastic/uptime +/x-pack/test/apm_api_integration/tests/csm/ @elastic/uptime # Beats /x-pack/plugins/beats_management/ @elastic/beats @@ -100,7 +101,7 @@ # Observability UIs /x-pack/plugins/infra/ @elastic/logs-metrics-ui -/x-pack/plugins/fleet/ @elastic/ingest-management +/x-pack/plugins/fleet/ @elastic/fleet /x-pack/plugins/observability/ @elastic/observability-ui /x-pack/plugins/monitoring/ @elastic/stack-monitoring-ui /x-pack/plugins/uptime @elastic/uptime @@ -330,6 +331,9 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib # Security Intelligence And Analytics /x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules @elastic/security-intelligence-analytics +# Security Asset Management +/x-pack/plugins/osquery @elastic/security-asset-management + # Design (at the bottom for specificity of SASS files) **/*.scss @elastic/kibana-design #CC# /packages/kbn-ui-framework/ @elastic/kibana-design diff --git a/.github/paths-labeller.yml b/.github/paths-labeller.yml index f74870578ecb1..81d57be9b2d95 100644 --- a/.github/paths-labeller.yml +++ b/.github/paths-labeller.yml @@ -10,7 +10,7 @@ - "src/plugins/bfetch/**/*.*" - "Team:apm": - "x-pack/plugins/apm/**/*.*" - - "Team:Ingest Management": + - "Team:Fleet": - "x-pack/plugins/fleet/**/*.*" - "x-pack/test/fleet_api_integration/**/*.*" - "Team:uptime": diff --git a/.gitignore b/.gitignore index 79d022a2d701b..2d7dd52e3ef9e 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,7 @@ report.asciidoc # Yarn local mirror content .yarn-local-mirror + +# Bazel +/bazel-* +/.bazelrc.user diff --git a/.teamcity/src/Extensions.kt b/.teamcity/src/Extensions.kt index ce99c9c49e198..0a8abf4a149cf 100644 --- a/.teamcity/src/Extensions.kt +++ b/.teamcity/src/Extensions.kt @@ -20,21 +20,20 @@ fun BuildType.kibanaAgent(size: Int) { } val testArtifactRules = """ - target/junit/**/* target/kibana-* - target/kibana-coverage/**/* - target/kibana-security-solution/**/*.png target/test-metrics/* + target/kibana-security-solution/**/*.png + target/junit/**/* target/test-suites-ci-plan.json - test/**/screenshots/diff/*.png - test/**/screenshots/failure/*.png test/**/screenshots/session/*.png + test/**/screenshots/failure/*.png + test/**/screenshots/diff/*.png test/functional/failure_debug/html/*.html - x-pack/test/**/screenshots/diff/*.png - x-pack/test/**/screenshots/failure/*.png x-pack/test/**/screenshots/session/*.png - x-pack/test/functional/apps/reporting/reports/session/*.pdf + x-pack/test/**/screenshots/failure/*.png + x-pack/test/**/screenshots/diff/*.png x-pack/test/functional/failure_debug/html/*.html + x-pack/test/functional/apps/reporting/reports/session/*.pdf """.trimIndent() fun BuildType.addTestSettings() { diff --git a/.teamcity/src/builds/test/AllTests.kt b/.teamcity/src/builds/test/AllTests.kt index a49d5f2b07f4c..9506d98cbe50e 100644 --- a/.teamcity/src/builds/test/AllTests.kt +++ b/.teamcity/src/builds/test/AllTests.kt @@ -9,5 +9,5 @@ object AllTests : BuildType({ description = "All Non-Functional Tests" type = Type.COMPOSITE - dependsOn(QuickTests, Jest, JestIntegration, OssApiServerIntegration) + dependsOn(QuickTests, Jest, XPackJest, JestIntegration, OssApiServerIntegration) }) diff --git a/.teamcity/src/builds/test/Jest.kt b/.teamcity/src/builds/test/Jest.kt index c9d170b5e5c3d..c33c9c2678ca4 100644 --- a/.teamcity/src/builds/test/Jest.kt +++ b/.teamcity/src/builds/test/Jest.kt @@ -12,7 +12,7 @@ object Jest : BuildType({ kibanaAgent(8) steps { - runbld("Jest Unit", "./.ci/teamcity/tests/jest.sh") + runbld("Jest Unit", "./.ci/teamcity/oss/jest.sh") } addTestSettings() diff --git a/.teamcity/src/builds/test/XPackJest.kt b/.teamcity/src/builds/test/XPackJest.kt new file mode 100644 index 0000000000000..8246b60823ff9 --- /dev/null +++ b/.teamcity/src/builds/test/XPackJest.kt @@ -0,0 +1,19 @@ +package builds.test + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import kibanaAgent +import runbld + +object XPackJest : BuildType({ + name = "X-Pack Jest Unit" + description = "Executes X-Pack Jest Unit Tests" + + kibanaAgent(16) + + steps { + runbld("X-Pack Jest Unit", "./.ci/teamcity/default/jest.sh") + } + + addTestSettings() +}) diff --git a/.teamcity/src/projects/Kibana.kt b/.teamcity/src/projects/Kibana.kt index c84b65027dee6..5cddcf18e067f 100644 --- a/.teamcity/src/projects/Kibana.kt +++ b/.teamcity/src/projects/Kibana.kt @@ -77,6 +77,7 @@ fun Kibana(config: KibanaConfiguration = KibanaConfiguration()) : Project { name = "Jest" buildType(Jest) + buildType(XPackJest) buildType(JestIntegration) } diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel new file mode 100644 index 0000000000000..0828157524fa1 --- /dev/null +++ b/WORKSPACE.bazel @@ -0,0 +1,3 @@ +workspace( + name = "kibana", +) diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index fd4ed75352b1f..0ab1c89c1d8f7 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -150,8 +150,8 @@ It also provides a stateful version of it on the start contract. Content is fetched from the remote (https://feeds.elastic.co and https://feeds-staging.elastic.co in dev mode) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. -|{kib-repo}blob/{branch}/src/plugins/presentation_util/README.md[presentationUtil] -|Utilities and components used by the presentation-related plugins +|{kib-repo}blob/{branch}/src/plugins/presentation_util/README.mdx[presentationUtil] +|The Presentation Utility Plugin is a set of common, shared components and toolkits for solutions within the Presentation space, (e.g. Dashboards, Canvas). |{kib-repo}blob/{branch}/src/plugins/region_map/README.md[regionMap] @@ -460,6 +460,10 @@ Elastic. |This plugin provides shared components and services for use across observability solutions, as well as the observability landing page UI. +|{kib-repo}blob/{branch}/x-pack/plugins/osquery/README.md[osquery] +|This plugin adds extended support to Security Solution Fleet Osquery integration + + |{kib-repo}blob/{branch}/x-pack/plugins/painless_lab/README.md[painlessLab] |This plugin helps users learn how to use the Painless scripting language. @@ -555,10 +559,6 @@ in their infrastructure. |NOTE: This plugin contains implementation of URL drilldown. For drilldowns infrastructure code refer to ui_actions_enhanced plugin. -|{kib-repo}blob/{branch}/x-pack/plugins/vis_type_timeseries_enhanced/README.md[visTypeTimeseriesEnhanced] -|The vis_type_timeseries_enhanced plugin is the x-pack counterpart to the OSS vis_type_timeseries plugin. - - |{kib-repo}blob/{branch}/x-pack/plugins/watcher/README.md[watcher] |This plugins adopts some conventions in addition to or in place of conventions in Kibana (at the time of the plugin's creation): diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchclientconfig.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchclientconfig.md index 1ba359e81b9c6..a854e5ddad19a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchclientconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchclientconfig.md @@ -9,7 +9,7 @@ Configuration options to be used to create a [cluster client](./kibana-plugin-co Signature: ```typescript -export declare type ElasticsearchClientConfig = Pick & { +export declare type ElasticsearchClientConfig = Pick & { pingTimeout?: ElasticsearchConfig['pingTimeout'] | ClientOptions['pingTimeout']; requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout']; ssl?: Partial; diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchconfig.logqueries.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchconfig.logqueries.md deleted file mode 100644 index 001fb7bfeeb97..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchconfig.logqueries.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [ElasticsearchConfig](./kibana-plugin-core-server.elasticsearchconfig.md) > [logQueries](./kibana-plugin-core-server.elasticsearchconfig.logqueries.md) - -## ElasticsearchConfig.logQueries property - -Specifies whether all queries to the client should be logged (status code, method, query etc.). - -Signature: - -```typescript -readonly logQueries: boolean; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.elasticsearchconfig.md b/docs/development/core/server/kibana-plugin-core-server.elasticsearchconfig.md index 5ec3ce7f41859..d87ea63d59b8d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.elasticsearchconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.elasticsearchconfig.md @@ -27,7 +27,6 @@ export declare class ElasticsearchConfig | [healthCheckDelay](./kibana-plugin-core-server.elasticsearchconfig.healthcheckdelay.md) | | Duration | The interval between health check requests Kibana sends to the Elasticsearch. | | [hosts](./kibana-plugin-core-server.elasticsearchconfig.hosts.md) | | string[] | Hosts that the client will connect to. If sniffing is enabled, this list will be used as seeds to discover the rest of your cluster. | | [ignoreVersionMismatch](./kibana-plugin-core-server.elasticsearchconfig.ignoreversionmismatch.md) | | boolean | Whether to allow kibana to connect to a non-compatible elasticsearch node. | -| [logQueries](./kibana-plugin-core-server.elasticsearchconfig.logqueries.md) | | boolean | Specifies whether all queries to the client should be logged (status code, method, query etc.). | | [password](./kibana-plugin-core-server.elasticsearchconfig.password.md) | | string | If Elasticsearch is protected with basic authentication, this setting provides the password that the Kibana server uses to perform its administrative functions. | | [pingTimeout](./kibana-plugin-core-server.elasticsearchconfig.pingtimeout.md) | | Duration | Timeout after which PING HTTP request will be aborted and retried. | | [requestHeadersWhitelist](./kibana-plugin-core-server.elasticsearchconfig.requestheaderswhitelist.md) | | string[] | List of Kibana client-side headers to send to Elasticsearch when request scoped cluster client is used. If this is an empty array then \*no\* client-side will be sent. | diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient._constructor_.md b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient._constructor_.md index 823f34bd7dd23..ed2763d980279 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient._constructor_.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `LegacyClusterClient` class Signature: ```typescript -constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); +constructor(config: LegacyElasticsearchClientConfig, log: Logger, type: string, getAuthHeaders?: GetAuthHeaders); ``` ## Parameters @@ -18,5 +18,6 @@ constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders | --- | --- | --- | | config | LegacyElasticsearchClientConfig | | | log | Logger | | +| type | string | | | getAuthHeaders | GetAuthHeaders | | diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md index d24aeb44ca86a..0872e5ba7c219 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyclusterclient.md @@ -21,7 +21,7 @@ export declare class LegacyClusterClient implements ILegacyClusterClient | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(config, log, getAuthHeaders)](./kibana-plugin-core-server.legacyclusterclient._constructor_.md) | | Constructs a new instance of the LegacyClusterClient class | +| [(constructor)(config, log, type, getAuthHeaders)](./kibana-plugin-core-server.legacyclusterclient._constructor_.md) | | Constructs a new instance of the LegacyClusterClient class | ## Properties diff --git a/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearchclientconfig.md b/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearchclientconfig.md index 78f7bf582d355..b028a09bee453 100644 --- a/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearchclientconfig.md +++ b/docs/development/core/server/kibana-plugin-core-server.legacyelasticsearchclientconfig.md @@ -11,7 +11,7 @@ Signature: ```typescript -export declare type LegacyElasticsearchClientConfig = Pick & Pick & { +export declare type LegacyElasticsearchClientConfig = Pick & Pick & { pingTimeout?: ElasticsearchConfig['pingTimeout'] | ConfigOptions['pingTimeout']; requestTimeout?: ElasticsearchConfig['requestTimeout'] | ConfigOptions['requestTimeout']; sniffInterval?: ElasticsearchConfig['sniffInterval'] | ConfigOptions['sniffInterval']; diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md index 4ab0cb74f809f..3b5754eb4fa39 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md @@ -4,14 +4,17 @@ ## PluginInitializerContext.config property +Accessors for the plugin's configuration + Signature: ```typescript config: { legacy: { globalConfig$: Observable; + get: () => SharedGlobalConfig; }; create: () => Observable; - createIfExists: () => Observable; + get: () => T; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md index 106fdaad9bc22..e5de046eccf1d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md @@ -4,8 +4,29 @@ ## PluginInitializerContext.logger property + instance already bound to the plugin's logging context + Signature: ```typescript logger: LoggerFactory; ``` + +## Example + + +```typescript +// plugins/my-plugin/server/plugin.ts +// "id: myPlugin" in `plugins/my-plugin/kibana.yaml` + +export class MyPlugin implements Plugin { + constructor(private readonly initContext: PluginInitializerContext) { + this.logger = initContext.logger.get(); + // `logger` context: `plugins.myPlugin` + this.mySubLogger = initContext.logger.get('sub'); // or this.logger.get('sub'); + // `mySubLogger` context: `plugins.myPlugin.sub` + } +} + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md index 18760170afa1f..90a19d53bd5e1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md @@ -16,8 +16,8 @@ export interface PluginInitializerContext | Property | Type | Description | | --- | --- | --- | -| [config](./kibana-plugin-core-server.plugininitializercontext.config.md) | {
legacy: {
globalConfig$: Observable<SharedGlobalConfig>;
};
create: <T = ConfigSchema>() => Observable<T>;
createIfExists: <T = ConfigSchema>() => Observable<T | undefined>;
} | | +| [config](./kibana-plugin-core-server.plugininitializercontext.config.md) | {
legacy: {
globalConfig$: Observable<SharedGlobalConfig>;
get: () => SharedGlobalConfig;
};
create: <T = ConfigSchema>() => Observable<T>;
get: <T = ConfigSchema>() => T;
} | Accessors for the plugin's configuration | | [env](./kibana-plugin-core-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
packageInfo: Readonly<PackageInfo>;
instanceUuid: string;
} | | -| [logger](./kibana-plugin-core-server.plugininitializercontext.logger.md) | LoggerFactory | | +| [logger](./kibana-plugin-core-server.plugininitializercontext.logger.md) | LoggerFactory | instance already bound to the plugin's logging context | | [opaqueId](./kibana-plugin-core-server.plugininitializercontext.opaqueid.md) | PluginOpaqueId | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md new file mode 100644 index 0000000000000..2b897db7bba4c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [createIndexAliasNotFoundError](./kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md) + +## SavedObjectsErrorHelpers.createIndexAliasNotFoundError() method + +Signature: + +```typescript +static createIndexAliasNotFoundError(alias: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| alias | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md new file mode 100644 index 0000000000000..c7e10fc42ead1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [decorateIndexAliasNotFoundError](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md) + +## SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError() method + +Signature: + +```typescript +static decorateIndexAliasNotFoundError(error: Error, alias: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | | +| alias | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md new file mode 100644 index 0000000000000..4b4ede2f77a7e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [isGeneralError](./kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md) + +## SavedObjectsErrorHelpers.isGeneralError() method + +Signature: + +```typescript +static isGeneralError(error: Error | DecoratedError): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | DecoratedError | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md index 9b69012ed5f12..2dc78f2df3a83 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md @@ -18,6 +18,7 @@ export declare class SavedObjectsErrorHelpers | [createBadRequestError(reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createbadrequesterror.md) | static | | | [createConflictError(type, id, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | | [createGenericNotFoundError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfounderror.md) | static | | +| [createIndexAliasNotFoundError(alias)](./kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createTooManyRequestsError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createtoomanyrequestserror.md) | static | | | [createUnsupportedTypeError(type)](./kibana-plugin-core-server.savedobjectserrorhelpers.createunsupportedtypeerror.md) | static | | @@ -27,6 +28,7 @@ export declare class SavedObjectsErrorHelpers | [decorateEsUnavailableError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateesunavailableerror.md) | static | | | [decorateForbiddenError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateforbiddenerror.md) | static | | | [decorateGeneralError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorategeneralerror.md) | static | | +| [decorateIndexAliasNotFoundError(error, alias)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateindexaliasnotfounderror.md) | static | | | [decorateNotAuthorizedError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoratenotauthorizederror.md) | static | | | [decorateRequestEntityTooLargeError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoraterequestentitytoolargeerror.md) | static | | | [decorateTooManyRequestsError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoratetoomanyrequestserror.md) | static | | @@ -35,6 +37,7 @@ export declare class SavedObjectsErrorHelpers | [isEsCannotExecuteScriptError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md) | static | | | [isEsUnavailableError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isesunavailableerror.md) | static | | | [isForbiddenError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isforbiddenerror.md) | static | | +| [isGeneralError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isgeneralerror.md) | static | | | [isInvalidVersionError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isinvalidversionerror.md) | static | | | [isNotAuthorizedError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isnotauthorizederror.md) | static | | | [isNotFoundError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isnotfounderror.md) | static | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md index b5ac4a4e53887..5f8966f0227ac 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchinterceptor.handlesearcherror.md @@ -7,14 +7,14 @@ Signature: ```typescript -protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; +protected handleSearchError(e: KibanaServerError | AbortError, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| e | any | | +| e | KibanaServerError | AbortError | | | timeoutSignal | AbortSignal | | | options | ISearchOptions | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md index 8fd17e6b1a1d9..e96fe8b8e08dc 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch.md @@ -4,8 +4,12 @@ ## SearchSource.fetch() method -Fetch this source and reject the returned Promise on error +> Warning: This API is now obsolete. +> +> Use fetch$ instead +> +Fetch this source and reject the returned Promise on error Signature: diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md new file mode 100644 index 0000000000000..bcf220a9a27e6 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.fetch_.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [SearchSource](./kibana-plugin-plugins-data-public.searchsource.md) > [fetch$](./kibana-plugin-plugins-data-public.searchsource.fetch_.md) + +## SearchSource.fetch$() method + +Fetch this source from Elasticsearch, returning an observable over the response(s) + +Signature: + +```typescript +fetch$(options?: ISearchOptions): import("rxjs").Observable>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| options | ISearchOptions | | + +Returns: + +`import("rxjs").Observable>` + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md index df302e9f3b0d3..2af9cc14e3668 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchsource.md @@ -33,6 +33,7 @@ export declare class SearchSource | [createCopy()](./kibana-plugin-plugins-data-public.searchsource.createcopy.md) | | creates a copy of this search source (without its children) | | [destroy()](./kibana-plugin-plugins-data-public.searchsource.destroy.md) | | Completely destroy the SearchSource. {undefined} | | [fetch(options)](./kibana-plugin-plugins-data-public.searchsource.fetch.md) | | Fetch this source and reject the returned Promise on error | +| [fetch$(options)](./kibana-plugin-plugins-data-public.searchsource.fetch_.md) | | Fetch this source from Elasticsearch, returning an observable over the response(s) | | [getField(field, recurse)](./kibana-plugin-plugins-data-public.searchsource.getfield.md) | | Gets a single field from the fields | | [getFields()](./kibana-plugin-plugins-data-public.searchsource.getfields.md) | | returns all search source fields | | [getId()](./kibana-plugin-plugins-data-public.searchsource.getid.md) | | returns search source id | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md index 1c6370c7d0356..b4eecca665e82 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchtimeouterror._constructor_.md @@ -9,13 +9,13 @@ Constructs a new instance of the `SearchTimeoutError` class Signature: ```typescript -constructor(err: Error, mode: TimeoutErrorMode); +constructor(err: Record, mode: TimeoutErrorMode); ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| err | Error | | +| err | Record<string, any> | | | mode | TimeoutErrorMode | | diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md deleted file mode 100644 index 29a511d57d7bd..0000000000000 --- a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) > [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) > [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md) - -## IKbnUrlStateStorage.cancel property - -cancels any pending url updates - -Signature: - -```typescript -cancel: () => void; -``` diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md deleted file mode 100644 index dfeef1cdce22c..0000000000000 --- a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) > [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) > [flush](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md) - -## IKbnUrlStateStorage.flush property - -Synchronously runs any pending url updates, returned boolean indicates if change occurred. - -Signature: - -```typescript -flush: (opts?: { - replace?: boolean; - }) => boolean; -``` diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md new file mode 100644 index 0000000000000..8e3b9a7bfeb3f --- /dev/null +++ b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) > [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) > [kbnUrlControls](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md) + +## IKbnUrlStateStorage.kbnUrlControls property + +Lower level wrapper around history library that handles batching multiple URL updates into one history change + +Signature: + +```typescript +kbnUrlControls: IKbnUrlControls; +``` diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md index 371f7b7c15362..7fb8717fae003 100644 --- a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md +++ b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md @@ -20,9 +20,8 @@ export interface IKbnUrlStateStorage extends IStateStorage | Property | Type | Description | | --- | --- | --- | -| [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md) | () => void | cancels any pending url updates | | [change$](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.change_.md) | <State = unknown>(key: string) => Observable<State | null> | | -| [flush](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md) | (opts?: {
replace?: boolean;
}) => boolean | Synchronously runs any pending url updates, returned boolean indicates if change occurred. | | [get](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.get.md) | <State = unknown>(key: string) => State | null | | +| [kbnUrlControls](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md) | IKbnUrlControls | Lower level wrapper around history library that handles batching multiple URL updates into one history change | | [set](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.set.md) | <State>(key: string, state: State, opts?: {
replace: boolean;
}) => Promise<string | undefined> | | diff --git a/docs/maps/maps-aggregations.asciidoc b/docs/maps/maps-aggregations.asciidoc index f09d99250c22d..3c66e187bf59c 100644 --- a/docs/maps/maps-aggregations.asciidoc +++ b/docs/maps/maps-aggregations.asciidoc @@ -98,6 +98,13 @@ Point to point uses an {es} {ref}/search-aggregations-bucket-terms-aggregation.h Then, a nested {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[GeoTile grid aggregation] groups sources for each destination into grids. A line connects each source grid centroid to each destination. +Point-to-point layers are used in several common use cases: + +* Source-destination maps for network traffic +* Origin-destination maps for flight data +* Origin-destination flows for import/export/migration +* Origin-destination for pick-up/drop-off data + image::maps/images/point_to_point.png[] [role="xpack"] diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index 4449b58b4bab3..c7bdff800bb0b 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -194,10 +194,89 @@ This page has moved. Refer to <>. This page has moved. Refer to <>. +[float] +[[time-series-visual-builder]] +=== Time Series Visual Builder + +This page was deleted. Refer to <>. + +[float] +[[kibana-keystore-has-moved-from-the-data-folder-to-the-config-folder]] +=== Kibana Keystore has moved from the Data Folder to the Config Folder + +This page has been deleted. Refer to link:https://www.elastic.co/guide/en/kibana/7.9/breaking-changes-7.9.html#user-facing-changes-79[Breaking changes in 7.9]. + +[float] +[[createvis]] +=== Create Visualization + +This page has been deleted. Refer to <>. + +[float] +[[data-table]] +=== Data Table + +This page has been deleted. Refer to <>. + + +[float] +[[xy-chart]] +=== Line, Area, and Bar Chart + +This page has been deleted. Refer to <>. + +[float] +[[add-canvas-events]] +=== Add Canvas Elements + +This page has been moved. Refer to <>. + +[float] +[[vega-lite-tutorial]] +=== Vega-Lite Tutorial + +This page has been moved. Refer to <>. + +[float] +[[heatmap-chart]] +=== Heatmap Chart + +This page has been moved. Refer to <>. + +[float] +[[interface-overview]] +=== Interface Overview + +This page has been moved. Refer to <>. + +[float] +[[time-series-visualizations]] +=== Featured Visualizations + +This page has been moved. Refer to <>. + +[float] +[[timelion-customize]] +=== Customize and format visualizations + +This page has been moved. Refer to <>. + +[float] +[[dashboard-drilldown]] +=== Dashboard Drilldowns + +This page has been moved. Refer to <>. + +[float] +[[development-plugin-localization]] +=== Localization for plugins + +This page has been moved. PRefer to <>. + [role="exclude",id="visualize"] == Visualize -This content has moved. See <>. +This content has moved. Refer to <>. [role="exclude",id="explore-dashboard-data"] -This content has moved. See <>. +This content has moved. Refer to <>. diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 26f095c59c644..ecdb41c897b12 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -59,7 +59,7 @@ To enable SSL/TLS for outbound connections to {es}, use the `https` protocol in this setting. | `elasticsearch.logQueries:` - | Log queries sent to {es}. Requires <> set to `true`. + | *deprecated* This setting is no longer used and will get removed in Kibana 8.0. Instead, set <> to `true` This is useful for seeing the query DSL generated by applications that currently do not have an inspector, for example Timelion and Monitoring. *Default: `false`* diff --git a/docs/setup/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index 7436536d22781..cc6e363872808 100644 --- a/docs/setup/upgrade/upgrade-migrations.asciidoc +++ b/docs/setup/upgrade/upgrade-migrations.asciidoc @@ -19,17 +19,16 @@ Saved objects are stored in two indices: * `.kibana_{kibana_version}_001`, or if the `kibana.index` configuration setting is set `.{kibana.index}_{kibana_version}_001`. E.g. for Kibana v7.12.0 `.kibana_7.12.0_001`. * `.kibana_task_manager_{kibana_version}_001`, or if the `xpack.tasks.index` configuration setting is set `.{xpack.tasks.index}_{kibana_version}_001` E.g. for Kibana v7.12.0 `.kibana_task_manager_7.12.0_001`. -The index aliases `.kibana` and `.kibana_task_manager` will always point to the most up-to-date version indices. +The index aliases `.kibana` and `.kibana_task_manager` will always point to +the most up-to-date saved object indices. The first time a newer {kib} starts, it will first perform an upgrade migration before starting plugins or serving HTTP traffic. To prevent losing acknowledged writes old nodes should be shutdown before starting the upgrade. To reduce the likelihood of old nodes losing acknowledged writes, {kib} 7.12.0 and later will add a write block to the outdated index. Table 1 lists the saved objects indices used by previous versions of {kib}. .Saved object indices and aliases per {kib} version [options="header"] -[cols="a,a,a"] |======================= -|Upgrading from version | Outdated index (alias) | Upgraded index (alias) -| 6.0.0 through 6.4.x | `.kibana` 1.3+^.^| `.kibana_7.12.0_001` -(`.kibana` alias) +|Upgrading from version | Outdated index (alias) +| 6.0.0 through 6.4.x | `.kibana` `.kibana_task_manager_7.12.0_001` (`.kibana_task_manager` alias) | 6.5.0 through 7.3.x | `.kibana_N` (`.kibana` alias) diff --git a/docs/user/alerting/alert-types.asciidoc b/docs/user/alerting/alert-types.asciidoc index 7c5a957d1cf79..279739e95b522 100644 --- a/docs/user/alerting/alert-types.asciidoc +++ b/docs/user/alerting/alert-types.asciidoc @@ -8,7 +8,7 @@ This section covers stack alerts. For domain-specific alert types, refer to the Users will need `all` access to the *Stack Alerts* feature to be able to create and edit any of the alerts listed below. See <> for more information on configuring roles that provide access to this feature. -Currently {kib} provides one stack alert: the <> type. +Currently {kib} provides two stack alerts: <> and <>. [float] [[alert-type-index-threshold]] @@ -112,6 +112,47 @@ You can interactively change the time window and observe the effect it has on th [role="screenshot"] image::images/alert-types-index-threshold-example-comparison.png[Comparing two time windows] +[float] +[[alert-type-es-query]] +=== ES query + +The ES query alert type is designed to run a user-configured {es} query over indices, compare the number of matches to a configured threshold, and schedule +actions to run when the threshold condition is met. + +[float] +==== Creating the alert + +An ES query alert can be created from the *Create* button in the <>. Fill in the <>, then select *ES query*. + +[role="screenshot"] +image::images/alert-types-es-query-select.png[Choosing an ES query alert type] + +[float] +==== Defining the conditions +The ES query alert has 4 clauses that define the condition to detect. +[role="screenshot"] +image::images/alert-types-es-query-conditions.png[Four clauses define the condition to detect] + +Index:: This clause requires an *index or index pattern* and a *time field* that will be used for the *time window*. +ES query:: This clause specifies the ES DSL query to execute. The number of documents that match this query will be evaulated against the threshold +condition. Aggregations are not supported at this time. +Threshold:: This clause defines a threshold value and a comparison operator (`is above`, `is above or equals`, `is below`, `is below or equals`, or `is between`). The number of documents that match the specified query is compared to this threshold. +Time window:: This clause determines how far back to search for documents, using the *time field* set in the *index* clause. Generally this value should be set to a value higher than the *check every* value in the <>, to avoid gaps in detection. + +[float] +==== Testing your query + +Use the *Test query* feature to verify that your query DSL is valid. +When your query is valid:: Valid queries will be executed against the configured *index* using the configured *time window*. The number of documents that +match the query will be displayed. + +[role="screenshot"] +image::images/alert-types-es-query-valid.png[Test ES query returns number of matches when valid] + +When your query is invalid:: An error message is shown if the query is invalid. + +[role="screenshot"] +image::images/alert-types-es-query-invalid.png[Test ES query shows error when invalid] \ No newline at end of file diff --git a/docs/user/alerting/images/alert-types-es-query-conditions.png b/docs/user/alerting/images/alert-types-es-query-conditions.png new file mode 100644 index 0000000000000..ce2bd6a42a4b5 Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-conditions.png differ diff --git a/docs/user/alerting/images/alert-types-es-query-invalid.png b/docs/user/alerting/images/alert-types-es-query-invalid.png new file mode 100644 index 0000000000000..ce8b8e92181a9 Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-invalid.png differ diff --git a/docs/user/alerting/images/alert-types-es-query-select.png b/docs/user/alerting/images/alert-types-es-query-select.png new file mode 100644 index 0000000000000..61fe724ea1412 Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-select.png differ diff --git a/docs/user/alerting/images/alert-types-es-query-valid.png b/docs/user/alerting/images/alert-types-es-query-valid.png new file mode 100644 index 0000000000000..1894ad2b445f8 Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-valid.png differ diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 4a0598cc569cd..2c961dca44474 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -223,7 +223,7 @@ experimental[] Access the Elastic Map Service files via the same mechanism: ---- url: { // "type" defaults to "elasticsearch" otherwise - type: emsfile + %type%: emsfile // Name of the file, exactly as in the Region map visualization name: World Countries } @@ -289,7 +289,7 @@ experimental[] You can use the *Vega* https://vega.github.io/vega/docs/data/[dat ---- url: { // "type" defaults to "elasticsearch" otherwise - type: emsfile + %type%: emsfile // Name of the file, exactly as in the Region map visualization name: World Countries } diff --git a/docs/user/images/alerts-and-actions.png b/docs/user/images/alerts-and-actions.png new file mode 100755 index 0000000000000..227abd9441e15 Binary files /dev/null and b/docs/user/images/alerts-and-actions.png differ diff --git a/docs/user/images/app-navigation-search.png b/docs/user/images/app-navigation-search.png new file mode 100644 index 0000000000000..3b89eed44b28f Binary files /dev/null and b/docs/user/images/app-navigation-search.png differ diff --git a/docs/user/images/features-control.png b/docs/user/images/features-control.png new file mode 100755 index 0000000000000..abe75d5ab6fc1 Binary files /dev/null and b/docs/user/images/features-control.png differ diff --git a/docs/user/images/home-page.png b/docs/user/images/home-page.png new file mode 100755 index 0000000000000..9ca4b7f43f427 Binary files /dev/null and b/docs/user/images/home-page.png differ diff --git a/docs/user/images/kibana-main-menu.png b/docs/user/images/kibana-main-menu.png new file mode 100755 index 0000000000000..79e0a3dca8658 Binary files /dev/null and b/docs/user/images/kibana-main-menu.png differ diff --git a/docs/user/images/login-screen.png b/docs/user/images/login-screen.png new file mode 100755 index 0000000000000..7a97c952e1039 Binary files /dev/null and b/docs/user/images/login-screen.png differ diff --git a/docs/user/images/roles-and-privileges.png b/docs/user/images/roles-and-privileges.png new file mode 100755 index 0000000000000..28bff6d13c871 Binary files /dev/null and b/docs/user/images/roles-and-privileges.png differ diff --git a/docs/user/images/select-your-space.png b/docs/user/images/select-your-space.png new file mode 100755 index 0000000000000..887e8eea27c5c Binary files /dev/null and b/docs/user/images/select-your-space.png differ diff --git a/docs/user/images/tags-search.png b/docs/user/images/tags-search.png new file mode 100755 index 0000000000000..67458200c50d1 Binary files /dev/null and b/docs/user/images/tags-search.png differ diff --git a/docs/user/images/visualization-journey.png b/docs/user/images/visualization-journey.png new file mode 100644 index 0000000000000..ef7634485bccd Binary files /dev/null and b/docs/user/images/visualization-journey.png differ diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index a41b42f259471..fb91f6a6a1c9a 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -1,145 +1,444 @@ [[introduction]] -== {kib} — your window into the Elastic Stack +== {kib}—your window into Elastic ++++ What is Kibana? ++++ -**_Visualize and analyze your data and manage all things Elastic Stack._** +{kib} enables you to give +shape to your data and navigate the Elastic Stack. With {kib}, you can: -Whether you’re an analyst or an admin, {kib} makes your data actionable by providing -three key functions. Kibana is: +* *Visualize and analyze your data.* +Search for hidden insights, visualize what you've found in charts, gauges, +maps and more, and combine them in a dashboard. -* **An open-source analytics and visualization platform.** -Use {kib} to explore your {es} data, and then build beautiful visualizations and dashboards. +* *Search, observe, and protect.* +From discovering documents to analyzing logs to finding security vulnerabilities, +{kib} is your portal for accessing these capabilities and more. -* **A UI for managing the Elastic Stack.** -Manage your security settings, assign user roles, take snapshots, roll up your data, -and more — all from the convenience of a {kib} UI. +* *Manage, monitor, and secure the Elastic Stack.* +Manage your indices and ingest pipelines, monitor the health of your +Elastic Stack cluster, and control which users have access to +which features. -* **A centralized hub for Elastic's solutions.** From log analytics to -document discovery to SIEM, {kib} is the portal for accessing these and other capabilities. -[role="screenshot"] -image::images/intro-kibana.png[Kibana home page] +*{kib} is for administrators, analysts, and business users.* +As an admin, your role is to manage the Elastic Stack, from creating your +deployment to getting {es} data into {kib}, and then +managing the data. As an analyst, your job is to discover insights +in the data, visualize your data on dashboards, and share your findings. As a business user, +you want to view existing dashboards and drill down into details. + +*{kib} works with all types of data.* Your data can be structured or unstructured text, +numerical data, time-series data, geospatial data, logs, metrics, security events, +and more. Kibana is designed to use Elasticsearch as a data store. +No matter your data, {kib} can help you uncover patterns and relationships and visualize the results. [float] -[[get-data-into-kibana]] -=== Ingest data +[[kibana-home-page]] +=== Where to start + +Start with the home page, where you’re guided toward the most common use cases. +For a quick reference of {kib} use cases, refer to <> -{kib} is designed to use {es} as a data source. Think of Elasticsearch as the engine that stores -and processes the data, with {kib} sitting on top. +[role="screenshot"] +image::images/home-page.png[Kibana home page] + +The main menu gets you to where you need to go. Like the home page, +the menu is organized by use case. Want to work with your logs, metrics, APM, or +Uptime data? The apps you need are under *Observability*. The main menu also includes +*Recently viewed*, so you can easily access your previously opened apps. -To start working with your data in Kibana, use one of the many ingest options, -available from the home page. You can collect data from an app or service or upload a file that contains your data. -If you're not ready to use your own data, you can add a sample data set -to give {kib} a test drive. +Hidden by default, you open the main menu by clicking the +hamburger icon. To keep the main menu visible at all times, click the *Dock navigation* item. [role="screenshot"] -image::setup/images/add-data-home.png[Built-in options for adding data to Kibana: Add data, Add Elastic Agent, Upload a file] +image::images/kibana-main-menu.png[Kibana main menu] [float] -[[explore-and-query]] -=== Explore & query +[[kibana-navigation-search]] +=== Search {kib} + +Using the Search field in the global header, you can +search for applications and objects, such as +dashboards and visualizations. + +Search suggestions include deep links into applications, +allowing you to directly navigate to the views you need most. -Ready to dive into your data? With <>, you can explore your data and -search for hidden insights and relationships. Ask your questions, and then -narrow the results to just the data you want. +[role="screenshot"] +image::images/app-navigation-search.png[Example of searching for apps] + +When searching for objects, you can search by type, name, and tag. +Tags are keywords or labels that you assign to {kib} objects, +so you can classify the objects in a way that is meaningful to you. +You can then quickly search for related objects based on shared tags. [role="screenshot"] -image::images/intro-discover.png[Discover UI] +image::images/tags-search.png[Example of searching for tags] + +To get the most from the search feature, follow these tips: + +* Use the keyboard shortcut—Ctrl+/ on Windows and Linux, Command+/ on MacOS—to focus on the input at any time. + +* Use the provided syntax keywords. ++ +[cols=2*] +|=== +|Search by type +|`type:dashboard` + +Available types: `application`, `canvas-workpad`, `dashboard`, `index-pattern`, `lens`, `maps`, `query`, `search`, `visualization` + +|Search by tag +|`tag:mytagname` + +`tag:"tag name with spaces"` + +|Search by type and name +|`type:dashboard my_dashboard_title` + +|Advanced searches +|`tag:(tagname1 or tagname2) my_dashboard_title` + +`type:lens tag:(tagname1 or tagname2)` + +`type:(dashboard or canvas-workpad) logs` + +|=== + [float] [[visualize-and-analyze]] -=== Visualize & analyze +=== Analyze your data -A visualization is worth a thousand log lines, and {kib} provides -many options for showcasing your data. Use <>, -our drag-and-drop interface, -to rapidly build -charts, tables, metrics, and more. If there -is a better visualization for your data, *Lens* suggests it, allowing for quick -switching between visualization types. - -Once your visualizations are just the way you want, -use <> to collect them in one place. A dashboard provides -insights into your data from multiple perspectives. +Data analysis is the core functionality of {kib}. +You can quickly search through large amounts of data, explore fields and values, +and then use {kib}’s drag-and-drop interface to rapidly build charts, tables, metrics, and more. [role="screenshot"] -image::images/intro-dashboard.png[Sample eCommerce data set dashboard] +image::images/visualization-journey.png[User visualization journey] + +[[get-data-into-kibana]] +. *Add data.* The best way to add {es} data to {kib} is to use one of our guided processes, +available from the <>. You can collect data from an app or service, upload a +file, or add a sample data set. -{kib} also offers these visualization features: +. *Explore.* With <>, you can search your data for hidden +insights and relationships. Ask your questions, and then filter the results to just the data you want. +You can also limit your results to the most recent documents added to {es}. -* <> gives you the ability to present your data in a -visually compelling, pixel-perfect report. Give your data the “wow” factor -needed to impress your CEO or to captivate coworkers with a big-screen display. +. *Visualize.* {kib} provides many options to create visualizations of your data, from +aggregation-based data to time series data. +<> is your starting point to create visualizations, +and then pulling them together to show your data from multiple perspectives. -* <> enables you to ask (and answer) meaningful -questions of your location-based data. Maps supports multiple -layers and data sources, mapping of individual geo points and shapes, -and dynamic client-side styling. +. *Present.* With <>, you can display your data on a visually +compelling, pixel-perfect workpad. **Canvas** can give your data +the “wow” factor needed to impress your CEO and captivate coworkers with a big-screen display. -* <> allows you to combine -an infinite number of aggregations to display complex data. -With TSVB, you can customize -every aspect of your visualization. Choose your own date format and color -gradients, and easily switch your data view between time series, metric, -top N, gauge, and markdown. +. *Share.* Ready to <> your findings with a larger audience? {kib} offers many options—embed +a dashboard, share a link, export to PDF, and more. [float] -[[organize-and-secure]] -=== Organize & secure +==== Plot location data on a map +If you’re looking to better understand the “where’’ in your data, your data +analysis journey will also include <>. This app is the right +choice when you’re looking for a spatial pattern, performing ad-hoc location-driven analysis, +or analyzing metrics with a geographic perspective. With *Maps*, you can build +world country maps, administrative region maps, and point-to-point origin-destination maps. +You can also visualize and track movement over space and through time. -Want to share Kibana’s goodness with other people or teams? You can do so with -<>, built for organizing your visualizations, dashboards, and indices. -Think of a space as its own mini {kib} installation — it’s isolated from -all other spaces, so you can tailor it to your specific needs without impacting others. +[float] +==== Model data behavior -You can even choose which features to enable within each space. Don’t need -Machine learning in your “Executive” space? Simply turn it off. +To model the behavior of your data, you'll want to use +<>. +This app can help you extract insights from your data that you might otherwise miss. +You can forecast unusual behavior in your time series data. +You can also perform outlier detection, regression, and classification analysis +on your data and generate annotated results. -[role="screenshot"] -image::images/intro-spaces.png[Space selector screen] +[float] +==== Graph relationships -You can take this all one step further with Kibana’s security features, and -control which users have access to each space. {kib} allows for fine-grained -controls, so you can give a user read-only access to -dashboards in one space, but full access to all of Kibana’s features in another. +Looking to uncover how items in your data are related? +<> is your app. Graphing relationships is useful in a variety of use cases, +from fraud detection to recommendation engines. For example, graph exploration +can help you uncover website vulnerabilities that hackers are targeting, +so you can harden your website. Or, you might provide graph-based +personalized recommendations to your e-commerce customers. + +[float] +[[extend-your-use-case]] +=== Search, observe, and protect + +Being able to search, observe, and protect your data is a requirement for any analyst. +{kib} provides solutions for each of these use cases. + +* https://www.elastic.co/guide/en/enterprise-search/current/index.html[*Enterprise Search*] enables you to create a search experience for your app, workplace, and website. + +* {observability-guide}/observability-introduction.html[*Elastic Observability*] enables you to monitor and apply analytics in real time +to events happening across all your environments. You can analyze log events, monitor the performance metrics for the host or container +that it ran in, trace the transaction, and check the overall service availability. + +* Designed for security analysts, {security-guide}/es-overview.html[*Elastic Security*] provides an overview of +the events and alerts from your environment. Elastic Security helps you defend +your organization from threats before damage and loss occur. ++ +[role="screenshot"] +image::siem/images/detections-ui.png[] [float] [[manage-all-things-stack]] === Manage all things Elastic Stack -<> provides guided processes for managing all -things Elastic Stack — indices, clusters, licenses, UI settings, -and more. Want to update your {es} indices? Set user roles and privileges? -Turn on dark mode? Kibana has UIs for all that. +{kib}'s <> takes you under the hood, +so you can twist the levers and turn the knobs. *Stack Management* provides +guided processes for administering all things Elastic Stack, +including data, indices, clusters, alerts, and security. [role="screenshot"] image::images/intro-management.png[] [float] -[[extend-your-use-case]] -=== Extend your use case — or add a new one +==== Manage your data, indices, and clusters + +{kib} offers these data management tasks—all from the convenience of a UI: + +* Refresh, flush, and clear the cache of your indices. +* Define the lifecycle of an index as it ages. +* Define a policy for taking snapshots of your cluster. +* Roll up data from one or more indices into a new, compact index. +* Replicate indices on a remote cluster and copy them to a local cluster. + +[float] +==== Alert and take action +Detecting and acting on significant shifts and signals in your data is a need +that exists in almost every use case. For example, you might set an alert to notify you when: -As a hub for Elastic's https://www.elastic.co/products/[solutions], {kib} -can help you find security vulnerabilities, -monitor performance, and address your business needs. Get alerted if a key -metric spikes. Detect anomalous behavior or forecast future spikes. Root out -bottlenecks in your application code. Kibana doesn’t limit or dictate how you explore your data. +* A shift occurs in your business critical KPIs. +* System resources, such as memory, CPU and disk space, take a dip. +* An unusually high number of service requests, suspicious processes, and login attempts occurs. + +An alert is triggered when a specified condition is met. For example, +an alert might trigger when the average or max of one of +your metrics exceeds a threshold within a specified time frame. + +When the alert triggers, you can send a notification to a system that is part of +your daily workflow. {kib} integrates with email, Slack, PagerDuty, and ServiceNow, +to name a few. + +A dedicated view for creating, searching, and editing alerts is in <>. [role="screenshot"] -image::siem/images/detections-ui.png[] +image::images/alerts-and-actions.png[Alerts and Actions view] + [float] -[[try-kibana]] -=== Give {kib} a try +[[organize-and-secure]] +=== Organize your work in spaces + +Want to share {kib}’s goodness with other people or teams without overwhelming them? You can do so +with <>, built for organizing your visualizations, dashboards, and indices. +Think of a space as its own mini {kib} installation—it’s isolated from all other spaces, +so you can tailor it to your specific needs without impacting others. + +[role="screenshot"] +image::images/select-your-space.png[Space selector screen] + +Most of {kib}’s entities are space-aware, including dashboards, visualizations, index patterns, +Canvas workpads, Timelion visualizations, graphs, tags, and machine learning jobs. + +In addition: + +* **Elastic Security** is space-aware, so the timelines and investigations +you open in one space will not be available to other spaces. + +* **Observability** is currently partially space-aware, but will be enhanced to become fully space-aware. + +* Most of the **Stack Management** features are not space aware because they +are primarily used to manage features of {es}, which serves as a shared data store for all spaces. + +* Alerts are space-aware and work nicely with the {kib} role-based access control +model to allow you secure access to them, depending on the alert type and your user roles. +For example, roles with no access to an app will not have access to its alerts. + +[float] +==== Control feature visibility + +You can take spaces one step further and control which features are visible +within each space. For example, you might hide **Dev Tools** in your "Executive" +space or show **Stack Monitoring** only in your "Admin" space. + +Controlling feature visibility is not a security feature. To secure access +to specific features on a per-user basis, you must configure +<>. + +[role="screenshot"] +image::images/features-control.png[Features Controls screen] -There is no faster way to try out {kib} than with our hosted {es} Service. -https://www.elastic.co/cloud/elasticsearch-service/signup[Sign up for a free trial] -and start exploring data in minutes. +[float] +[[intro-kibana-Security]] +=== Secure {kib} + +{kib} offers a range of security features for you to control who has access to what. +The security features are automatically turned on when +{ref}/get-started-enable-security.html[security is enabled in +{es}]. For a description of all available configuration options, +see <>. + +[float] +==== Log in +Kibana supports several <>, +allowing you to login using {es}’s built-in realms, or by your own single sign-on provider. + +[role="screenshot"] +image::images/login-screen.png[Login screen] + +[float] +==== Secure access + +{kib} provides roles and privileges for controlling which users can +view and manage {kib} features. Privileges grant permission to view an application +or perform a specific action and are assigned to roles. Roles allow you to describe +a “template” of capabilities that you can grant to many users, +without having to redefine what each user should be able to do. + +When you create a role, you can scope the assigned {kib} privileges to specific spaces. +This makes it possible to grant users different access levels in different spaces, +or even give users their very own private space. For example, power users might +have privileges to create and edit visualizations and dashboards, +while analysts or executives might have *Dashboard* and *Canvas* with read-only privileges. + +{kib}’s role management interface allows you to describe these various access +levels, or you can automate role creation via our <>. + +[role="screenshot"] +image::images/roles-and-privileges.png[{kib privileges}] + +[float] +==== Audit access + +Once you have your users and roles configured, you might want to maintain a +record of who did what, when. The {kib} audit log will record this information for you, +which can then be correlated with {es} audit logs to gain more insights into your +users’ behavior. For more information, see <>. + +[float] +[[whats-the-right-app]] +=== What’s the right app for you? + +{kib} has a wealth of apps, each with its own area of specialty. +Scan this table to quickly find the app that gets you to our goal. + +[cols=2*] +|=== + +2+| *Get started* + +|Get {kib} +|https://www.elastic.co/cloud/elasticsearch-service/signup[Sign up for a free trial] and start exploring data in minutes. + +|Don’t know where to begin +|The home page. If you’re looking to explore and visualize your data, follow +the <>. + +|Add data +|The Add data page, available from the home page. + +|See the full list of {kib} features +|The https://www.elastic.co/kibana/features[{kib} features page on elastic.co] + +2+| *Analyze and visualize your data* + +|Know what’s in your data +|<> + +|Create charts and other visualizations +|<> + +|Show your data from different perspectives +|<> + +|Work with location data +|<> + +|Create a presentation of your data +|<> + +|Generate models for your data’s behavior +|<> + +|Explore connections in your data +|<> + +|Share your data +|<>, <> + +2+|*Build a search experience* + +|Create a search experience for your workplace +|https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html[Workplace Search] + +|Build a search experience for your app +|https://www.elastic.co/guide/en/app-search/current/getting-started.html[App Search] + + +2+|*Monitor, analyze, and react to events* + +|Monitor software services and applications in real-time by collecting performance information +|{observability-guide}/apm.html[APM] + +|Monitor the availability of your sites and services +|{observability-guide}/monitor-uptime.html[Uptime] + +|Search, filter, and tail all your logs +|{observability-guide}/monitor-logs.html[Logs] + +|Analyze metrics from your infrastructure, apps, and services +|{observability-guide}/analyze-metrics.html[Metrics] + +2+|*Prevent, detect, and respond to threats* + +|Create and manage rules for suspicious source events, and view the alerts these rules create. +|{security-guide}/detection-engine-overview.html[Detections] + +|View all hosts and host-related security events. +|{security-guide}/hosts-overview.html[Hosts] + +|View key network activity metrics via an interactive map. +|{security-guide}/network-page-overview.html[Network] + +|Investigate alerts and complex threats, such as lateral movement of malware across hosts in your network. +|{security-guide}/timelines-ui.html[Timelines] + +|Create and track security issues +|{security-guide}/cases-overview.html[Cases] + +|View and manage hosts that are running Endpoint Security +|{security-guide}/admin-page-ov.html[Administration] + +2+|*Administer your Kibana instance* + +|Manage your Elasticsearch data +|< Data>> + +|Set up alerts +|< Alerts and Actions>> + +|Organize your workspace and users +|< Spaces>> + +|Define user roles and privileges +|< Users>> + +|Customize {kib} to suit your needs +|< Advanced Settings>> + +|=== + +[float] +[[try-kibana]] +=== Getting help -You can also <> — no code, no additional -infrastructure required. +Using our in-product guidance can help you get up and running, faster. +Click the help icon image:images/intro-help-icon.png[Help icon in navigation bar] +for help with questions or to provide feedback. -Our <> and in-product guidance can -help you get up and running, faster. Click the help icon image:images/intro-help-icon.png[Help icon in navigation bar] for help with questions or to provide feedback. +To keep up with what’s new and changed in Elastic, click the celebration icon in the global header. diff --git a/jest.config.integration.js b/jest.config.integration.js index 99728c5471dfb..2064abb7e36a1 100644 --- a/jest.config.integration.js +++ b/jest.config.integration.js @@ -17,7 +17,6 @@ module.exports = { testPathIgnorePatterns: preset.testPathIgnorePatterns.filter( (pattern) => !pattern.includes('integration_tests') ), - setupFilesAfterEnv: ['/packages/kbn-test/target/jest/setup/after_env.integration.js'], reporters: [ 'default', [ @@ -25,7 +24,5 @@ module.exports = { { reportName: 'Jest Integration Tests' }, ], ], - coverageReporters: !!process.env.CI - ? [['json', { file: 'jest-integration.json' }]] - : ['html', 'text'], + setupFilesAfterEnv: ['/packages/kbn-test/target/jest/setup/after_env.integration.js'], }; diff --git a/jest.config.js b/jest.config.js index 9ac5e57254e5a..f1833772c82a1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,14 +7,6 @@ */ module.exports = { - preset: '@kbn/test', rootDir: '.', - projects: [ - '/packages/*/jest.config.js', - '/src/*/jest.config.js', - '/src/legacy/*/jest.config.js', - '/src/plugins/*/jest.config.js', - '/test/*/jest.config.js', - '/x-pack/plugins/*/jest.config.js', - ], + projects: [...require('./jest.config.oss').projects, ...require('./x-pack/jest.config').projects], }; diff --git a/jest.config.oss.js b/jest.config.oss.js new file mode 100644 index 0000000000000..1b478aa85bdba --- /dev/null +++ b/jest.config.oss.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '.', + projects: [ + '/packages/*/jest.config.js', + '/src/*/jest.config.js', + '/src/legacy/*/jest.config.js', + '/src/plugins/*/jest.config.js', + '/test/*/jest.config.js', + ], +}; diff --git a/package.json b/package.json index 42949a7014131..920e0c8ba5192 100644 --- a/package.json +++ b/package.json @@ -312,7 +312,7 @@ "tabbable": "1.1.3", "tar": "4.4.13", "tinygradient": "0.4.3", - "tinymath": "1.2.1", + "@kbn/tinymath": "link:packages/kbn-tinymath", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", @@ -346,6 +346,7 @@ "@babel/register": "^7.12.10", "@babel/traverse": "^7.12.12", "@babel/types": "^7.12.12", + "@bazel/ibazel": "^0.14.0", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", @@ -392,6 +393,7 @@ "@storybook/addon-essentials": "^6.0.26", "@storybook/addon-knobs": "^6.0.26", "@storybook/addon-storyshots": "^6.0.26", + "@storybook/addon-docs": "^6.0.26", "@storybook/components": "^6.0.26", "@storybook/core": "^6.0.26", "@storybook/core-events": "^6.0.26", @@ -595,7 +597,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^87.0.3", + "chromedriver": "^88.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compare-versions": "3.5.1", @@ -846,4 +848,4 @@ "yargs": "^15.4.1", "zlib": "^1.0.5" } -} +} \ No newline at end of file diff --git a/packages/elastic-eslint-config-kibana/.eslintrc.js b/packages/elastic-eslint-config-kibana/.eslintrc.js index c0f8bf0ecb508..2e978c543cc69 100644 --- a/packages/elastic-eslint-config-kibana/.eslintrc.js +++ b/packages/elastic-eslint-config-kibana/.eslintrc.js @@ -65,6 +65,11 @@ module.exports = { to: false, disallowedMessage: `Don't import monaco directly, use or add exports to @kbn/monaco` }, + { + from: 'tinymath', + to: '@kbn/tinymath', + disallowedMessage: `Don't use 'tinymath', use '@kbn/tinymath'` + }, ], ], }, diff --git a/packages/kbn-config/src/config_service.mock.ts b/packages/kbn-config/src/config_service.mock.ts index cd6f399ddcce2..59f788767004c 100644 --- a/packages/kbn-config/src/config_service.mock.ts +++ b/packages/kbn-config/src/config_service.mock.ts @@ -17,8 +17,8 @@ const createConfigServiceMock = ({ }: { atPath?: Record; getConfig$?: Record } = {}) => { const mocked: jest.Mocked = { atPath: jest.fn(), + atPathSync: jest.fn(), getConfig$: jest.fn(), - optionalAtPath: jest.fn(), getUsedPaths: jest.fn(), getUnusedPaths: jest.fn(), isEnabledAtPath: jest.fn(), @@ -27,6 +27,7 @@ const createConfigServiceMock = ({ validate: jest.fn(), }; mocked.atPath.mockReturnValue(new BehaviorSubject(atPath)); + mocked.atPathSync.mockReturnValue(atPath); mocked.getConfig$.mockReturnValue(new BehaviorSubject(new ObjectToConfigAdapter(getConfig$))); mocked.getUsedPaths.mockResolvedValue([]); mocked.getUnusedPaths.mockResolvedValue([]); diff --git a/packages/kbn-config/src/config_service.test.ts b/packages/kbn-config/src/config_service.test.ts index 96d1f794a691c..e55916d7d348c 100644 --- a/packages/kbn-config/src/config_service.test.ts +++ b/packages/kbn-config/src/config_service.test.ts @@ -105,27 +105,6 @@ test('re-validate config when updated', async () => { `); }); -test("returns undefined if fetching optional config at a path that doesn't exist", async () => { - const rawConfig = getRawConfigProvider({}); - const configService = new ConfigService(rawConfig, defaultEnv, logger); - - const value$ = configService.optionalAtPath('unique-name'); - const value = await value$.pipe(first()).toPromise(); - - expect(value).toBeUndefined(); -}); - -test('returns observable config at optional path if it exists', async () => { - const rawConfig = getRawConfigProvider({ value: 'bar' }); - const configService = new ConfigService(rawConfig, defaultEnv, logger); - await configService.setSchema('value', schema.string()); - - const value$ = configService.optionalAtPath('value'); - const value: any = await value$.pipe(first()).toPromise(); - - expect(value).toBe('bar'); -}); - test("does not push new configs when reloading if config at path hasn't changed", async () => { const rawConfig$ = new BehaviorSubject>({ key: 'value' }); const rawConfigProvider = rawConfigServiceMock.create({ rawConfig$ }); @@ -209,34 +188,38 @@ test('flags schema paths as handled when registering a schema', async () => { test('tracks unhandled paths', async () => { const initialConfig = { - bar: { - deep1: { - key: '123', - }, - deep2: { - key: '321', - }, + service: { + string: 'str', + number: 42, }, - foo: 'value', - quux: { - deep1: { - key: 'hello', - }, - deep2: { - key: 'world', - }, + plugin: { + foo: 'bar', + }, + unknown: { + hello: 'dolly', + number: 9000, }, }; const rawConfigProvider = rawConfigServiceMock.create({ rawConfig: initialConfig }); const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); - - configService.atPath('foo'); - configService.atPath(['bar', 'deep2']); + await configService.setSchema( + 'service', + schema.object({ + string: schema.string(), + number: schema.number(), + }) + ); + await configService.setSchema( + 'plugin', + schema.object({ + foo: schema.string(), + }) + ); const unused = await configService.getUnusedPaths(); - expect(unused).toEqual(['bar.deep1.key', 'quux.deep1.key', 'quux.deep2.key']); + expect(unused).toEqual(['unknown.hello', 'unknown.number']); }); test('correctly passes context', async () => { @@ -339,22 +322,18 @@ test('does not throw if schema does not define "enabled" schema', async () => { const rawConfigProvider = rawConfigServiceMock.create({ rawConfig: initialConfig }); const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); - await expect( + expect( configService.setSchema( 'pid', schema.object({ file: schema.string(), }) ) - ).resolves.toBeUndefined(); + ).toBeUndefined(); const value$ = configService.atPath('pid'); const value: any = await value$.pipe(first()).toPromise(); expect(value.enabled).toBe(undefined); - - const valueOptional$ = configService.optionalAtPath('pid'); - const valueOptional: any = await valueOptional$.pipe(first()).toPromise(); - expect(valueOptional.enabled).toBe(undefined); }); test('treats config as enabled if config path is not present in config', async () => { @@ -457,3 +436,44 @@ test('logs deprecation warning during validation', async () => { ] `); }); + +describe('atPathSync', () => { + test('returns the value at path', async () => { + const rawConfig = getRawConfigProvider({ key: 'foo' }); + const configService = new ConfigService(rawConfig, defaultEnv, logger); + const stringSchema = schema.string(); + await configService.setSchema('key', stringSchema); + + await configService.validate(); + + const value = configService.atPathSync('key'); + expect(value).toBe('foo'); + }); + + test('throws if called before `validate`', async () => { + const rawConfig = getRawConfigProvider({ key: 'foo' }); + const configService = new ConfigService(rawConfig, defaultEnv, logger); + const stringSchema = schema.string(); + await configService.setSchema('key', stringSchema); + + expect(() => configService.atPathSync('key')).toThrowErrorMatchingInlineSnapshot( + `"\`atPathSync\` called before config was validated"` + ); + }); + + test('returns the last config value', async () => { + const rawConfig$ = new BehaviorSubject>({ key: 'value' }); + const rawConfigProvider = rawConfigServiceMock.create({ rawConfig$ }); + + const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); + await configService.setSchema('key', schema.string()); + + await configService.validate(); + + expect(configService.atPathSync('key')).toEqual('value'); + + rawConfig$.next({ key: 'new-value' }); + + expect(configService.atPathSync('key')).toEqual('new-value'); + }); +}); diff --git a/packages/kbn-config/src/config_service.ts b/packages/kbn-config/src/config_service.ts index 9518279f35766..929735ffc15f2 100644 --- a/packages/kbn-config/src/config_service.ts +++ b/packages/kbn-config/src/config_service.ts @@ -10,7 +10,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { Type } from '@kbn/config-schema'; import { isEqual } from 'lodash'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, first, map, shareReplay, take } from 'rxjs/operators'; +import { distinctUntilChanged, first, map, shareReplay, take, tap } from 'rxjs/operators'; import { Logger, LoggerFactory } from '@kbn/logging'; import { Config, ConfigPath, Env } from '.'; @@ -32,13 +32,15 @@ export class ConfigService { private readonly log: Logger; private readonly deprecationLog: Logger; + private validated = false; private readonly config$: Observable; + private lastConfig?: Config; /** * Whenever a config if read at a path, we mark that path as 'handled'. We can * then list all unhandled config paths when the startup process is completed. */ - private readonly handledPaths: ConfigPath[] = []; + private readonly handledPaths: Set = new Set(); private readonly schemas = new Map>(); private readonly deprecations = new BehaviorSubject([]); @@ -55,6 +57,9 @@ export class ConfigService { const migrated = applyDeprecations(rawConfig, deprecations); return new LegacyObjectToConfigAdapter(migrated); }), + tap((config) => { + this.lastConfig = config; + }), shareReplay(1) ); } @@ -62,7 +67,7 @@ export class ConfigService { /** * Set config schema for a path and performs its validation */ - public async setSchema(path: ConfigPath, schema: Type) { + public setSchema(path: ConfigPath, schema: Type) { const namespace = pathToString(path); if (this.schemas.has(namespace)) { throw new Error(`Validation schema for [${path}] was already registered.`); @@ -94,15 +99,16 @@ export class ConfigService { public async validate() { const namespaces = [...this.schemas.keys()]; for (let i = 0; i < namespaces.length; i++) { - await this.validateConfigAtPath(namespaces[i]).pipe(first()).toPromise(); + await this.getValidatedConfigAtPath$(namespaces[i]).pipe(first()).toPromise(); } await this.logDeprecation(); + this.validated = true; } /** * Returns the full config object observable. This is not intended for - * "normal use", but for features that _need_ access to the full object. + * "normal use", but for internal features that _need_ access to the full object. */ public getConfig$() { return this.config$; @@ -110,27 +116,26 @@ export class ConfigService { /** * Reads the subset of the config at the specified `path` and validates it - * against the static `schema` on the given `ConfigClass`. + * against its registered schema. * * @param path - The path to the desired subset of the config. */ public atPath(path: ConfigPath) { - return this.validateConfigAtPath(path) as Observable; + return this.getValidatedConfigAtPath$(path) as Observable; } /** - * Same as `atPath`, but returns `undefined` if there is no config at the - * specified path. + * Similar to {@link atPath}, but return the last emitted value synchronously instead of an + * observable. * - * {@link ConfigService.atPath} + * @param path - The path to the desired subset of the config. */ - public optionalAtPath(path: ConfigPath) { - return this.getDistinctConfig(path).pipe( - map((config) => { - if (config === undefined) return undefined; - return this.validateAtPath(path, config) as TSchema; - }) - ); + public atPathSync(path: ConfigPath) { + if (!this.validated) { + throw new Error('`atPathSync` called before config was validated'); + } + const configAtPath = this.lastConfig!.get(path); + return this.validateAtPath(path, configAtPath) as TSchema; } public async isEnabledAtPath(path: ConfigPath) { @@ -144,10 +149,7 @@ export class ConfigService { const config = await this.config$.pipe(first()).toPromise(); // if plugin hasn't got a config schema, we try to read "enabled" directly - const isEnabled = - validatedConfig && validatedConfig.enabled !== undefined - ? validatedConfig.enabled - : config.get(enabledPath); + const isEnabled = validatedConfig?.enabled ?? config.get(enabledPath); // not declared. consider that plugin is enabled by default if (isEnabled === undefined) { @@ -170,15 +172,13 @@ export class ConfigService { public async getUnusedPaths() { const config = await this.config$.pipe(first()).toPromise(); - const handledPaths = this.handledPaths.map(pathToString); - + const handledPaths = [...this.handledPaths.values()].map(pathToString); return config.getFlattenedPaths().filter((path) => !isPathHandled(path, handledPaths)); } public async getUsedPaths() { const config = await this.config$.pipe(first()).toPromise(); - const handledPaths = this.handledPaths.map(pathToString); - + const handledPaths = [...this.handledPaths.values()].map(pathToString); return config.getFlattenedPaths().filter((path) => isPathHandled(path, handledPaths)); } @@ -210,22 +210,17 @@ export class ConfigService { ); } - private validateConfigAtPath(path: ConfigPath) { - return this.getDistinctConfig(path).pipe(map((config) => this.validateAtPath(path, config))); - } - - private getDistinctConfig(path: ConfigPath) { - this.markAsHandled(path); - + private getValidatedConfigAtPath$(path: ConfigPath) { return this.config$.pipe( map((config) => config.get(path)), - distinctUntilChanged(isEqual) + distinctUntilChanged(isEqual), + map((config) => this.validateAtPath(path, config)) ); } private markAsHandled(path: ConfigPath) { this.log.debug(`Marking config path as handled: ${path}`); - this.handledPaths.push(path); + this.handledPaths.add(path); } } diff --git a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts index c037c5f0308c8..c12a147fddddc 100644 --- a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts +++ b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts @@ -84,7 +84,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { }; } - private static transformPlugins(configValue: LegacyVars) { + private static transformPlugins(configValue: LegacyVars = {}) { // These properties are the only ones we use from the existing `plugins` config node // since `scanDirs` isn't respected by new platform plugin discovery. return { diff --git a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts index 56c75c5aca419..6272d6ba00ee8 100644 --- a/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts +++ b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts @@ -25,5 +25,6 @@ export async function emptyKibanaIndexAction({ await cleanKibanaIndices({ client, stats, log, kibanaPluginIds }); await migrateKibanaIndex({ client, kbnClient }); - return stats; + stats.createdIndex('.kibana'); + return stats.toJSON(); } diff --git a/packages/kbn-es-archiver/src/es_archiver.ts b/packages/kbn-es-archiver/src/es_archiver.ts index f101c5d6867f1..8601dedad0e27 100644 --- a/packages/kbn-es-archiver/src/es_archiver.ts +++ b/packages/kbn-es-archiver/src/es_archiver.ts @@ -155,7 +155,7 @@ export class EsArchiver { * @return Promise */ async emptyKibanaIndex() { - await emptyKibanaIndexAction({ + return await emptyKibanaIndexAction({ client: this.client, log: this.log, kbnClient: this.kbnClient, diff --git a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts index 0459a4301cf6b..91c0bd8343a36 100644 --- a/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -76,7 +76,9 @@ export async function migrateKibanaIndex({ */ async function fetchKibanaIndices(client: Client) { const kibanaIndices = await client.cat.indices({ index: '.kibana*', format: 'json' }); - const isKibanaIndex = (index: string) => /^\.kibana(:?_\d*)?$/.test(index); + const isKibanaIndex = (index: string) => + /^\.kibana(:?_\d*)?$/.test(index) || + /^\.kibana(_task_manager)?_(pre)?\d+\.\d+\.\d+/.test(index); return kibanaIndices.map((x: { index: string }) => x.index).filter(isKibanaIndex); } @@ -103,7 +105,7 @@ export async function cleanKibanaIndices({ while (true) { const resp = await client.deleteByQuery({ - index: `.kibana`, + index: `.kibana,.kibana_task_manager`, body: { query: { bool: { @@ -115,7 +117,7 @@ export async function cleanKibanaIndices({ }, }, }, - ignore: [409], + ignore: [404, 409], }); if (resp.total !== resp.deleted) { diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index c8e25a95594c6..8ea83d744bcb9 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -8,6 +8,7 @@ "devOnly": true }, "scripts": { + "build": "node scripts/build", "kbn:bootstrap": "node scripts/build", "kbn:watch": "node scripts/build --watch" }, diff --git a/packages/kbn-es/src/cli_commands/snapshot.js b/packages/kbn-es/src/cli_commands/snapshot.js index d66c352a356aa..711992a5895ed 100644 --- a/packages/kbn-es/src/cli_commands/snapshot.js +++ b/packages/kbn-es/src/cli_commands/snapshot.js @@ -62,8 +62,6 @@ exports.run = async (defaults = {}) => { await cluster.extractDataDirectory(installPath, options.dataArchive); } - options.bundledJDK = true; - await cluster.run(installPath, options); } }; diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 60f8a327594d6..5948e9ecececc 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -246,7 +246,7 @@ exports.Cluster = class Cluster { this._log.info(chalk.bold('Starting')); this._log.indent(4); - const esArgs = ['indices.query.bool.max_nested_depth=100'].concat(options.esArgs || []); + const esArgs = options.esArgs || []; // Add to esArgs if ssl is enabled if (this._ssl) { @@ -279,7 +279,7 @@ exports.Cluster = class Cluster { env: { ...(installPath ? { ES_TMPDIR: path.resolve(installPath, 'ES_TMPDIR') } : {}), ...process.env, - ...(options.bundledJDK ? { JAVA_HOME: '' } : {}), + JAVA_HOME: '', // By default, we want to always unset JAVA_HOME so that the bundled JDK will be used ...(options.esEnvVars || {}), }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/packages/kbn-es/src/install/archive.js b/packages/kbn-es/src/install/archive.js index 1e9e19f4533af..80ff4eb6f83b0 100644 --- a/packages/kbn-es/src/install/archive.js +++ b/packages/kbn-es/src/install/archive.js @@ -34,7 +34,6 @@ exports.installArchive = async function installArchive(archive, options = {}) { basePath = BASE_PATH, installPath = path.resolve(basePath, path.basename(archive, '.tar.gz')), log = defaultLog, - bundledJDK = false, esArgs = [], } = options; @@ -64,7 +63,7 @@ exports.installArchive = async function installArchive(archive, options = {}) { await appendToConfig(installPath, 'xpack.security.enabled', 'true'); await appendToConfig(installPath, 'xpack.license.self_generated.type', license); - await configureKeystore(installPath, log, bundledJDK, [ + await configureKeystore(installPath, log, [ ['bootstrap.password', password], ...parseSettings(esArgs, { filter: SettingsFilter.SecureOnly }), ]); @@ -89,20 +88,11 @@ async function appendToConfig(installPath, key, value) { * * @param {String} installPath * @param {ToolingLog} log - * @param {boolean} bundledJDK * @param {Array<[string, string]>} secureSettings List of custom Elasticsearch secure settings to * add into the keystore. */ -async function configureKeystore( - installPath, - log = defaultLog, - bundledJDK = false, - secureSettings -) { - const env = {}; - if (bundledJDK) { - env.JAVA_HOME = ''; - } +async function configureKeystore(installPath, log = defaultLog, secureSettings) { + const env = { JAVA_HOME: '' }; await execa(ES_KEYSTORE_BIN, ['create'], { cwd: installPath, env }); for (const [secureSettingName, secureSettingValue] of secureSettings) { diff --git a/packages/kbn-es/src/install/snapshot.js b/packages/kbn-es/src/install/snapshot.js index 55c0e41ea9640..b9562f20d81b7 100644 --- a/packages/kbn-es/src/install/snapshot.js +++ b/packages/kbn-es/src/install/snapshot.js @@ -61,7 +61,6 @@ exports.installSnapshot = async function installSnapshot({ basePath = BASE_PATH, installPath = path.resolve(basePath, version), log = defaultLog, - bundledJDK = true, esArgs, }) { const { downloadPath } = await exports.downloadSnapshot({ @@ -78,7 +77,6 @@ exports.installSnapshot = async function installSnapshot({ basePath, installPath, log, - bundledJDK, esArgs, }); }; diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index c1adc84ddc954..684667355852d 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -264,9 +264,7 @@ describe('#start(installPath)', () => { expect(extractConfigFiles.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - Array [ - "indices.query.bool.max_nested_depth=100", - ], + Array [], undefined, Object { "log": , @@ -342,9 +340,7 @@ describe('#run()', () => { expect(extractConfigFiles.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - Array [ - "indices.query.bool.max_nested_depth=100", - ], + Array [], undefined, Object { "log": , diff --git a/packages/kbn-legacy-logging/src/setup_logging.test.ts b/packages/kbn-legacy-logging/src/setup_logging.test.ts new file mode 100644 index 0000000000000..6386b400329b9 --- /dev/null +++ b/packages/kbn-legacy-logging/src/setup_logging.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Server } from '@hapi/hapi'; +import { reconfigureLogging, setupLogging } from './setup_logging'; +import { LegacyLoggingConfig } from './schema'; + +describe('reconfigureLogging', () => { + test(`doesn't throw an error`, () => { + const server = new Server(); + const config: LegacyLoggingConfig = { + silent: false, + quiet: false, + verbose: true, + events: {}, + dest: '/tmp/foo', + filter: {}, + json: true, + rotate: { + enabled: false, + everyBytes: 0, + keepFiles: 0, + pollingInterval: 0, + usePolling: false, + }, + }; + setupLogging(server, config, 10); + reconfigureLogging(server, { ...config, dest: '/tmp/bar' }, 0); + }); +}); diff --git a/packages/kbn-legacy-logging/src/setup_logging.ts b/packages/kbn-legacy-logging/src/setup_logging.ts index 4370e4ab77d68..ffe3be558f366 100644 --- a/packages/kbn-legacy-logging/src/setup_logging.ts +++ b/packages/kbn-legacy-logging/src/setup_logging.ts @@ -37,5 +37,5 @@ export function reconfigureLogging( opsInterval: number ) { const loggingOptions = getLoggingConfiguration(config, opsInterval); - (server.plugins as any)['@elastic/good'].reconfigure(loggingOptions); + (server.plugins as any).good.reconfigure(loggingOptions); } diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 1a4fb390d0c17..794503656ba04 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -34,7 +34,7 @@ pageLoadAssetSize: indexLifecycleManagement: 107090 indexManagement: 140608 indexPatternManagement: 154222 - infra: 197873 + infra: 204800 fleet: 415829 ingestPipelines: 58003 inputControlVis: 172675 @@ -103,4 +103,5 @@ pageLoadAssetSize: stackAlerts: 29684 presentationUtil: 28545 spacesOss: 18817 + osquery: 107090 mapsFileUpload: 23775 diff --git a/packages/kbn-optimizer/src/worker/webpack.config.ts b/packages/kbn-optimizer/src/worker/webpack.config.ts index 96356e01f8c04..089ff163a692d 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -216,7 +216,6 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: extensions: ['.js', '.ts', '.tsx', '.json'], mainFields: ['browser', 'main'], alias: { - tinymath: require.resolve('tinymath/lib/tinymath.es5.js'), core_app_image_assets: Path.resolve(worker.repoRoot, 'src/core/public/core_app/images'), }, }, diff --git a/packages/kbn-pm/README.md b/packages/kbn-pm/README.md index c169b5c75e178..eb1ac6ffa92aa 100644 --- a/packages/kbn-pm/README.md +++ b/packages/kbn-pm/README.md @@ -150,14 +150,14 @@ e.g. `build` or `test`. Instead of jumping into each package and running `yarn build` you can run: ``` -yarn kbn run build +yarn kbn run build --skip-missing ``` And if needed, you can skip packages in the same way as for bootstrapping, e.g. with `--exclude` and `--skip-kibana-plugins`: ``` -yarn kbn run build --exclude kibana +yarn kbn run build --exclude kibana --skip-missing ``` ### Watching diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 09995a9be30a6..df04965cd8c32 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -94,7 +94,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _cli__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "run", function() { return _cli__WEBPACK_IMPORTED_MODULE_0__["run"]; }); -/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(510); +/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(512); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildProductionProjects"]; }); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(248); @@ -106,7 +106,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(251); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "transformDependencies", function() { return _utils_package_json__WEBPACK_IMPORTED_MODULE_4__["transformDependencies"]; }); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(509); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(511); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getProjectPaths", function() { return _config__WEBPACK_IMPORTED_MODULE_5__["getProjectPaths"]; }); /* @@ -139,7 +139,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5); /* harmony import */ var _kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3__); /* harmony import */ var _commands__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(128); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(503); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(505); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(246); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one @@ -179,12 +179,15 @@ function help() { --debug Set log level to debug --quiet Set log level to error --silent Disable log output + + "run" options: + --skip-missing Ignore packages which don't have the requested script ` + '\n'); } async function run(argv) { _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].setLogLevel(Object(_kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3__["pickLevelFromFlags"])(getopts__WEBPACK_IMPORTED_MODULE_1___default()(argv, { - boolean: ['verbose', 'debug', 'quiet', 'silent'] + boolean: ['verbose', 'debug', 'quiet', 'silent', 'skip-missing'] }))); // We can simplify this setup (and remove this extra handling) once Yarn // starts forwarding the `--` directly to this script, see // https://github.com/yarnpkg/yarn/blob/b2d3e1a8fe45ef376b716d597cc79b38702a9320/src/cli/index.js#L174-L182 @@ -8824,9 +8827,9 @@ exports.ToolingLogCollectingWriter = ToolingLogCollectingWriter; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "commands", function() { return commands; }); /* harmony import */ var _bootstrap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(129); -/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(371); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(402); -/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(403); +/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(373); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(404); +/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(405); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -8862,6 +8865,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_bootstrap_cache_file__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(368); /* harmony import */ var _utils_yarn_lock__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(365); /* harmony import */ var _utils_validate_dependencies__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(369); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(371); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -8878,19 +8882,23 @@ __webpack_require__.r(__webpack_exports__); + const BootstrapCommand = { description: 'Install dependencies and crosslink projects', name: 'bootstrap', async run(projects, projectGraph, { options, - kbn + kbn, + rootPath }) { var _projects$get; const batchedProjects = Object(_utils_projects__WEBPACK_IMPORTED_MODULE_4__["topologicallyBatchProjects"])(projects, projectGraph); const kibanaProjectPath = (_projects$get = projects.get('kibana')) === null || _projects$get === void 0 ? void 0 : _projects$get.path; - const extraArgs = [...(options['frozen-lockfile'] === true ? ['--frozen-lockfile'] : []), ...(options['prefer-offline'] === true ? ['--prefer-offline'] : [])]; + const extraArgs = [...(options['frozen-lockfile'] === true ? ['--frozen-lockfile'] : []), ...(options['prefer-offline'] === true ? ['--prefer-offline'] : [])]; // Install bazel machinery tools if needed + + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["installBazelTools"])(rootPath); // Install monorepo npm dependencies for (const batch of batchedProjects) { for (const project of batch) { @@ -47932,12 +47940,90 @@ function addProjectToTree(tree, pathParts, project) { /* 371 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony import */ var _install_tools__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(372); +/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return _install_tools__WEBPACK_IMPORTED_MODULE_0__["installBazelTools"]; }); + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + + +/***/ }), +/* 372 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return installBazelTools; }); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(319); +/* harmony import */ var _fs__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(131); +/* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(246); +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + + + + + +async function readBazelToolsVersionFile(repoRootPath, versionFilename) { + const version = (await Object(_fs__WEBPACK_IMPORTED_MODULE_2__["readFile"])(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(repoRootPath, versionFilename))).toString().split('\n')[0]; + + if (!version) { + throw new Error(`[bazel_tools] Failed on reading bazel tools versions\n ${versionFilename} file do not contain any version set`); + } + + return version; +} + +async function installBazelTools(repoRootPath) { + _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] reading bazel tools versions from version files`); + const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); + const bazelVersion = await readBazelToolsVersionFile(repoRootPath, '.bazelversion'); // Check what globals are installed + + _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] verify if bazelisk is installed`); + const { + stdout + } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_1__["spawn"])('yarn', ['global', 'list'], { + stdio: 'pipe' + }); // Install bazelisk if not installed + + if (!stdout.includes(`@bazel/bazelisk@${bazeliskVersion}`)) { + _log__WEBPACK_IMPORTED_MODULE_3__["log"].info(`[bazel_tools] installing Bazel tools`); + _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}`); + await Object(_child_process__WEBPACK_IMPORTED_MODULE_1__["spawn"])('yarn', ['global', 'add', `@bazel/bazelisk@${bazeliskVersion}`], { + env: { + USE_BAZEL_VERSION: bazelVersion + }, + stdio: 'pipe' + }); + } + + _log__WEBPACK_IMPORTED_MODULE_3__["log"].success(`[bazel_tools] all bazel tools are correctly installed`); +} + +/***/ }), +/* 373 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CleanCommand", function() { return CleanCommand; }); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(372); +/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(374); /* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(ora__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); @@ -48026,20 +48112,20 @@ const CleanCommand = { }; /***/ }), -/* 372 */ +/* 374 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readline = __webpack_require__(373); -const chalk = __webpack_require__(374); -const cliCursor = __webpack_require__(381); -const cliSpinners = __webpack_require__(383); -const logSymbols = __webpack_require__(385); -const stripAnsi = __webpack_require__(394); -const wcwidth = __webpack_require__(396); -const isInteractive = __webpack_require__(400); -const MuteStream = __webpack_require__(401); +const readline = __webpack_require__(375); +const chalk = __webpack_require__(376); +const cliCursor = __webpack_require__(383); +const cliSpinners = __webpack_require__(385); +const logSymbols = __webpack_require__(387); +const stripAnsi = __webpack_require__(396); +const wcwidth = __webpack_require__(398); +const isInteractive = __webpack_require__(402); +const MuteStream = __webpack_require__(403); const TEXT = Symbol('text'); const PREFIX_TEXT = Symbol('prefixText'); @@ -48392,23 +48478,23 @@ module.exports.promise = (action, options) => { /***/ }), -/* 373 */ +/* 375 */ /***/ (function(module, exports) { module.exports = require("readline"); /***/ }), -/* 374 */ +/* 376 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiStyles = __webpack_require__(375); +const ansiStyles = __webpack_require__(377); const {stdout: stdoutColor, stderr: stderrColor} = __webpack_require__(120); const { stringReplaceAll, stringEncaseCRLFWithFirstIndex -} = __webpack_require__(379); +} = __webpack_require__(381); // `supportsColor.level` → `ansiStyles.color[name]` mapping const levelMapping = [ @@ -48609,7 +48695,7 @@ const chalkTag = (chalk, ...strings) => { } if (template === undefined) { - template = __webpack_require__(380); + template = __webpack_require__(382); } return template(chalk, parts.join('')); @@ -48638,7 +48724,7 @@ module.exports = chalk; /***/ }), -/* 375 */ +/* 377 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -48684,7 +48770,7 @@ const setLazyProperty = (object, property, get) => { let colorConvert; const makeDynamicStyles = (wrap, targetSpace, identity, isBackground) => { if (colorConvert === undefined) { - colorConvert = __webpack_require__(376); + colorConvert = __webpack_require__(378); } const offset = isBackground ? 10 : 0; @@ -48809,11 +48895,11 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(115)(module))) /***/ }), -/* 376 */ +/* 378 */ /***/ (function(module, exports, __webpack_require__) { -const conversions = __webpack_require__(377); -const route = __webpack_require__(378); +const conversions = __webpack_require__(379); +const route = __webpack_require__(380); const convert = {}; @@ -48896,7 +48982,7 @@ module.exports = convert; /***/ }), -/* 377 */ +/* 379 */ /***/ (function(module, exports, __webpack_require__) { /* MIT license */ @@ -49741,10 +49827,10 @@ convert.rgb.gray = function (rgb) { /***/ }), -/* 378 */ +/* 380 */ /***/ (function(module, exports, __webpack_require__) { -const conversions = __webpack_require__(377); +const conversions = __webpack_require__(379); /* This function routes a model to all other models. @@ -49844,7 +49930,7 @@ module.exports = function (fromModel) { /***/ }), -/* 379 */ +/* 381 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -49890,7 +49976,7 @@ module.exports = { /***/ }), -/* 380 */ +/* 382 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -50031,12 +50117,12 @@ module.exports = (chalk, temporary) => { /***/ }), -/* 381 */ +/* 383 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const restoreCursor = __webpack_require__(382); +const restoreCursor = __webpack_require__(384); let isHidden = false; @@ -50073,7 +50159,7 @@ exports.toggle = (force, writableStream) => { /***/ }), -/* 382 */ +/* 384 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -50089,13 +50175,13 @@ module.exports = onetime(() => { /***/ }), -/* 383 */ +/* 385 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const spinners = Object.assign({}, __webpack_require__(384)); +const spinners = Object.assign({}, __webpack_require__(386)); const spinnersList = Object.keys(spinners); @@ -50113,18 +50199,18 @@ module.exports.default = spinners; /***/ }), -/* 384 */ +/* 386 */ /***/ (function(module) { module.exports = JSON.parse("{\"dots\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠹\",\"⠸\",\"⠼\",\"⠴\",\"⠦\",\"⠧\",\"⠇\",\"⠏\"]},\"dots2\":{\"interval\":80,\"frames\":[\"⣾\",\"⣽\",\"⣻\",\"⢿\",\"⡿\",\"⣟\",\"⣯\",\"⣷\"]},\"dots3\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠞\",\"⠖\",\"⠦\",\"⠴\",\"⠲\",\"⠳\",\"⠓\"]},\"dots4\":{\"interval\":80,\"frames\":[\"⠄\",\"⠆\",\"⠇\",\"⠋\",\"⠙\",\"⠸\",\"⠰\",\"⠠\",\"⠰\",\"⠸\",\"⠙\",\"⠋\",\"⠇\",\"⠆\"]},\"dots5\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\"]},\"dots6\":{\"interval\":80,\"frames\":[\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠴\",\"⠲\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠚\",\"⠙\",\"⠉\",\"⠁\"]},\"dots7\":{\"interval\":80,\"frames\":[\"⠈\",\"⠉\",\"⠋\",\"⠓\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠖\",\"⠦\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\"]},\"dots8\":{\"interval\":80,\"frames\":[\"⠁\",\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\",\"⠈\"]},\"dots9\":{\"interval\":80,\"frames\":[\"⢹\",\"⢺\",\"⢼\",\"⣸\",\"⣇\",\"⡧\",\"⡗\",\"⡏\"]},\"dots10\":{\"interval\":80,\"frames\":[\"⢄\",\"⢂\",\"⢁\",\"⡁\",\"⡈\",\"⡐\",\"⡠\"]},\"dots11\":{\"interval\":100,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⡀\",\"⢀\",\"⠠\",\"⠐\",\"⠈\"]},\"dots12\":{\"interval\":80,\"frames\":[\"⢀⠀\",\"⡀⠀\",\"⠄⠀\",\"⢂⠀\",\"⡂⠀\",\"⠅⠀\",\"⢃⠀\",\"⡃⠀\",\"⠍⠀\",\"⢋⠀\",\"⡋⠀\",\"⠍⠁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⢈⠩\",\"⡀⢙\",\"⠄⡙\",\"⢂⠩\",\"⡂⢘\",\"⠅⡘\",\"⢃⠨\",\"⡃⢐\",\"⠍⡐\",\"⢋⠠\",\"⡋⢀\",\"⠍⡁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⠈⠩\",\"⠀⢙\",\"⠀⡙\",\"⠀⠩\",\"⠀⢘\",\"⠀⡘\",\"⠀⠨\",\"⠀⢐\",\"⠀⡐\",\"⠀⠠\",\"⠀⢀\",\"⠀⡀\"]},\"dots8Bit\":{\"interval\":80,\"frames\":[\"⠀\",\"⠁\",\"⠂\",\"⠃\",\"⠄\",\"⠅\",\"⠆\",\"⠇\",\"⡀\",\"⡁\",\"⡂\",\"⡃\",\"⡄\",\"⡅\",\"⡆\",\"⡇\",\"⠈\",\"⠉\",\"⠊\",\"⠋\",\"⠌\",\"⠍\",\"⠎\",\"⠏\",\"⡈\",\"⡉\",\"⡊\",\"⡋\",\"⡌\",\"⡍\",\"⡎\",\"⡏\",\"⠐\",\"⠑\",\"⠒\",\"⠓\",\"⠔\",\"⠕\",\"⠖\",\"⠗\",\"⡐\",\"⡑\",\"⡒\",\"⡓\",\"⡔\",\"⡕\",\"⡖\",\"⡗\",\"⠘\",\"⠙\",\"⠚\",\"⠛\",\"⠜\",\"⠝\",\"⠞\",\"⠟\",\"⡘\",\"⡙\",\"⡚\",\"⡛\",\"⡜\",\"⡝\",\"⡞\",\"⡟\",\"⠠\",\"⠡\",\"⠢\",\"⠣\",\"⠤\",\"⠥\",\"⠦\",\"⠧\",\"⡠\",\"⡡\",\"⡢\",\"⡣\",\"⡤\",\"⡥\",\"⡦\",\"⡧\",\"⠨\",\"⠩\",\"⠪\",\"⠫\",\"⠬\",\"⠭\",\"⠮\",\"⠯\",\"⡨\",\"⡩\",\"⡪\",\"⡫\",\"⡬\",\"⡭\",\"⡮\",\"⡯\",\"⠰\",\"⠱\",\"⠲\",\"⠳\",\"⠴\",\"⠵\",\"⠶\",\"⠷\",\"⡰\",\"⡱\",\"⡲\",\"⡳\",\"⡴\",\"⡵\",\"⡶\",\"⡷\",\"⠸\",\"⠹\",\"⠺\",\"⠻\",\"⠼\",\"⠽\",\"⠾\",\"⠿\",\"⡸\",\"⡹\",\"⡺\",\"⡻\",\"⡼\",\"⡽\",\"⡾\",\"⡿\",\"⢀\",\"⢁\",\"⢂\",\"⢃\",\"⢄\",\"⢅\",\"⢆\",\"⢇\",\"⣀\",\"⣁\",\"⣂\",\"⣃\",\"⣄\",\"⣅\",\"⣆\",\"⣇\",\"⢈\",\"⢉\",\"⢊\",\"⢋\",\"⢌\",\"⢍\",\"⢎\",\"⢏\",\"⣈\",\"⣉\",\"⣊\",\"⣋\",\"⣌\",\"⣍\",\"⣎\",\"⣏\",\"⢐\",\"⢑\",\"⢒\",\"⢓\",\"⢔\",\"⢕\",\"⢖\",\"⢗\",\"⣐\",\"⣑\",\"⣒\",\"⣓\",\"⣔\",\"⣕\",\"⣖\",\"⣗\",\"⢘\",\"⢙\",\"⢚\",\"⢛\",\"⢜\",\"⢝\",\"⢞\",\"⢟\",\"⣘\",\"⣙\",\"⣚\",\"⣛\",\"⣜\",\"⣝\",\"⣞\",\"⣟\",\"⢠\",\"⢡\",\"⢢\",\"⢣\",\"⢤\",\"⢥\",\"⢦\",\"⢧\",\"⣠\",\"⣡\",\"⣢\",\"⣣\",\"⣤\",\"⣥\",\"⣦\",\"⣧\",\"⢨\",\"⢩\",\"⢪\",\"⢫\",\"⢬\",\"⢭\",\"⢮\",\"⢯\",\"⣨\",\"⣩\",\"⣪\",\"⣫\",\"⣬\",\"⣭\",\"⣮\",\"⣯\",\"⢰\",\"⢱\",\"⢲\",\"⢳\",\"⢴\",\"⢵\",\"⢶\",\"⢷\",\"⣰\",\"⣱\",\"⣲\",\"⣳\",\"⣴\",\"⣵\",\"⣶\",\"⣷\",\"⢸\",\"⢹\",\"⢺\",\"⢻\",\"⢼\",\"⢽\",\"⢾\",\"⢿\",\"⣸\",\"⣹\",\"⣺\",\"⣻\",\"⣼\",\"⣽\",\"⣾\",\"⣿\"]},\"line\":{\"interval\":130,\"frames\":[\"-\",\"\\\\\",\"|\",\"/\"]},\"line2\":{\"interval\":100,\"frames\":[\"⠂\",\"-\",\"–\",\"—\",\"–\",\"-\"]},\"pipe\":{\"interval\":100,\"frames\":[\"┤\",\"┘\",\"┴\",\"└\",\"├\",\"┌\",\"┬\",\"┐\"]},\"simpleDots\":{\"interval\":400,\"frames\":[\". \",\".. \",\"...\",\" \"]},\"simpleDotsScrolling\":{\"interval\":200,\"frames\":[\". \",\".. \",\"...\",\" ..\",\" .\",\" \"]},\"star\":{\"interval\":70,\"frames\":[\"✶\",\"✸\",\"✹\",\"✺\",\"✹\",\"✷\"]},\"star2\":{\"interval\":80,\"frames\":[\"+\",\"x\",\"*\"]},\"flip\":{\"interval\":70,\"frames\":[\"_\",\"_\",\"_\",\"-\",\"`\",\"`\",\"'\",\"´\",\"-\",\"_\",\"_\",\"_\"]},\"hamburger\":{\"interval\":100,\"frames\":[\"☱\",\"☲\",\"☴\"]},\"growVertical\":{\"interval\":120,\"frames\":[\"▁\",\"▃\",\"▄\",\"▅\",\"▆\",\"▇\",\"▆\",\"▅\",\"▄\",\"▃\"]},\"growHorizontal\":{\"interval\":120,\"frames\":[\"▏\",\"▎\",\"▍\",\"▌\",\"▋\",\"▊\",\"▉\",\"▊\",\"▋\",\"▌\",\"▍\",\"▎\"]},\"balloon\":{\"interval\":140,\"frames\":[\" \",\".\",\"o\",\"O\",\"@\",\"*\",\" \"]},\"balloon2\":{\"interval\":120,\"frames\":[\".\",\"o\",\"O\",\"°\",\"O\",\"o\",\".\"]},\"noise\":{\"interval\":100,\"frames\":[\"▓\",\"▒\",\"░\"]},\"bounce\":{\"interval\":120,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⠂\"]},\"boxBounce\":{\"interval\":120,\"frames\":[\"▖\",\"▘\",\"▝\",\"▗\"]},\"boxBounce2\":{\"interval\":100,\"frames\":[\"▌\",\"▀\",\"▐\",\"▄\"]},\"triangle\":{\"interval\":50,\"frames\":[\"◢\",\"◣\",\"◤\",\"◥\"]},\"arc\":{\"interval\":100,\"frames\":[\"◜\",\"◠\",\"◝\",\"◞\",\"◡\",\"◟\"]},\"circle\":{\"interval\":120,\"frames\":[\"◡\",\"⊙\",\"◠\"]},\"squareCorners\":{\"interval\":180,\"frames\":[\"◰\",\"◳\",\"◲\",\"◱\"]},\"circleQuarters\":{\"interval\":120,\"frames\":[\"◴\",\"◷\",\"◶\",\"◵\"]},\"circleHalves\":{\"interval\":50,\"frames\":[\"◐\",\"◓\",\"◑\",\"◒\"]},\"squish\":{\"interval\":100,\"frames\":[\"╫\",\"╪\"]},\"toggle\":{\"interval\":250,\"frames\":[\"⊶\",\"⊷\"]},\"toggle2\":{\"interval\":80,\"frames\":[\"▫\",\"▪\"]},\"toggle3\":{\"interval\":120,\"frames\":[\"□\",\"■\"]},\"toggle4\":{\"interval\":100,\"frames\":[\"■\",\"□\",\"▪\",\"▫\"]},\"toggle5\":{\"interval\":100,\"frames\":[\"▮\",\"▯\"]},\"toggle6\":{\"interval\":300,\"frames\":[\"ဝ\",\"၀\"]},\"toggle7\":{\"interval\":80,\"frames\":[\"⦾\",\"⦿\"]},\"toggle8\":{\"interval\":100,\"frames\":[\"◍\",\"◌\"]},\"toggle9\":{\"interval\":100,\"frames\":[\"◉\",\"◎\"]},\"toggle10\":{\"interval\":100,\"frames\":[\"㊂\",\"㊀\",\"㊁\"]},\"toggle11\":{\"interval\":50,\"frames\":[\"⧇\",\"⧆\"]},\"toggle12\":{\"interval\":120,\"frames\":[\"☗\",\"☖\"]},\"toggle13\":{\"interval\":80,\"frames\":[\"=\",\"*\",\"-\"]},\"arrow\":{\"interval\":100,\"frames\":[\"←\",\"↖\",\"↑\",\"↗\",\"→\",\"↘\",\"↓\",\"↙\"]},\"arrow2\":{\"interval\":80,\"frames\":[\"⬆️ \",\"↗️ \",\"➡️ \",\"↘️ \",\"⬇️ \",\"↙️ \",\"⬅️ \",\"↖️ \"]},\"arrow3\":{\"interval\":120,\"frames\":[\"▹▹▹▹▹\",\"▸▹▹▹▹\",\"▹▸▹▹▹\",\"▹▹▸▹▹\",\"▹▹▹▸▹\",\"▹▹▹▹▸\"]},\"bouncingBar\":{\"interval\":80,\"frames\":[\"[ ]\",\"[= ]\",\"[== ]\",\"[=== ]\",\"[ ===]\",\"[ ==]\",\"[ =]\",\"[ ]\",\"[ =]\",\"[ ==]\",\"[ ===]\",\"[====]\",\"[=== ]\",\"[== ]\",\"[= ]\"]},\"bouncingBall\":{\"interval\":80,\"frames\":[\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ●)\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"(● )\"]},\"smiley\":{\"interval\":200,\"frames\":[\"😄 \",\"😝 \"]},\"monkey\":{\"interval\":300,\"frames\":[\"🙈 \",\"🙈 \",\"🙉 \",\"🙊 \"]},\"hearts\":{\"interval\":100,\"frames\":[\"💛 \",\"💙 \",\"💜 \",\"💚 \",\"❤️ \"]},\"clock\":{\"interval\":100,\"frames\":[\"🕛 \",\"🕐 \",\"🕑 \",\"🕒 \",\"🕓 \",\"🕔 \",\"🕕 \",\"🕖 \",\"🕗 \",\"🕘 \",\"🕙 \",\"🕚 \"]},\"earth\":{\"interval\":180,\"frames\":[\"🌍 \",\"🌎 \",\"🌏 \"]},\"material\":{\"interval\":17,\"frames\":[\"█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"███▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"███████▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"████████▁▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"██████████▁▁▁▁▁▁▁▁▁▁\",\"███████████▁▁▁▁▁▁▁▁▁\",\"█████████████▁▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁▁██████████████▁▁▁▁\",\"▁▁▁██████████████▁▁▁\",\"▁▁▁▁█████████████▁▁▁\",\"▁▁▁▁██████████████▁▁\",\"▁▁▁▁██████████████▁▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁▁██████████████\",\"▁▁▁▁▁▁██████████████\",\"▁▁▁▁▁▁▁█████████████\",\"▁▁▁▁▁▁▁█████████████\",\"▁▁▁▁▁▁▁▁████████████\",\"▁▁▁▁▁▁▁▁████████████\",\"▁▁▁▁▁▁▁▁▁███████████\",\"▁▁▁▁▁▁▁▁▁███████████\",\"▁▁▁▁▁▁▁▁▁▁██████████\",\"▁▁▁▁▁▁▁▁▁▁██████████\",\"▁▁▁▁▁▁▁▁▁▁▁▁████████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁██████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"███▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"████▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"████████▁▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"███████████▁▁▁▁▁▁▁▁▁\",\"████████████▁▁▁▁▁▁▁▁\",\"████████████▁▁▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁▁▁█████████████▁▁▁▁\",\"▁▁▁▁▁████████████▁▁▁\",\"▁▁▁▁▁████████████▁▁▁\",\"▁▁▁▁▁▁███████████▁▁▁\",\"▁▁▁▁▁▁▁▁█████████▁▁▁\",\"▁▁▁▁▁▁▁▁█████████▁▁▁\",\"▁▁▁▁▁▁▁▁▁█████████▁▁\",\"▁▁▁▁▁▁▁▁▁█████████▁▁\",\"▁▁▁▁▁▁▁▁▁▁█████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁███████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁███████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\"]},\"moon\":{\"interval\":80,\"frames\":[\"🌑 \",\"🌒 \",\"🌓 \",\"🌔 \",\"🌕 \",\"🌖 \",\"🌗 \",\"🌘 \"]},\"runner\":{\"interval\":140,\"frames\":[\"🚶 \",\"🏃 \"]},\"pong\":{\"interval\":80,\"frames\":[\"▐⠂ ▌\",\"▐⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂▌\",\"▐ ⠠▌\",\"▐ ⡀▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐⠠ ▌\"]},\"shark\":{\"interval\":120,\"frames\":[\"▐|\\\\____________▌\",\"▐_|\\\\___________▌\",\"▐__|\\\\__________▌\",\"▐___|\\\\_________▌\",\"▐____|\\\\________▌\",\"▐_____|\\\\_______▌\",\"▐______|\\\\______▌\",\"▐_______|\\\\_____▌\",\"▐________|\\\\____▌\",\"▐_________|\\\\___▌\",\"▐__________|\\\\__▌\",\"▐___________|\\\\_▌\",\"▐____________|\\\\▌\",\"▐____________/|▌\",\"▐___________/|_▌\",\"▐__________/|__▌\",\"▐_________/|___▌\",\"▐________/|____▌\",\"▐_______/|_____▌\",\"▐______/|______▌\",\"▐_____/|_______▌\",\"▐____/|________▌\",\"▐___/|_________▌\",\"▐__/|__________▌\",\"▐_/|___________▌\",\"▐/|____________▌\"]},\"dqpb\":{\"interval\":100,\"frames\":[\"d\",\"q\",\"p\",\"b\"]},\"weather\":{\"interval\":100,\"frames\":[\"☀️ \",\"☀️ \",\"☀️ \",\"🌤 \",\"⛅️ \",\"🌥 \",\"☁️ \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"⛈ \",\"🌨 \",\"🌧 \",\"🌨 \",\"☁️ \",\"🌥 \",\"⛅️ \",\"🌤 \",\"☀️ \",\"☀️ \"]},\"christmas\":{\"interval\":400,\"frames\":[\"🌲\",\"🎄\"]},\"grenade\":{\"interval\":80,\"frames\":[\"، \",\"′ \",\" ´ \",\" ‾ \",\" ⸌\",\" ⸊\",\" |\",\" ⁎\",\" ⁕\",\" ෴ \",\" ⁓\",\" \",\" \",\" \"]},\"point\":{\"interval\":125,\"frames\":[\"∙∙∙\",\"●∙∙\",\"∙●∙\",\"∙∙●\",\"∙∙∙\"]},\"layer\":{\"interval\":150,\"frames\":[\"-\",\"=\",\"≡\"]},\"betaWave\":{\"interval\":80,\"frames\":[\"ρββββββ\",\"βρβββββ\",\"ββρββββ\",\"βββρβββ\",\"ββββρββ\",\"βββββρβ\",\"ββββββρ\"]},\"aesthetic\":{\"interval\":80,\"frames\":[\"▰▱▱▱▱▱▱\",\"▰▰▱▱▱▱▱\",\"▰▰▰▱▱▱▱\",\"▰▰▰▰▱▱▱\",\"▰▰▰▰▰▱▱\",\"▰▰▰▰▰▰▱\",\"▰▰▰▰▰▰▰\",\"▰▱▱▱▱▱▱\"]}}"); /***/ }), -/* 385 */ +/* 387 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const chalk = __webpack_require__(386); +const chalk = __webpack_require__(388); const isSupported = process.platform !== 'win32' || process.env.CI || process.env.TERM === 'xterm-256color'; @@ -50146,16 +50232,16 @@ module.exports = isSupported ? main : fallbacks; /***/ }), -/* 386 */ +/* 388 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(265); -const ansiStyles = __webpack_require__(387); -const stdoutColor = __webpack_require__(392).stdout; +const ansiStyles = __webpack_require__(389); +const stdoutColor = __webpack_require__(394).stdout; -const template = __webpack_require__(393); +const template = __webpack_require__(395); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -50381,12 +50467,12 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 387 */ +/* 389 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; /* WEBPACK VAR INJECTION */(function(module) { -const colorConvert = __webpack_require__(388); +const colorConvert = __webpack_require__(390); const wrapAnsi16 = (fn, offset) => function () { const code = fn.apply(colorConvert, arguments); @@ -50554,11 +50640,11 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(115)(module))) /***/ }), -/* 388 */ +/* 390 */ /***/ (function(module, exports, __webpack_require__) { -var conversions = __webpack_require__(389); -var route = __webpack_require__(391); +var conversions = __webpack_require__(391); +var route = __webpack_require__(393); var convert = {}; @@ -50638,11 +50724,11 @@ module.exports = convert; /***/ }), -/* 389 */ +/* 391 */ /***/ (function(module, exports, __webpack_require__) { /* MIT license */ -var cssKeywords = __webpack_require__(390); +var cssKeywords = __webpack_require__(392); // NOTE: conversions should only return primitive values (i.e. arrays, or // values that give correct `typeof` results). @@ -51512,7 +51598,7 @@ convert.rgb.gray = function (rgb) { /***/ }), -/* 390 */ +/* 392 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -51671,10 +51757,10 @@ module.exports = { /***/ }), -/* 391 */ +/* 393 */ /***/ (function(module, exports, __webpack_require__) { -var conversions = __webpack_require__(389); +var conversions = __webpack_require__(391); /* this function routes a model to all other models. @@ -51774,7 +51860,7 @@ module.exports = function (fromModel) { /***/ }), -/* 392 */ +/* 394 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -51912,7 +51998,7 @@ module.exports = { /***/ }), -/* 393 */ +/* 395 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52047,18 +52133,18 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 394 */ +/* 396 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiRegex = __webpack_require__(395); +const ansiRegex = __webpack_require__(397); module.exports = string => typeof string === 'string' ? string.replace(ansiRegex(), '') : string; /***/ }), -/* 395 */ +/* 397 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52075,14 +52161,14 @@ module.exports = ({onlyFirst = false} = {}) => { /***/ }), -/* 396 */ +/* 398 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var defaults = __webpack_require__(397) -var combining = __webpack_require__(399) +var defaults = __webpack_require__(399) +var combining = __webpack_require__(401) var DEFAULTS = { nul: 0, @@ -52181,10 +52267,10 @@ function bisearch(ucs) { /***/ }), -/* 397 */ +/* 399 */ /***/ (function(module, exports, __webpack_require__) { -var clone = __webpack_require__(398); +var clone = __webpack_require__(400); module.exports = function(options, defaults) { options = options || {}; @@ -52199,7 +52285,7 @@ module.exports = function(options, defaults) { }; /***/ }), -/* 398 */ +/* 400 */ /***/ (function(module, exports, __webpack_require__) { var clone = (function() { @@ -52371,7 +52457,7 @@ if ( true && module.exports) { /***/ }), -/* 399 */ +/* 401 */ /***/ (function(module, exports) { module.exports = [ @@ -52427,7 +52513,7 @@ module.exports = [ /***/ }), -/* 400 */ +/* 402 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52443,7 +52529,7 @@ module.exports = ({stream = process.stdout} = {}) => { /***/ }), -/* 401 */ +/* 403 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(138) @@ -52594,7 +52680,7 @@ MuteStream.prototype.close = proxy('close') /***/ }), -/* 402 */ +/* 404 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52620,7 +52706,8 @@ const RunCommand = { name: 'run', async run(projects, projectGraph, { - extraArgs + extraArgs, + options }) { const batchedProjects = Object(_utils_projects__WEBPACK_IMPORTED_MODULE_3__["topologicallyBatchProjects"])(projects, projectGraph); @@ -52631,20 +52718,26 @@ const RunCommand = { const scriptName = extraArgs[0]; const scriptArgs = extraArgs.slice(1); await Object(_utils_parallelize__WEBPACK_IMPORTED_MODULE_2__["parallelizeBatches"])(batchedProjects, async project => { - if (project.hasScript(scriptName)) { - _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].info(`[${project.name}] running "${scriptName}" script`); - await project.runScriptStreaming(scriptName, { - args: scriptArgs - }); - _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].success(`[${project.name}] complete`); + if (!project.hasScript(scriptName)) { + if (!!options['skip-missing']) { + return; + } + + throw new _utils_errors__WEBPACK_IMPORTED_MODULE_0__["CliError"](`[${project.name}] no "${scriptName}" script defined. To skip packages without the "${scriptName}" script pass --skip-missing`); } + + _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].info(`[${project.name}] running "${scriptName}" script`); + await project.runScriptStreaming(scriptName, { + args: scriptArgs + }); + _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].success(`[${project.name}] complete`); }); } }; /***/ }), -/* 403 */ +/* 405 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52654,7 +52747,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(246); /* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(247); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(248); -/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(404); +/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(406); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -52729,14 +52822,14 @@ const WatchCommand = { }; /***/ }), -/* 404 */ +/* 406 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "waitUntilWatchIsReady", function() { return waitUntilWatchIsReady; }); /* harmony import */ var rxjs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8); -/* harmony import */ var rxjs_operators__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(405); +/* harmony import */ var rxjs_operators__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(407); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -52792,141 +52885,141 @@ function waitUntilWatchIsReady(stream, opts = {}) { } /***/ }), -/* 405 */ +/* 407 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(406); +/* harmony import */ var _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(408); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "audit", function() { return _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__["audit"]; }); -/* harmony import */ var _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(407); +/* harmony import */ var _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(409); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "auditTime", function() { return _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__["auditTime"]; }); -/* harmony import */ var _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(408); +/* harmony import */ var _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(410); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buffer", function() { return _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__["buffer"]; }); -/* harmony import */ var _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(409); +/* harmony import */ var _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(411); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferCount", function() { return _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__["bufferCount"]; }); -/* harmony import */ var _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(410); +/* harmony import */ var _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(412); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferTime", function() { return _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__["bufferTime"]; }); -/* harmony import */ var _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(411); +/* harmony import */ var _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(413); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferToggle", function() { return _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__["bufferToggle"]; }); -/* harmony import */ var _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(412); +/* harmony import */ var _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(414); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferWhen", function() { return _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__["bufferWhen"]; }); -/* harmony import */ var _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(413); +/* harmony import */ var _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(415); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "catchError", function() { return _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__["catchError"]; }); -/* harmony import */ var _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(414); +/* harmony import */ var _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(416); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "combineAll", function() { return _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__["combineAll"]; }); -/* harmony import */ var _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(415); +/* harmony import */ var _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(417); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "combineLatest", function() { return _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__["combineLatest"]; }); -/* harmony import */ var _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(416); +/* harmony import */ var _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(418); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concat", function() { return _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__["concat"]; }); /* harmony import */ var _internal_operators_concatAll__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(80); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatAll", function() { return _internal_operators_concatAll__WEBPACK_IMPORTED_MODULE_11__["concatAll"]; }); -/* harmony import */ var _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(417); +/* harmony import */ var _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(419); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatMap", function() { return _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__["concatMap"]; }); -/* harmony import */ var _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(418); +/* harmony import */ var _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(420); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatMapTo", function() { return _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__["concatMapTo"]; }); -/* harmony import */ var _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(419); +/* harmony import */ var _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(421); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "count", function() { return _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__["count"]; }); -/* harmony import */ var _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(420); +/* harmony import */ var _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(422); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "debounce", function() { return _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__["debounce"]; }); -/* harmony import */ var _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(421); +/* harmony import */ var _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(423); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "debounceTime", function() { return _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__["debounceTime"]; }); -/* harmony import */ var _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(422); +/* harmony import */ var _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(424); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "defaultIfEmpty", function() { return _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__["defaultIfEmpty"]; }); -/* harmony import */ var _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(423); +/* harmony import */ var _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(425); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "delay", function() { return _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__["delay"]; }); -/* harmony import */ var _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(425); +/* harmony import */ var _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(427); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "delayWhen", function() { return _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__["delayWhen"]; }); -/* harmony import */ var _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(426); +/* harmony import */ var _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(428); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "dematerialize", function() { return _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__["dematerialize"]; }); -/* harmony import */ var _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(427); +/* harmony import */ var _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(429); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinct", function() { return _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__["distinct"]; }); -/* harmony import */ var _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__ = __webpack_require__(428); +/* harmony import */ var _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__ = __webpack_require__(430); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinctUntilChanged", function() { return _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__["distinctUntilChanged"]; }); -/* harmony import */ var _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__ = __webpack_require__(429); +/* harmony import */ var _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__ = __webpack_require__(431); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinctUntilKeyChanged", function() { return _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__["distinctUntilKeyChanged"]; }); -/* harmony import */ var _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__ = __webpack_require__(430); +/* harmony import */ var _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__ = __webpack_require__(432); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "elementAt", function() { return _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__["elementAt"]; }); -/* harmony import */ var _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(433); +/* harmony import */ var _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(435); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "endWith", function() { return _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__["endWith"]; }); -/* harmony import */ var _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(434); +/* harmony import */ var _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(436); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "every", function() { return _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__["every"]; }); -/* harmony import */ var _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(435); +/* harmony import */ var _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(437); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "exhaust", function() { return _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__["exhaust"]; }); -/* harmony import */ var _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__ = __webpack_require__(436); +/* harmony import */ var _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__ = __webpack_require__(438); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "exhaustMap", function() { return _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__["exhaustMap"]; }); -/* harmony import */ var _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__ = __webpack_require__(437); +/* harmony import */ var _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__ = __webpack_require__(439); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "expand", function() { return _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__["expand"]; }); /* harmony import */ var _internal_operators_filter__WEBPACK_IMPORTED_MODULE_30__ = __webpack_require__(105); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "filter", function() { return _internal_operators_filter__WEBPACK_IMPORTED_MODULE_30__["filter"]; }); -/* harmony import */ var _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__ = __webpack_require__(438); +/* harmony import */ var _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__ = __webpack_require__(440); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "finalize", function() { return _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__["finalize"]; }); -/* harmony import */ var _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(439); +/* harmony import */ var _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(441); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "find", function() { return _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__["find"]; }); -/* harmony import */ var _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__ = __webpack_require__(440); +/* harmony import */ var _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__ = __webpack_require__(442); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "findIndex", function() { return _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__["findIndex"]; }); -/* harmony import */ var _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(441); +/* harmony import */ var _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(443); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "first", function() { return _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__["first"]; }); /* harmony import */ var _internal_operators_groupBy__WEBPACK_IMPORTED_MODULE_35__ = __webpack_require__(31); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "groupBy", function() { return _internal_operators_groupBy__WEBPACK_IMPORTED_MODULE_35__["groupBy"]; }); -/* harmony import */ var _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(442); +/* harmony import */ var _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(444); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ignoreElements", function() { return _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__["ignoreElements"]; }); -/* harmony import */ var _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(443); +/* harmony import */ var _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(445); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "isEmpty", function() { return _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__["isEmpty"]; }); -/* harmony import */ var _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(444); +/* harmony import */ var _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(446); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "last", function() { return _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__["last"]; }); /* harmony import */ var _internal_operators_map__WEBPACK_IMPORTED_MODULE_39__ = __webpack_require__(66); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "map", function() { return _internal_operators_map__WEBPACK_IMPORTED_MODULE_39__["map"]; }); -/* harmony import */ var _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(446); +/* harmony import */ var _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(448); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mapTo", function() { return _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__["mapTo"]; }); -/* harmony import */ var _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__ = __webpack_require__(447); +/* harmony import */ var _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__ = __webpack_require__(449); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "materialize", function() { return _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__["materialize"]; }); -/* harmony import */ var _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(448); +/* harmony import */ var _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(450); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "max", function() { return _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__["max"]; }); -/* harmony import */ var _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(451); +/* harmony import */ var _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(453); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "merge", function() { return _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__["merge"]; }); /* harmony import */ var _internal_operators_mergeAll__WEBPACK_IMPORTED_MODULE_44__ = __webpack_require__(81); @@ -52937,175 +53030,175 @@ __webpack_require__.r(__webpack_exports__); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "flatMap", function() { return _internal_operators_mergeMap__WEBPACK_IMPORTED_MODULE_45__["flatMap"]; }); -/* harmony import */ var _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(452); +/* harmony import */ var _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(454); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mergeMapTo", function() { return _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__["mergeMapTo"]; }); -/* harmony import */ var _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__ = __webpack_require__(453); +/* harmony import */ var _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__ = __webpack_require__(455); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mergeScan", function() { return _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__["mergeScan"]; }); -/* harmony import */ var _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__ = __webpack_require__(454); +/* harmony import */ var _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__ = __webpack_require__(456); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "min", function() { return _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__["min"]; }); -/* harmony import */ var _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(455); +/* harmony import */ var _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(457); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "multicast", function() { return _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__["multicast"]; }); /* harmony import */ var _internal_operators_observeOn__WEBPACK_IMPORTED_MODULE_50__ = __webpack_require__(41); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "observeOn", function() { return _internal_operators_observeOn__WEBPACK_IMPORTED_MODULE_50__["observeOn"]; }); -/* harmony import */ var _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__ = __webpack_require__(456); +/* harmony import */ var _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__ = __webpack_require__(458); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "onErrorResumeNext", function() { return _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__["onErrorResumeNext"]; }); -/* harmony import */ var _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__ = __webpack_require__(457); +/* harmony import */ var _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__ = __webpack_require__(459); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "pairwise", function() { return _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__["pairwise"]; }); -/* harmony import */ var _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__ = __webpack_require__(458); +/* harmony import */ var _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__ = __webpack_require__(460); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "partition", function() { return _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__["partition"]; }); -/* harmony import */ var _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__ = __webpack_require__(459); +/* harmony import */ var _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__ = __webpack_require__(461); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "pluck", function() { return _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__["pluck"]; }); -/* harmony import */ var _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__ = __webpack_require__(460); +/* harmony import */ var _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__ = __webpack_require__(462); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publish", function() { return _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__["publish"]; }); -/* harmony import */ var _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__ = __webpack_require__(461); +/* harmony import */ var _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__ = __webpack_require__(463); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishBehavior", function() { return _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__["publishBehavior"]; }); -/* harmony import */ var _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__ = __webpack_require__(462); +/* harmony import */ var _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__ = __webpack_require__(464); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishLast", function() { return _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__["publishLast"]; }); -/* harmony import */ var _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__ = __webpack_require__(463); +/* harmony import */ var _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__ = __webpack_require__(465); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishReplay", function() { return _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__["publishReplay"]; }); -/* harmony import */ var _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__ = __webpack_require__(464); +/* harmony import */ var _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__ = __webpack_require__(466); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "race", function() { return _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__["race"]; }); -/* harmony import */ var _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__ = __webpack_require__(449); +/* harmony import */ var _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__ = __webpack_require__(451); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "reduce", function() { return _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__["reduce"]; }); -/* harmony import */ var _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__ = __webpack_require__(465); +/* harmony import */ var _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__ = __webpack_require__(467); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "repeat", function() { return _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__["repeat"]; }); -/* harmony import */ var _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__ = __webpack_require__(466); +/* harmony import */ var _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__ = __webpack_require__(468); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "repeatWhen", function() { return _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__["repeatWhen"]; }); -/* harmony import */ var _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__ = __webpack_require__(467); +/* harmony import */ var _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__ = __webpack_require__(469); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "retry", function() { return _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__["retry"]; }); -/* harmony import */ var _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__ = __webpack_require__(468); +/* harmony import */ var _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__ = __webpack_require__(470); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "retryWhen", function() { return _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__["retryWhen"]; }); /* harmony import */ var _internal_operators_refCount__WEBPACK_IMPORTED_MODULE_65__ = __webpack_require__(30); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "refCount", function() { return _internal_operators_refCount__WEBPACK_IMPORTED_MODULE_65__["refCount"]; }); -/* harmony import */ var _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__ = __webpack_require__(469); +/* harmony import */ var _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__ = __webpack_require__(471); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sample", function() { return _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__["sample"]; }); -/* harmony import */ var _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__ = __webpack_require__(470); +/* harmony import */ var _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__ = __webpack_require__(472); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sampleTime", function() { return _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__["sampleTime"]; }); -/* harmony import */ var _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__ = __webpack_require__(450); +/* harmony import */ var _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__ = __webpack_require__(452); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "scan", function() { return _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__["scan"]; }); -/* harmony import */ var _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__ = __webpack_require__(471); +/* harmony import */ var _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__ = __webpack_require__(473); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sequenceEqual", function() { return _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__["sequenceEqual"]; }); -/* harmony import */ var _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__ = __webpack_require__(472); +/* harmony import */ var _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__ = __webpack_require__(474); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "share", function() { return _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__["share"]; }); -/* harmony import */ var _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__ = __webpack_require__(473); +/* harmony import */ var _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__ = __webpack_require__(475); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "shareReplay", function() { return _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__["shareReplay"]; }); -/* harmony import */ var _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__ = __webpack_require__(474); +/* harmony import */ var _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__ = __webpack_require__(476); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "single", function() { return _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__["single"]; }); -/* harmony import */ var _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__ = __webpack_require__(475); +/* harmony import */ var _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__ = __webpack_require__(477); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skip", function() { return _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__["skip"]; }); -/* harmony import */ var _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__ = __webpack_require__(476); +/* harmony import */ var _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__ = __webpack_require__(478); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipLast", function() { return _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__["skipLast"]; }); -/* harmony import */ var _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__ = __webpack_require__(477); +/* harmony import */ var _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__ = __webpack_require__(479); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipUntil", function() { return _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__["skipUntil"]; }); -/* harmony import */ var _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__ = __webpack_require__(478); +/* harmony import */ var _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__ = __webpack_require__(480); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipWhile", function() { return _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__["skipWhile"]; }); -/* harmony import */ var _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__ = __webpack_require__(479); +/* harmony import */ var _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__ = __webpack_require__(481); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "startWith", function() { return _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__["startWith"]; }); -/* harmony import */ var _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__ = __webpack_require__(480); +/* harmony import */ var _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__ = __webpack_require__(482); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "subscribeOn", function() { return _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__["subscribeOn"]; }); -/* harmony import */ var _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__ = __webpack_require__(482); +/* harmony import */ var _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__ = __webpack_require__(484); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchAll", function() { return _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__["switchAll"]; }); -/* harmony import */ var _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__ = __webpack_require__(483); +/* harmony import */ var _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__ = __webpack_require__(485); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchMap", function() { return _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__["switchMap"]; }); -/* harmony import */ var _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__ = __webpack_require__(484); +/* harmony import */ var _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__ = __webpack_require__(486); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchMapTo", function() { return _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__["switchMapTo"]; }); -/* harmony import */ var _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__ = __webpack_require__(432); +/* harmony import */ var _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__ = __webpack_require__(434); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "take", function() { return _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__["take"]; }); -/* harmony import */ var _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__ = __webpack_require__(445); +/* harmony import */ var _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__ = __webpack_require__(447); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeLast", function() { return _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__["takeLast"]; }); -/* harmony import */ var _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__ = __webpack_require__(485); +/* harmony import */ var _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__ = __webpack_require__(487); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeUntil", function() { return _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__["takeUntil"]; }); -/* harmony import */ var _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__ = __webpack_require__(486); +/* harmony import */ var _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__ = __webpack_require__(488); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeWhile", function() { return _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__["takeWhile"]; }); -/* harmony import */ var _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__ = __webpack_require__(487); +/* harmony import */ var _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__ = __webpack_require__(489); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "tap", function() { return _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__["tap"]; }); -/* harmony import */ var _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__ = __webpack_require__(488); +/* harmony import */ var _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__ = __webpack_require__(490); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throttle", function() { return _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__["throttle"]; }); -/* harmony import */ var _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__ = __webpack_require__(489); +/* harmony import */ var _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__ = __webpack_require__(491); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throttleTime", function() { return _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__["throttleTime"]; }); -/* harmony import */ var _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__ = __webpack_require__(431); +/* harmony import */ var _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__ = __webpack_require__(433); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throwIfEmpty", function() { return _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__["throwIfEmpty"]; }); -/* harmony import */ var _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__ = __webpack_require__(490); +/* harmony import */ var _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__ = __webpack_require__(492); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeInterval", function() { return _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__["timeInterval"]; }); -/* harmony import */ var _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__ = __webpack_require__(491); +/* harmony import */ var _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__ = __webpack_require__(493); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeout", function() { return _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__["timeout"]; }); -/* harmony import */ var _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__ = __webpack_require__(492); +/* harmony import */ var _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__ = __webpack_require__(494); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeoutWith", function() { return _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__["timeoutWith"]; }); -/* harmony import */ var _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__ = __webpack_require__(493); +/* harmony import */ var _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__ = __webpack_require__(495); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timestamp", function() { return _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__["timestamp"]; }); -/* harmony import */ var _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__ = __webpack_require__(494); +/* harmony import */ var _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__ = __webpack_require__(496); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "toArray", function() { return _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__["toArray"]; }); -/* harmony import */ var _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__ = __webpack_require__(495); +/* harmony import */ var _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__ = __webpack_require__(497); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "window", function() { return _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__["window"]; }); -/* harmony import */ var _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__ = __webpack_require__(496); +/* harmony import */ var _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__ = __webpack_require__(498); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowCount", function() { return _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__["windowCount"]; }); -/* harmony import */ var _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__ = __webpack_require__(497); +/* harmony import */ var _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__ = __webpack_require__(499); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowTime", function() { return _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__["windowTime"]; }); -/* harmony import */ var _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__ = __webpack_require__(498); +/* harmony import */ var _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__ = __webpack_require__(500); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowToggle", function() { return _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__["windowToggle"]; }); -/* harmony import */ var _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__ = __webpack_require__(499); +/* harmony import */ var _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__ = __webpack_require__(501); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowWhen", function() { return _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__["windowWhen"]; }); -/* harmony import */ var _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__ = __webpack_require__(500); +/* harmony import */ var _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__ = __webpack_require__(502); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "withLatestFrom", function() { return _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__["withLatestFrom"]; }); -/* harmony import */ var _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__ = __webpack_require__(501); +/* harmony import */ var _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__ = __webpack_require__(503); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "zip", function() { return _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__["zip"]; }); -/* harmony import */ var _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__ = __webpack_require__(502); +/* harmony import */ var _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__ = __webpack_require__(504); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "zipAll", function() { return _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__["zipAll"]; }); /** PURE_IMPORTS_START PURE_IMPORTS_END */ @@ -53216,7 +53309,7 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 406 */ +/* 408 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53295,14 +53388,14 @@ var AuditSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 407 */ +/* 409 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "auditTime", function() { return auditTime; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(55); -/* harmony import */ var _audit__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(406); +/* harmony import */ var _audit__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(408); /* harmony import */ var _observable_timer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(108); /** PURE_IMPORTS_START _scheduler_async,_audit,_observable_timer PURE_IMPORTS_END */ @@ -53318,7 +53411,7 @@ function auditTime(duration, scheduler) { /***/ }), -/* 408 */ +/* 410 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53365,7 +53458,7 @@ var BufferSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 409 */ +/* 411 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53466,7 +53559,7 @@ var BufferSkipCountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 410 */ +/* 412 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53627,7 +53720,7 @@ function dispatchBufferClose(arg) { /***/ }), -/* 411 */ +/* 413 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53746,7 +53839,7 @@ var BufferToggleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 412 */ +/* 414 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53839,7 +53932,7 @@ var BufferWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 413 */ +/* 415 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53899,7 +53992,7 @@ var CatchSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 414 */ +/* 416 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53915,7 +54008,7 @@ function combineAll(project) { /***/ }), -/* 415 */ +/* 417 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53947,7 +54040,7 @@ function combineLatest() { /***/ }), -/* 416 */ +/* 418 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53967,7 +54060,7 @@ function concat() { /***/ }), -/* 417 */ +/* 419 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53983,13 +54076,13 @@ function concatMap(project, resultSelector) { /***/ }), -/* 418 */ +/* 420 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "concatMapTo", function() { return concatMapTo; }); -/* harmony import */ var _concatMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(417); +/* harmony import */ var _concatMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(419); /** PURE_IMPORTS_START _concatMap PURE_IMPORTS_END */ function concatMapTo(innerObservable, resultSelector) { @@ -53999,7 +54092,7 @@ function concatMapTo(innerObservable, resultSelector) { /***/ }), -/* 419 */ +/* 421 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54064,7 +54157,7 @@ var CountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 420 */ +/* 422 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54149,7 +54242,7 @@ var DebounceSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 421 */ +/* 423 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54225,7 +54318,7 @@ function dispatchNext(subscriber) { /***/ }), -/* 422 */ +/* 424 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54275,7 +54368,7 @@ var DefaultIfEmptySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 423 */ +/* 425 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54283,7 +54376,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "delay", function() { return delay; }); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(12); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(55); -/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(424); +/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(426); /* harmony import */ var _Subscriber__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(11); /* harmony import */ var _Notification__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(42); /** PURE_IMPORTS_START tslib,_scheduler_async,_util_isDate,_Subscriber,_Notification PURE_IMPORTS_END */ @@ -54382,7 +54475,7 @@ var DelayMessage = /*@__PURE__*/ (function () { /***/ }), -/* 424 */ +/* 426 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54396,7 +54489,7 @@ function isDate(value) { /***/ }), -/* 425 */ +/* 427 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54542,7 +54635,7 @@ var SubscriptionDelaySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 426 */ +/* 428 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54580,7 +54673,7 @@ var DeMaterializeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 427 */ +/* 429 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54656,7 +54749,7 @@ var DistinctSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 428 */ +/* 430 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54727,13 +54820,13 @@ var DistinctUntilChangedSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 429 */ +/* 431 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "distinctUntilKeyChanged", function() { return distinctUntilKeyChanged; }); -/* harmony import */ var _distinctUntilChanged__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(428); +/* harmony import */ var _distinctUntilChanged__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(430); /** PURE_IMPORTS_START _distinctUntilChanged PURE_IMPORTS_END */ function distinctUntilKeyChanged(key, compare) { @@ -54743,7 +54836,7 @@ function distinctUntilKeyChanged(key, compare) { /***/ }), -/* 430 */ +/* 432 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54751,9 +54844,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "elementAt", function() { return elementAt; }); /* harmony import */ var _util_ArgumentOutOfRangeError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(62); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(105); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(431); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(422); -/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(432); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(433); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(424); +/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(434); /** PURE_IMPORTS_START _util_ArgumentOutOfRangeError,_filter,_throwIfEmpty,_defaultIfEmpty,_take PURE_IMPORTS_END */ @@ -54775,7 +54868,7 @@ function elementAt(index, defaultValue) { /***/ }), -/* 431 */ +/* 433 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54841,7 +54934,7 @@ function defaultErrorFactory() { /***/ }), -/* 432 */ +/* 434 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54903,7 +54996,7 @@ var TakeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 433 */ +/* 435 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54925,7 +55018,7 @@ function endWith() { /***/ }), -/* 434 */ +/* 436 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54987,7 +55080,7 @@ var EverySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 435 */ +/* 437 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55041,7 +55134,7 @@ var SwitchFirstSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 436 */ +/* 438 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55135,7 +55228,7 @@ var ExhaustMapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 437 */ +/* 439 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55247,7 +55340,7 @@ var ExpandSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 438 */ +/* 440 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55285,7 +55378,7 @@ var FinallySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 439 */ +/* 441 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55357,13 +55450,13 @@ var FindValueSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 440 */ +/* 442 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "findIndex", function() { return findIndex; }); -/* harmony import */ var _operators_find__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(439); +/* harmony import */ var _operators_find__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(441); /** PURE_IMPORTS_START _operators_find PURE_IMPORTS_END */ function findIndex(predicate, thisArg) { @@ -55373,7 +55466,7 @@ function findIndex(predicate, thisArg) { /***/ }), -/* 441 */ +/* 443 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55381,9 +55474,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "first", function() { return first; }); /* harmony import */ var _util_EmptyError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(63); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(105); -/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(432); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(422); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(431); +/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(434); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(424); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(433); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(25); /** PURE_IMPORTS_START _util_EmptyError,_filter,_take,_defaultIfEmpty,_throwIfEmpty,_util_identity PURE_IMPORTS_END */ @@ -55400,7 +55493,7 @@ function first(predicate, defaultValue) { /***/ }), -/* 442 */ +/* 444 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55437,7 +55530,7 @@ var IgnoreElementsSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 443 */ +/* 445 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55481,7 +55574,7 @@ var IsEmptySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 444 */ +/* 446 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55489,9 +55582,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "last", function() { return last; }); /* harmony import */ var _util_EmptyError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(63); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(105); -/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(445); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(431); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(422); +/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(447); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(433); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(424); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(25); /** PURE_IMPORTS_START _util_EmptyError,_filter,_takeLast,_throwIfEmpty,_defaultIfEmpty,_util_identity PURE_IMPORTS_END */ @@ -55508,7 +55601,7 @@ function last(predicate, defaultValue) { /***/ }), -/* 445 */ +/* 447 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55585,7 +55678,7 @@ var TakeLastSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 446 */ +/* 448 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55624,7 +55717,7 @@ var MapToSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 447 */ +/* 449 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55674,13 +55767,13 @@ var MaterializeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 448 */ +/* 450 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "max", function() { return max; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(449); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(451); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function max(comparer) { @@ -55693,15 +55786,15 @@ function max(comparer) { /***/ }), -/* 449 */ +/* 451 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "reduce", function() { return reduce; }); -/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(450); -/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(445); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(422); +/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(452); +/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(447); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(424); /* harmony import */ var _util_pipe__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(24); /** PURE_IMPORTS_START _scan,_takeLast,_defaultIfEmpty,_util_pipe PURE_IMPORTS_END */ @@ -55722,7 +55815,7 @@ function reduce(accumulator, seed) { /***/ }), -/* 450 */ +/* 452 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55804,7 +55897,7 @@ var ScanSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 451 */ +/* 453 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55824,7 +55917,7 @@ function merge() { /***/ }), -/* 452 */ +/* 454 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55849,7 +55942,7 @@ function mergeMapTo(innerObservable, resultSelector, concurrent) { /***/ }), -/* 453 */ +/* 455 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55958,13 +56051,13 @@ var MergeScanSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 454 */ +/* 456 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "min", function() { return min; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(449); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(451); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function min(comparer) { @@ -55977,7 +56070,7 @@ function min(comparer) { /***/ }), -/* 455 */ +/* 457 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56026,7 +56119,7 @@ var MulticastOperator = /*@__PURE__*/ (function () { /***/ }), -/* 456 */ +/* 458 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56116,7 +56209,7 @@ var OnErrorResumeNextSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 457 */ +/* 459 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56164,7 +56257,7 @@ var PairwiseSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 458 */ +/* 460 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56187,7 +56280,7 @@ function partition(predicate, thisArg) { /***/ }), -/* 459 */ +/* 461 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56227,14 +56320,14 @@ function plucker(props, length) { /***/ }), -/* 460 */ +/* 462 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publish", function() { return publish; }); /* harmony import */ var _Subject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(27); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(457); /** PURE_IMPORTS_START _Subject,_multicast PURE_IMPORTS_END */ @@ -56247,14 +56340,14 @@ function publish(selector) { /***/ }), -/* 461 */ +/* 463 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishBehavior", function() { return publishBehavior; }); /* harmony import */ var _BehaviorSubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(32); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(457); /** PURE_IMPORTS_START _BehaviorSubject,_multicast PURE_IMPORTS_END */ @@ -56265,14 +56358,14 @@ function publishBehavior(value) { /***/ }), -/* 462 */ +/* 464 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishLast", function() { return publishLast; }); /* harmony import */ var _AsyncSubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(50); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(457); /** PURE_IMPORTS_START _AsyncSubject,_multicast PURE_IMPORTS_END */ @@ -56283,14 +56376,14 @@ function publishLast() { /***/ }), -/* 463 */ +/* 465 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishReplay", function() { return publishReplay; }); /* harmony import */ var _ReplaySubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(33); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(457); /** PURE_IMPORTS_START _ReplaySubject,_multicast PURE_IMPORTS_END */ @@ -56306,7 +56399,7 @@ function publishReplay(bufferSize, windowTime, selectorOrScheduler, scheduler) { /***/ }), -/* 464 */ +/* 466 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56333,7 +56426,7 @@ function race() { /***/ }), -/* 465 */ +/* 467 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56398,7 +56491,7 @@ var RepeatSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 466 */ +/* 468 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56492,7 +56585,7 @@ var RepeatWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 467 */ +/* 469 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56545,7 +56638,7 @@ var RetrySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 468 */ +/* 470 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56631,7 +56724,7 @@ var RetryWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 469 */ +/* 471 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56686,7 +56779,7 @@ var SampleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 470 */ +/* 472 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56746,7 +56839,7 @@ function dispatchNotification(state) { /***/ }), -/* 471 */ +/* 473 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56869,13 +56962,13 @@ var SequenceEqualCompareToSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 472 */ +/* 474 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "share", function() { return share; }); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(457); /* harmony import */ var _refCount__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(30); /* harmony import */ var _Subject__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(27); /** PURE_IMPORTS_START _multicast,_refCount,_Subject PURE_IMPORTS_END */ @@ -56892,7 +56985,7 @@ function share() { /***/ }), -/* 473 */ +/* 475 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56961,7 +57054,7 @@ function shareReplayOperator(_a) { /***/ }), -/* 474 */ +/* 476 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57041,7 +57134,7 @@ var SingleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 475 */ +/* 477 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57083,7 +57176,7 @@ var SkipSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 476 */ +/* 478 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57145,7 +57238,7 @@ var SkipLastSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 477 */ +/* 479 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57202,7 +57295,7 @@ var SkipUntilSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 478 */ +/* 480 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57258,7 +57351,7 @@ var SkipWhileSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 479 */ +/* 481 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57287,13 +57380,13 @@ function startWith() { /***/ }), -/* 480 */ +/* 482 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "subscribeOn", function() { return subscribeOn; }); -/* harmony import */ var _observable_SubscribeOnObservable__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(481); +/* harmony import */ var _observable_SubscribeOnObservable__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(483); /** PURE_IMPORTS_START _observable_SubscribeOnObservable PURE_IMPORTS_END */ function subscribeOn(scheduler, delay) { @@ -57318,7 +57411,7 @@ var SubscribeOnOperator = /*@__PURE__*/ (function () { /***/ }), -/* 481 */ +/* 483 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57382,13 +57475,13 @@ var SubscribeOnObservable = /*@__PURE__*/ (function (_super) { /***/ }), -/* 482 */ +/* 484 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "switchAll", function() { return switchAll; }); -/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(483); +/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(485); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(25); /** PURE_IMPORTS_START _switchMap,_util_identity PURE_IMPORTS_END */ @@ -57400,7 +57493,7 @@ function switchAll() { /***/ }), -/* 483 */ +/* 485 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57488,13 +57581,13 @@ var SwitchMapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 484 */ +/* 486 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "switchMapTo", function() { return switchMapTo; }); -/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(483); +/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(485); /** PURE_IMPORTS_START _switchMap PURE_IMPORTS_END */ function switchMapTo(innerObservable, resultSelector) { @@ -57504,7 +57597,7 @@ function switchMapTo(innerObservable, resultSelector) { /***/ }), -/* 485 */ +/* 487 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57552,7 +57645,7 @@ var TakeUntilSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 486 */ +/* 488 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57620,7 +57713,7 @@ var TakeWhileSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 487 */ +/* 489 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57708,7 +57801,7 @@ var TapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 488 */ +/* 490 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57810,7 +57903,7 @@ var ThrottleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 489 */ +/* 491 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57819,7 +57912,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(12); /* harmony import */ var _Subscriber__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(11); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(55); -/* harmony import */ var _throttle__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(488); +/* harmony import */ var _throttle__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(490); /** PURE_IMPORTS_START tslib,_Subscriber,_scheduler_async,_throttle PURE_IMPORTS_END */ @@ -57908,7 +58001,7 @@ function dispatchNext(arg) { /***/ }), -/* 490 */ +/* 492 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57916,7 +58009,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeInterval", function() { return timeInterval; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TimeInterval", function() { return TimeInterval; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(55); -/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(450); +/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(452); /* harmony import */ var _observable_defer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(91); /* harmony import */ var _map__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(66); /** PURE_IMPORTS_START _scheduler_async,_scan,_observable_defer,_map PURE_IMPORTS_END */ @@ -57952,7 +58045,7 @@ var TimeInterval = /*@__PURE__*/ (function () { /***/ }), -/* 491 */ +/* 493 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57960,7 +58053,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeout", function() { return timeout; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(55); /* harmony import */ var _util_TimeoutError__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(64); -/* harmony import */ var _timeoutWith__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(492); +/* harmony import */ var _timeoutWith__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(494); /* harmony import */ var _observable_throwError__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(49); /** PURE_IMPORTS_START _scheduler_async,_util_TimeoutError,_timeoutWith,_observable_throwError PURE_IMPORTS_END */ @@ -57977,7 +58070,7 @@ function timeout(due, scheduler) { /***/ }), -/* 492 */ +/* 494 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57985,7 +58078,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeoutWith", function() { return timeoutWith; }); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(12); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(55); -/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(424); +/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(426); /* harmony import */ var _innerSubscribe__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(90); /** PURE_IMPORTS_START tslib,_scheduler_async,_util_isDate,_innerSubscribe PURE_IMPORTS_END */ @@ -58056,7 +58149,7 @@ var TimeoutWithSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 493 */ +/* 495 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58086,13 +58179,13 @@ var Timestamp = /*@__PURE__*/ (function () { /***/ }), -/* 494 */ +/* 496 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "toArray", function() { return toArray; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(449); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(451); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function toArrayReducer(arr, item, index) { @@ -58109,7 +58202,7 @@ function toArray() { /***/ }), -/* 495 */ +/* 497 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58187,7 +58280,7 @@ var WindowSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 496 */ +/* 498 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58277,7 +58370,7 @@ var WindowCountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 497 */ +/* 499 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58447,7 +58540,7 @@ function dispatchWindowClose(state) { /***/ }), -/* 498 */ +/* 500 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58590,7 +58683,7 @@ var WindowToggleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 499 */ +/* 501 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58687,7 +58780,7 @@ var WindowSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 500 */ +/* 502 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58782,7 +58875,7 @@ var WithLatestFromSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 501 */ +/* 503 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58804,7 +58897,7 @@ function zip() { /***/ }), -/* 502 */ +/* 504 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58820,7 +58913,7 @@ function zipAll(project) { /***/ }), -/* 503 */ +/* 505 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58830,7 +58923,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(246); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(248); /* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(370); -/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(504); +/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(506); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -58901,7 +58994,7 @@ function toArray(value) { } /***/ }), -/* 504 */ +/* 506 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58909,13 +59002,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Kibana", function() { return Kibana; }); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(505); +/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(507); /* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(multimatch__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(239); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(is_path_inside__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(365); /* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(248); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(509); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(511); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -59066,15 +59159,15 @@ class Kibana { } /***/ }), -/* 505 */ +/* 507 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const minimatch = __webpack_require__(150); -const arrayUnion = __webpack_require__(506); -const arrayDiffer = __webpack_require__(507); -const arrify = __webpack_require__(508); +const arrayUnion = __webpack_require__(508); +const arrayDiffer = __webpack_require__(509); +const arrify = __webpack_require__(510); module.exports = (list, patterns, options = {}) => { list = arrify(list); @@ -59098,7 +59191,7 @@ module.exports = (list, patterns, options = {}) => { /***/ }), -/* 506 */ +/* 508 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59110,7 +59203,7 @@ module.exports = (...arguments_) => { /***/ }), -/* 507 */ +/* 509 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59125,7 +59218,7 @@ module.exports = arrayDiffer; /***/ }), -/* 508 */ +/* 510 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59155,7 +59248,7 @@ module.exports = arrify; /***/ }), -/* 509 */ +/* 511 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59214,12 +59307,12 @@ function getProjectPaths({ } /***/ }), -/* 510 */ +/* 512 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _build_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(511); +/* harmony import */ var _build_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(513); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return _build_production_projects__WEBPACK_IMPORTED_MODULE_0__["buildProductionProjects"]; }); /* @@ -59232,19 +59325,19 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 511 */ +/* 513 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return buildProductionProjects; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(512); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(514); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(509); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(511); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(131); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(246); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(251); @@ -59370,7 +59463,7 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { } /***/ }), -/* 512 */ +/* 514 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59378,14 +59471,14 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { const EventEmitter = __webpack_require__(156); const path = __webpack_require__(4); const os = __webpack_require__(121); -const pMap = __webpack_require__(513); -const arrify = __webpack_require__(508); -const globby = __webpack_require__(514); -const hasGlob = __webpack_require__(710); -const cpFile = __webpack_require__(712); -const junk = __webpack_require__(722); -const pFilter = __webpack_require__(723); -const CpyError = __webpack_require__(725); +const pMap = __webpack_require__(515); +const arrify = __webpack_require__(510); +const globby = __webpack_require__(516); +const hasGlob = __webpack_require__(712); +const cpFile = __webpack_require__(714); +const junk = __webpack_require__(724); +const pFilter = __webpack_require__(725); +const CpyError = __webpack_require__(727); const defaultOptions = { ignoreJunk: true @@ -59536,7 +59629,7 @@ module.exports = (source, destination, { /***/ }), -/* 513 */ +/* 515 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59624,17 +59717,17 @@ module.exports = async ( /***/ }), -/* 514 */ +/* 516 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const arrayUnion = __webpack_require__(515); +const arrayUnion = __webpack_require__(517); const glob = __webpack_require__(147); -const fastGlob = __webpack_require__(517); -const dirGlob = __webpack_require__(703); -const gitignore = __webpack_require__(706); +const fastGlob = __webpack_require__(519); +const dirGlob = __webpack_require__(705); +const gitignore = __webpack_require__(708); const DEFAULT_FILTER = () => false; @@ -59779,12 +59872,12 @@ module.exports.gitignore = gitignore; /***/ }), -/* 515 */ +/* 517 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var arrayUniq = __webpack_require__(516); +var arrayUniq = __webpack_require__(518); module.exports = function () { return arrayUniq([].concat.apply([], arguments)); @@ -59792,7 +59885,7 @@ module.exports = function () { /***/ }), -/* 516 */ +/* 518 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59861,10 +59954,10 @@ if ('Set' in global) { /***/ }), -/* 517 */ +/* 519 */ /***/ (function(module, exports, __webpack_require__) { -const pkg = __webpack_require__(518); +const pkg = __webpack_require__(520); module.exports = pkg.async; module.exports.default = pkg.async; @@ -59877,19 +59970,19 @@ module.exports.generateTasks = pkg.generateTasks; /***/ }), -/* 518 */ +/* 520 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var optionsManager = __webpack_require__(519); -var taskManager = __webpack_require__(520); -var reader_async_1 = __webpack_require__(674); -var reader_stream_1 = __webpack_require__(698); -var reader_sync_1 = __webpack_require__(699); -var arrayUtils = __webpack_require__(701); -var streamUtils = __webpack_require__(702); +var optionsManager = __webpack_require__(521); +var taskManager = __webpack_require__(522); +var reader_async_1 = __webpack_require__(676); +var reader_stream_1 = __webpack_require__(700); +var reader_sync_1 = __webpack_require__(701); +var arrayUtils = __webpack_require__(703); +var streamUtils = __webpack_require__(704); /** * Synchronous API. */ @@ -59955,7 +60048,7 @@ function isString(source) { /***/ }), -/* 519 */ +/* 521 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59993,13 +60086,13 @@ exports.prepare = prepare; /***/ }), -/* 520 */ +/* 522 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var patternUtils = __webpack_require__(521); +var patternUtils = __webpack_require__(523); /** * Generate tasks based on parent directory of each pattern. */ @@ -60090,16 +60183,16 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 521 */ +/* 523 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var globParent = __webpack_require__(522); +var globParent = __webpack_require__(524); var isGlob = __webpack_require__(172); -var micromatch = __webpack_require__(525); +var micromatch = __webpack_require__(527); var GLOBSTAR = '**'; /** * Return true for static pattern. @@ -60245,15 +60338,15 @@ exports.matchAny = matchAny; /***/ }), -/* 522 */ +/* 524 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var path = __webpack_require__(4); -var isglob = __webpack_require__(523); -var pathDirname = __webpack_require__(524); +var isglob = __webpack_require__(525); +var pathDirname = __webpack_require__(526); var isWin32 = __webpack_require__(121).platform() === 'win32'; module.exports = function globParent(str) { @@ -60276,7 +60369,7 @@ module.exports = function globParent(str) { /***/ }), -/* 523 */ +/* 525 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -60307,7 +60400,7 @@ module.exports = function isGlob(str) { /***/ }), -/* 524 */ +/* 526 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60457,7 +60550,7 @@ module.exports.win32 = win32; /***/ }), -/* 525 */ +/* 527 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60468,18 +60561,18 @@ module.exports.win32 = win32; */ var util = __webpack_require__(112); -var braces = __webpack_require__(526); -var toRegex = __webpack_require__(527); -var extend = __webpack_require__(640); +var braces = __webpack_require__(528); +var toRegex = __webpack_require__(529); +var extend = __webpack_require__(642); /** * Local dependencies */ -var compilers = __webpack_require__(642); -var parsers = __webpack_require__(669); -var cache = __webpack_require__(670); -var utils = __webpack_require__(671); +var compilers = __webpack_require__(644); +var parsers = __webpack_require__(671); +var cache = __webpack_require__(672); +var utils = __webpack_require__(673); var MAX_LENGTH = 1024 * 64; /** @@ -61341,7 +61434,7 @@ module.exports = micromatch; /***/ }), -/* 526 */ +/* 528 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61351,18 +61444,18 @@ module.exports = micromatch; * Module dependencies */ -var toRegex = __webpack_require__(527); -var unique = __webpack_require__(549); -var extend = __webpack_require__(550); +var toRegex = __webpack_require__(529); +var unique = __webpack_require__(551); +var extend = __webpack_require__(552); /** * Local dependencies */ -var compilers = __webpack_require__(552); -var parsers = __webpack_require__(565); -var Braces = __webpack_require__(569); -var utils = __webpack_require__(553); +var compilers = __webpack_require__(554); +var parsers = __webpack_require__(567); +var Braces = __webpack_require__(571); +var utils = __webpack_require__(555); var MAX_LENGTH = 1024 * 64; var cache = {}; @@ -61666,16 +61759,16 @@ module.exports = braces; /***/ }), -/* 527 */ +/* 529 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var safe = __webpack_require__(528); -var define = __webpack_require__(534); -var extend = __webpack_require__(542); -var not = __webpack_require__(546); +var safe = __webpack_require__(530); +var define = __webpack_require__(536); +var extend = __webpack_require__(544); +var not = __webpack_require__(548); var MAX_LENGTH = 1024 * 64; /** @@ -61828,10 +61921,10 @@ module.exports.makeRe = makeRe; /***/ }), -/* 528 */ +/* 530 */ /***/ (function(module, exports, __webpack_require__) { -var parse = __webpack_require__(529); +var parse = __webpack_require__(531); var types = parse.types; module.exports = function (re, opts) { @@ -61877,13 +61970,13 @@ function isRegExp (x) { /***/ }), -/* 529 */ +/* 531 */ /***/ (function(module, exports, __webpack_require__) { -var util = __webpack_require__(530); -var types = __webpack_require__(531); -var sets = __webpack_require__(532); -var positions = __webpack_require__(533); +var util = __webpack_require__(532); +var types = __webpack_require__(533); +var sets = __webpack_require__(534); +var positions = __webpack_require__(535); module.exports = function(regexpStr) { @@ -62165,11 +62258,11 @@ module.exports.types = types; /***/ }), -/* 530 */ +/* 532 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(531); -var sets = __webpack_require__(532); +var types = __webpack_require__(533); +var sets = __webpack_require__(534); // All of these are private and only used by randexp. @@ -62282,7 +62375,7 @@ exports.error = function(regexp, msg) { /***/ }), -/* 531 */ +/* 533 */ /***/ (function(module, exports) { module.exports = { @@ -62298,10 +62391,10 @@ module.exports = { /***/ }), -/* 532 */ +/* 534 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(531); +var types = __webpack_require__(533); var INTS = function() { return [{ type: types.RANGE , from: 48, to: 57 }]; @@ -62386,10 +62479,10 @@ exports.anyChar = function() { /***/ }), -/* 533 */ +/* 535 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(531); +var types = __webpack_require__(533); exports.wordBoundary = function() { return { type: types.POSITION, value: 'b' }; @@ -62409,7 +62502,7 @@ exports.end = function() { /***/ }), -/* 534 */ +/* 536 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62422,8 +62515,8 @@ exports.end = function() { -var isobject = __webpack_require__(535); -var isDescriptor = __webpack_require__(536); +var isobject = __webpack_require__(537); +var isDescriptor = __webpack_require__(538); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -62454,7 +62547,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 535 */ +/* 537 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62473,7 +62566,7 @@ module.exports = function isObject(val) { /***/ }), -/* 536 */ +/* 538 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62486,9 +62579,9 @@ module.exports = function isObject(val) { -var typeOf = __webpack_require__(537); -var isAccessor = __webpack_require__(538); -var isData = __webpack_require__(540); +var typeOf = __webpack_require__(539); +var isAccessor = __webpack_require__(540); +var isData = __webpack_require__(542); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -62502,7 +62595,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 537 */ +/* 539 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -62637,7 +62730,7 @@ function isBuffer(val) { /***/ }), -/* 538 */ +/* 540 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62650,7 +62743,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(539); +var typeOf = __webpack_require__(541); // accessor descriptor properties var accessor = { @@ -62713,7 +62806,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 539 */ +/* 541 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -62848,7 +62941,7 @@ function isBuffer(val) { /***/ }), -/* 540 */ +/* 542 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62861,7 +62954,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(541); +var typeOf = __webpack_require__(543); module.exports = function isDataDescriptor(obj, prop) { // data descriptor properties @@ -62904,7 +62997,7 @@ module.exports = function isDataDescriptor(obj, prop) { /***/ }), -/* 541 */ +/* 543 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63039,14 +63132,14 @@ function isBuffer(val) { /***/ }), -/* 542 */ +/* 544 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(543); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(545); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -63106,7 +63199,7 @@ function isEnum(obj, key) { /***/ }), -/* 543 */ +/* 545 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63119,7 +63212,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -63127,7 +63220,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 544 */ +/* 546 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63140,7 +63233,7 @@ module.exports = function isExtendable(val) { -var isObject = __webpack_require__(535); +var isObject = __webpack_require__(537); function isObjectObject(o) { return isObject(o) === true @@ -63171,7 +63264,7 @@ module.exports = function isPlainObject(o) { /***/ }), -/* 545 */ +/* 547 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63218,14 +63311,14 @@ module.exports = function(receiver, objects) { /***/ }), -/* 546 */ +/* 548 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(547); -var safe = __webpack_require__(528); +var extend = __webpack_require__(549); +var safe = __webpack_require__(530); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -63297,14 +63390,14 @@ module.exports = toRegex; /***/ }), -/* 547 */ +/* 549 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(548); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(550); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -63364,7 +63457,7 @@ function isEnum(obj, key) { /***/ }), -/* 548 */ +/* 550 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63377,7 +63470,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -63385,7 +63478,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 549 */ +/* 551 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63435,13 +63528,13 @@ module.exports.immutable = function uniqueImmutable(arr) { /***/ }), -/* 550 */ +/* 552 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(551); +var isObject = __webpack_require__(553); module.exports = function extend(o/*, objects*/) { if (!isObject(o)) { o = {}; } @@ -63475,7 +63568,7 @@ function hasOwn(obj, key) { /***/ }), -/* 551 */ +/* 553 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63495,13 +63588,13 @@ module.exports = function isExtendable(val) { /***/ }), -/* 552 */ +/* 554 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(553); +var utils = __webpack_require__(555); module.exports = function(braces, options) { braces.compiler @@ -63784,25 +63877,25 @@ function hasQueue(node) { /***/ }), -/* 553 */ +/* 555 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var splitString = __webpack_require__(554); +var splitString = __webpack_require__(556); var utils = module.exports; /** * Module dependencies */ -utils.extend = __webpack_require__(550); -utils.flatten = __webpack_require__(557); -utils.isObject = __webpack_require__(535); -utils.fillRange = __webpack_require__(558); -utils.repeat = __webpack_require__(564); -utils.unique = __webpack_require__(549); +utils.extend = __webpack_require__(552); +utils.flatten = __webpack_require__(559); +utils.isObject = __webpack_require__(537); +utils.fillRange = __webpack_require__(560); +utils.repeat = __webpack_require__(566); +utils.unique = __webpack_require__(551); utils.define = function(obj, key, val) { Object.defineProperty(obj, key, { @@ -64134,7 +64227,7 @@ utils.escapeRegex = function(str) { /***/ }), -/* 554 */ +/* 556 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64147,7 +64240,7 @@ utils.escapeRegex = function(str) { -var extend = __webpack_require__(555); +var extend = __webpack_require__(557); module.exports = function(str, options, fn) { if (typeof str !== 'string') { @@ -64312,14 +64405,14 @@ function keepEscaping(opts, str, idx) { /***/ }), -/* 555 */ +/* 557 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(556); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(558); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -64379,7 +64472,7 @@ function isEnum(obj, key) { /***/ }), -/* 556 */ +/* 558 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64392,7 +64485,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -64400,7 +64493,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 557 */ +/* 559 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64429,7 +64522,7 @@ function flat(arr, res) { /***/ }), -/* 558 */ +/* 560 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64443,10 +64536,10 @@ function flat(arr, res) { var util = __webpack_require__(112); -var isNumber = __webpack_require__(559); -var extend = __webpack_require__(550); -var repeat = __webpack_require__(562); -var toRegex = __webpack_require__(563); +var isNumber = __webpack_require__(561); +var extend = __webpack_require__(552); +var repeat = __webpack_require__(564); +var toRegex = __webpack_require__(565); /** * Return a range of numbers or letters. @@ -64644,7 +64737,7 @@ module.exports = fillRange; /***/ }), -/* 559 */ +/* 561 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64657,7 +64750,7 @@ module.exports = fillRange; -var typeOf = __webpack_require__(560); +var typeOf = __webpack_require__(562); module.exports = function isNumber(num) { var type = typeOf(num); @@ -64673,10 +64766,10 @@ module.exports = function isNumber(num) { /***/ }), -/* 560 */ +/* 562 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(561); +var isBuffer = __webpack_require__(563); var toString = Object.prototype.toString; /** @@ -64795,7 +64888,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 561 */ +/* 563 */ /***/ (function(module, exports) { /*! @@ -64822,7 +64915,7 @@ function isSlowBuffer (obj) { /***/ }), -/* 562 */ +/* 564 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64899,7 +64992,7 @@ function repeat(str, num) { /***/ }), -/* 563 */ +/* 565 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64912,8 +65005,8 @@ function repeat(str, num) { -var repeat = __webpack_require__(562); -var isNumber = __webpack_require__(559); +var repeat = __webpack_require__(564); +var isNumber = __webpack_require__(561); var cache = {}; function toRegexRange(min, max, options) { @@ -65200,7 +65293,7 @@ module.exports = toRegexRange; /***/ }), -/* 564 */ +/* 566 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65225,14 +65318,14 @@ module.exports = function repeat(ele, num) { /***/ }), -/* 565 */ +/* 567 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Node = __webpack_require__(566); -var utils = __webpack_require__(553); +var Node = __webpack_require__(568); +var utils = __webpack_require__(555); /** * Braces parsers @@ -65592,15 +65685,15 @@ function concatNodes(pos, node, parent, options) { /***/ }), -/* 566 */ +/* 568 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(535); -var define = __webpack_require__(567); -var utils = __webpack_require__(568); +var isObject = __webpack_require__(537); +var define = __webpack_require__(569); +var utils = __webpack_require__(570); var ownNames; /** @@ -66091,7 +66184,7 @@ exports = module.exports = Node; /***/ }), -/* 567 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -66104,7 +66197,7 @@ exports = module.exports = Node; -var isDescriptor = __webpack_require__(536); +var isDescriptor = __webpack_require__(538); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -66129,13 +66222,13 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 568 */ +/* 570 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(560); +var typeOf = __webpack_require__(562); var utils = module.exports; /** @@ -67155,17 +67248,17 @@ function assert(val, message) { /***/ }), -/* 569 */ +/* 571 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(550); -var Snapdragon = __webpack_require__(570); -var compilers = __webpack_require__(552); -var parsers = __webpack_require__(565); -var utils = __webpack_require__(553); +var extend = __webpack_require__(552); +var Snapdragon = __webpack_require__(572); +var compilers = __webpack_require__(554); +var parsers = __webpack_require__(567); +var utils = __webpack_require__(555); /** * Customize Snapdragon parser and renderer @@ -67266,17 +67359,17 @@ module.exports = Braces; /***/ }), -/* 570 */ +/* 572 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Base = __webpack_require__(571); -var define = __webpack_require__(598); -var Compiler = __webpack_require__(608); -var Parser = __webpack_require__(637); -var utils = __webpack_require__(617); +var Base = __webpack_require__(573); +var define = __webpack_require__(600); +var Compiler = __webpack_require__(610); +var Parser = __webpack_require__(639); +var utils = __webpack_require__(619); var regexCache = {}; var cache = {}; @@ -67447,20 +67540,20 @@ module.exports.Parser = Parser; /***/ }), -/* 571 */ +/* 573 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var define = __webpack_require__(572); -var CacheBase = __webpack_require__(573); -var Emitter = __webpack_require__(574); -var isObject = __webpack_require__(535); -var merge = __webpack_require__(592); -var pascal = __webpack_require__(595); -var cu = __webpack_require__(596); +var define = __webpack_require__(574); +var CacheBase = __webpack_require__(575); +var Emitter = __webpack_require__(576); +var isObject = __webpack_require__(537); +var merge = __webpack_require__(594); +var pascal = __webpack_require__(597); +var cu = __webpack_require__(598); /** * Optionally define a custom `cache` namespace to use. @@ -67889,7 +67982,7 @@ module.exports.namespace = namespace; /***/ }), -/* 572 */ +/* 574 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67902,7 +67995,7 @@ module.exports.namespace = namespace; -var isDescriptor = __webpack_require__(536); +var isDescriptor = __webpack_require__(538); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -67927,21 +68020,21 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 573 */ +/* 575 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(535); -var Emitter = __webpack_require__(574); -var visit = __webpack_require__(575); -var toPath = __webpack_require__(578); -var union = __webpack_require__(579); -var del = __webpack_require__(583); -var get = __webpack_require__(581); -var has = __webpack_require__(588); -var set = __webpack_require__(591); +var isObject = __webpack_require__(537); +var Emitter = __webpack_require__(576); +var visit = __webpack_require__(577); +var toPath = __webpack_require__(580); +var union = __webpack_require__(581); +var del = __webpack_require__(585); +var get = __webpack_require__(583); +var has = __webpack_require__(590); +var set = __webpack_require__(593); /** * Create a `Cache` constructor that when instantiated will @@ -68195,7 +68288,7 @@ module.exports.namespace = namespace; /***/ }), -/* 574 */ +/* 576 */ /***/ (function(module, exports, __webpack_require__) { @@ -68364,7 +68457,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 575 */ +/* 577 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68377,8 +68470,8 @@ Emitter.prototype.hasListeners = function(event){ -var visit = __webpack_require__(576); -var mapVisit = __webpack_require__(577); +var visit = __webpack_require__(578); +var mapVisit = __webpack_require__(579); module.exports = function(collection, method, val) { var result; @@ -68401,7 +68494,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 576 */ +/* 578 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68414,7 +68507,7 @@ module.exports = function(collection, method, val) { -var isObject = __webpack_require__(535); +var isObject = __webpack_require__(537); module.exports = function visit(thisArg, method, target, val) { if (!isObject(thisArg) && typeof thisArg !== 'function') { @@ -68441,14 +68534,14 @@ module.exports = function visit(thisArg, method, target, val) { /***/ }), -/* 577 */ +/* 579 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var visit = __webpack_require__(576); +var visit = __webpack_require__(578); /** * Map `visit` over an array of objects. @@ -68485,7 +68578,7 @@ function isObject(val) { /***/ }), -/* 578 */ +/* 580 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68498,7 +68591,7 @@ function isObject(val) { -var typeOf = __webpack_require__(560); +var typeOf = __webpack_require__(562); module.exports = function toPath(args) { if (typeOf(args) !== 'arguments') { @@ -68525,16 +68618,16 @@ function filter(arr) { /***/ }), -/* 579 */ +/* 581 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(551); -var union = __webpack_require__(580); -var get = __webpack_require__(581); -var set = __webpack_require__(582); +var isObject = __webpack_require__(553); +var union = __webpack_require__(582); +var get = __webpack_require__(583); +var set = __webpack_require__(584); module.exports = function unionValue(obj, prop, value) { if (!isObject(obj)) { @@ -68562,7 +68655,7 @@ function arrayify(val) { /***/ }), -/* 580 */ +/* 582 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68598,7 +68691,7 @@ module.exports = function union(init) { /***/ }), -/* 581 */ +/* 583 */ /***/ (function(module, exports) { /*! @@ -68654,7 +68747,7 @@ function toString(val) { /***/ }), -/* 582 */ +/* 584 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68667,10 +68760,10 @@ function toString(val) { -var split = __webpack_require__(554); -var extend = __webpack_require__(550); -var isPlainObject = __webpack_require__(544); -var isObject = __webpack_require__(551); +var split = __webpack_require__(556); +var extend = __webpack_require__(552); +var isPlainObject = __webpack_require__(546); +var isObject = __webpack_require__(553); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -68716,7 +68809,7 @@ function isValidKey(key) { /***/ }), -/* 583 */ +/* 585 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68729,8 +68822,8 @@ function isValidKey(key) { -var isObject = __webpack_require__(535); -var has = __webpack_require__(584); +var isObject = __webpack_require__(537); +var has = __webpack_require__(586); module.exports = function unset(obj, prop) { if (!isObject(obj)) { @@ -68755,7 +68848,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 584 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68768,9 +68861,9 @@ module.exports = function unset(obj, prop) { -var isObject = __webpack_require__(585); -var hasValues = __webpack_require__(587); -var get = __webpack_require__(581); +var isObject = __webpack_require__(587); +var hasValues = __webpack_require__(589); +var get = __webpack_require__(583); module.exports = function(obj, prop, noZero) { if (isObject(obj)) { @@ -68781,7 +68874,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 585 */ +/* 587 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68794,7 +68887,7 @@ module.exports = function(obj, prop, noZero) { -var isArray = __webpack_require__(586); +var isArray = __webpack_require__(588); module.exports = function isObject(val) { return val != null && typeof val === 'object' && isArray(val) === false; @@ -68802,7 +68895,7 @@ module.exports = function isObject(val) { /***/ }), -/* 586 */ +/* 588 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -68813,7 +68906,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 587 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68856,7 +68949,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 588 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68869,9 +68962,9 @@ module.exports = function hasValue(o, noZero) { -var isObject = __webpack_require__(535); -var hasValues = __webpack_require__(589); -var get = __webpack_require__(581); +var isObject = __webpack_require__(537); +var hasValues = __webpack_require__(591); +var get = __webpack_require__(583); module.exports = function(val, prop) { return hasValues(isObject(val) && prop ? get(val, prop) : val); @@ -68879,7 +68972,7 @@ module.exports = function(val, prop) { /***/ }), -/* 589 */ +/* 591 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68892,8 +68985,8 @@ module.exports = function(val, prop) { -var typeOf = __webpack_require__(590); -var isNumber = __webpack_require__(559); +var typeOf = __webpack_require__(592); +var isNumber = __webpack_require__(561); module.exports = function hasValue(val) { // is-number checks for NaN and other edge cases @@ -68946,10 +69039,10 @@ module.exports = function hasValue(val) { /***/ }), -/* 590 */ +/* 592 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(561); +var isBuffer = __webpack_require__(563); var toString = Object.prototype.toString; /** @@ -69071,7 +69164,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 591 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69084,10 +69177,10 @@ module.exports = function kindOf(val) { -var split = __webpack_require__(554); -var extend = __webpack_require__(550); -var isPlainObject = __webpack_require__(544); -var isObject = __webpack_require__(551); +var split = __webpack_require__(556); +var extend = __webpack_require__(552); +var isPlainObject = __webpack_require__(546); +var isObject = __webpack_require__(553); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -69133,14 +69226,14 @@ function isValidKey(key) { /***/ }), -/* 592 */ +/* 594 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(593); -var forIn = __webpack_require__(594); +var isExtendable = __webpack_require__(595); +var forIn = __webpack_require__(596); function mixinDeep(target, objects) { var len = arguments.length, i = 0; @@ -69204,7 +69297,7 @@ module.exports = mixinDeep; /***/ }), -/* 593 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69217,7 +69310,7 @@ module.exports = mixinDeep; -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -69225,7 +69318,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 594 */ +/* 596 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69248,7 +69341,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 595 */ +/* 597 */ /***/ (function(module, exports) { /*! @@ -69275,14 +69368,14 @@ module.exports = pascalcase; /***/ }), -/* 596 */ +/* 598 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var utils = __webpack_require__(597); +var utils = __webpack_require__(599); /** * Expose class utils @@ -69647,7 +69740,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 597 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69661,10 +69754,10 @@ var utils = {}; * Lazily required module dependencies */ -utils.union = __webpack_require__(580); -utils.define = __webpack_require__(598); -utils.isObj = __webpack_require__(535); -utils.staticExtend = __webpack_require__(605); +utils.union = __webpack_require__(582); +utils.define = __webpack_require__(600); +utils.isObj = __webpack_require__(537); +utils.staticExtend = __webpack_require__(607); /** @@ -69675,7 +69768,7 @@ module.exports = utils; /***/ }), -/* 598 */ +/* 600 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69688,7 +69781,7 @@ module.exports = utils; -var isDescriptor = __webpack_require__(599); +var isDescriptor = __webpack_require__(601); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -69713,7 +69806,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 599 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69726,9 +69819,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(600); -var isAccessor = __webpack_require__(601); -var isData = __webpack_require__(603); +var typeOf = __webpack_require__(602); +var isAccessor = __webpack_require__(603); +var isData = __webpack_require__(605); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -69742,7 +69835,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 600 */ +/* 602 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -69895,7 +69988,7 @@ function isBuffer(val) { /***/ }), -/* 601 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69908,7 +70001,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(602); +var typeOf = __webpack_require__(604); // accessor descriptor properties var accessor = { @@ -69971,10 +70064,10 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 602 */ +/* 604 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(561); +var isBuffer = __webpack_require__(563); var toString = Object.prototype.toString; /** @@ -70093,7 +70186,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 603 */ +/* 605 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70106,7 +70199,7 @@ module.exports = function kindOf(val) { -var typeOf = __webpack_require__(604); +var typeOf = __webpack_require__(606); // data descriptor properties var data = { @@ -70155,10 +70248,10 @@ module.exports = isDataDescriptor; /***/ }), -/* 604 */ +/* 606 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(561); +var isBuffer = __webpack_require__(563); var toString = Object.prototype.toString; /** @@ -70277,7 +70370,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 605 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70290,8 +70383,8 @@ module.exports = function kindOf(val) { -var copy = __webpack_require__(606); -var define = __webpack_require__(598); +var copy = __webpack_require__(608); +var define = __webpack_require__(600); var util = __webpack_require__(112); /** @@ -70374,15 +70467,15 @@ module.exports = extend; /***/ }), -/* 606 */ +/* 608 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(560); -var copyDescriptor = __webpack_require__(607); -var define = __webpack_require__(598); +var typeOf = __webpack_require__(562); +var copyDescriptor = __webpack_require__(609); +var define = __webpack_require__(600); /** * Copy static properties, prototype properties, and descriptors from one object to another. @@ -70555,7 +70648,7 @@ module.exports.has = has; /***/ }), -/* 607 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70643,16 +70736,16 @@ function isObject(val) { /***/ }), -/* 608 */ +/* 610 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(609); -var define = __webpack_require__(598); -var debug = __webpack_require__(611)('snapdragon:compiler'); -var utils = __webpack_require__(617); +var use = __webpack_require__(611); +var define = __webpack_require__(600); +var debug = __webpack_require__(613)('snapdragon:compiler'); +var utils = __webpack_require__(619); /** * Create a new `Compiler` with the given `options`. @@ -70806,7 +70899,7 @@ Compiler.prototype = { // source map support if (opts.sourcemap) { - var sourcemaps = __webpack_require__(636); + var sourcemaps = __webpack_require__(638); sourcemaps(this); this.mapVisit(this.ast.nodes); this.applySourceMaps(); @@ -70827,7 +70920,7 @@ module.exports = Compiler; /***/ }), -/* 609 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70840,7 +70933,7 @@ module.exports = Compiler; -var utils = __webpack_require__(610); +var utils = __webpack_require__(612); module.exports = function base(app, opts) { if (!utils.isObject(app) && typeof app !== 'function') { @@ -70955,7 +71048,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 610 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70969,8 +71062,8 @@ var utils = {}; * Lazily required module dependencies */ -utils.define = __webpack_require__(598); -utils.isObject = __webpack_require__(535); +utils.define = __webpack_require__(600); +utils.isObject = __webpack_require__(537); utils.isString = function(val) { @@ -70985,7 +71078,7 @@ module.exports = utils; /***/ }), -/* 611 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -70994,14 +71087,14 @@ module.exports = utils; */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(612); + module.exports = __webpack_require__(614); } else { - module.exports = __webpack_require__(615); + module.exports = __webpack_require__(617); } /***/ }), -/* 612 */ +/* 614 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71010,7 +71103,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(613); +exports = module.exports = __webpack_require__(615); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -71192,7 +71285,7 @@ function localstorage() { /***/ }), -/* 613 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { @@ -71208,7 +71301,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(614); +exports.humanize = __webpack_require__(616); /** * The currently active debug mode names, and names to skip. @@ -71400,7 +71493,7 @@ function coerce(val) { /***/ }), -/* 614 */ +/* 616 */ /***/ (function(module, exports) { /** @@ -71558,7 +71651,7 @@ function plural(ms, n, name) { /***/ }), -/* 615 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71574,7 +71667,7 @@ var util = __webpack_require__(112); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(613); +exports = module.exports = __webpack_require__(615); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -71753,7 +71846,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(616); + var net = __webpack_require__(618); stream = new net.Socket({ fd: fd, readable: false, @@ -71812,13 +71905,13 @@ exports.enable(load()); /***/ }), -/* 616 */ +/* 618 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 617 */ +/* 619 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71828,9 +71921,9 @@ module.exports = require("net"); * Module dependencies */ -exports.extend = __webpack_require__(550); -exports.SourceMap = __webpack_require__(618); -exports.sourceMapResolve = __webpack_require__(629); +exports.extend = __webpack_require__(552); +exports.SourceMap = __webpack_require__(620); +exports.sourceMapResolve = __webpack_require__(631); /** * Convert backslash in the given string to forward slashes @@ -71873,7 +71966,7 @@ exports.last = function(arr, n) { /***/ }), -/* 618 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -71881,13 +71974,13 @@ exports.last = function(arr, n) { * Licensed under the New BSD license. See LICENSE.txt or: * http://opensource.org/licenses/BSD-3-Clause */ -exports.SourceMapGenerator = __webpack_require__(619).SourceMapGenerator; -exports.SourceMapConsumer = __webpack_require__(625).SourceMapConsumer; -exports.SourceNode = __webpack_require__(628).SourceNode; +exports.SourceMapGenerator = __webpack_require__(621).SourceMapGenerator; +exports.SourceMapConsumer = __webpack_require__(627).SourceMapConsumer; +exports.SourceNode = __webpack_require__(630).SourceNode; /***/ }), -/* 619 */ +/* 621 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -71897,10 +71990,10 @@ exports.SourceNode = __webpack_require__(628).SourceNode; * http://opensource.org/licenses/BSD-3-Clause */ -var base64VLQ = __webpack_require__(620); -var util = __webpack_require__(622); -var ArraySet = __webpack_require__(623).ArraySet; -var MappingList = __webpack_require__(624).MappingList; +var base64VLQ = __webpack_require__(622); +var util = __webpack_require__(624); +var ArraySet = __webpack_require__(625).ArraySet; +var MappingList = __webpack_require__(626).MappingList; /** * An instance of the SourceMapGenerator represents a source map which is @@ -72309,7 +72402,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 620 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72349,7 +72442,7 @@ exports.SourceMapGenerator = SourceMapGenerator; * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var base64 = __webpack_require__(621); +var base64 = __webpack_require__(623); // A single base 64 digit can contain 6 bits of data. For the base 64 variable // length quantities we use in the source map spec, the first bit is the sign, @@ -72455,7 +72548,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 621 */ +/* 623 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72528,7 +72621,7 @@ exports.decode = function (charCode) { /***/ }), -/* 622 */ +/* 624 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72951,7 +73044,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 623 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72961,7 +73054,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(622); +var util = __webpack_require__(624); var has = Object.prototype.hasOwnProperty; var hasNativeMap = typeof Map !== "undefined"; @@ -73078,7 +73171,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 624 */ +/* 626 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73088,7 +73181,7 @@ exports.ArraySet = ArraySet; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(622); +var util = __webpack_require__(624); /** * Determine whether mappingB is after mappingA with respect to generated @@ -73163,7 +73256,7 @@ exports.MappingList = MappingList; /***/ }), -/* 625 */ +/* 627 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73173,11 +73266,11 @@ exports.MappingList = MappingList; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(622); -var binarySearch = __webpack_require__(626); -var ArraySet = __webpack_require__(623).ArraySet; -var base64VLQ = __webpack_require__(620); -var quickSort = __webpack_require__(627).quickSort; +var util = __webpack_require__(624); +var binarySearch = __webpack_require__(628); +var ArraySet = __webpack_require__(625).ArraySet; +var base64VLQ = __webpack_require__(622); +var quickSort = __webpack_require__(629).quickSort; function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; @@ -74251,7 +74344,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 626 */ +/* 628 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74368,7 +74461,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 627 */ +/* 629 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74488,7 +74581,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 628 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74498,8 +74591,8 @@ exports.quickSort = function (ary, comparator) { * http://opensource.org/licenses/BSD-3-Clause */ -var SourceMapGenerator = __webpack_require__(619).SourceMapGenerator; -var util = __webpack_require__(622); +var SourceMapGenerator = __webpack_require__(621).SourceMapGenerator; +var util = __webpack_require__(624); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). @@ -74907,17 +75000,17 @@ exports.SourceNode = SourceNode; /***/ }), -/* 629 */ +/* 631 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014, 2015, 2016, 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var sourceMappingURL = __webpack_require__(630) -var resolveUrl = __webpack_require__(631) -var decodeUriComponent = __webpack_require__(632) -var urix = __webpack_require__(634) -var atob = __webpack_require__(635) +var sourceMappingURL = __webpack_require__(632) +var resolveUrl = __webpack_require__(633) +var decodeUriComponent = __webpack_require__(634) +var urix = __webpack_require__(636) +var atob = __webpack_require__(637) @@ -75215,7 +75308,7 @@ module.exports = { /***/ }), -/* 630 */ +/* 632 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;// Copyright 2014 Simon Lydell @@ -75278,7 +75371,7 @@ void (function(root, factory) { /***/ }), -/* 631 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75296,13 +75389,13 @@ module.exports = resolveUrl /***/ }), -/* 632 */ +/* 634 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var decodeUriComponent = __webpack_require__(633) +var decodeUriComponent = __webpack_require__(635) function customDecodeUriComponent(string) { // `decodeUriComponent` turns `+` into ` `, but that's not wanted. @@ -75313,7 +75406,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 633 */ +/* 635 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75414,7 +75507,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 634 */ +/* 636 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75437,7 +75530,7 @@ module.exports = urix /***/ }), -/* 635 */ +/* 637 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75451,7 +75544,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 636 */ +/* 638 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75459,8 +75552,8 @@ module.exports = atob.atob = atob; var fs = __webpack_require__(134); var path = __webpack_require__(4); -var define = __webpack_require__(598); -var utils = __webpack_require__(617); +var define = __webpack_require__(600); +var utils = __webpack_require__(619); /** * Expose `mixin()`. @@ -75603,19 +75696,19 @@ exports.comment = function(node) { /***/ }), -/* 637 */ +/* 639 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(609); +var use = __webpack_require__(611); var util = __webpack_require__(112); -var Cache = __webpack_require__(638); -var define = __webpack_require__(598); -var debug = __webpack_require__(611)('snapdragon:parser'); -var Position = __webpack_require__(639); -var utils = __webpack_require__(617); +var Cache = __webpack_require__(640); +var define = __webpack_require__(600); +var debug = __webpack_require__(613)('snapdragon:parser'); +var Position = __webpack_require__(641); +var utils = __webpack_require__(619); /** * Create a new `Parser` with the given `input` and `options`. @@ -76143,7 +76236,7 @@ module.exports = Parser; /***/ }), -/* 638 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76250,13 +76343,13 @@ MapCache.prototype.del = function mapDelete(key) { /***/ }), -/* 639 */ +/* 641 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(598); +var define = __webpack_require__(600); /** * Store position for a node @@ -76271,14 +76364,14 @@ module.exports = function Position(start, parser) { /***/ }), -/* 640 */ +/* 642 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(641); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(643); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -76338,7 +76431,7 @@ function isEnum(obj, key) { /***/ }), -/* 641 */ +/* 643 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76351,7 +76444,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -76359,14 +76452,14 @@ module.exports = function isExtendable(val) { /***/ }), -/* 642 */ +/* 644 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var nanomatch = __webpack_require__(643); -var extglob = __webpack_require__(658); +var nanomatch = __webpack_require__(645); +var extglob = __webpack_require__(660); module.exports = function(snapdragon) { var compilers = snapdragon.compiler.compilers; @@ -76443,7 +76536,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 643 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76454,17 +76547,17 @@ function escapeExtglobs(compiler) { */ var util = __webpack_require__(112); -var toRegex = __webpack_require__(527); -var extend = __webpack_require__(644); +var toRegex = __webpack_require__(529); +var extend = __webpack_require__(646); /** * Local dependencies */ -var compilers = __webpack_require__(646); -var parsers = __webpack_require__(647); -var cache = __webpack_require__(650); -var utils = __webpack_require__(652); +var compilers = __webpack_require__(648); +var parsers = __webpack_require__(649); +var cache = __webpack_require__(652); +var utils = __webpack_require__(654); var MAX_LENGTH = 1024 * 64; /** @@ -77288,14 +77381,14 @@ module.exports = nanomatch; /***/ }), -/* 644 */ +/* 646 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(645); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(647); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -77355,7 +77448,7 @@ function isEnum(obj, key) { /***/ }), -/* 645 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77368,7 +77461,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -77376,7 +77469,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 646 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77722,15 +77815,15 @@ module.exports = function(nanomatch, options) { /***/ }), -/* 647 */ +/* 649 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regexNot = __webpack_require__(546); -var toRegex = __webpack_require__(527); -var isOdd = __webpack_require__(648); +var regexNot = __webpack_require__(548); +var toRegex = __webpack_require__(529); +var isOdd = __webpack_require__(650); /** * Characters to use in negation regex (we want to "not" match @@ -78116,7 +78209,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 648 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78129,7 +78222,7 @@ module.exports.not = NOT_REGEX; -var isNumber = __webpack_require__(649); +var isNumber = __webpack_require__(651); module.exports = function isOdd(i) { if (!isNumber(i)) { @@ -78143,7 +78236,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 649 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78171,14 +78264,14 @@ module.exports = function isNumber(num) { /***/ }), -/* 650 */ +/* 652 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(651))(); +module.exports = new (__webpack_require__(653))(); /***/ }), -/* 651 */ +/* 653 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78191,7 +78284,7 @@ module.exports = new (__webpack_require__(651))(); -var MapCache = __webpack_require__(638); +var MapCache = __webpack_require__(640); /** * Create a new `FragmentCache` with an optional object to use for `caches`. @@ -78313,7 +78406,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 652 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78326,14 +78419,14 @@ var path = __webpack_require__(4); * Module dependencies */ -var isWindows = __webpack_require__(653)(); -var Snapdragon = __webpack_require__(570); -utils.define = __webpack_require__(654); -utils.diff = __webpack_require__(655); -utils.extend = __webpack_require__(644); -utils.pick = __webpack_require__(656); -utils.typeOf = __webpack_require__(657); -utils.unique = __webpack_require__(549); +var isWindows = __webpack_require__(655)(); +var Snapdragon = __webpack_require__(572); +utils.define = __webpack_require__(656); +utils.diff = __webpack_require__(657); +utils.extend = __webpack_require__(646); +utils.pick = __webpack_require__(658); +utils.typeOf = __webpack_require__(659); +utils.unique = __webpack_require__(551); /** * Returns true if the given value is effectively an empty string @@ -78699,7 +78792,7 @@ utils.unixify = function(options) { /***/ }), -/* 653 */ +/* 655 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*! @@ -78727,7 +78820,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 654 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78740,8 +78833,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ -var isobject = __webpack_require__(535); -var isDescriptor = __webpack_require__(536); +var isobject = __webpack_require__(537); +var isDescriptor = __webpack_require__(538); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -78772,7 +78865,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 655 */ +/* 657 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78826,7 +78919,7 @@ function diffArray(one, two) { /***/ }), -/* 656 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78839,7 +78932,7 @@ function diffArray(one, two) { -var isObject = __webpack_require__(535); +var isObject = __webpack_require__(537); module.exports = function pick(obj, keys) { if (!isObject(obj) && typeof obj !== 'function') { @@ -78868,7 +78961,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 657 */ +/* 659 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -79003,7 +79096,7 @@ function isBuffer(val) { /***/ }), -/* 658 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79013,18 +79106,18 @@ function isBuffer(val) { * Module dependencies */ -var extend = __webpack_require__(550); -var unique = __webpack_require__(549); -var toRegex = __webpack_require__(527); +var extend = __webpack_require__(552); +var unique = __webpack_require__(551); +var toRegex = __webpack_require__(529); /** * Local dependencies */ -var compilers = __webpack_require__(659); -var parsers = __webpack_require__(665); -var Extglob = __webpack_require__(668); -var utils = __webpack_require__(667); +var compilers = __webpack_require__(661); +var parsers = __webpack_require__(667); +var Extglob = __webpack_require__(670); +var utils = __webpack_require__(669); var MAX_LENGTH = 1024 * 64; /** @@ -79341,13 +79434,13 @@ module.exports = extglob; /***/ }), -/* 659 */ +/* 661 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(660); +var brackets = __webpack_require__(662); /** * Extglob compilers @@ -79517,7 +79610,7 @@ module.exports = function(extglob) { /***/ }), -/* 660 */ +/* 662 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79527,17 +79620,17 @@ module.exports = function(extglob) { * Local dependencies */ -var compilers = __webpack_require__(661); -var parsers = __webpack_require__(663); +var compilers = __webpack_require__(663); +var parsers = __webpack_require__(665); /** * Module dependencies */ -var debug = __webpack_require__(611)('expand-brackets'); -var extend = __webpack_require__(550); -var Snapdragon = __webpack_require__(570); -var toRegex = __webpack_require__(527); +var debug = __webpack_require__(613)('expand-brackets'); +var extend = __webpack_require__(552); +var Snapdragon = __webpack_require__(572); +var toRegex = __webpack_require__(529); /** * Parses the given POSIX character class `pattern` and returns a @@ -79735,13 +79828,13 @@ module.exports = brackets; /***/ }), -/* 661 */ +/* 663 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var posix = __webpack_require__(662); +var posix = __webpack_require__(664); module.exports = function(brackets) { brackets.compiler @@ -79829,7 +79922,7 @@ module.exports = function(brackets) { /***/ }), -/* 662 */ +/* 664 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79858,14 +79951,14 @@ module.exports = { /***/ }), -/* 663 */ +/* 665 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(664); -var define = __webpack_require__(598); +var utils = __webpack_require__(666); +var define = __webpack_require__(600); /** * Text regex @@ -80084,14 +80177,14 @@ module.exports.TEXT_REGEX = TEXT_REGEX; /***/ }), -/* 664 */ +/* 666 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var toRegex = __webpack_require__(527); -var regexNot = __webpack_require__(546); +var toRegex = __webpack_require__(529); +var regexNot = __webpack_require__(548); var cached; /** @@ -80125,15 +80218,15 @@ exports.createRegex = function(pattern, include) { /***/ }), -/* 665 */ +/* 667 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(660); -var define = __webpack_require__(666); -var utils = __webpack_require__(667); +var brackets = __webpack_require__(662); +var define = __webpack_require__(668); +var utils = __webpack_require__(669); /** * Characters to use in text regex (we want to "not" match @@ -80288,7 +80381,7 @@ module.exports = parsers; /***/ }), -/* 666 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80301,7 +80394,7 @@ module.exports = parsers; -var isDescriptor = __webpack_require__(536); +var isDescriptor = __webpack_require__(538); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -80326,14 +80419,14 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 667 */ +/* 669 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regex = __webpack_require__(546); -var Cache = __webpack_require__(651); +var regex = __webpack_require__(548); +var Cache = __webpack_require__(653); /** * Utils @@ -80402,7 +80495,7 @@ utils.createRegex = function(str) { /***/ }), -/* 668 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80412,16 +80505,16 @@ utils.createRegex = function(str) { * Module dependencies */ -var Snapdragon = __webpack_require__(570); -var define = __webpack_require__(666); -var extend = __webpack_require__(550); +var Snapdragon = __webpack_require__(572); +var define = __webpack_require__(668); +var extend = __webpack_require__(552); /** * Local dependencies */ -var compilers = __webpack_require__(659); -var parsers = __webpack_require__(665); +var compilers = __webpack_require__(661); +var parsers = __webpack_require__(667); /** * Customize Snapdragon parser and renderer @@ -80487,16 +80580,16 @@ module.exports = Extglob; /***/ }), -/* 669 */ +/* 671 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extglob = __webpack_require__(658); -var nanomatch = __webpack_require__(643); -var regexNot = __webpack_require__(546); -var toRegex = __webpack_require__(527); +var extglob = __webpack_require__(660); +var nanomatch = __webpack_require__(645); +var regexNot = __webpack_require__(548); +var toRegex = __webpack_require__(529); var not; /** @@ -80577,14 +80670,14 @@ function textRegex(pattern) { /***/ }), -/* 670 */ +/* 672 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(651))(); +module.exports = new (__webpack_require__(653))(); /***/ }), -/* 671 */ +/* 673 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80597,13 +80690,13 @@ var path = __webpack_require__(4); * Module dependencies */ -var Snapdragon = __webpack_require__(570); -utils.define = __webpack_require__(672); -utils.diff = __webpack_require__(655); -utils.extend = __webpack_require__(640); -utils.pick = __webpack_require__(656); -utils.typeOf = __webpack_require__(673); -utils.unique = __webpack_require__(549); +var Snapdragon = __webpack_require__(572); +utils.define = __webpack_require__(674); +utils.diff = __webpack_require__(657); +utils.extend = __webpack_require__(642); +utils.pick = __webpack_require__(658); +utils.typeOf = __webpack_require__(675); +utils.unique = __webpack_require__(551); /** * Returns true if the platform is windows, or `path.sep` is `\\`. @@ -80900,7 +80993,7 @@ utils.unixify = function(options) { /***/ }), -/* 672 */ +/* 674 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80913,8 +81006,8 @@ utils.unixify = function(options) { -var isobject = __webpack_require__(535); -var isDescriptor = __webpack_require__(536); +var isobject = __webpack_require__(537); +var isDescriptor = __webpack_require__(538); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -80945,7 +81038,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 673 */ +/* 675 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -81080,7 +81173,7 @@ function isBuffer(val) { /***/ }), -/* 674 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81099,9 +81192,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(675); -var reader_1 = __webpack_require__(688); -var fs_stream_1 = __webpack_require__(692); +var readdir = __webpack_require__(677); +var reader_1 = __webpack_require__(690); +var fs_stream_1 = __webpack_require__(694); var ReaderAsync = /** @class */ (function (_super) { __extends(ReaderAsync, _super); function ReaderAsync() { @@ -81162,15 +81255,15 @@ exports.default = ReaderAsync; /***/ }), -/* 675 */ +/* 677 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readdirSync = __webpack_require__(676); -const readdirAsync = __webpack_require__(684); -const readdirStream = __webpack_require__(687); +const readdirSync = __webpack_require__(678); +const readdirAsync = __webpack_require__(686); +const readdirStream = __webpack_require__(689); module.exports = exports = readdirAsyncPath; exports.readdir = exports.readdirAsync = exports.async = readdirAsyncPath; @@ -81254,7 +81347,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 676 */ +/* 678 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81262,11 +81355,11 @@ function readdirStreamStat (dir, options) { module.exports = readdirSync; -const DirectoryReader = __webpack_require__(677); +const DirectoryReader = __webpack_require__(679); let syncFacade = { - fs: __webpack_require__(682), - forEach: __webpack_require__(683), + fs: __webpack_require__(684), + forEach: __webpack_require__(685), sync: true }; @@ -81295,7 +81388,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 677 */ +/* 679 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81304,9 +81397,9 @@ function readdirSync (dir, options, internalOptions) { const Readable = __webpack_require__(138).Readable; const EventEmitter = __webpack_require__(156).EventEmitter; const path = __webpack_require__(4); -const normalizeOptions = __webpack_require__(678); -const stat = __webpack_require__(680); -const call = __webpack_require__(681); +const normalizeOptions = __webpack_require__(680); +const stat = __webpack_require__(682); +const call = __webpack_require__(683); /** * Asynchronously reads the contents of a directory and streams the results @@ -81682,14 +81775,14 @@ module.exports = DirectoryReader; /***/ }), -/* 678 */ +/* 680 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const globToRegExp = __webpack_require__(679); +const globToRegExp = __webpack_require__(681); module.exports = normalizeOptions; @@ -81866,7 +81959,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 679 */ +/* 681 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -82003,13 +82096,13 @@ module.exports = function (glob, opts) { /***/ }), -/* 680 */ +/* 682 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const call = __webpack_require__(681); +const call = __webpack_require__(683); module.exports = stat; @@ -82084,7 +82177,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 681 */ +/* 683 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82145,14 +82238,14 @@ function callOnce (fn) { /***/ }), -/* 682 */ +/* 684 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const call = __webpack_require__(681); +const call = __webpack_require__(683); /** * A facade around {@link fs.readdirSync} that allows it to be called @@ -82216,7 +82309,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 683 */ +/* 685 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82245,7 +82338,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 684 */ +/* 686 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82253,12 +82346,12 @@ function syncForEach (array, iterator, done) { module.exports = readdirAsync; -const maybe = __webpack_require__(685); -const DirectoryReader = __webpack_require__(677); +const maybe = __webpack_require__(687); +const DirectoryReader = __webpack_require__(679); let asyncFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(686), + forEach: __webpack_require__(688), async: true }; @@ -82300,7 +82393,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 685 */ +/* 687 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82327,7 +82420,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 686 */ +/* 688 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82363,7 +82456,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 687 */ +/* 689 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82371,11 +82464,11 @@ function asyncForEach (array, iterator, done) { module.exports = readdirStream; -const DirectoryReader = __webpack_require__(677); +const DirectoryReader = __webpack_require__(679); let streamFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(686), + forEach: __webpack_require__(688), async: true }; @@ -82395,16 +82488,16 @@ function readdirStream (dir, options, internalOptions) { /***/ }), -/* 688 */ +/* 690 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var deep_1 = __webpack_require__(689); -var entry_1 = __webpack_require__(691); -var pathUtil = __webpack_require__(690); +var deep_1 = __webpack_require__(691); +var entry_1 = __webpack_require__(693); +var pathUtil = __webpack_require__(692); var Reader = /** @class */ (function () { function Reader(options) { this.options = options; @@ -82470,14 +82563,14 @@ exports.default = Reader; /***/ }), -/* 689 */ +/* 691 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(690); -var patternUtils = __webpack_require__(521); +var pathUtils = __webpack_require__(692); +var patternUtils = __webpack_require__(523); var DeepFilter = /** @class */ (function () { function DeepFilter(options, micromatchOptions) { this.options = options; @@ -82560,7 +82653,7 @@ exports.default = DeepFilter; /***/ }), -/* 690 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82591,14 +82684,14 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 691 */ +/* 693 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(690); -var patternUtils = __webpack_require__(521); +var pathUtils = __webpack_require__(692); +var patternUtils = __webpack_require__(523); var EntryFilter = /** @class */ (function () { function EntryFilter(options, micromatchOptions) { this.options = options; @@ -82683,7 +82776,7 @@ exports.default = EntryFilter; /***/ }), -/* 692 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82703,8 +82796,8 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var fsStat = __webpack_require__(693); -var fs_1 = __webpack_require__(697); +var fsStat = __webpack_require__(695); +var fs_1 = __webpack_require__(699); var FileSystemStream = /** @class */ (function (_super) { __extends(FileSystemStream, _super); function FileSystemStream() { @@ -82754,14 +82847,14 @@ exports.default = FileSystemStream; /***/ }), -/* 693 */ +/* 695 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const optionsManager = __webpack_require__(694); -const statProvider = __webpack_require__(696); +const optionsManager = __webpack_require__(696); +const statProvider = __webpack_require__(698); /** * Asynchronous API. */ @@ -82792,13 +82885,13 @@ exports.statSync = statSync; /***/ }), -/* 694 */ +/* 696 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsAdapter = __webpack_require__(695); +const fsAdapter = __webpack_require__(697); function prepare(opts) { const options = Object.assign({ fs: fsAdapter.getFileSystemAdapter(opts ? opts.fs : undefined), @@ -82811,7 +82904,7 @@ exports.prepare = prepare; /***/ }), -/* 695 */ +/* 697 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82834,7 +82927,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 696 */ +/* 698 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82886,7 +82979,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 697 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82917,7 +83010,7 @@ exports.default = FileSystem; /***/ }), -/* 698 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82937,9 +83030,9 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var readdir = __webpack_require__(675); -var reader_1 = __webpack_require__(688); -var fs_stream_1 = __webpack_require__(692); +var readdir = __webpack_require__(677); +var reader_1 = __webpack_require__(690); +var fs_stream_1 = __webpack_require__(694); var TransformStream = /** @class */ (function (_super) { __extends(TransformStream, _super); function TransformStream(reader) { @@ -83007,7 +83100,7 @@ exports.default = ReaderStream; /***/ }), -/* 699 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83026,9 +83119,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(675); -var reader_1 = __webpack_require__(688); -var fs_sync_1 = __webpack_require__(700); +var readdir = __webpack_require__(677); +var reader_1 = __webpack_require__(690); +var fs_sync_1 = __webpack_require__(702); var ReaderSync = /** @class */ (function (_super) { __extends(ReaderSync, _super); function ReaderSync() { @@ -83088,7 +83181,7 @@ exports.default = ReaderSync; /***/ }), -/* 700 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83107,8 +83200,8 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var fsStat = __webpack_require__(693); -var fs_1 = __webpack_require__(697); +var fsStat = __webpack_require__(695); +var fs_1 = __webpack_require__(699); var FileSystemSync = /** @class */ (function (_super) { __extends(FileSystemSync, _super); function FileSystemSync() { @@ -83154,7 +83247,7 @@ exports.default = FileSystemSync; /***/ }), -/* 701 */ +/* 703 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83170,7 +83263,7 @@ exports.flatten = flatten; /***/ }), -/* 702 */ +/* 704 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83191,13 +83284,13 @@ exports.merge = merge; /***/ }), -/* 703 */ +/* 705 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const pathType = __webpack_require__(704); +const pathType = __webpack_require__(706); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -83263,13 +83356,13 @@ module.exports.sync = (input, opts) => { /***/ }), -/* 704 */ +/* 706 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const pify = __webpack_require__(705); +const pify = __webpack_require__(707); function type(fn, fn2, fp) { if (typeof fp !== 'string') { @@ -83312,7 +83405,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 705 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83403,17 +83496,17 @@ module.exports = (obj, opts) => { /***/ }), -/* 706 */ +/* 708 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); const path = __webpack_require__(4); -const fastGlob = __webpack_require__(517); -const gitIgnore = __webpack_require__(707); -const pify = __webpack_require__(708); -const slash = __webpack_require__(709); +const fastGlob = __webpack_require__(519); +const gitIgnore = __webpack_require__(709); +const pify = __webpack_require__(710); +const slash = __webpack_require__(711); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -83511,7 +83604,7 @@ module.exports.sync = options => { /***/ }), -/* 707 */ +/* 709 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -83980,7 +84073,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 708 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84055,7 +84148,7 @@ module.exports = (input, options) => { /***/ }), -/* 709 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84073,7 +84166,7 @@ module.exports = input => { /***/ }), -/* 710 */ +/* 712 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84086,7 +84179,7 @@ module.exports = input => { -var isGlob = __webpack_require__(711); +var isGlob = __webpack_require__(713); module.exports = function hasGlob(val) { if (val == null) return false; @@ -84106,7 +84199,7 @@ module.exports = function hasGlob(val) { /***/ }), -/* 711 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -84137,17 +84230,17 @@ module.exports = function isGlob(str) { /***/ }), -/* 712 */ +/* 714 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); const {constants: fsConstants} = __webpack_require__(134); -const pEvent = __webpack_require__(713); -const CpFileError = __webpack_require__(716); -const fs = __webpack_require__(718); -const ProgressEmitter = __webpack_require__(721); +const pEvent = __webpack_require__(715); +const CpFileError = __webpack_require__(718); +const fs = __webpack_require__(720); +const ProgressEmitter = __webpack_require__(723); const cpFileAsync = async (source, destination, options, progressEmitter) => { let readError; @@ -84261,12 +84354,12 @@ module.exports.sync = (source, destination, options) => { /***/ }), -/* 713 */ +/* 715 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pTimeout = __webpack_require__(714); +const pTimeout = __webpack_require__(716); const symbolAsyncIterator = Symbol.asyncIterator || '@@asyncIterator'; @@ -84557,12 +84650,12 @@ module.exports.iterator = (emitter, event, options) => { /***/ }), -/* 714 */ +/* 716 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pFinally = __webpack_require__(715); +const pFinally = __webpack_require__(717); class TimeoutError extends Error { constructor(message) { @@ -84608,7 +84701,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 715 */ +/* 717 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84630,12 +84723,12 @@ module.exports = (promise, onFinally) => { /***/ }), -/* 716 */ +/* 718 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(717); +const NestedError = __webpack_require__(719); class CpFileError extends NestedError { constructor(message, nested) { @@ -84649,7 +84742,7 @@ module.exports = CpFileError; /***/ }), -/* 717 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(112).inherits; @@ -84705,16 +84798,16 @@ module.exports = NestedError; /***/ }), -/* 718 */ +/* 720 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(112); const fs = __webpack_require__(133); -const makeDir = __webpack_require__(719); -const pEvent = __webpack_require__(713); -const CpFileError = __webpack_require__(716); +const makeDir = __webpack_require__(721); +const pEvent = __webpack_require__(715); +const CpFileError = __webpack_require__(718); const stat = promisify(fs.stat); const lstat = promisify(fs.lstat); @@ -84811,7 +84904,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 719 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84819,7 +84912,7 @@ exports.copyFileSync = (source, destination, flags) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); const {promisify} = __webpack_require__(112); -const semver = __webpack_require__(720); +const semver = __webpack_require__(722); const useNativeRecursiveOption = semver.satisfies(process.version, '>=10.12.0'); @@ -84974,7 +85067,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 720 */ +/* 722 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -86576,7 +86669,7 @@ function coerce (version, options) { /***/ }), -/* 721 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86617,7 +86710,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 722 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86663,12 +86756,12 @@ exports.default = module.exports; /***/ }), -/* 723 */ +/* 725 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pMap = __webpack_require__(724); +const pMap = __webpack_require__(726); const pFilter = async (iterable, filterer, options) => { const values = await pMap( @@ -86685,7 +86778,7 @@ module.exports.default = pFilter; /***/ }), -/* 724 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86764,12 +86857,12 @@ module.exports.default = pMap; /***/ }), -/* 725 */ +/* 727 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(717); +const NestedError = __webpack_require__(719); class CpyError extends NestedError { constructor(message, nested) { diff --git a/packages/kbn-pm/src/cli.ts b/packages/kbn-pm/src/cli.ts index a27587085eab1..e6be8d1821d01 100644 --- a/packages/kbn-pm/src/cli.ts +++ b/packages/kbn-pm/src/cli.ts @@ -41,6 +41,9 @@ function help() { --debug Set log level to debug --quiet Set log level to error --silent Disable log output + + "run" options: + --skip-missing Ignore packages which don't have the requested script ` + '\n' ); } @@ -49,7 +52,7 @@ export async function run(argv: string[]) { log.setLogLevel( pickLevelFromFlags( getopts(argv, { - boolean: ['verbose', 'debug', 'quiet', 'silent'], + boolean: ['verbose', 'debug', 'quiet', 'silent', 'skip-missing'], }) ) ); diff --git a/packages/kbn-pm/src/commands/bootstrap.ts b/packages/kbn-pm/src/commands/bootstrap.ts index 0ad420899870d..8cd346a56f278 100644 --- a/packages/kbn-pm/src/commands/bootstrap.ts +++ b/packages/kbn-pm/src/commands/bootstrap.ts @@ -17,12 +17,13 @@ import { getAllChecksums } from '../utils/project_checksums'; import { BootstrapCacheFile } from '../utils/bootstrap_cache_file'; import { readYarnLock } from '../utils/yarn_lock'; import { validateDependencies } from '../utils/validate_dependencies'; +import { installBazelTools } from '../utils/bazel'; export const BootstrapCommand: ICommand = { description: 'Install dependencies and crosslink projects', name: 'bootstrap', - async run(projects, projectGraph, { options, kbn }) { + async run(projects, projectGraph, { options, kbn, rootPath }) { const batchedProjects = topologicallyBatchProjects(projects, projectGraph); const kibanaProjectPath = projects.get('kibana')?.path; const extraArgs = [ @@ -30,6 +31,10 @@ export const BootstrapCommand: ICommand = { ...(options['prefer-offline'] === true ? ['--prefer-offline'] : []), ]; + // Install bazel machinery tools if needed + await installBazelTools(rootPath); + + // Install monorepo npm dependencies for (const batch of batchedProjects) { for (const project of batch) { const isExternalPlugin = project.path.includes(`${kibanaProjectPath}${sep}plugins`); diff --git a/packages/kbn-pm/src/commands/run.ts b/packages/kbn-pm/src/commands/run.ts index acbafe07b9a84..fb306f37082fe 100644 --- a/packages/kbn-pm/src/commands/run.ts +++ b/packages/kbn-pm/src/commands/run.ts @@ -16,7 +16,7 @@ export const RunCommand: ICommand = { description: 'Run script defined in package.json in each package that contains that script.', name: 'run', - async run(projects, projectGraph, { extraArgs }) { + async run(projects, projectGraph, { extraArgs, options }) { const batchedProjects = topologicallyBatchProjects(projects, projectGraph); if (extraArgs.length === 0) { @@ -27,13 +27,21 @@ export const RunCommand: ICommand = { const scriptArgs = extraArgs.slice(1); await parallelizeBatches(batchedProjects, async (project) => { - if (project.hasScript(scriptName)) { - log.info(`[${project.name}] running "${scriptName}" script`); - await project.runScriptStreaming(scriptName, { - args: scriptArgs, - }); - log.success(`[${project.name}] complete`); + if (!project.hasScript(scriptName)) { + if (!!options['skip-missing']) { + return; + } + + throw new CliError( + `[${project.name}] no "${scriptName}" script defined. To skip packages without the "${scriptName}" script pass --skip-missing` + ); } + + log.info(`[${project.name}] running "${scriptName}" script`); + await project.runScriptStreaming(scriptName, { + args: scriptArgs, + }); + log.success(`[${project.name}] complete`); }); }, }; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts b/packages/kbn-pm/src/utils/bazel/index.ts similarity index 77% rename from src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts rename to packages/kbn-pm/src/utils/bazel/index.ts index f101372f5bbce..957c4bdf7f6aa 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts +++ b/packages/kbn-pm/src/utils/bazel/index.ts @@ -6,6 +6,4 @@ * Public License, v 1. */ -import { VegaBaseView } from './vega_base_view'; - -export class VegaMapView extends VegaBaseView {} +export * from './install_tools'; diff --git a/packages/kbn-pm/src/utils/bazel/install_tools.ts b/packages/kbn-pm/src/utils/bazel/install_tools.ts new file mode 100644 index 0000000000000..4e19974590e83 --- /dev/null +++ b/packages/kbn-pm/src/utils/bazel/install_tools.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { resolve } from 'path'; +import { spawn } from '../child_process'; +import { readFile } from '../fs'; +import { log } from '../log'; + +async function readBazelToolsVersionFile(repoRootPath: string, versionFilename: string) { + const version = (await readFile(resolve(repoRootPath, versionFilename))) + .toString() + .split('\n')[0]; + + if (!version) { + throw new Error( + `[bazel_tools] Failed on reading bazel tools versions\n ${versionFilename} file do not contain any version set` + ); + } + + return version; +} + +export async function installBazelTools(repoRootPath: string) { + log.debug(`[bazel_tools] reading bazel tools versions from version files`); + const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); + const bazelVersion = await readBazelToolsVersionFile(repoRootPath, '.bazelversion'); + + // Check what globals are installed + log.debug(`[bazel_tools] verify if bazelisk is installed`); + const { stdout } = await spawn('yarn', ['global', 'list'], { stdio: 'pipe' }); + + // Install bazelisk if not installed + if (!stdout.includes(`@bazel/bazelisk@${bazeliskVersion}`)) { + log.info(`[bazel_tools] installing Bazel tools`); + + log.debug( + `[bazel_tools] bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}` + ); + await spawn('yarn', ['global', 'add', `@bazel/bazelisk@${bazeliskVersion}`], { + env: { + USE_BAZEL_VERSION: bazelVersion, + }, + stdio: 'pipe', + }); + } + + log.success(`[bazel_tools] all bazel tools are correctly installed`); +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts index b6dcd40b53d2e..08bfa5eb404ca 100644 --- a/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts +++ b/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts @@ -23,7 +23,7 @@ export function checkMatchingSchemasTask({ roots }: TaskContext, throwOnDiff: bo root.esMappingDiffs = Object.keys(differences); if (root.esMappingDiffs.length && throwOnDiff) { throw Error( - `The following changes must be persisted in ${fullPath} file. Use '--fix' to update.\n${JSON.stringify( + `The following changes must be persisted in ${fullPath} file. Run 'node scripts/telemetry_check --fix' to update.\n${JSON.stringify( differences, null, 2 diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index ebedb314f9594..ed88944ed862d 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -19,7 +19,7 @@ module.exports = { coveragePathIgnorePatterns: ['/node_modules/', '.*\\.d\\.ts'], // A list of reporter names that Jest uses when writing coverage reports - coverageReporters: !!process.env.CI ? [['json', { file: 'jest.json' }]] : ['html', 'text'], + coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html', 'text'], // An array of file extensions your modules use moduleFileExtensions: ['js', 'mjs', 'json', 'ts', 'tsx', 'node'], diff --git a/packages/kbn-tinymath/README.md b/packages/kbn-tinymath/README.md new file mode 100644 index 0000000000000..1094c4286c851 --- /dev/null +++ b/packages/kbn-tinymath/README.md @@ -0,0 +1,72 @@ +# kbn-tinymath + +kbn-tinymath is a tiny arithmetic and function evaluator for simple numbers and arrays. Named properties can be accessed from an optional scope parameter. +It's available as an expression function called `math` in Canvas, and the grammar/AST structure is available +for use by Kibana plugins that want to use math. + +See [Function Documentation](/docs/functions.md) for details on built-in functions available in Tinymath. + +```javascript +const { evaluate } = require('@kbn/tinymath'); + +// Simple math +evaluate('10 + 20'); // 30 +evaluate('round(3.141592)') // 3 + +// Named properties +evaluate('foo + 20', {foo: 5}); // 25 + +// Arrays +evaluate('bar + 20', {bar: [1, 2, 3]}); // [21, 22, 23] +evaluate('bar + baz', {bar: [1, 2, 3], baz: [4, 5, 6]}); // [5, 7, 9] +evaluate('multiply(bar, baz) / 10', {bar: [1, 2, 3], baz: [4, 5, 6]}); // [0.4, 1, 1.8] +``` + +### Adding Functions + +Functions can be injected, and built in function overwritten, via the 3rd argument to `evaluate`: + +```javascript +const { evaluate } = require('@kbn/tinymath'); + +evaluate('plustwo(foo)', {foo: 5}, { + plustwo: function(a) { + return a + 2; + } +}); // 7 +``` + +### Parsing + +You can get to the parsed AST by importing `parse` + +```javascript +const { parse } = require('@kbn/tinymath'); + +parse('1 + random()') +/* +{ + "name": "add", + "args": [ + 1, + { + "name": "random", + "args": [] + } + ] +} +*/ +``` + +#### Notes + +* Floating point operations have the normal Javascript limitations + +### Building kbn-tinymath + +This package is rebuilt when running `yarn kbn bootstrap`, but can also be build directly +using `yarn build` from the `packages/kbn-tinymath` directory. +### Running tests + +To test `@kbn/tinymath` from Kibana, run `yarn run jest --watch packages/kbn-tinymath` from +the top level of Kibana. diff --git a/packages/kbn-tinymath/babel.config.js b/packages/kbn-tinymath/babel.config.js new file mode 100644 index 0000000000000..c578a02ede1fb --- /dev/null +++ b/packages/kbn-tinymath/babel.config.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +module.exports = { + env: { + web: { + presets: ['@kbn/babel-preset/webpack_preset'], + }, + node: { + presets: ['@kbn/babel-preset/node_preset'], + }, + }, + ignore: ['**/*.test.ts', '**/*.test.tsx'], +}; diff --git a/packages/kbn-tinymath/docs/functions.md b/packages/kbn-tinymath/docs/functions.md new file mode 100644 index 0000000000000..0c7460a8189dd --- /dev/null +++ b/packages/kbn-tinymath/docs/functions.md @@ -0,0 +1,687 @@ +# TinyMath Functions +This document provides detailed information about the functions available in Tinymath and lists what parameters each function accepts, the return value of that function, and examples of how each function behaves. Most of the functions below accept arrays and apply JavaScript Math methods to each element of that array. For the functions that accept multiple arrays as parameters, the function generally does calculation index by index. Any function below can be wrapped by another function as long as the return type of the inner function matches the acceptable parameter type of the outer function. + +## _abs(_ _a_ _)_ +Calculates the absolute value of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The absolute value of `a`. Returns an array with the the absolute values of each element if `a` is an array. +**Example** +```js +abs(-1) // returns 1 +abs(2) // returns 2 +abs([-1 , -2, 3, -4]) // returns [1, 2, 3, 4] +``` +*** +## _add(_ ..._args_ _)_ +Calculates the sum of one or more numbers/arrays passed into the function. If at least one array of numbers is passed into the function, the function will calculate the sum by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The sum of all numbers in `args` if `args` contains only numbers. Returns an array of sums of the elements at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Throws**: + +- `'Array length mismatch'` if `args` contains arrays of different lengths + +**Example** +```js +add(1, 2, 3) // returns 6 +add([10, 20, 30, 40], 10, 20, 30) // returns [70, 80, 90, 100] +add([1, 2], 3, [4, 5], 6) // returns [(1 + 3 + 4 + 6), (2 + 3 + 5 + 6)] = [14, 16] +``` +*** +## _cbrt(_ _a_ _)_ +Calculates the cube root of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The cube root of `a`. Returns an array with the the cube roots of each element if `a` is an array. +**Example** +```js +cbrt(-27) // returns -3 +cbrt(94) // returns 4.546835943776344 +cbrt([27, 64, 125]) // returns [3, 4, 5] +``` +*** +## _ceil(_ _a_ _)_ +Calculates the ceiling of a number, i.e. rounds a number towards positive infinity. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The ceiling of `a`. Returns an array with the the ceilings of each element if `a` is an array. +**Example** +```js +ceil(1.2) // returns 2 +ceil(-1.8) // returns -1 +ceil([1.1, 2.2, 3.3]) // returns [2, 3, 4] +``` +*** +## _clamp(_ ..._a_, _min_, _max_ _)_ +Restricts value to a given range and returns closed available value. If only min is provided, values are restricted to only a lower bound. + + +| Param | Type | Description | +| --- | --- | --- | +| ...a | number \| Array.<number> | one or more numbers or arrays of numbers | +| min | number \| Array.<number> | The minimum value this function will return. | +| max | number \| Array.<number> | The maximum value this function will return. | + +**Returns**: number \| Array.<number> - The closest value between `min` (inclusive) and `max` (inclusive). Returns an array with values greater than or equal to `min` and less than or equal to `max` (if provided) at each index. +**Throws**: + +- `'Array length mismatch'` if `a`, `min`, and/or `max` are arrays of different lengths +- `'Min must be less than max'` if `max` is less than `min` +- `'Missing minimum value. You may want to use the 'max' function instead'` if min is not provided +- `'Missing maximum value. You may want to use the 'min' function instead'` if max is not provided + +**Example** +```js +clamp(1, 2, 3) // returns 2 +clamp([10, 20, 30, 40], 15, 25) // returns [15, 20, 25, 25] +clamp(10, [15, 2, 4, 20], 25) // returns [15, 10, 10, 20] +clamp(35, 10, [20, 30, 40, 50]) // returns [20, 30, 35, 35] +clamp([1, 9], 3, [4, 5]) // returns [clamp([1, 3, 4]), clamp([9, 3, 5])] = [3, 5] +``` +*** +## _cos(_ _a_ _)_ +Calculates the the cosine of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in radians. | + +**Returns**: number \| Array.<number> - The cosine of `a`. Returns an array with the the cosine of each element if `a` is an array. +**Example** +```js +cos(0) // returns 1 +cos(1.5707963267948966) // returns 6.123233995736766e-17 +cos([0, 1.5707963267948966]) // returns [1, 6.123233995736766e-17] +``` +*** +## _count(_ _a_ _)_ +Returns the length of an array. Alias for size + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: number - The length of the array. Returns 1 if `a` is not an array. +**Throws**: + +- `'Must pass an array'` if `a` is not an array + +**Example** +```js +count([]) // returns 0 +count([-1, -2, -3, -4]) // returns 4 +count(100) // returns 1 +``` +*** +## _cube(_ _a_ _)_ +Calculates the cube of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The cube of `a`. Returns an array with the the cubes of each element if `a` is an array. +**Example** +```js +cube(-3) // returns -27 +cube([3, 4, 5]) // returns [27, 64, 125] +``` +*** +## _degtorad(_ _a_ _)_ +Converts degrees to radians for a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in degrees. | + +**Returns**: number \| Array.<number> - The radians of `a`. Returns an array with the the radians of each element if `a` is an array. +**Example** +```js +degtorad(0) // returns 0 +degtorad(90) // returns 1.5707963267948966 +degtorad([0, 90, 180, 360]) // returns [0, 1.5707963267948966, 3.141592653589793, 6.283185307179586] +``` +*** +## _divide(_ _a_, _b_ _)_ +Divides two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | dividend, a number or an array of numbers | +| b | number \| Array.<number> | divisor, a number or an array of numbers, `b` != 0 | + +**Returns**: number \| Array.<number> - The quotient of `a` and `b` if both are numbers. Returns an array with the quotients applied index-wise to each element if `a` or `b` is an array. +**Throws**: + +- `'Array length mismatch'` if `a` and `b` are arrays with different lengths +- `'Cannot divide by 0'` if `b` equals 0 or contains 0 + +**Example** +```js +divide(6, 3) // returns 2 +divide([10, 20, 30, 40], 10) // returns [1, 2, 3, 4] +divide(10, [1, 2, 5, 10]) // returns [10, 5, 2, 1] +divide([14, 42, 65, 108], [2, 7, 5, 12]) // returns [7, 6, 13, 9] +``` +*** +## _exp(_ _a_ _)_ +Calculates _e^x_ where _e_ is Euler's number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - `e^a`. Returns an array with the values of `e^x` evaluated where `x` is each element of `a` if `a` is an array. +**Example** +```js +exp(2) // returns e^2 = 7.3890560989306495 +exp([1, 2, 3]) // returns [e^1, e^2, e^3] = [2.718281828459045, 7.3890560989306495, 20.085536923187668] +``` +*** +## _first(_ _a_ _)_ +Returns the first element of an array. If anything other than an array is passed in, the input is returned. + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: \* - The first element of `a`. Returns `a` if `a` is not an array. +**Example** +```js +first(2) // returns 2 +first([1, 2, 3]) // returns 1 +``` +*** +## _fix(_ _a_ _)_ +Calculates the fix of a number, i.e. rounds a number towards 0. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The fix of `a`. Returns an array with the the fixes for each element if `a` is an array. +**Example** +```js +fix(1.2) // returns 1 +fix(-1.8) // returns -1 +fix([1.8, 2.9, -3.7, -4.6]) // returns [1, 2, -3, -4] +``` +*** +## _floor(_ _a_ _)_ +Calculates the floor of a number, i.e. rounds a number towards negative infinity. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The floor of `a`. Returns an array with the the floor of each element if `a` is an array. +**Example** +```js +floor(1.8) // returns 1 +floor(-1.2) // returns -2 +floor([1.7, 2.8, 3.9]) // returns [1, 2, 3] +``` +*** +## _last(_ _a_ _)_ +Returns the last element of an array. If anything other than an array is passed in, the input is returned. + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: \* - The last element of `a`. Returns `a` if `a` is not an array. +**Example** +```js +last(2) // returns 2 +last([1, 2, 3]) // returns 3 +``` +*** +## _log(_ _a_, _b_ _)_ +Calculates the logarithm of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` must be greater than 0 | +| b | Object | (optional) base for the logarithm. If not provided a value, the default base is e, and the natural log is calculated. | + +**Returns**: number \| Array.<number> - The logarithm of `a`. Returns an array with the the logarithms of each element if `a` is an array. +**Throws**: + +- `'Base out of range'` if `b` <= 0 +- 'Must be greater than 0' if `a` > 0 + +**Example** +```js +log(1) // returns 0 +log(64, 8) // returns 2 +log(42, 5) // returns 2.322344707681546 +log([2, 4, 8, 16, 32], 2) // returns [1, 2, 3, 4, 5] +``` +*** +## _log10(_ _a_ _)_ +Calculates the logarithm base 10 of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` must be greater than 0 | + +**Returns**: number \| Array.<number> - The logarithm of `a`. Returns an array with the the logarithms base 10 of each element if `a` is an array. +**Throws**: + +- `'Must be greater than 0'` if `a` < 0 + +**Example** +```js +log(10) // returns 1 +log(100) // returns 2 +log(80) // returns 1.9030899869919433 +log([10, 100, 1000, 10000, 100000]) // returns [1, 2, 3, 4, 5] +``` +*** +## _max(_ ..._args_ _)_ +Finds the maximum value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the maximum by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The maximum value of all numbers if `args` contains only numbers. Returns an array with the the maximum values at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Throws**: + +- `'Array length mismatch'` if `args` contains arrays of different lengths + +**Example** +```js +max(1, 2, 3) // returns 3 +max([10, 20, 30, 40], 15) // returns [15, 20, 30, 40] +max([1, 9], 4, [3, 5]) // returns [max([1, 4, 3]), max([9, 4, 5])] = [4, 9] +``` +*** +## _mean(_ ..._args_ _)_ +Finds the mean value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the mean by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The mean value of all numbers if `args` contains only numbers. Returns an array with the the mean values of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Example** +```js +mean(1, 2, 3) // returns 2 +mean([10, 20, 30, 40], 20) // returns [15, 20, 25, 30] +mean([1, 9], 5, [3, 4]) // returns [mean([1, 5, 3]), mean([9, 5, 4])] = [3, 6] +``` +*** +## _median(_ ..._args_ _)_ +Finds the median value(s) of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the median by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The median value of all numbers if `args` contains only numbers. Returns an array with the the median values of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Example** +```js +median(1, 1, 2, 3) // returns 1.5 +median(1, 1, 2, 2, 3) // returns 2 +median([10, 20, 30, 40], 10, 20, 30) // returns [15, 20, 25, 25] +median([1, 9], 2, 4, [3, 5]) // returns [median([1, 2, 4, 3]), median([9, 2, 4, 5])] = [2.5, 4.5] +``` +*** +## _min(_ ..._args_ _)_ +Finds the minimum value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the minimum by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The minimum value of all numbers if `args` contains only numbers. Returns an array with the the minimum values of each index, including all scalar numbers in `args` in the calculation at each index if `a` is an array. +**Throws**: + +- `'Array length mismatch'` if `args` contains arrays of different lengths + +**Example** +```js +min(1, 2, 3) // returns 1 +min([10, 20, 30, 40], 25) // returns [10, 20, 25, 25] +min([1, 9], 4, [3, 5]) // returns [min([1, 4, 3]), min([9, 4, 5])] = [1, 4] +``` +*** +## _mod(_ _a_, _b_ _)_ +Remainder after dividing two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | dividend, a number or an array of numbers | +| b | number \| Array.<number> | divisor, a number or an array of numbers, `b` != 0 | + +**Returns**: number \| Array.<number> - The remainder of `a` divided by `b` if both are numbers. Returns an array with the the remainders applied index-wise to each element if `a` or `b` is an array. +**Throws**: + +- `'Array length mismatch'` if `a` and `b` are arrays with different lengths +- `'Cannot divide by 0'` if `b` equals 0 or contains 0 + +**Example** +```js +mod(10, 7) // returns 3 +mod([11, 22, 33, 44], 10) // returns [1, 2, 3, 4] +mod(100, [3, 7, 11, 23]) // returns [1, 2, 1, 8] +mod([14, 42, 65, 108], [5, 4, 14, 2]) // returns [5, 2, 9, 0] +``` +*** +## _mode(_ ..._args_ _)_ +Finds the mode value(s) of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the mode by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: Array.<number> \| Array.<Array.<number>> - An array mode value(s) of all numbers if `args` contains only numbers. Returns an array of arrays with mode value(s) of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Example** +```js +mode(1, 1, 2, 3) // returns [1] +mode(1, 1, 2, 2, 3) // returns [1,2] +mode([10, 20, 30, 40], 10, 20, 30) // returns [[10], [20], [30], [10, 20, 30, 40]] +mode([1, 9], 1, 4, [3, 5]) // returns [mode([1, 1, 4, 3]), mode([9, 1, 4, 5])] = [[1], [4, 5, 9]] +``` +*** +## _multiply(_ _a_, _b_ _)_ +Multiplies two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | +| b | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The product of `a` and `b` if both are numbers. Returns an array with the the products applied index-wise to each element if `a` or `b` is an array. +**Throws**: + +- `'Array length mismatch'` if `a` and `b` are arrays with different lengths + +**Example** +```js +multiply(6, 3) // returns 18 +multiply([10, 20, 30, 40], 10) // returns [100, 200, 300, 400] +multiply(10, [1, 2, 5, 10]) // returns [10, 20, 50, 100] +multiply([1, 2, 3, 4], [2, 7, 5, 12]) // returns [2, 14, 15, 48] +``` +*** +## _pi(__)_ +Returns the mathematical constant PI + +**Returns**: number - The mathematical constant PI +**Example** +```js +pi() // 3.141592653589793 +``` +*** +## _pow(_ _a_, _b_ _)_ +Raises a number to a given exponent. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | +| b | number | the power that `a` is raised to | + +**Returns**: number \| Array.<number> - `a` raised to the power of `b`. Returns an array with the each element raised to the power of `b` if `a` is an array. +**Throws**: + +- `'Missing exponent'` if `b` is not provided + +**Example** +```js +pow(2,3) // returns 8 +pow([1, 2, 3], 4) // returns [1, 16, 81] +``` +*** +## _radtodeg(_ _a_ _)_ +Converts radians to degrees for a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in radians. | + +**Returns**: number \| Array.<number> - The degrees of `a`. Returns an array with the the degrees of each element if `a` is an array. +**Example** +```js +radtodeg(0) // returns 0 +radtodeg(1.5707963267948966) // returns 90 +radtodeg([0, 1.5707963267948966, 3.141592653589793, 6.283185307179586]) // returns [0, 90, 180, 360] +``` +*** +## _random(_ _a_, _b_ _)_ +Generates a random number within the given range where the lower bound is inclusive and the upper bound is exclusive. If no numbers are passed in, it will return a number between 0 and 1. If only one number is passed in, it will return . + + +| Param | Type | Description | +| --- | --- | --- | +| a | number | (optional) must be greater than 0 if `b` is not provided | +| b | number | (optional) must be greater | + +**Returns**: number - A random number between 0 and 1 if no numbers are passed in. Returns a random number between 0 and `a` if only one number is passed in. Returns a random number between `a` and `b` if two numbers are passed in. +**Throws**: + +- `'Min is be greater than max'` if `a` < 0 when only `a` is passed in or if `a` > `b` when both `a` and `b` are passed in + +**Example** +```js +random() // returns a random number between 0 (inclusive) and 1 (exclusive) +random(10) // returns a random number between 0 (inclusive) and 10 (exclusive) +random(-10,10) // returns a random number between -10 (inclusive) and 10 (exclusive) +``` +*** +## _range(_ ..._args_ _)_ +Finds the range of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the range by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The range value of all numbers if `args` contains only numbers. Returns an array with the the range values at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Example** +```js +range(1, 2, 3) // returns 2 +range([10, 20, 30, 40], 15) // returns [5, 5, 15, 25] +range([1, 9], 4, [3, 5]) // returns [range([1, 4, 3]), range([9, 4, 5])] = [3, 5] +``` +*** +## _round(_ _a_, _b_ _)_ +Rounds a number towards the nearest integer by default or decimal place if specified. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | +| b | number | (optional) number of decimal places, default value: 0 | + +**Returns**: number \| Array.<number> - The rounded value of `a`. Returns an array with the the rounded values of each element if `a` is an array. +**Example** +```js +round(1.2) // returns 2 +round(-10.51) // returns -11 +round(-10.1, 2) // returns -10.1 +round(10.93745987, 4) // returns 10.9375 +round([2.9234, 5.1234, 3.5234, 4.49234324], 2) // returns [2.92, 5.12, 3.52, 4.49] +``` +*** +## _sin(_ _a_ _)_ +Calculates the the sine of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in radians. | + +**Returns**: number \| Array.<number> - The sine of `a`. Returns an array with the the sine of each element if `a` is an array. +**Example** +```js +sin(0) // returns 0 +sin(1.5707963267948966) // returns 1 +sin([0, 1.5707963267948966]) // returns [0, 1] +``` +*** +## _size(_ _a_ _)_ +Returns the length of an array. Alias for count + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: number - The length of the array. Returns 1 if `a` is not an array. +**Throws**: + +- `'Must pass an array'` if `a` is not an array + +**Example** +```js +size([]) // returns 0 +size([-1, -2, -3, -4]) // returns 4 +size(100) // returns 1 +``` +*** +## _sqrt(_ _a_ _)_ +Calculates the square root of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The square root of `a`. Returns an array with the the square roots of each element if `a` is an array. +**Throws**: + +- `'Unable find the square root of a negative number'` if `a` < 0 + +**Example** +```js +sqrt(9) // returns 3 +sqrt(30) //5.477225575051661 +sqrt([9, 16, 25]) // returns [3, 4, 5] +``` +*** +## _square(_ _a_ _)_ +Calculates the square of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The square of `a`. Returns an array with the the squares of each element if `a` is an array. +**Example** +```js +square(-3) // returns 9 +square([3, 4, 5]) // returns [9, 16, 25] +``` +*** +## _subtract(_ _a_, _b_ _)_ +Subtracts two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | +| b | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The difference of `a` and `b` if both are numbers or an array of differences applied index-wise to each element. +**Throws**: + +- `'Array length mismatch'` if `a` and `b` are arrays with different lengths + +**Example** +```js +subtract(6, 3) // returns 3 +subtract([10, 20, 30, 40], 10) // returns [0, 10, 20, 30] +subtract(10, [1, 2, 5, 10]) // returns [9, 8, 5, 0] +subtract([14, 42, 65, 108], [2, 7, 5, 12]) // returns [12, 35, 52, 96] +``` +*** +## _sum(_ ..._args_ _)_ +Calculates the sum of one or more numbers/arrays passed into the function. If at least one array is passed, the function will sum up one or more numbers/arrays of numbers and distinct values of an array. Sum accepts arrays of different lengths. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number - The sum of one or more numbers/arrays of numbers including distinct values in arrays +**Example** +```js +sum(1, 2, 3) // returns 6 +sum([10, 20, 30, 40], 10, 20, 30) // returns 160 +sum([1, 2], 3, [4, 5], 6) // returns sum(1, 2, 3, 4, 5, 6) = 21 +sum([10, 20, 30, 40], 10, [1, 2, 3], 22) // returns sum(10, 20, 30, 40, 10, 1, 2, 3, 22) = 138 +``` +*** +## _tan(_ _a_ _)_ +Calculates the the tangent of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in radians. | + +**Returns**: number \| Array.<number> - The tangent of `a`. Returns an array with the the tangent of each element if `a` is an array. +**Example** +```js +tan(0) // returns 0 +tan(1) // returns 1.5574077246549023 +tan([0, 1, -1]) // returns [0, 1.5574077246549023, -1.5574077246549023] +``` +*** +## _unique(_ _a_ _)_ +Counts the number of unique values in an array + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: number - The number of unique values in the array. Returns 1 if `a` is not an array. +**Example** +```js +unique(100) // returns 1 +unique([]) // returns 0 +unique([1, 2, 3, 4]) // returns 4 +unique([1, 2, 3, 4, 2, 2, 2, 3, 4, 2, 4, 5, 2, 1, 4, 2]) // returns 5 +``` diff --git a/packages/kbn-tinymath/docs/template/functions.hbs b/packages/kbn-tinymath/docs/template/functions.hbs new file mode 100644 index 0000000000000..60f821e6d15bf --- /dev/null +++ b/packages/kbn-tinymath/docs/template/functions.hbs @@ -0,0 +1,15 @@ +# TinyMath Functions +This document provides detailed information about the functions available in Tinymath and lists what parameters each function accepts, the return value of that function, and examples of how each function behaves. Most of the functions below accept arrays and apply JavaScript Math methods to each element of that array. For the functions that accept multiple arrays as parameters, the function generally does calculation index by index. Any function below can be wrapped by another function as long as the return type of the inner function matches the acceptable parameter type of the outer function. + +{{#functions}} +## _{{name}}(_{{#each params}} {{#if variable}}...{{/if}}_{{name}}_{{#unless @last}},{{/unless}} {{/each}}_)_ +{{description}} + +{{>params~}} +{{>returns~}} +{{>throws~}} +{{>examples~}} +{{#unless @last}} +*** +{{/unless}} +{{/functions}} diff --git a/packages/kbn-tinymath/jest.config.js b/packages/kbn-tinymath/jest.config.js new file mode 100644 index 0000000000000..2fb97d8aa416a --- /dev/null +++ b/packages/kbn-tinymath/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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-tinymath'], +}; diff --git a/packages/kbn-tinymath/package.json b/packages/kbn-tinymath/package.json new file mode 100644 index 0000000000000..34fd593672b5a --- /dev/null +++ b/packages/kbn-tinymath/package.json @@ -0,0 +1,12 @@ +{ + "name": "@kbn/tinymath", + "version": "2.0.0", + "license": "SSPL-1.0 OR Elastic License", + "private": true, + "main": "src/index.js", + "scripts": { + "kbn:bootstrap": "yarn build", + "build": "../../node_modules/.bin/pegjs -o src/grammar.js src/grammar.pegjs" + }, + "dependencies": {} +} \ No newline at end of file diff --git a/packages/kbn-tinymath/src/functions/abs.js b/packages/kbn-tinymath/src/functions/abs.js new file mode 100644 index 0000000000000..aa9eaba1ce3b2 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/abs.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the absolute value of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The absolute value of `a`. Returns an array with the the absolute values of each element if `a` is an array. + * + * @example + * abs(-1) // returns 1 + * abs(2) // returns 2 + * abs([-1 , -2, 3, -4]) // returns [1, 2, 3, 4] + */ + +module.exports = { abs }; + +function abs(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.abs(a)); + } + return Math.abs(a); +} diff --git a/packages/kbn-tinymath/src/functions/add.js b/packages/kbn-tinymath/src/functions/add.js new file mode 100644 index 0000000000000..5a4d6802a85ea --- /dev/null +++ b/packages/kbn-tinymath/src/functions/add.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the sum of one or more numbers/arrays passed into the function. If at least one array of numbers is passed into the function, the function will calculate the sum by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The sum of all numbers in `args` if `args` contains only numbers. Returns an array of sums of the elements at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * @throws `'Array length mismatch'` if `args` contains arrays of different lengths + * @example + * add(1, 2, 3) // returns 6 + * add([10, 20, 30, 40], 10, 20, 30) // returns [70, 80, 90, 100] + * add([1, 2], 3, [4, 5], 6) // returns [(1 + 3 + 4 + 6), (2 + 3 + 5 + 6)] = [14, 16] + */ + +module.exports = { add }; + +function add(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) return args[0].reduce((result, current) => result + current); + return args[0]; + } + + return args.reduce((result, current) => { + if (Array.isArray(result) && Array.isArray(current)) { + if (current.length !== result.length) throw new Error('Array length mismatch'); + return result.map((val, i) => val + current[i]); + } + if (Array.isArray(result)) return result.map((val) => val + current); + if (Array.isArray(current)) return current.map((val) => val + result); + return result + current; + }); +} diff --git a/packages/kbn-tinymath/src/functions/cbrt.js b/packages/kbn-tinymath/src/functions/cbrt.js new file mode 100644 index 0000000000000..017a661702761 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/cbrt.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the cube root of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The cube root of `a`. Returns an array with the the cube roots of each element if `a` is an array. + * + * @example + * cbrt(-27) // returns -3 + * cbrt(94) // returns 4.546835943776344 + * cbrt([27, 64, 125]) // returns [3, 4, 5] + */ + +module.exports = { cbrt }; + +function cbrt(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.cbrt(a)); + } + return Math.cbrt(a); +} diff --git a/packages/kbn-tinymath/src/functions/ceil.js b/packages/kbn-tinymath/src/functions/ceil.js new file mode 100644 index 0000000000000..7fbbabe481073 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/ceil.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the ceiling of a number, i.e. rounds a number towards positive infinity. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The ceiling of `a`. Returns an array with the the ceilings of each element if `a` is an array. + * + * @example + * ceil(1.2) // returns 2 + * ceil(-1.8) // returns -1 + * ceil([1.1, 2.2, 3.3]) // returns [2, 3, 4] + */ + +module.exports = { ceil }; + +function ceil(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.ceil(a)); + } + return Math.ceil(a); +} diff --git a/packages/kbn-tinymath/src/functions/clamp.js b/packages/kbn-tinymath/src/functions/clamp.js new file mode 100644 index 0000000000000..66b9e9eaf4f0d --- /dev/null +++ b/packages/kbn-tinymath/src/functions/clamp.js @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const findClamp = (a, min, max) => { + if (min > max) throw new Error('Min must be less than max'); + return Math.min(Math.max(a, min), max); +}; + +/** + * Restricts value to a given range and returns closed available value. If only min is provided, values are restricted to only a lower bound. + * @param {...(number|number[])} a one or more numbers or arrays of numbers + * @param {(number|number[])} min The minimum value this function will return. + * @param {(number|number[])} max The maximum value this function will return. + * @return {(number|number[])} The closest value between `min` (inclusive) and `max` (inclusive). Returns an array with values greater than or equal to `min` and less than or equal to `max` (if provided) at each index. + * @throws `'Array length mismatch'` if `a`, `min`, and/or `max` are arrays of different lengths + * @throws `'Min must be less than max'` if `max` is less than `min` + * @throws `'Missing minimum value. You may want to use the 'max' function instead'` if min is not provided + * @throws `'Missing maximum value. You may want to use the 'min' function instead'` if max is not provided + * + * @example + * clamp(1, 2, 3) // returns 2 + * clamp([10, 20, 30, 40], 15, 25) // returns [15, 20, 25, 25] + * clamp(10, [15, 2, 4, 20], 25) // returns [15, 10, 10, 20] + * clamp(35, 10, [20, 30, 40, 50]) // returns [20, 30, 35, 35] + * clamp([1, 9], 3, [4, 5]) // returns [clamp([1, 3, 4]), clamp([9, 3, 5])] = [3, 5] + */ + +module.exports = { clamp }; + +function clamp(a, min, max) { + if (max === null) + throw new Error("Missing maximum value. You may want to use the 'min' function instead"); + if (min === null) + throw new Error("Missing minimum value. You may want to use the 'max' function instead"); + + if (Array.isArray(max)) { + if (Array.isArray(a) && Array.isArray(min)) { + if (a.length !== max.length || a.length !== min.length) + throw new Error('Array length mismatch'); + return max.map((max, i) => findClamp(a[i], min[i], max)); + } + + if (Array.isArray(a)) { + if (a.length !== max.length) throw new Error('Array length mismatch'); + return max.map((max, i) => findClamp(a[i], min, max)); + } + + if (Array.isArray(min)) { + if (min.length !== max.length) throw new Error('Array length mismatch'); + return max.map((max, i) => findClamp(a, min[i], max)); + } + + return max.map((max) => findClamp(a, min, max)); + } + + if (Array.isArray(a) && Array.isArray(min)) { + if (a.length !== min.length) throw new Error('Array length mismatch'); + return a.map((a, i) => findClamp(a, min[i])); + } + + if (Array.isArray(a)) { + return a.map((a) => findClamp(a, min, max)); + } + + if (Array.isArray(min)) { + return min.map((min) => findClamp(a, min, max)); + } + + return findClamp(a, min, max); +} diff --git a/packages/kbn-tinymath/src/functions/cos.js b/packages/kbn-tinymath/src/functions/cos.js new file mode 100644 index 0000000000000..0385f52793c27 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/cos.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the the cosine of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in radians. + * @return {(number|number[])} The cosine of `a`. Returns an array with the the cosine of each element if `a` is an array. + * @example + * cos(0) // returns 1 + * cos(1.5707963267948966) // returns 6.123233995736766e-17 + * cos([0, 1.5707963267948966]) // returns [1, 6.123233995736766e-17] + */ + +module.exports = { cos }; + +function cos(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.cos(a)); + } + return Math.cos(a); +} diff --git a/packages/kbn-tinymath/src/functions/count.js b/packages/kbn-tinymath/src/functions/count.js new file mode 100644 index 0000000000000..b037999b7ac8a --- /dev/null +++ b/packages/kbn-tinymath/src/functions/count.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { size } = require('./size.js'); + +/** + * Returns the length of an array. Alias for size + * @param {any[]} a array of any values + * @return {(number)} The length of the array. Returns 1 if `a` is not an array. + * @throws `'Must pass an array'` if `a` is not an array + * @example + * count([]) // returns 0 + * count([-1, -2, -3, -4]) // returns 4 + * count(100) // returns 1 + */ + +module.exports = { count }; + +function count(a) { + return size(a); +} + +count.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/functions/cube.js b/packages/kbn-tinymath/src/functions/cube.js new file mode 100644 index 0000000000000..de14ac8749ae1 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/cube.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { pow } = require('./pow.js'); + +/** + * Calculates the cube of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The cube of `a`. Returns an array with the the cubes of each element if `a` is an array. + * + * @example + * cube(-3) // returns -27 + * cube([3, 4, 5]) // returns [27, 64, 125] + */ + +module.exports = { cube }; + +function cube(a) { + return pow(a, 3); +} diff --git a/packages/kbn-tinymath/src/functions/degtorad.js b/packages/kbn-tinymath/src/functions/degtorad.js new file mode 100644 index 0000000000000..20fd8ac9e2060 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/degtorad.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Converts degrees to radians for a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in degrees. + * @return {(number|number[])} The radians of `a`. Returns an array with the the radians of each element if `a` is an array. + * @example + * degtorad(0) // returns 0 + * degtorad(90) // returns 1.5707963267948966 + * degtorad([0, 90, 180, 360]) // returns [0, 1.5707963267948966, 3.141592653589793, 6.283185307179586] + */ + +module.exports = { degtorad }; + +function degtorad(a) { + if (Array.isArray(a)) { + return a.map((a) => (a * Math.PI) / 180); + } + return (a * Math.PI) / 180; +} diff --git a/packages/kbn-tinymath/src/functions/divide.js b/packages/kbn-tinymath/src/functions/divide.js new file mode 100644 index 0000000000000..889e2305cbd9e --- /dev/null +++ b/packages/kbn-tinymath/src/functions/divide.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Divides two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + * @param {(number|number[])} a dividend, a number or an array of numbers + * @param {(number|number[])} b divisor, a number or an array of numbers, `b` != 0 + * @return {(number|number[])} The quotient of `a` and `b` if both are numbers. Returns an array with the quotients applied index-wise to each element if `a` or `b` is an array. + * @throws `'Array length mismatch'` if `a` and `b` are arrays with different lengths + * - `'Cannot divide by 0'` if `b` equals 0 or contains 0 + * @example + * divide(6, 3) // returns 2 + * divide([10, 20, 30, 40], 10) // returns [1, 2, 3, 4] + * divide(10, [1, 2, 5, 10]) // returns [10, 5, 2, 1] + * divide([14, 42, 65, 108], [2, 7, 5, 12]) // returns [7, 6, 13, 9] + */ + +module.exports = { divide }; + +function divide(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) throw new Error('Array length mismatch'); + return a.map((val, i) => { + if (b[i] === 0) throw new Error('Cannot divide by 0'); + return val / b[i]; + }); + } + if (Array.isArray(b)) return b.map((b) => a / b); + if (b === 0) throw new Error('Cannot divide by 0'); + if (Array.isArray(a)) return a.map((a) => a / b); + return a / b; +} diff --git a/packages/kbn-tinymath/src/functions/exp.js b/packages/kbn-tinymath/src/functions/exp.js new file mode 100644 index 0000000000000..d7fd3877001c9 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/exp.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates _e^x_ where _e_ is Euler's number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} `e^a`. Returns an array with the values of `e^x` evaluated where `x` is each element of `a` if `a` is an array. + * + * @example + * exp(2) // returns e^2 = 7.3890560989306495 + * exp([1, 2, 3]) // returns [e^1, e^2, e^3] = [2.718281828459045, 7.3890560989306495, 20.085536923187668] + */ + +module.exports = { exp }; + +function exp(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.exp(a)); + } + return Math.exp(a); +} diff --git a/packages/kbn-tinymath/src/functions/first.js b/packages/kbn-tinymath/src/functions/first.js new file mode 100644 index 0000000000000..911482541b1d3 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/first.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Returns the first element of an array. If anything other than an array is passed in, the input is returned. + * @param {any[]} a array of any values + * @return {*} The first element of `a`. Returns `a` if `a` is not an array. + * + * @example + * first(2) // returns 2 + * first([1, 2, 3]) // returns 1 + */ + +module.exports = { first }; + +function first(a) { + if (Array.isArray(a)) { + return a[0]; + } + return a; +} + +first.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/functions/fix.js b/packages/kbn-tinymath/src/functions/fix.js new file mode 100644 index 0000000000000..16ed2d0dcb54f --- /dev/null +++ b/packages/kbn-tinymath/src/functions/fix.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const fixer = (a) => { + if (a > 0) { + return Math.floor(a); + } + return Math.ceil(a); +}; + +/** + * Calculates the fix of a number, i.e. rounds a number towards 0. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The fix of `a`. Returns an array with the the fixes for each element if `a` is an array. + * + * @example + * fix(1.2) // returns 1 + * fix(-1.8) // returns -1 + * fix([1.8, 2.9, -3.7, -4.6]) // returns [1, 2, -3, -4] + */ + +module.exports = { fix }; + +function fix(a) { + if (Array.isArray(a)) { + return a.map((a) => fixer(a)); + } + return fixer(a); +} diff --git a/packages/kbn-tinymath/src/functions/floor.js b/packages/kbn-tinymath/src/functions/floor.js new file mode 100644 index 0000000000000..db90697edc346 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/floor.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the floor of a number, i.e. rounds a number towards negative infinity. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The floor of `a`. Returns an array with the the floor of each element if `a` is an array. + * + * @example + * floor(1.8) // returns 1 + * floor(-1.2) // returns -2 + * floor([1.7, 2.8, 3.9]) // returns [1, 2, 3] + */ + +module.exports = { floor }; + +function floor(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.floor(a)); + } + return Math.floor(a); +} diff --git a/packages/kbn-tinymath/src/functions/index.js b/packages/kbn-tinymath/src/functions/index.js new file mode 100644 index 0000000000000..ab5805cc0a77e --- /dev/null +++ b/packages/kbn-tinymath/src/functions/index.js @@ -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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { abs } = require('./abs'); +const { add } = require('./add'); +const { cbrt } = require('./cbrt'); +const { ceil } = require('./ceil'); +const { clamp } = require('./clamp'); +const { cos } = require('./cos'); +const { count } = require('./count'); +const { cube } = require('./cube'); +const { degtorad } = require('./degtorad'); +const { divide } = require('./divide'); +const { exp } = require('./exp'); +const { first } = require('./first'); +const { fix } = require('./fix'); +const { floor } = require('./floor'); +const { last } = require('./last'); +const { log } = require('./log'); +const { log10 } = require('./log10'); +const { max } = require('./max'); +const { mean } = require('./mean'); +const { median } = require('./median'); +const { min } = require('./min'); +const { mod } = require('./mod'); +const { mode } = require('./mode'); +const { multiply } = require('./multiply'); +const { pi } = require('./pi'); +const { pow } = require('./pow'); +const { radtodeg } = require('./radtodeg'); +const { random } = require('./random'); +const { range } = require('./range'); +const { round } = require('./round'); +const { sin } = require('./sin'); +const { size } = require('./size'); +const { sqrt } = require('./sqrt'); +const { square } = require('./square'); +const { subtract } = require('./subtract'); +const { sum } = require('./sum'); +const { tan } = require('./tan'); +const { unique } = require('./unique'); + +module.exports = { + functions: { + abs, + add, + cbrt, + ceil, + clamp, + cos, + count, + cube, + degtorad, + divide, + exp, + first, + fix, + floor, + last, + log, + log10, + max, + mean, + median, + min, + mod, + mode, + multiply, + pi, + pow, + radtodeg, + random, + range, + round, + sin, + size, + sqrt, + square, + subtract, + sum, + tan, + unique, + }, +}; diff --git a/packages/kbn-tinymath/src/functions/last.js b/packages/kbn-tinymath/src/functions/last.js new file mode 100644 index 0000000000000..08964c784ba88 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/last.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Returns the last element of an array. If anything other than an array is passed in, the input is returned. + * @param {any[]} a array of any values + * @return {*} The last element of `a`. Returns `a` if `a` is not an array. + * + * @example + * last(2) // returns 2 + * last([1, 2, 3]) // returns 3 + */ + +module.exports = { last }; + +function last(a) { + if (Array.isArray(a)) { + return a[a.length - 1]; + } + return a; +} + +last.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/functions/lib/transpose.js b/packages/kbn-tinymath/src/functions/lib/transpose.js new file mode 100644 index 0000000000000..6a771f4f54336 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/lib/transpose.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Transposes a 2D array, i.e. turns the rows into columns and vice versa. Scalar values are also included in the transpose. + * @param {any[][]} args an array or an array that contains arrays + * @param {number} index index of the first array element in args + * @return {any[][]} transpose of args + * @throws `'Array length mismatch'` if `args` contains arrays of different lengths + * @example + * transpose([[1,2], [3,4], [5,6]], 0) // returns [[1, 3, 5], [2, 4, 6]] + * transpose([10, 20, [10, 20, 30, 40], 30], 2) // returns [[10, 20, 10, 30], [10, 20, 20, 30], [10, 20, 30, 30], [10, 20, 40, 30]] + * transpose([4, [1, 9], [3, 5]], 1) // returns [[4, 1, 3], [4, 9, 5]] + */ + +module.exports = { transpose }; + +function transpose(args, index) { + const len = args[index].length; + return args[index].map((col, i) => + args.map((row) => { + if (Array.isArray(row)) { + if (row.length !== len) throw new Error('Array length mismatch'); + return row[i]; + } + return row; + }) + ); +} diff --git a/packages/kbn-tinymath/src/functions/log.js b/packages/kbn-tinymath/src/functions/log.js new file mode 100644 index 0000000000000..07fb8376438d6 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/log.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const changeOfBase = (a, b) => Math.log(a) / Math.log(b); + +/** + * Calculates the logarithm of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` must be greater than 0 + * @param {{number}} b (optional) base for the logarithm. If not provided a value, the default base is e, and the natural log is calculated. + * @return {(number|number[])} The logarithm of `a`. Returns an array with the the logarithms of each element if `a` is an array. + * @throws `'Base out of range'` if `b` <= 0 + * - 'Must be greater than 0' if `a` > 0 + * @example + * log(1) // returns 0 + * log(64, 8) // returns 2 + * log(42, 5) // returns 2.322344707681546 + * log([2, 4, 8, 16, 32], 2) // returns [1, 2, 3, 4, 5] + */ + +module.exports = { log }; + +function log(a, b = Math.E) { + if (b <= 0) throw new Error('Base out of range'); + + if (Array.isArray(a)) { + return a.map((a) => { + if (a < 0) throw new Error('Must be greater than 0'); + return changeOfBase(a, b); + }); + } + if (a < 0) throw new Error('Must be greater than 0'); + return changeOfBase(a, b); +} diff --git a/packages/kbn-tinymath/src/functions/log10.js b/packages/kbn-tinymath/src/functions/log10.js new file mode 100644 index 0000000000000..79417031d5ed8 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/log10.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { log } = require('./log.js'); + +/** + * Calculates the logarithm base 10 of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` must be greater than 0 + * @return {(number|number[])} The logarithm of `a`. Returns an array with the the logarithms base 10 of each element if `a` is an array. + * @throws `'Must be greater than 0'` if `a` < 0 + * @example + * log(10) // returns 1 + * log(100) // returns 2 + * log(80) // returns 1.9030899869919433 + * log([10, 100, 1000, 10000, 100000]) // returns [1, 2, 3, 4, 5] + */ + +module.exports = { log10 }; + +function log10(a) { + return log(a, 10); +} diff --git a/packages/kbn-tinymath/src/functions/max.js b/packages/kbn-tinymath/src/functions/max.js new file mode 100644 index 0000000000000..13cebbfdf662a --- /dev/null +++ b/packages/kbn-tinymath/src/functions/max.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Finds the maximum value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the maximum by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The maximum value of all numbers if `args` contains only numbers. Returns an array with the the maximum values at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * @throws `'Array length mismatch'` if `args` contains arrays of different lengths + * @example + * max(1, 2, 3) // returns 3 + * max([10, 20, 30, 40], 15) // returns [15, 20, 30, 40] + * max([1, 9], 4, [3, 5]) // returns [max([1, 4, 3]), max([9, 4, 5])] = [4, 9] + */ + +module.exports = { max }; + +function max(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) + return args[0].reduce((result, current) => Math.max(result, current)); + return args[0]; + } + + return args.reduce((result, current) => { + if (Array.isArray(result) && Array.isArray(current)) { + if (current.length !== result.length) throw new Error('Array length mismatch'); + return result.map((val, i) => Math.max(val, current[i])); + } + if (Array.isArray(result)) return result.map((val) => Math.max(val, current)); + if (Array.isArray(current)) return current.map((val) => Math.max(val, result)); + return Math.max(result, current); + }); +} diff --git a/packages/kbn-tinymath/src/functions/mean.js b/packages/kbn-tinymath/src/functions/mean.js new file mode 100644 index 0000000000000..ee37d77b10e71 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/mean.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { add } = require('./add.js'); + +/** + * Finds the mean value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the mean by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The mean value of all numbers if `args` contains only numbers. Returns an array with the the mean values of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * + * @example + * mean(1, 2, 3) // returns 2 + * mean([10, 20, 30, 40], 20) // returns [15, 20, 25, 30] + * mean([1, 9], 5, [3, 4]) // returns [mean([1, 5, 3]), mean([9, 5, 4])] = [3, 6] + */ + +module.exports = { mean }; + +function mean(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) return add(args[0]) / args[0].length; + return args[0]; + } + const sum = add(...args); + + if (Array.isArray(sum)) { + return sum.map((val) => val / args.length); + } + + return sum / args.length; +} diff --git a/packages/kbn-tinymath/src/functions/median.js b/packages/kbn-tinymath/src/functions/median.js new file mode 100644 index 0000000000000..6f1e3cd4972e5 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/median.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { transpose } = require('./lib/transpose'); + +const findMedian = (a) => { + const len = a.length; + const half = Math.floor(len / 2); + + a.sort((a, b) => b - a); + + if (len % 2 === 0) { + return (a[half] + a[half - 1]) / 2; + } + + return a[half]; +}; + +/** + * Finds the median value(s) of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the median by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The median value of all numbers if `args` contains only numbers. Returns an array with the the median values of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * + * @example + * median(1, 1, 2, 3) // returns 1.5 + * median(1, 1, 2, 2, 3) // returns 2 + * median([10, 20, 30, 40], 10, 20, 30) // returns [15, 20, 25, 25] + * median([1, 9], 2, 4, [3, 5]) // returns [median([1, 2, 4, 3]), median([9, 2, 4, 5])] = [2.5, 4.5] + */ + +module.exports = { median }; + +function median(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) return findMedian(args[0]); + return args[0]; + } + + const firstArray = args.findIndex((element) => Array.isArray(element)); + if (firstArray !== -1) { + const result = transpose(args, firstArray); + return result.map((val) => findMedian(val)); + } + return findMedian(args); +} diff --git a/packages/kbn-tinymath/src/functions/min.js b/packages/kbn-tinymath/src/functions/min.js new file mode 100644 index 0000000000000..44509bedfd088 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/min.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Finds the minimum value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the minimum by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The minimum value of all numbers if `args` contains only numbers. Returns an array with the the minimum values of each index, including all scalar numbers in `args` in the calculation at each index if `a` is an array. + * @throws `'Array length mismatch'` if `args` contains arrays of different lengths + * @example + * min(1, 2, 3) // returns 1 + * min([10, 20, 30, 40], 25) // returns [10, 20, 25, 25] + * min([1, 9], 4, [3, 5]) // returns [min([1, 4, 3]), min([9, 4, 5])] = [1, 4] + */ + +module.exports = { min }; + +function min(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) + return args[0].reduce((result, current) => Math.min(result, current)); + return args[0]; + } + + return args.reduce((result, current) => { + if (Array.isArray(result) && Array.isArray(current)) { + if (current.length !== result.length) throw new Error('Array length mismatch'); + return result.map((val, i) => Math.min(val, current[i])); + } + if (Array.isArray(result)) return result.map((val) => Math.min(val, current)); + if (Array.isArray(current)) return current.map((val) => Math.min(val, result)); + return Math.min(result, current); + }); +} diff --git a/packages/kbn-tinymath/src/functions/mod.js b/packages/kbn-tinymath/src/functions/mod.js new file mode 100644 index 0000000000000..93c23077a9d69 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/mod.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Remainder after dividing two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + * @param {(number|number[])} a dividend, a number or an array of numbers + * @param {(number|number[])} b divisor, a number or an array of numbers, `b` != 0 + * @return {(number|number[])} The remainder of `a` divided by `b` if both are numbers. Returns an array with the the remainders applied index-wise to each element if `a` or `b` is an array. + * @throws `'Array length mismatch'` if `a` and `b` are arrays with different lengths + * - `'Cannot divide by 0'` if `b` equals 0 or contains 0 + * @example + * mod(10, 7) // returns 3 + * mod([11, 22, 33, 44], 10) // returns [1, 2, 3, 4] + * mod(100, [3, 7, 11, 23]) // returns [1, 2, 1, 8] + * mod([14, 42, 65, 108], [5, 4, 14, 2]) // returns [5, 2, 9, 0] + */ + +module.exports = { mod }; + +function mod(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) throw new Error('Array length mismatch'); + return a.map((val, i) => { + if (b[i] === 0) throw new Error('Cannot divide by 0'); + return val % b[i]; + }); + } + if (Array.isArray(b)) return b.map((b) => a % b); + if (b === 0) throw new Error('Cannot divide by 0'); + if (Array.isArray(a)) return a.map((a) => a % b); + return a % b; +} diff --git a/packages/kbn-tinymath/src/functions/mode.js b/packages/kbn-tinymath/src/functions/mode.js new file mode 100644 index 0000000000000..4c7d8414602df --- /dev/null +++ b/packages/kbn-tinymath/src/functions/mode.js @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { transpose } = require('./lib/transpose'); + +const findMode = (a) => { + let maxFreq = 0; + const mapping = {}; + + a.map((val) => { + if (mapping[val] === undefined) { + mapping[val] = 0; + } + mapping[val] += 1; + if (mapping[val] > maxFreq) { + maxFreq = mapping[val]; + } + }); + + return Object.keys(mapping) + .filter((key) => mapping[key] === maxFreq) + .map((val) => parseFloat(val)) + .sort((a, b) => a - b); +}; + +/** + * Finds the mode value(s) of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the mode by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number[]|number[][])} An array mode value(s) of all numbers if `args` contains only numbers. Returns an array of arrays with mode value(s) of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * + * @example + * mode(1, 1, 2, 3) // returns [1] + * mode(1, 1, 2, 2, 3) // returns [1,2] + * mode([10, 20, 30, 40], 10, 20, 30) // returns [[10], [20], [30], [10, 20, 30, 40]] + * mode([1, 9], 1, 4, [3, 5]) // returns [mode([1, 1, 4, 3]), mode([9, 1, 4, 5])] = [[1], [4, 5, 9]] + */ + +module.exports = { mode }; + +function mode(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) return findMode(args[0]); + return args[0]; + } + + const firstArray = args.findIndex((element) => Array.isArray(element)); + if (firstArray !== -1) { + const result = transpose(args, firstArray); + return result.map((val) => findMode(val)); + } + return findMode(args); +} diff --git a/packages/kbn-tinymath/src/functions/multiply.js b/packages/kbn-tinymath/src/functions/multiply.js new file mode 100644 index 0000000000000..6334b510e550b --- /dev/null +++ b/packages/kbn-tinymath/src/functions/multiply.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Multiplies two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @param {(number|number[])} b a number or an array of numbers + * @return {(number|number[])} The product of `a` and `b` if both are numbers. Returns an array with the the products applied index-wise to each element if `a` or `b` is an array. + * @throws `'Array length mismatch'` if `a` and `b` are arrays with different lengths + * @example + * multiply(6, 3) // returns 18 + * multiply([10, 20, 30, 40], 10) // returns [100, 200, 300, 400] + * multiply(10, [1, 2, 5, 10]) // returns [10, 20, 50, 100] + * multiply([1, 2, 3, 4], [2, 7, 5, 12]) // returns [2, 14, 15, 48] + */ + +module.exports = { multiply }; + +function multiply(...args) { + return args.reduce((result, current) => { + if (Array.isArray(result) && Array.isArray(current)) { + if (current.length !== result.length) throw new Error('Array length mismatch'); + return result.map((val, i) => val * current[i]); + } + if (Array.isArray(result)) return result.map((val) => val * current); + if (Array.isArray(current)) return current.map((val) => val * result); + return result * current; + }); +} diff --git a/packages/kbn-tinymath/src/functions/pi.js b/packages/kbn-tinymath/src/functions/pi.js new file mode 100644 index 0000000000000..5dd625cf7f0d3 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/pi.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Returns the mathematical constant PI + * @return {(number)} The mathematical constant PI + * + * @example + * pi() // 3.141592653589793 + */ + +module.exports = { pi }; + +function pi() { + return Math.PI; +} diff --git a/packages/kbn-tinymath/src/functions/pow.js b/packages/kbn-tinymath/src/functions/pow.js new file mode 100644 index 0000000000000..b44b9679fc7f8 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/pow.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Raises a number to a given exponent. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @param {(number)} b the power that `a` is raised to + * @return {(number|number[])} `a` raised to the power of `b`. Returns an array with the each element raised to the power of `b` if `a` is an array. + * @throws `'Missing exponent'` if `b` is not provided + * @example + * pow(2,3) // returns 8 + * pow([1, 2, 3], 4) // returns [1, 16, 81] + */ + +module.exports = { pow }; + +function pow(a, b) { + if (b == null) throw new Error('Missing exponent'); + if (Array.isArray(a)) { + return a.map((a) => Math.pow(a, b)); + } + return Math.pow(a, b); +} diff --git a/packages/kbn-tinymath/src/functions/radtodeg.js b/packages/kbn-tinymath/src/functions/radtodeg.js new file mode 100644 index 0000000000000..51f911e2dcad0 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/radtodeg.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Converts radians to degrees for a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in radians. + * @return {(number|number[])} The degrees of `a`. Returns an array with the the degrees of each element if `a` is an array. + * @example + * radtodeg(0) // returns 0 + * radtodeg(1.5707963267948966) // returns 90 + * radtodeg([0, 1.5707963267948966, 3.141592653589793, 6.283185307179586]) // returns [0, 90, 180, 360] + */ + +module.exports = { radtodeg }; + +function radtodeg(a) { + if (Array.isArray(a)) { + return a.map((a) => (a * 180) / Math.PI); + } + return (a * 180) / Math.PI; +} diff --git a/packages/kbn-tinymath/src/functions/random.js b/packages/kbn-tinymath/src/functions/random.js new file mode 100644 index 0000000000000..ffe5c3a9cb8e2 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/random.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Generates a random number within the given range where the lower bound is inclusive and the upper bound is exclusive. If no numbers are passed in, it will return a number between 0 and 1. If only one number is passed in, it will return . + * @param {number} a (optional) must be greater than 0 if `b` is not provided + * @param {number} b (optional) must be greater + * @return {number} A random number between 0 and 1 if no numbers are passed in. Returns a random number between 0 and `a` if only one number is passed in. Returns a random number between `a` and `b` if two numbers are passed in. + * @throws `'Min is be greater than max'` if `a` < 0 when only `a` is passed in or if `a` > `b` when both `a` and `b` are passed in + * @example + * random() // returns a random number between 0 (inclusive) and 1 (exclusive) + * random(10) // returns a random number between 0 (inclusive) and 10 (exclusive) + * random(-10,10) // returns a random number between -10 (inclusive) and 10 (exclusive) + */ + +module.exports = { random }; + +function random(a, b) { + if (a == null) return Math.random(); + + // a: max, generate random number between 0 and a + if (b == null) { + if (a < 0) throw new Error(`Min is greater than max`); + return Math.random() * a; + } + + // a: min, b: max, generate random number between a and b + if (a > b) throw new Error(`Min is greater than max`); + return Math.random() * (b - a) + a; +} diff --git a/packages/kbn-tinymath/src/functions/range.js b/packages/kbn-tinymath/src/functions/range.js new file mode 100644 index 0000000000000..31f9b618bb1db --- /dev/null +++ b/packages/kbn-tinymath/src/functions/range.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { max } = require('./max.js'); +const { min } = require('./min.js'); +const { subtract } = require('./subtract.js'); + +/** + * Finds the range of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the range by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The range value of all numbers if `args` contains only numbers. Returns an array with the the range values at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * + * @example + * range(1, 2, 3) // returns 2 + * range([10, 20, 30, 40], 15) // returns [5, 5, 15, 25] + * range([1, 9], 4, [3, 5]) // returns [range([1, 4, 3]), range([9, 4, 5])] = [3, 5] + */ + +module.exports = { range }; + +function range(...args) { + return subtract(max(...args), min(...args)); +} diff --git a/packages/kbn-tinymath/src/functions/round.js b/packages/kbn-tinymath/src/functions/round.js new file mode 100644 index 0000000000000..1e8847e6dfd2b --- /dev/null +++ b/packages/kbn-tinymath/src/functions/round.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const rounder = (a, b) => Math.round(a * Math.pow(10, b)) / Math.pow(10, b); + +/** + * Rounds a number towards the nearest integer by default or decimal place if specified. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @param {(number)} b (optional) number of decimal places, default value: 0 + * @return {(number|number[])} The rounded value of `a`. Returns an array with the the rounded values of each element if `a` is an array. + * + * @example + * round(1.2) // returns 2 + * round(-10.51) // returns -11 + * round(-10.1, 2) // returns -10.1 + * round(10.93745987, 4) // returns 10.9375 + * round([2.9234, 5.1234, 3.5234, 4.49234324], 2) // returns [2.92, 5.12, 3.52, 4.49] + */ + +module.exports = { round }; + +function round(a, b = 0) { + if (Array.isArray(a)) { + return a.map((a) => rounder(a, b)); + } + return rounder(a, b); +} diff --git a/packages/kbn-tinymath/src/functions/sin.js b/packages/kbn-tinymath/src/functions/sin.js new file mode 100644 index 0000000000000..f08ffa8bdc197 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/sin.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the the sine of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in radians. + * @return {(number|number[])} The sine of `a`. Returns an array with the the sine of each element if `a` is an array. + * @example + * sin(0) // returns 0 + * sin(1.5707963267948966) // returns 1 + * sin([0, 1.5707963267948966]) // returns [0, 1] + */ + +module.exports = { sin }; + +function sin(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.sin(a)); + } + return Math.sin(a); +} diff --git a/packages/kbn-tinymath/src/functions/size.js b/packages/kbn-tinymath/src/functions/size.js new file mode 100644 index 0000000000000..5156a70b38d69 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/size.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Returns the length of an array. Alias for count + * @param {any[]} a array of any values + * @return {(number)} The length of the array. Returns 1 if `a` is not an array. + * @throws `'Must pass an array'` if `a` is not an array + * @example + * size([]) // returns 0 + * size([-1, -2, -3, -4]) // returns 4 + * size(100) // returns 1 + */ + +module.exports = { size }; + +function size(a) { + if (Array.isArray(a)) return a.length; + throw new Error('Must pass an array'); +} + +size.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/functions/sqrt.js b/packages/kbn-tinymath/src/functions/sqrt.js new file mode 100644 index 0000000000000..2c55b2256e0f6 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/sqrt.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the square root of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The square root of `a`. Returns an array with the the square roots of each element if `a` is an array. + * @throws `'Unable find the square root of a negative number'` if `a` < 0 + * @example + * sqrt(9) // returns 3 + * sqrt(30) //5.477225575051661 + * sqrt([9, 16, 25]) // returns [3, 4, 5] + */ + +module.exports = { sqrt }; + +function sqrt(a) { + if (Array.isArray(a)) { + return a.map((a) => { + if (a < 0) throw new Error('Unable find the square root of a negative number'); + return Math.sqrt(a); + }); + } + + if (a < 0) throw new Error('Unable find the square root of a negative number'); + return Math.sqrt(a); +} diff --git a/packages/kbn-tinymath/src/functions/square.js b/packages/kbn-tinymath/src/functions/square.js new file mode 100644 index 0000000000000..a5bccdef7661b --- /dev/null +++ b/packages/kbn-tinymath/src/functions/square.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { pow } = require('./pow.js'); + +/** + * Calculates the square of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The square of `a`. Returns an array with the the squares of each element if `a` is an array. + * + * @example + * square(-3) // returns 9 + * square([3, 4, 5]) // returns [9, 16, 25] + */ + +module.exports = { square }; + +function square(a) { + return pow(a, 2); +} diff --git a/packages/kbn-tinymath/src/functions/subtract.js b/packages/kbn-tinymath/src/functions/subtract.js new file mode 100644 index 0000000000000..8e5fd256bf158 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/subtract.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Subtracts two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @param {(number|number[])} b a number or an array of numbers + * @return {(number|number[])} The difference of `a` and `b` if both are numbers or an array of differences applied index-wise to each element. + * @throws `'Array length mismatch'` if `a` and `b` are arrays with different lengths + * @example + * subtract(6, 3) // returns 3 + * subtract([10, 20, 30, 40], 10) // returns [0, 10, 20, 30] + * subtract(10, [1, 2, 5, 10]) // returns [9, 8, 5, 0] + * subtract([14, 42, 65, 108], [2, 7, 5, 12]) // returns [12, 35, 52, 96] + */ + +module.exports = { subtract }; + +function subtract(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) throw new Error('Array length mismatch'); + return a.map((val, i) => val - b[i]); + } + if (Array.isArray(a)) return a.map((a) => a - b); + if (Array.isArray(b)) return b.map((b) => a - b); + return a - b; +} diff --git a/packages/kbn-tinymath/src/functions/sum.js b/packages/kbn-tinymath/src/functions/sum.js new file mode 100644 index 0000000000000..b13a86d5c2122 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/sum.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const findSum = (total, current) => total + current; + +/** + * Calculates the sum of one or more numbers/arrays passed into the function. If at least one array is passed, the function will sum up one or more numbers/arrays of numbers and distinct values of an array. Sum accepts arrays of different lengths. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {number} The sum of one or more numbers/arrays of numbers including distinct values in arrays + * + * @example + * sum(1, 2, 3) // returns 6 + * sum([10, 20, 30, 40], 10, 20, 30) // returns 160 + * sum([1, 2], 3, [4, 5], 6) // returns sum(1, 2, 3, 4, 5, 6) = 21 + * sum([10, 20, 30, 40], 10, [1, 2, 3], 22) // returns sum(10, 20, 30, 40, 10, 1, 2, 3, 22) = 138 + */ + +module.exports = { sum }; + +function sum(...args) { + return args.reduce((total, current) => { + if (Array.isArray(current)) { + return total + current.reduce(findSum, 0); + } + return total + current; + }, 0); +} diff --git a/packages/kbn-tinymath/src/functions/tan.js b/packages/kbn-tinymath/src/functions/tan.js new file mode 100644 index 0000000000000..56ea4c35f1459 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/tan.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the the tangent of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in radians. + * @return {(number|number[])} The tangent of `a`. Returns an array with the the tangent of each element if `a` is an array. + * @example + * tan(0) // returns 0 + * tan(1) // returns 1.5574077246549023 + * tan([0, 1, -1]) // returns [0, 1.5574077246549023, -1.5574077246549023] + */ + +module.exports = { tan }; + +function tan(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.tan(a)); + } + return Math.tan(a); +} diff --git a/packages/kbn-tinymath/src/functions/unique.js b/packages/kbn-tinymath/src/functions/unique.js new file mode 100644 index 0000000000000..60196e8568855 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/unique.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Counts the number of unique values in an array + * @param {any[]} a array of any values + * @return {number} The number of unique values in the array. Returns 1 if `a` is not an array. + * + * @example + * unique(100) // returns 1 + * unique([]) // returns 0 + * unique([1, 2, 3, 4]) // returns 4 + * unique([1, 2, 3, 4, 2, 2, 2, 3, 4, 2, 4, 5, 2, 1, 4, 2]) // returns 5 + */ + +module.exports = { unique }; + +function unique(a) { + if (Array.isArray(a)) { + return a.filter((val, i) => a.indexOf(val) === i).length; + } + return 1; +} + +unique.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/grammar.js b/packages/kbn-tinymath/src/grammar.js new file mode 100644 index 0000000000000..60dfcf4800631 --- /dev/null +++ b/packages/kbn-tinymath/src/grammar.js @@ -0,0 +1,1385 @@ +/* + * Generated by PEG.js 0.10.0. + * + * http://pegjs.org/ + */ + +"use strict"; + +function peg$subclass(child, parent) { + function ctor() { this.constructor = child; } + ctor.prototype = parent.prototype; + child.prototype = new ctor(); +} + +function peg$SyntaxError(message, expected, found, location) { + this.message = message; + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + + if (typeof Error.captureStackTrace === "function") { + Error.captureStackTrace(this, peg$SyntaxError); + } +} + +peg$subclass(peg$SyntaxError, Error); + +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + "class": function(expectation) { + var escapedParts = "", + i; + + for (i = 0; i < expectation.parts.length; i++) { + escapedParts += expectation.parts[i] instanceof Array + ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1]) + : classEscape(expectation.parts[i]); + } + + return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]"; + }, + + any: function(expectation) { + return "any character"; + }, + + end: function(expectation) { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; + } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } + + function literalEscape(s) { + return s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\0/g, '\\0') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, '\\\\') + .replace(/\]/g, '\\]') + .replace(/\^/g, '\\^') + .replace(/-/g, '\\-') + .replace(/\0/g, '\\0') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = new Array(expected.length), + i, j; + + for (i = 0; i < expected.length; i++) { + descriptions[i] = describeExpectation(expected[i]); + } + + descriptions.sort(); + + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; + +function peg$parse(input, options) { + options = options !== void 0 ? options : {}; + + var peg$FAILED = {}, + + peg$startRuleFunctions = { start: peg$parsestart }, + peg$startRuleFunction = peg$parsestart, + + peg$c0 = peg$otherExpectation("whitespace"), + peg$c1 = /^[ \t\n\r]/, + peg$c2 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false), + peg$c3 = /^[ ]/, + peg$c4 = peg$classExpectation([" "], false, false), + peg$c5 = /^["']/, + peg$c6 = peg$classExpectation(["\"", "'"], false, false), + peg$c7 = /^[A-Za-z_@.[\]\-]/, + peg$c8 = peg$classExpectation([["A", "Z"], ["a", "z"], "_", "@", ".", "[", "]", "-"], false, false), + peg$c9 = /^[0-9A-Za-z._@[\]\-]/, + peg$c10 = peg$classExpectation([["0", "9"], ["A", "Z"], ["a", "z"], ".", "_", "@", "[", "]", "-"], false, false), + peg$c11 = peg$otherExpectation("literal"), + peg$c12 = function(literal) { + return literal; + }, + peg$c13 = function(first, rest) { // We can open this up later. Strict for now. + return first + rest.join(''); + }, + peg$c14 = function(first, mid) { + return first + mid.map(m => m[0].join('') + m[1].join('')).join('') + }, + peg$c15 = "+", + peg$c16 = peg$literalExpectation("+", false), + peg$c17 = "-", + peg$c18 = peg$literalExpectation("-", false), + peg$c19 = function(left, rest) { + return rest.reduce((acc, curr) => ({ + name: curr[0] === '+' ? 'add' : 'subtract', + args: [acc, curr[1]] + }), left) + }, + peg$c20 = "*", + peg$c21 = peg$literalExpectation("*", false), + peg$c22 = "/", + peg$c23 = peg$literalExpectation("/", false), + peg$c24 = function(left, rest) { + return rest.reduce((acc, curr) => ({ + name: curr[0] === '*' ? 'multiply' : 'divide', + args: [acc, curr[1]] + }), left) + }, + peg$c25 = "(", + peg$c26 = peg$literalExpectation("(", false), + peg$c27 = ")", + peg$c28 = peg$literalExpectation(")", false), + peg$c29 = function(expr) { + return expr + }, + peg$c30 = peg$otherExpectation("arguments"), + peg$c31 = ",", + peg$c32 = peg$literalExpectation(",", false), + peg$c33 = function(first, arg) {return arg}, + peg$c34 = function(first, rest) { + return [first].concat(rest); + }, + peg$c35 = peg$otherExpectation("function"), + peg$c36 = /^[a-z]/, + peg$c37 = peg$classExpectation([["a", "z"]], false, false), + peg$c38 = function(name, args) { + return {name: name.join(''), args: args || []}; + }, + peg$c39 = peg$otherExpectation("number"), + peg$c40 = function() { return parseFloat(text()); }, + peg$c41 = /^[eE]/, + peg$c42 = peg$classExpectation(["e", "E"], false, false), + peg$c43 = peg$otherExpectation("exponent"), + peg$c44 = ".", + peg$c45 = peg$literalExpectation(".", false), + peg$c46 = "0", + peg$c47 = peg$literalExpectation("0", false), + peg$c48 = /^[1-9]/, + peg$c49 = peg$classExpectation([["1", "9"]], false, false), + peg$c50 = /^[0-9]/, + peg$c51 = peg$classExpectation([["0", "9"]], false, false), + + peg$currPos = 0, + peg$savedPos = 0, + peg$posDetailsCache = [{ line: 1, column: 1 }], + peg$maxFailPos = 0, + peg$maxFailExpected = [], + peg$silentFails = 0, + + peg$result; + + if ("startRule" in options) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) + + throw peg$buildSimpleError(message, location); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text: text, ignoreCase: ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description: description }; + } + + function peg$computePosDetails(pos) { + var details = peg$posDetailsCache[pos], p; + + if (details) { + return details; + } else { + p = pos - 1; + while (!peg$posDetailsCache[p]) { + p--; + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + return details; + } + } + + function peg$computeLocation(startPos, endPos) { + var startPosDetails = peg$computePosDetails(startPos), + endPosDetails = peg$computePosDetails(endPos); + + return { + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column + } + }; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsestart() { + var s0; + + s0 = peg$parseAddSubtract(); + + return s0; + } + + function peg$parse_() { + var s0, s1; + + peg$silentFails++; + s0 = []; + if (peg$c1.test(input.charAt(peg$currPos))) { + s1 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c2); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + if (peg$c1.test(input.charAt(peg$currPos))) { + s1 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c2); } + } + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c0); } + } + + return s0; + } + + function peg$parseSpace() { + var s0; + + if (peg$c3.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c4); } + } + + return s0; + } + + function peg$parseQuote() { + var s0; + + if (peg$c5.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c6); } + } + + return s0; + } + + function peg$parseStartChar() { + var s0; + + if (peg$c7.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c8); } + } + + return s0; + } + + function peg$parseValidChar() { + var s0; + + if (peg$c9.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c10); } + } + + return s0; + } + + function peg$parseLiteral() { + var s0, s1, s2, s3; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseNumber(); + if (s2 === peg$FAILED) { + s2 = peg$parseVariableWithQuote(); + if (s2 === peg$FAILED) { + s2 = peg$parseVariable(); + } + } + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c12(s2); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c11); } + } + + return s0; + } + + function peg$parseVariable() { + var s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseStartChar(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$parseValidChar(); + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$parseValidChar(); + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c13(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseVariableWithQuote() { + var s0, s1, s2, s3, s4, s5, s6, s7, s8; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseQuote(); + if (s2 !== peg$FAILED) { + s3 = peg$parseStartChar(); + if (s3 !== peg$FAILED) { + s4 = []; + s5 = peg$currPos; + s6 = []; + s7 = peg$parseSpace(); + while (s7 !== peg$FAILED) { + s6.push(s7); + s7 = peg$parseSpace(); + } + if (s6 !== peg$FAILED) { + s7 = []; + s8 = peg$parseValidChar(); + if (s8 !== peg$FAILED) { + while (s8 !== peg$FAILED) { + s7.push(s8); + s8 = peg$parseValidChar(); + } + } else { + s7 = peg$FAILED; + } + if (s7 !== peg$FAILED) { + s6 = [s6, s7]; + s5 = s6; + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + while (s5 !== peg$FAILED) { + s4.push(s5); + s5 = peg$currPos; + s6 = []; + s7 = peg$parseSpace(); + while (s7 !== peg$FAILED) { + s6.push(s7); + s7 = peg$parseSpace(); + } + if (s6 !== peg$FAILED) { + s7 = []; + s8 = peg$parseValidChar(); + if (s8 !== peg$FAILED) { + while (s8 !== peg$FAILED) { + s7.push(s8); + s8 = peg$parseValidChar(); + } + } else { + s7 = peg$FAILED; + } + if (s7 !== peg$FAILED) { + s6 = [s6, s7]; + s5 = s6; + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + } + if (s4 !== peg$FAILED) { + s5 = peg$parseQuote(); + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c14(s3, s4); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseAddSubtract() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseMultiplyDivide(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 43) { + s5 = peg$c15; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c16); } + } + if (s5 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 45) { + s5 = peg$c17; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c18); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parseMultiplyDivide(); + if (s6 !== peg$FAILED) { + s5 = [s5, s6]; + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 43) { + s5 = peg$c15; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c16); } + } + if (s5 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 45) { + s5 = peg$c17; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c18); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parseMultiplyDivide(); + if (s6 !== peg$FAILED) { + s5 = [s5, s6]; + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c19(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseMultiplyDivide() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseFactor(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 42) { + s5 = peg$c20; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c21); } + } + if (s5 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 47) { + s5 = peg$c22; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c23); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parseFactor(); + if (s6 !== peg$FAILED) { + s5 = [s5, s6]; + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 42) { + s5 = peg$c20; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c21); } + } + if (s5 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 47) { + s5 = peg$c22; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c23); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parseFactor(); + if (s6 !== peg$FAILED) { + s5 = [s5, s6]; + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c24(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseFactor() { + var s0; + + s0 = peg$parseGroup(); + if (s0 === peg$FAILED) { + s0 = peg$parseFunction(); + if (s0 === peg$FAILED) { + s0 = peg$parseLiteral(); + } + } + + return s0; + } + + function peg$parseGroup() { + var s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 40) { + s2 = peg$c25; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c26); } + } + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + if (s3 !== peg$FAILED) { + s4 = peg$parseAddSubtract(); + if (s4 !== peg$FAILED) { + s5 = peg$parse_(); + if (s5 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 41) { + s6 = peg$c27; + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c28); } + } + if (s6 !== peg$FAILED) { + s7 = peg$parse_(); + if (s7 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c29(s4); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseArguments() { + var s0, s1, s2, s3, s4, s5, s6, s7, s8; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseAddSubtract(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$currPos; + s5 = peg$parse_(); + if (s5 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 44) { + s6 = peg$c31; + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s6 !== peg$FAILED) { + s7 = peg$parse_(); + if (s7 !== peg$FAILED) { + s8 = peg$parseAddSubtract(); + if (s8 !== peg$FAILED) { + peg$savedPos = s4; + s5 = peg$c33(s2, s8); + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$currPos; + s5 = peg$parse_(); + if (s5 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 44) { + s6 = peg$c31; + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s6 !== peg$FAILED) { + s7 = peg$parse_(); + if (s7 !== peg$FAILED) { + s8 = peg$parseAddSubtract(); + if (s8 !== peg$FAILED) { + peg$savedPos = s4; + s5 = peg$c33(s2, s8); + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 44) { + s5 = peg$c31; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s5 === peg$FAILED) { + s5 = null; + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c34(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c30); } + } + + return s0; + } + + function peg$parseFunction() { + var s0, s1, s2, s3, s4, s5, s6, s7, s8; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = []; + if (peg$c36.test(input.charAt(peg$currPos))) { + s3 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c37); } + } + if (s3 !== peg$FAILED) { + while (s3 !== peg$FAILED) { + s2.push(s3); + if (peg$c36.test(input.charAt(peg$currPos))) { + s3 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c37); } + } + } + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 40) { + s3 = peg$c25; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c26); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + s5 = peg$parseArguments(); + if (s5 === peg$FAILED) { + s5 = null; + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + if (s6 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 41) { + s7 = peg$c27; + peg$currPos++; + } else { + s7 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c28); } + } + if (s7 !== peg$FAILED) { + s8 = peg$parse_(); + if (s8 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c38(s2, s5); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c35); } + } + + return s0; + } + + function peg$parseNumber() { + var s0, s1, s2, s3, s4; + + peg$silentFails++; + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 45) { + s1 = peg$c17; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c18); } + } + if (s1 === peg$FAILED) { + s1 = null; + } + if (s1 !== peg$FAILED) { + s2 = peg$parseInteger(); + if (s2 !== peg$FAILED) { + s3 = peg$parseFraction(); + if (s3 === peg$FAILED) { + s3 = null; + } + if (s3 !== peg$FAILED) { + s4 = peg$parseExp(); + if (s4 === peg$FAILED) { + s4 = null; + } + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c40(); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c39); } + } + + return s0; + } + + function peg$parseE() { + var s0; + + if (peg$c41.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c42); } + } + + return s0; + } + + function peg$parseExp() { + var s0, s1, s2, s3, s4; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parseE(); + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 45) { + s2 = peg$c17; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c18); } + } + if (s2 === peg$FAILED) { + s2 = null; + } + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$parseDigit(); + if (s4 !== peg$FAILED) { + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$parseDigit(); + } + } else { + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s1 = [s1, s2, s3]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c43); } + } + + return s0; + } + + function peg$parseFraction() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 46) { + s1 = peg$c44; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c45); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parseDigit(); + if (s3 !== peg$FAILED) { + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parseDigit(); + } + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s1 = [s1, s2]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseInteger() { + var s0, s1, s2, s3; + + if (input.charCodeAt(peg$currPos) === 48) { + s0 = peg$c46; + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c47); } + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (peg$c48.test(input.charAt(peg$currPos))) { + s1 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c49); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parseDigit(); + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parseDigit(); + } + if (s2 !== peg$FAILED) { + s1 = [s1, s2]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + + return s0; + } + + function peg$parseDigit() { + var s0; + + if (peg$c50.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c51); } + } + + return s0; + } + + peg$result = peg$startRuleFunction(); + + if (peg$result !== peg$FAILED && peg$currPos === input.length) { + return peg$result; + } else { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } +} + +module.exports = { + SyntaxError: peg$SyntaxError, + parse: peg$parse +}; diff --git a/packages/kbn-tinymath/src/grammar.pegjs b/packages/kbn-tinymath/src/grammar.pegjs new file mode 100644 index 0000000000000..cab8e024e60b3 --- /dev/null +++ b/packages/kbn-tinymath/src/grammar.pegjs @@ -0,0 +1,100 @@ +// tinymath parsing grammar + +start + = Expression + +// characters + +_ "whitespace" + = [ \t\n\r]* + +Space + = [ ] + +Quote + = [\"\'] + +StartChar + = [A-Za-z_@.\[\]-] + +ValidChar + = [0-9A-Za-z._@\[\]-] + +// literals and variables + +Literal "literal" + = _ literal:(Number / VariableWithQuote / Variable) _ { + return literal; + } + +Variable + = _ first:StartChar rest:ValidChar* _ { // We can open this up later. Strict for now. + return first + rest.join(''); + } + +VariableWithQuote + = _ Quote first:StartChar mid:(Space* ValidChar+)* Quote _ { + return first + mid.map(m => m[0].join('') + m[1].join('')).join('') + } + +// expressions + +Expression + = AddSubtract + +AddSubtract + = _ left:MultiplyDivide rest:(('+' / '-') MultiplyDivide)* _ { + return rest.reduce((acc, curr) => ({ + name: curr[0] === '+' ? 'add' : 'subtract', + args: [acc, curr[1]] + }), left) + } + +MultiplyDivide + = _ left:Factor rest:(('*' / '/') Factor)* _ { + return rest.reduce((acc, curr) => ({ + name: curr[0] === '*' ? 'multiply' : 'divide', + args: [acc, curr[1]] + }), left) + } + +Factor + = Group + / Function + / Literal + +Group + = _ '(' _ expr:Expression _ ')' _ { + return expr + } + +Arguments "arguments" + = _ first:Expression rest:(_ ',' _ arg:Expression {return arg})* _ ','? _ { + return [first].concat(rest); + } + +Function "function" + = _ name:[a-z]+ '(' _ args:Arguments? _ ')' _ { + return {name: name.join(''), args: args || []}; + } + +// Numbers. Lol. + +Number "number" + = '-'? Integer Fraction? Exp? { return parseFloat(text()); } + +E + = [eE] + +Exp "exponent" + = E '-'? Digit+ + +Fraction + = '.' Digit+ + +Integer + = '0' + / ([1-9] Digit*) + +Digit + = [0-9] diff --git a/packages/kbn-tinymath/src/index.js b/packages/kbn-tinymath/src/index.js new file mode 100644 index 0000000000000..e61956bd63e55 --- /dev/null +++ b/packages/kbn-tinymath/src/index.js @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { get } = require('lodash'); +const { parse: parseFn } = require('./grammar'); +const { functions: includedFunctions } = require('./functions'); + +module.exports = { parse, evaluate, interpret }; + +function parse(input, options) { + if (input == null) { + throw new Error('Missing expression'); + } + + if (typeof input !== 'string') { + throw new Error('Expression must be a string'); + } + + try { + return parseFn(input, options); + } catch (e) { + throw new Error(`Failed to parse expression. ${e.message}`); + } +} + +function evaluate(expression, scope = {}, injectedFunctions = {}) { + scope = scope || {}; + return interpret(parse(expression), scope, injectedFunctions); +} + +function interpret(node, scope, injectedFunctions) { + const functions = Object.assign({}, includedFunctions, injectedFunctions); // eslint-disable-line + return exec(node); + + function exec(node) { + const type = getType(node); + + if (type === 'function') return invoke(node); + + if (type === 'string') { + const val = getValue(scope, node); + if (typeof val === 'undefined') throw new Error(`Unknown variable: ${node}`); + return val; + } + + return node; // Can only be a number at this point + } + + function invoke(node) { + const { name, args } = node; + const fn = functions[name]; + if (!fn) throw new Error(`No such function: ${name}`); + const execOutput = args.map(exec); + if (fn.skipNumberValidation || isOperable(execOutput)) return fn(...execOutput); + return NaN; + } +} + +function getValue(scope, node) { + // attempt to read value from nested object first, check for exact match if value is undefined + const val = get(scope, node); + return typeof val !== 'undefined' ? val : scope[node]; +} + +function getType(x) { + const type = typeof x; + if (type === 'object') { + const keys = Object.keys(x); + if (keys.length !== 2 || !x.name || !x.args) throw new Error('Invalid AST object'); + return 'function'; + } + if (type === 'string' || type === 'number') return type; + throw new Error(`Unknown AST property type: ${type}`); +} + +function isOperable(args) { + return args.every((arg) => { + if (Array.isArray(arg)) return isOperable(arg); + return typeof arg === 'number' && !isNaN(arg); + }); +} diff --git a/packages/kbn-tinymath/test/functions/abs.test.js b/packages/kbn-tinymath/test/functions/abs.test.js new file mode 100644 index 0000000000000..09ae042d23de6 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/abs.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { abs } = require('../../src/functions/abs.js'); + +describe('Abs', () => { + it('numbers', () => { + expect(abs(-10)).toEqual(10); + expect(abs(10)).toEqual(10); + }); + + it('arrays', () => { + expect(abs([-1])).toEqual([1]); + expect(abs([-10, -20, -30, -40])).toEqual([10, 20, 30, 40]); + expect(abs([-13, 30, -90, 200])).toEqual([13, 30, 90, 200]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/add.test.js b/packages/kbn-tinymath/test/functions/add.test.js new file mode 100644 index 0000000000000..56b4fc48a62ad --- /dev/null +++ b/packages/kbn-tinymath/test/functions/add.test.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { add } = require('../../src/functions/add.js'); + +describe('Add', () => { + it('numbers', () => { + expect(add(1)).toEqual(1); + expect(add(10, 2, 5, 8)).toEqual(25); + expect(add(0.1, 0.2, 0.4, 0.3)).toEqual(0.1 + 0.2 + 0.3 + 0.4); + }); + + it('arrays & numbers', () => { + expect(add([10, 20, 30, 40], 10, 20, 30)).toEqual([70, 80, 90, 100]); + expect(add(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([43, 54, 65, 76]); + }); + + it('arrays', () => { + expect(add([1, 2, 3, 4])).toEqual(10); + expect(add([1, 2, 3, 4], [1, 2, 5, 10])).toEqual([2, 4, 8, 14]); + expect(add([1, 2, 3, 4], [1, 2, 5, 10], [10, 20, 30, 40])).toEqual([12, 24, 38, 54]); + expect(add([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([12, 50, 63, 76]); + }); + + it('array length mismatch', () => { + expect(() => add([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/cbrt.test.js b/packages/kbn-tinymath/test/functions/cbrt.test.js new file mode 100644 index 0000000000000..8b8b57c5a1ba1 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/cbrt.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { cbrt } = require('../../src/functions/cbrt.js'); + +describe('Cbrt', () => { + it('numbers', () => { + expect(cbrt(27)).toEqual(3); + expect(cbrt(-1)).toEqual(-1); + expect(cbrt(94)).toEqual(4.546835943776344); + }); + + it('arrays', () => { + expect(cbrt([27, 64, 125])).toEqual([3, 4, 5]); + expect(cbrt([1, 8, 1000])).toEqual([1, 2, 10]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/ceil.test.js b/packages/kbn-tinymath/test/functions/ceil.test.js new file mode 100644 index 0000000000000..0809c9ba1e9d5 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/ceil.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { ceil } = require('../../src/functions/ceil.js'); + +describe('Ceil', () => { + it('numbers', () => { + expect(ceil(-10.5)).toEqual(-10); + expect(ceil(-10.1)).toEqual(-10); + expect(ceil(10.9)).toEqual(11); + }); + + it('arrays', () => { + expect(ceil([-10.5, -20.9, -30.1, -40.2])).toEqual([-10, -20, -30, -40]); + expect(ceil([2.9, 5.1, 3.5, 4.3])).toEqual([3, 6, 4, 5]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/clamp.test.js b/packages/kbn-tinymath/test/functions/clamp.test.js new file mode 100644 index 0000000000000..7e6015bf304cf --- /dev/null +++ b/packages/kbn-tinymath/test/functions/clamp.test.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { clamp } = require('../../src/functions/clamp.js'); + +describe('Clamp', () => { + it('numbers', () => { + expect(clamp(10, 5, 8)).toEqual(8); + expect(clamp(1, 2, 3)).toEqual(2); + expect(clamp(0.5, 0.2, 0.4)).toEqual(0.4); + expect(clamp(3.58, 0, 1)).toEqual(1); + expect(clamp(-0.48, 0, 1)).toEqual(0); + expect(clamp(1.38, -1, 0)).toEqual(0); + }); + + it('arrays & numbers', () => { + expect(clamp([10, 20, 30, 40], 15, 25)).toEqual([15, 20, 25, 25]); + expect(clamp(10, [15, 2, 4, 20], 25)).toEqual([15, 10, 10, 20]); + expect(clamp(5, 10, [20, 30, 40, 50])).toEqual([10, 10, 10, 10]); + expect(clamp(35, 10, [20, 30, 40, 50])).toEqual([20, 30, 35, 35]); + expect(clamp([1, 9], 3, [4, 5])).toEqual([3, 5]); + }); + + it('arrays', () => { + expect(clamp([6, 28, 32, 10], [11, 2, 5, 10], [20, 21, 22, 23])).toEqual([11, 21, 22, 10]); + }); + + it('errors', () => { + expect(() => clamp(1, 4, 3)).toThrow('Min must be less than max'); + expect(() => clamp([1, 2], [3], 3)).toThrow('Array length mismatch'); + expect(() => clamp([1, 2], [3], 3)).toThrow('Array length mismatch'); + expect(() => clamp(10, 20, null)).toThrow( + "Missing maximum value. You may want to use the 'min' function instead" + ); + expect(() => clamp([10, 20, 30, 40], 15, null)).toThrow( + "Missing maximum value. You may want to use the 'min' function instead" + ); + expect(() => clamp(10, null, 30)).toThrow( + "Missing minimum value. You may want to use the 'max' function instead" + ); + expect(() => clamp([11, 28, 60, 10], null, [1, 48, 3, -17])).toThrow( + "Missing minimum value. You may want to use the 'max' function instead" + ); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/cos.test.js b/packages/kbn-tinymath/test/functions/cos.test.js new file mode 100644 index 0000000000000..9e4461512fe06 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/cos.test.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { cos } = require('../../src/functions/cos.js'); + +describe('Cosine', () => { + it('numbers', () => { + expect(cos(0)).toEqual(1); + expect(cos(1.5707963267948966)).toEqual(6.123233995736766e-17); + }); + + it('arrays', () => { + expect(cos([0, 1.5707963267948966])).toEqual([1, 6.123233995736766e-17]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/cube.test.js b/packages/kbn-tinymath/test/functions/cube.test.js new file mode 100644 index 0000000000000..f91cbd3c58059 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/cube.test.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { cube } = require('../../src/functions/cube.js'); + +describe('Cube', () => { + it('numbers', () => { + expect(cube(3)).toEqual(27); + expect(cube(-1)).toEqual(-1); + }); + + it('arrays', () => { + expect(cube([3, 4, 5])).toEqual([27, 64, 125]); + expect(cube([1, 2, 10])).toEqual([1, 8, 1000]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/degtorad.test.js b/packages/kbn-tinymath/test/functions/degtorad.test.js new file mode 100644 index 0000000000000..8ce78851e7844 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/degtorad.test.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { degtorad } = require('../../src/functions/degtorad.js'); + +describe('Degrees to Radians', () => { + it('numbers', () => { + expect(degtorad(0)).toEqual(0); + expect(degtorad(90)).toEqual(1.5707963267948966); + }); + + it('arrays', () => { + expect(degtorad([0, 90, 180, 360])).toEqual([ + 0, + 1.5707963267948966, + 3.141592653589793, + 6.283185307179586, + ]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/divide.test.js b/packages/kbn-tinymath/test/functions/divide.test.js new file mode 100644 index 0000000000000..f3eea83c3fb80 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/divide.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { divide } = require('../../src/functions/divide.js'); + +describe('Divide', () => { + it('number, number', () => { + expect(divide(10, 2)).toEqual(5); + expect(divide(0.1, 0.02)).toEqual(0.1 / 0.02); + }); + + it('array, number', () => { + expect(divide([10, 20, 30, 40], 10)).toEqual([1, 2, 3, 4]); + }); + + it('number, array', () => { + expect(divide(10, [1, 2, 5, 10])).toEqual([10, 5, 2, 1]); + }); + + it('array, array', () => { + expect(divide([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([11, 24, 20, 18]); + }); + + it('array length mismatch', () => { + expect(() => divide([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/exp.test.js b/packages/kbn-tinymath/test/functions/exp.test.js new file mode 100644 index 0000000000000..0bb25d772ae2c --- /dev/null +++ b/packages/kbn-tinymath/test/functions/exp.test.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { exp } = require('../../src/functions/exp.js'); + +describe('Exp', () => { + it('numbers', () => { + expect(exp(3)).toEqual(Math.exp(3)); + expect(exp(0)).toEqual(Math.exp(0)); + expect(exp(5)).toEqual(Math.exp(5)); + }); + + it('arrays', () => { + expect(exp([3, 4, 5])).toEqual([Math.exp(3), Math.exp(4), Math.exp(5)]); + expect(exp([1, 2, 10])).toEqual([Math.exp(1), Math.exp(2), Math.exp(10)]); + expect(exp([10])).toEqual([Math.exp(10)]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/first.test.js b/packages/kbn-tinymath/test/functions/first.test.js new file mode 100644 index 0000000000000..c977f68117724 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/first.test.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { first } = require('../../src/functions/first.js'); + +describe('First', () => { + it('numbers', () => { + expect(first(-10)).toEqual(-10); + expect(first(10)).toEqual(10); + }); + + it('arrays', () => { + expect(first([])).toEqual(undefined); + expect(first([-1])).toEqual(-1); + expect(first([-10, -20, -30, -40])).toEqual(-10); + expect(first([-13, 30, -90, 200])).toEqual(-13); + }); + + it('skips number validation', () => { + expect(first).toHaveProperty('skipNumberValidation', true); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/fix.test.js b/packages/kbn-tinymath/test/functions/fix.test.js new file mode 100644 index 0000000000000..59a71352ac680 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/fix.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { fix } = require('../../src/functions/fix.js'); + +describe('Fix', () => { + it('numbers', () => { + expect(fix(-10.5)).toEqual(-10); + expect(fix(-10.1)).toEqual(-10); + expect(fix(10.9)).toEqual(10); + }); + + it('arrays', () => { + expect(fix([-10.5, -20.9, -30.1, -40.2])).toEqual([-10, -20, -30, -40]); + expect(fix([2.9, 5.1, 3.5, 4.3])).toEqual([2, 5, 3, 4]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/floor.test.js b/packages/kbn-tinymath/test/functions/floor.test.js new file mode 100644 index 0000000000000..19f80e9bb7b06 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/floor.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { floor } = require('../../src/functions/floor.js'); + +describe('Floor', () => { + it('numbers', () => { + expect(floor(-10.5)).toEqual(-11); + expect(floor(-10.1)).toEqual(-11); + expect(floor(10.9)).toEqual(10); + }); + + it('arrays', () => { + expect(floor([-10.5, -20.9, -30.1, -40.2])).toEqual([-11, -21, -31, -41]); + expect(floor([2.9, 5.1, 3.5, 4.3])).toEqual([2, 5, 3, 4]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/last.test.js b/packages/kbn-tinymath/test/functions/last.test.js new file mode 100644 index 0000000000000..a333541b147ea --- /dev/null +++ b/packages/kbn-tinymath/test/functions/last.test.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { last } = require('../../src/functions/last.js'); + +describe('Last', () => { + it('numbers', () => { + expect(last(-10)).toEqual(-10); + expect(last(10)).toEqual(10); + }); + + it('arrays', () => { + expect(last([])).toEqual(undefined); + expect(last([-1])).toEqual(-1); + expect(last([-10, -20, -30, -40])).toEqual(-40); + expect(last([-13, 30, -90, 200])).toEqual(200); + }); + + it('skips number validation', () => { + expect(last).toHaveProperty('skipNumberValidation', true); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/log.test.js b/packages/kbn-tinymath/test/functions/log.test.js new file mode 100644 index 0000000000000..de142b997039b --- /dev/null +++ b/packages/kbn-tinymath/test/functions/log.test.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { log } = require('../../src/functions/log.js'); + +describe('Log', () => { + it('numbers', () => { + expect(log(1)).toEqual(Math.log(1)); + expect(log(3, 2)).toEqual(Math.log(3) / Math.log(2)); + expect(log(11, 3)).toEqual(Math.log(11) / Math.log(3)); + expect(log(42, 5)).toEqual(2.322344707681546); + }); + + it('arrays', () => { + expect(log([3, 4, 5], 3)).toEqual([ + Math.log(3) / Math.log(3), + Math.log(4) / Math.log(3), + Math.log(5) / Math.log(3), + ]); + expect(log([1, 2, 10], 10)).toEqual([ + Math.log(1) / Math.log(10), + Math.log(2) / Math.log(10), + Math.log(10) / Math.log(10), + ]); + }); + + it('number less than 1', () => { + expect(() => log(-1)).toThrow('Must be greater than 0'); + }); + + it('base out of range', () => { + expect(() => log(1, -1)).toThrow('Base out of range'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/log10.test.js b/packages/kbn-tinymath/test/functions/log10.test.js new file mode 100644 index 0000000000000..e0edfaa8388f0 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/log10.test.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { log10 } = require('../../src/functions/log10.js'); + +describe('Log10', () => { + it('numbers', () => { + expect(log10(1)).toEqual(Math.log(1) / Math.log(10)); + expect(log10(3)).toEqual(Math.log(3) / Math.log(10)); + expect(log10(11)).toEqual(Math.log(11) / Math.log(10)); + expect(log10(80)).toEqual(1.9030899869919433); + }); + + it('arrays', () => { + expect(log10([3, 4, 5], 3)).toEqual([ + Math.log(3) / Math.log(10), + Math.log(4) / Math.log(10), + Math.log(5) / Math.log(10), + ]); + expect(log10([1, 2, 10], 10)).toEqual([ + Math.log(1) / Math.log(10), + Math.log(2) / Math.log(10), + Math.log(10) / Math.log(10), + ]); + }); + + it('number less than 1', () => { + expect(() => log10(-1)).toThrow('Must be greater than 0'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/max.test.js b/packages/kbn-tinymath/test/functions/max.test.js new file mode 100644 index 0000000000000..ab4de7b958e68 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/max.test.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { max } = require('../../src/functions/max.js'); + +describe('Max', () => { + it('numbers', () => { + expect(max(1)).toEqual(1); + expect(max(10, 2, 5, 8)).toEqual(10); + expect(max(0.1, 0.2, 0.4, 0.3)).toEqual(0.4); + }); + + it('arrays & numbers', () => { + expect(max([88, 20, 30, 40], 60, [30, 10, 70, 90])).toEqual([88, 60, 70, 90]); + expect(max(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([22, 22, 30, 40]); + }); + + it('arrays', () => { + expect(max([1, 2, 3, 4])).toEqual(4); + expect(max([6, 2, 3, 10], [11, 2, 5, 10])).toEqual([11, 2, 5, 10]); + expect(max([30, 55, 9, 4], [72, 24, 48, 10], [10, 20, 30, 40])).toEqual([72, 55, 48, 40]); + expect(max([11, 28, 60, 10], [1, 48, 3, -17])).toEqual([11, 48, 60, 10]); + }); + + it('array length mismatch', () => { + expect(() => max([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/mean.test.js b/packages/kbn-tinymath/test/functions/mean.test.js new file mode 100644 index 0000000000000..6fb1c1fa18b98 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/mean.test.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { mean } = require('../../src/functions/mean.js'); + +describe('Mean', () => { + it('numbers', () => { + expect(mean(1)).toEqual(1); + expect(mean(10, 2, 5, 8)).toEqual(25 / 4); + expect(mean(0.1, 0.2, 0.4, 0.3)).toEqual((0.1 + 0.2 + 0.3 + 0.4) / 4); + }); + + it('arrays & numbers', () => { + expect(mean([10, 20, 30, 40], 10, 20, 30)).toEqual([70 / 4, 80 / 4, 90 / 4, 100 / 4]); + expect(mean(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([43 / 4, 54 / 4, 65 / 4, 76 / 4]); + }); + + it('arrays', () => { + expect(mean([1, 2, 3, 4])).toEqual(10 / 4); + expect(mean([1, 2, 3, 4], [1, 2, 5, 10])).toEqual([2 / 2, 4 / 2, 8 / 2, 14 / 2]); + expect(mean([1, 2, 3, 4], [1, 2, 5, 10], [10, 20, 30, 40])).toEqual([ + 12 / 3, + 24 / 3, + 38 / 3, + 54 / 3, + ]); + expect(mean([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([12 / 2, 50 / 2, 63 / 2, 76 / 2]); + }); + + it('array length mismatch', () => { + expect(() => mean([1, 2], [3])).toThrow(); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/median.test.js b/packages/kbn-tinymath/test/functions/median.test.js new file mode 100644 index 0000000000000..e7dd56b4c6fc4 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/median.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { median } = require('../../src/functions/median.js'); + +describe('Median', () => { + it('numbers', () => { + expect(median(1)).toEqual(1); + expect(median(10, 2, 5, 8)).toEqual((8 + 5) / 2); + expect(median(0.1, 0.2, 0.4, 0.3)).toEqual((0.2 + 0.3) / 2); + }); + + it('arrays & numbers', () => { + expect(median([10, 20, 30, 40], 10, 20, 30)).toEqual([15, 20, 25, 25]); + expect(median(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([10, 15, 16, 16]); + }); + + it('arrays', () => { + expect(median([1, 2, 3, 4], [1, 2, 5, 10])).toEqual([1, 2, 4, 7]); + expect(median([1, 2, 3, 4], [1, 2, 5, 10], [10, 20, 30, 40])).toEqual([1, 2, 5, 10]); + expect(median([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([12 / 2, 50 / 2, 63 / 2, 76 / 2]); + }); + + it('array length mismatch', () => { + expect(() => median([1, 2], [3])).toThrow(); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/min.test.js b/packages/kbn-tinymath/test/functions/min.test.js new file mode 100644 index 0000000000000..9612ce4274d11 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/min.test.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { min } = require('../../src/functions/min.js'); + +describe('Min', () => { + it('numbers', () => { + expect(min(1)).toEqual(1); + expect(min(10, 2, 5, 8)).toEqual(2); + expect(min(0.1, 0.2, 0.4, 0.3)).toEqual(0.1); + }); + + it('arrays & numbers', () => { + expect(min([88, 20, 30, 100], 60, [30, 10, 70, 90])).toEqual([30, 10, 30, 60]); + expect(min([50, 20, 3, 40], 10, [13, 2, 34, 4], 22)).toEqual([10, 2, 3, 4]); + expect(min(10, [50, 20, 3, 40], [13, 2, 34, 4], 22)).toEqual([10, 2, 3, 4]); + }); + + it('arrays', () => { + expect(min([1, 2, 3, 4])).toEqual(1); + expect(min([6, 2, 30, 10], [11, 2, 5, 15])).toEqual([6, 2, 5, 10]); + expect(min([30, 55, 9, 4], [72, 24, 48, 10], [10, 20, 30, 40])).toEqual([10, 20, 9, 4]); + expect(min([11, 28, 60, 10], [1, 48, 3, -17])).toEqual([1, 28, 3, -17]); + }); + + it('array length mismatch', () => { + expect(() => min([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/mod.test.js b/packages/kbn-tinymath/test/functions/mod.test.js new file mode 100644 index 0000000000000..ba3fc35b7e70c --- /dev/null +++ b/packages/kbn-tinymath/test/functions/mod.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { mod } = require('../../src/functions/mod.js'); + +describe('Mod', () => { + it('number, number', () => { + expect(mod(13, 8)).toEqual(5); + expect(mod(0.1, 0.02)).toEqual(0.1 % 0.02); + }); + + it('array, number', () => { + expect(mod([13, 26, 34, 42], 10)).toEqual([3, 6, 4, 2]); + }); + + it('number, array', () => { + expect(mod(10, [3, 7, 2, 4])).toEqual([1, 3, 0, 2]); + }); + + it('array, array', () => { + expect(mod([11, 48, 60, 72], [4, 13, 9, 5])).toEqual([3, 9, 6, 2]); + }); + + it('array length mismatch', () => { + expect(() => mod([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/mode.test.js b/packages/kbn-tinymath/test/functions/mode.test.js new file mode 100644 index 0000000000000..6f33140d41ef0 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/mode.test.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { mode } = require('../../src/functions/mode.js'); + +describe('Mode', () => { + it('numbers', () => { + expect(mode(1)).toEqual(1); + expect(mode(10, 2, 5, 8)).toEqual([2, 5, 8, 10]); + expect(mode(0.1, 0.2, 0.4, 0.3)).toEqual([0.1, 0.2, 0.3, 0.4]); + expect(mode(1, 1, 2, 3, 1, 4, 3, 2, 4)).toEqual([1]); + }); + + it('arrays & numbers', () => { + expect(mode([10, 20, 30, 40], 10, 20, 30)).toEqual([[10], [20], [30], [10, 20, 30, 40]]); + expect(mode([1, 2, 3, 4], 2, 3, [3, 2, 4, 3])).toEqual([[3], [2], [3], [3]]); + }); + + it('arrays', () => { + expect(mode([1, 2, 3, 4], [1, 2, 5, 10])).toEqual([[1], [2], [3, 5], [4, 10]]); + expect(mode([1, 2, 3, 4], [1, 2, 1, 2], [2, 3, 2, 3], [4, 3, 2, 3])).toEqual([ + [1], + [2, 3], + [2], + [3], + ]); + }); + + it('array length mismatch', () => { + expect(() => mode([1, 2], [3])).toThrow(); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/multiply.test.js b/packages/kbn-tinymath/test/functions/multiply.test.js new file mode 100644 index 0000000000000..f3a35d1f45695 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/multiply.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { multiply } = require('../../src/functions/multiply.js'); + +describe('Multiply', () => { + it('number, number', () => { + expect(multiply(10, 2)).toEqual(20); + expect(multiply(0.1, 0.2)).toEqual(0.1 * 0.2); + }); + + it('array, number', () => { + expect(multiply([10, 20, 30, 40], 10)).toEqual([100, 200, 300, 400]); + }); + + it('number, array', () => { + expect(multiply(10, [1, 2, 5, 10])).toEqual([10, 20, 50, 100]); + }); + + it('array, array', () => { + expect(multiply([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([11, 96, 180, 288]); + }); + + it('array length mismatch', () => { + expect(() => multiply([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/pi.test.js b/packages/kbn-tinymath/test/functions/pi.test.js new file mode 100644 index 0000000000000..7f1cdd019401d --- /dev/null +++ b/packages/kbn-tinymath/test/functions/pi.test.js @@ -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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { pi } = require('../../src/functions/pi.js'); + +describe('PI', () => { + it('constant', () => { + expect(pi()).toEqual(Math.PI); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/pow.test.js b/packages/kbn-tinymath/test/functions/pow.test.js new file mode 100644 index 0000000000000..05193aa2177a6 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/pow.test.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { pow } = require('../../src/functions/pow.js'); + +describe('Pow', () => { + it('numbers', () => { + expect(pow(3, 2)).toEqual(9); + expect(pow(-1, -1)).toEqual(-1); + expect(pow(5, 0)).toEqual(1); + }); + + it('arrays', () => { + expect(pow([3, 4, 5], 3)).toEqual([Math.pow(3, 3), Math.pow(4, 3), Math.pow(5, 3)]); + expect(pow([1, 2, 10], 10)).toEqual([Math.pow(1, 10), Math.pow(2, 10), Math.pow(10, 10)]); + }); + + it('missing exponent', () => { + expect(() => pow(1)).toThrow('Missing exponent'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/radtodeg.test.js b/packages/kbn-tinymath/test/functions/radtodeg.test.js new file mode 100644 index 0000000000000..0b97d3d2695be --- /dev/null +++ b/packages/kbn-tinymath/test/functions/radtodeg.test.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { radtodeg } = require('../../src/functions/radtodeg.js'); + +describe('Radians to Degrees', () => { + it('numbers', () => { + expect(radtodeg(0)).toEqual(0); + expect(radtodeg(1.5707963267948966)).toEqual(90); + }); + + it('arrays', () => { + expect(radtodeg([0, 1.5707963267948966, 3.141592653589793, 6.283185307179586])).toEqual([ + 0, + 90, + 180, + 360, + ]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/random.test.js b/packages/kbn-tinymath/test/functions/random.test.js new file mode 100644 index 0000000000000..2b259f2b84771 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/random.test.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { random } = require('../../src/functions/random.js'); + +describe('Random', () => { + it('numbers', () => { + const random1 = random(); + expect(random1).toBeGreaterThanOrEqual(0); + expect(random1).toBeLessThan(1); + expect(random(0)).toEqual(0); + const random3 = random(3); + expect(random3).toBeGreaterThanOrEqual(0); + expect(random3).toBeLessThan(3); + const random100 = random(-100, 100); + expect(random100).toBeGreaterThanOrEqual(-100); + expect(random100).toBeLessThan(100); + expect(random(1, 1)).toEqual(1); + expect(random(100, 100)).toEqual(100); + }); + + it('min greater than max', () => { + expect(() => random(-1)).toThrow('Min is greater than max'); + expect(() => random(3, 1)).toThrow('Min is greater than max'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/range.test.js b/packages/kbn-tinymath/test/functions/range.test.js new file mode 100644 index 0000000000000..920986d5e1368 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/range.test.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { range } = require('../../src/functions/range.js'); + +describe('Range', () => { + it('numbers', () => { + expect(range(1)).toEqual(0); + expect(range(10, 2, 5, 8)).toEqual(8); + expect(range(0.1, 0.2, 0.4, 0.3)).toEqual(0.4 - 0.1); + }); + + it('arrays & numbers', () => { + expect(range([88, 20, 30, 40], 60, [30, 10, 70, 90])).toEqual([58, 50, 40, 50]); + expect(range(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([21, 20, 27, 36]); + }); + + it('arrays', () => { + expect(range([1, 2, 3, 4])).toEqual(3); + expect(range([6, 2, 3, 10], [11, 2, 5, 10])).toEqual([5, 0, 2, 0]); + expect(range([30, 55, 9, 4], [72, 24, 48, 10], [10, 20, 30, 40])).toEqual([62, 35, 39, 36]); + expect(range([11, 28, 60, 10], [1, 48, 3, -17])).toEqual([10, 20, 57, 27]); + }); + + it('array length mismatch', () => { + expect(() => range([1, 2], [3])).toThrow(); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/round.test.js b/packages/kbn-tinymath/test/functions/round.test.js new file mode 100644 index 0000000000000..bea0a9a8377d7 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/round.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { round } = require('../../src/functions/round.js'); + +describe('Round', () => { + it('numbers', () => { + expect(round(-10.51)).toEqual(-11); + expect(round(-10.1, 2)).toEqual(-10.1); + expect(round(10.93745987, 4)).toEqual(10.9375); + }); + + it('arrays', () => { + expect(round([-10.51, -20.9, -30.1, -40.2])).toEqual([-11, -21, -30, -40]); + expect(round([2.9234, 5.1234, 3.5234, 4.49234324], 2)).toEqual([2.92, 5.12, 3.52, 4.49]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/sin.test.js b/packages/kbn-tinymath/test/functions/sin.test.js new file mode 100644 index 0000000000000..35a37abe35a68 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/sin.test.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { sin } = require('../../src/functions/sin.js'); + +describe('Sine', () => { + it('numbers', () => { + expect(sin(0)).toEqual(0); + expect(sin(1.5707963267948966)).toEqual(1); + }); + + it('arrays', () => { + expect(sin([0, 1.5707963267948966])).toEqual([0, 1]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/size.test.js b/packages/kbn-tinymath/test/functions/size.test.js new file mode 100644 index 0000000000000..b4db587f30230 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/size.test.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { size } = require('../../src/functions/size.js'); + +describe('Size (also Count)', () => { + it('array', () => { + expect(size([])).toEqual(0); + expect(size([10, 20, 30, 40])).toEqual(4); + }); + + it('not an array', () => { + expect(() => size(null)).toThrow('Must pass an array'); + expect(() => size(undefined)).toThrow('Must pass an array'); + expect(() => size('string')).toThrow('Must pass an array'); + expect(() => size(10)).toThrow('Must pass an array'); + expect(() => size(true)).toThrow('Must pass an array'); + expect(() => size({})).toThrow('Must pass an array'); + expect(() => size(function () {})).toThrow('Must pass an array'); + }); + + it('skips number validation', () => { + expect(size).toHaveProperty('skipNumberValidation', true); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/sqrt.test.js b/packages/kbn-tinymath/test/functions/sqrt.test.js new file mode 100644 index 0000000000000..a170140598d06 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/sqrt.test.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { sqrt } = require('../../src/functions/sqrt.js'); + +describe('Sqrt', () => { + it('numbers', () => { + expect(sqrt(9)).toEqual(3); + expect(sqrt(0)).toEqual(0); + expect(sqrt(30)).toEqual(5.477225575051661); + }); + + it('arrays', () => { + expect(sqrt([49, 64, 81])).toEqual([7, 8, 9]); + expect(sqrt([1, 4, 100])).toEqual([1, 2, 10]); + }); + + it('Invalid negative number', () => { + expect(() => sqrt(-1)).toThrow('Unable find the square root of a negative number'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/square.test.js b/packages/kbn-tinymath/test/functions/square.test.js new file mode 100644 index 0000000000000..3b91a5f79c8d3 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/square.test.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { square } = require('../../src/functions/square.js'); + +describe('Square', () => { + it('numbers', () => { + expect(square(3)).toEqual(9); + expect(square(-1)).toEqual(1); + }); + + it('arrays', () => { + expect(square([3, 4, 5])).toEqual([9, 16, 25]); + expect(square([1, 2, 10])).toEqual([1, 4, 100]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/subtract.test.js b/packages/kbn-tinymath/test/functions/subtract.test.js new file mode 100644 index 0000000000000..9cdc1fb85a562 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/subtract.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { subtract } = require('../../src/functions/subtract.js'); + +describe('Subtract', () => { + it('number, number', () => { + expect(subtract(10, 2)).toEqual(8); + expect(subtract(0.1, 0.2)).toEqual(0.1 - 0.2); + }); + + it('array, number', () => { + expect(subtract([10, 20, 30, 40], 10)).toEqual([0, 10, 20, 30]); + }); + + it('number, array', () => { + expect(subtract(10, [1, 2, 5, 10])).toEqual([9, 8, 5, 0]); + }); + + it('array, array', () => { + expect(subtract([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([10, 46, 57, 68]); + }); + + it('array length mismatch', () => { + expect(() => subtract([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/sum.test.js b/packages/kbn-tinymath/test/functions/sum.test.js new file mode 100644 index 0000000000000..a7d8c3d135253 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/sum.test.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { sum } = require('../../src/functions/sum.js'); + +describe('Sum', () => { + it('numbers', () => { + expect(sum(10, 2, 5, 8)).toEqual(25); + expect(sum(0.1, 0.2, 0.4, 0.3)).toEqual(0.1 + 0.2 + 0.3 + 0.4); + }); + + it('arrays & numbers', () => { + expect(sum([10, 20, 30, 40], 10, 20, 30)).toEqual(160); + expect(sum([10, 20, 30, 40], 10, [1, 2, 3], 22)).toEqual(138); + }); + + it('arrays', () => { + expect(sum([1, 2, 3, 4], [1, 2, 5, 10])).toEqual(28); + expect(sum([1, 2, 3, 4], [1, 2, 5, 10], [10, 20, 30, 40])).toEqual(128); + expect(sum([11, 48, 60, 72], [1, 2, 3, 4])).toEqual(201); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/tan.test.js b/packages/kbn-tinymath/test/functions/tan.test.js new file mode 100644 index 0000000000000..ba6960c0c1d8a --- /dev/null +++ b/packages/kbn-tinymath/test/functions/tan.test.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { tan } = require('../../src/functions/tan.js'); + +describe('Tangent', () => { + it('numbers', () => { + expect(tan(0)).toEqual(0); + expect(tan(1)).toEqual(1.5574077246549023); + }); + + it('arrays', () => { + expect(tan([0, 1])).toEqual([0, 1.5574077246549023]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/transpose.test.js b/packages/kbn-tinymath/test/functions/transpose.test.js new file mode 100644 index 0000000000000..eb0b8d0c7a0e2 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/transpose.test.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { transpose } = require('../../src/functions/lib/transpose'); + +describe('transpose', () => { + it('2D arrays', () => { + expect( + transpose( + [ + [1, 2], + [3, 4], + [5, 6], + ], + 0 + ) + ).toEqual([ + [1, 3, 5], + [2, 4, 6], + ]); + expect(transpose([10, 20, [10, 20, 30, 40], 30], 2)).toEqual([ + [10, 20, 10, 30], + [10, 20, 20, 30], + [10, 20, 30, 30], + [10, 20, 40, 30], + ]); + expect(transpose([4, [1, 9], [3, 5]], 1)).toEqual([ + [4, 1, 3], + [4, 9, 5], + ]); + }); + + it('array length mismatch', () => { + expect(() => transpose([[1], [2, 3]], 0)).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/unique.test.js b/packages/kbn-tinymath/test/functions/unique.test.js new file mode 100644 index 0000000000000..d58c190876e2c --- /dev/null +++ b/packages/kbn-tinymath/test/functions/unique.test.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { unique } = require('../../src/functions/unique.js'); + +describe('Unique', () => { + it('numbers', () => { + expect(unique(1)).toEqual(1); + expect(unique(10000)).toEqual(1); + }); + + it('arrays', () => { + expect(unique([])).toEqual(0); + expect(unique([-10, -20, -30, -40])).toEqual(4); + expect(unique([-13, 30, -90, 200])).toEqual(4); + expect(unique([1, 2, 3, 4, 2, 2, 2, 3, 4, 2, 4, 5, 2, 1, 4, 2])).toEqual(5); + }); + + it('skips number validation', () => { + expect(unique).toHaveProperty('skipNumberValidation', true); + }); +}); diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js new file mode 100644 index 0000000000000..7569cf90b2e35 --- /dev/null +++ b/packages/kbn-tinymath/test/library.test.js @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/* + TODO: These tests are wildly imcomplete + Need tests for spacing, etc +*/ + +const { evaluate, parse } = require('..'); + +describe('Parser', () => { + describe('Numbers', () => { + it('integers', () => { + expect(parse('10')).toEqual(10); + }); + + it('floats', () => { + expect(parse('10.5')).toEqual(10.5); + }); + + it('negatives', () => { + expect(parse('-10')).toEqual(-10); + expect(parse('-10.5')).toEqual(-10.5); + }); + }); + + describe('Variables', () => { + it('strings', () => { + expect(parse('f')).toEqual('f'); + expect(parse('foo')).toEqual('foo'); + }); + + it('allowed characters', () => { + expect(parse('_foo')).toEqual('_foo'); + expect(parse('@foo')).toEqual('@foo'); + expect(parse('.foo')).toEqual('.foo'); + expect(parse('-foo')).toEqual('-foo'); + expect(parse('_foo0')).toEqual('_foo0'); + expect(parse('@foo0')).toEqual('@foo0'); + expect(parse('.foo0')).toEqual('.foo0'); + expect(parse('-foo0')).toEqual('-foo0'); + }); + }); + + describe('quoted variables', () => { + it('strings with double quotes', () => { + expect(parse('"foo"')).toEqual('foo'); + expect(parse('"f b"')).toEqual('f b'); + expect(parse('"foo bar"')).toEqual('foo bar'); + expect(parse('"foo bar fizz buzz"')).toEqual('foo bar fizz buzz'); + expect(parse('"foo bar baby"')).toEqual('foo bar baby'); + }); + + it('strings with single quotes', () => { + /* eslint-disable prettier/prettier */ + expect(parse("'foo'")).toEqual('foo'); + expect(parse("'f b'")).toEqual('f b'); + expect(parse("'foo bar'")).toEqual('foo bar'); + expect(parse("'foo bar fizz buzz'")).toEqual('foo bar fizz buzz'); + expect(parse("'foo bar baby'")).toEqual('foo bar baby'); + /* eslint-enable prettier/prettier */ + }); + + it('allowed characters', () => { + expect(parse('"_foo bar"')).toEqual('_foo bar'); + expect(parse('"@foo bar"')).toEqual('@foo bar'); + expect(parse('".foo bar"')).toEqual('.foo bar'); + expect(parse('"-foo bar"')).toEqual('-foo bar'); + expect(parse('"_foo0 bar1"')).toEqual('_foo0 bar1'); + expect(parse('"@foo0 bar1"')).toEqual('@foo0 bar1'); + expect(parse('".foo0 bar1"')).toEqual('.foo0 bar1'); + expect(parse('"-foo0 bar1"')).toEqual('-foo0 bar1'); + }); + + it('invalid characters in double quotes', () => { + const check = (str) => () => parse(str); + expect(check('" foo bar"')).toThrow('but "\\"" found'); + expect(check('"foo bar "')).toThrow('but "\\"" found'); + expect(check('"0foo"')).toThrow('but "\\"" found'); + expect(check('" foo bar"')).toThrow('but "\\"" found'); + expect(check('"foo bar "')).toThrow('but "\\"" found'); + expect(check('"0foo"')).toThrow('but "\\"" found'); + }); + + it('invalid characters in single quotes', () => { + const check = (str) => () => parse(str); + /* eslint-disable prettier/prettier */ + expect(check("' foo bar'")).toThrow('but "\'" found'); + expect(check("'foo bar '")).toThrow('but "\'" found'); + expect(check("'0foo'")).toThrow('but "\'" found'); + expect(check("' foo bar'")).toThrow('but "\'" found'); + expect(check("'foo bar '")).toThrow('but "\'" found'); + expect(check("'0foo'")).toThrow('but "\'" found'); + /* eslint-enable prettier/prettier */ + }); + }); + + describe('Functions', () => { + it('no arguments', () => { + expect(parse('foo()')).toEqual({ name: 'foo', args: [] }); + }); + + it('arguments', () => { + expect(parse('foo(5,10)')).toEqual({ name: 'foo', args: [5, 10] }); + }); + + it('arguments with strings', () => { + expect(parse('foo("string with spaces")')).toEqual({ + name: 'foo', + args: ['string with spaces'], + }); + + /* eslint-disable prettier/prettier */ + expect(parse("foo('string with spaces')")).toEqual({ + name: 'foo', + args: ['string with spaces'], + }); + /* eslint-enable prettier/prettier */ + }); + }); + + it('Missing expression', () => { + expect(() => parse(undefined)).toThrow('Missing expression'); + expect(() => parse(null)).toThrow('Missing expression'); + }); + + it('Failed parse', () => { + expect(() => parse('')).toThrow('Failed to parse expression'); + }); + + it('Not a string', () => { + expect(() => parse(3)).toThrow('Expression must be a string'); + }); +}); + +describe('Evaluate', () => { + it('numbers', () => { + expect(evaluate('10')).toEqual(10); + }); + + it('variables', () => { + expect(evaluate('foo', { foo: 10 })).toEqual(10); + expect(evaluate('bar', { bar: [1, 2] })).toEqual([1, 2]); + }); + + it('variables with spaces', () => { + expect(evaluate('"foo bar"', { 'foo bar': 10 })).toEqual(10); + expect(evaluate('"key with many spaces in it"', { 'key with many spaces in it': 10 })).toEqual( + 10 + ); + }); + + it('valiables with dots', () => { + expect(evaluate('foo.bar', { 'foo.bar': 20 })).toEqual(20); + expect(evaluate('"is.null"', { 'is.null': null })).toEqual(null); + expect(evaluate('"is.false"', { 'is.null': null, 'is.false': false })).toEqual(false); + expect(evaluate('"with space.val"', { 'with space.val': 42 })).toEqual(42); + }); + + it('variables with dot notation', () => { + expect(evaluate('foo.bar', { foo: { bar: 20 } })).toEqual(20); + expect(evaluate('foo.bar[0].baz', { foo: { bar: [{ baz: 30 }, { beer: 40 }] } })).toEqual(30); + expect(evaluate('"is.false"', { is: { null: null, false: false } })).toEqual(false); + }); + + it('equations', () => { + expect(evaluate('3 + 4')).toEqual(7); + expect(evaluate('10 - 2')).toEqual(8); + expect(evaluate('8 + 6 / 3')).toEqual(10); + expect(evaluate('10 * (1 + 2)')).toEqual(30); + expect(evaluate('(3 - 4) * 10')).toEqual(-10); + expect(evaluate('-1 - -12')).toEqual(11); + expect(evaluate('5/20')).toEqual(0.25); + expect(evaluate('1 + 1 + 2 + 3 + 12')).toEqual(19); + expect(evaluate('100 / 10 / 10')).toEqual(1); + }); + + it('equations with functions', () => { + expect(evaluate('3 + multiply(10, 4)')).toEqual(43); + expect(evaluate('3 + multiply(10, 4, 5)')).toEqual(203); + }); + + it('equations with trigonometry', () => { + expect(evaluate('pi()')).toEqual(Math.PI); + expect(evaluate('sin(degtorad(0))')).toEqual(0); + expect(evaluate('sin(degtorad(180))')).toEqual(1.2246467991473532e-16); + expect(evaluate('cos(degtorad(0))')).toEqual(1); + expect(evaluate('cos(degtorad(180))')).toEqual(-1); + expect(evaluate('tan(degtorad(0))')).toEqual(0); + expect(evaluate('tan(degtorad(180))')).toEqual(-1.2246467991473532e-16); + }); + + it('equations with variables', () => { + expect(evaluate('3 + foo', { foo: 5 })).toEqual(8); + expect(evaluate('3 + foo', { foo: [5, 10] })).toEqual([8, 13]); + expect(evaluate('3 + foo', { foo: 5 })).toEqual(8); + expect(evaluate('sum(foo)', { foo: [5, 10, 15] })).toEqual(30); + expect(evaluate('90 / sum(foo)', { foo: [5, 10, 15] })).toEqual(3); + expect(evaluate('multiply(foo, bar)', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([4, 10, 18]); + }); + + it('equations with quoted variables', () => { + expect(evaluate('"b" * 7', { b: 3 })).toEqual(21); + expect(evaluate('"space name" * 2', { 'space name': [1, 2, 21] })).toEqual([2, 4, 42]); + expect(evaluate('sum("space name")', { 'space name': [1, 2, 21] })).toEqual(24); + }); + + it('equations with injected functions', () => { + expect( + evaluate( + 'plustwo(foo)', + { foo: 5 }, + { + plustwo: function (a) { + return a + 2; + }, + } + ) + ).toEqual(7); + expect( + evaluate('negate(1)', null, { + negate: function (a) { + return -a; + }, + }) + ).toEqual(-1); + expect( + evaluate('stringify(2)', null, { + stringify: function (a) { + return '' + a; + }, + }) + ).toEqual('2'); + }); + + it('equations with arrays using special operator functions', () => { + expect(evaluate('foo + bar', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([5, 7, 9]); + expect(evaluate('foo - bar', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([-3, -3, -3]); + expect(evaluate('foo * bar', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([4, 10, 18]); + expect(evaluate('foo / bar', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([ + 1 / 4, + 2 / 5, + 3 / 6, + ]); + }); + + it('missing expression', () => { + expect(() => evaluate('')).toThrow('Failed to parse expression'); + }); + + it('missing referenced scope when used in injected function', () => { + expect(() => + evaluate('increment(foo)', null, { + increment: function (a) { + return a + 1; + }, + }) + ).toThrow('Unknown variable: foo'); + }); + + it('invalid context datatypes', () => { + expect(evaluate('mean(foo)', { foo: [true, true, false] })).toBeNaN(); + expect(evaluate('mean(foo + bar)', { foo: [true, true, false], bar: [1, 2, 3] })).toBeNaN(); + expect(evaluate('mean(foo)', { foo: ['dog', 'cat', 'mouse'] })).toBeNaN(); + expect(evaluate('mean(foo + 2)', { foo: ['dog', 'cat', 'mouse'] })).toBeNaN(); + expect(evaluate('foo + bar', { foo: NaN, bar: [4, 5, 6] })).toBeNaN(); + }); +}); diff --git a/src/core/server/elasticsearch/client/client_config.test.ts b/src/core/server/elasticsearch/client/client_config.test.ts index 57bc7407a9a0f..768d165d5f8be 100644 --- a/src/core/server/elasticsearch/client/client_config.test.ts +++ b/src/core/server/elasticsearch/client/client_config.test.ts @@ -15,7 +15,6 @@ const createConfig = ( ): ElasticsearchClientConfig => { return { customHeaders: {}, - logQueries: false, sniffOnStart: false, sniffOnConnectionFault: false, sniffInterval: false, diff --git a/src/core/server/elasticsearch/client/client_config.ts b/src/core/server/elasticsearch/client/client_config.ts index 5762ef16704a5..01d2222a45e3a 100644 --- a/src/core/server/elasticsearch/client/client_config.ts +++ b/src/core/server/elasticsearch/client/client_config.ts @@ -22,7 +22,6 @@ import { DEFAULT_HEADERS } from '../default_headers'; export type ElasticsearchClientConfig = Pick< ElasticsearchConfig, | 'customHeaders' - | 'logQueries' | 'sniffOnStart' | 'sniffOnConnectionFault' | 'requestHeadersWhitelist' diff --git a/src/core/server/elasticsearch/client/cluster_client.test.ts b/src/core/server/elasticsearch/client/cluster_client.test.ts index b94bf08f1185b..1d6d373ec185c 100644 --- a/src/core/server/elasticsearch/client/cluster_client.test.ts +++ b/src/core/server/elasticsearch/client/cluster_client.test.ts @@ -19,7 +19,6 @@ const createConfig = ( parts: Partial = {} ): ElasticsearchClientConfig => { return { - logQueries: false, sniffOnStart: false, sniffOnConnectionFault: false, sniffInterval: false, @@ -57,16 +56,25 @@ describe('ClusterClient', () => { it('creates a single internal and scoped client during initialization', () => { const config = createConfig(); - new ClusterClient(config, logger, getAuthHeaders); + new ClusterClient(config, logger, 'custom-type', getAuthHeaders); expect(configureClientMock).toHaveBeenCalledTimes(2); - expect(configureClientMock).toHaveBeenCalledWith(config, { logger }); - expect(configureClientMock).toHaveBeenCalledWith(config, { logger, scoped: true }); + expect(configureClientMock).toHaveBeenCalledWith(config, { logger, type: 'custom-type' }); + expect(configureClientMock).toHaveBeenCalledWith(config, { + logger, + type: 'custom-type', + scoped: true, + }); }); describe('#asInternalUser', () => { it('returns the internal client', () => { - const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const clusterClient = new ClusterClient( + createConfig(), + logger, + 'custom-type', + getAuthHeaders + ); expect(clusterClient.asInternalUser).toBe(internalClient); }); @@ -74,7 +82,12 @@ describe('ClusterClient', () => { describe('#asScoped', () => { it('returns a scoped cluster client bound to the request', () => { - const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const clusterClient = new ClusterClient( + createConfig(), + logger, + 'custom-type', + getAuthHeaders + ); const request = httpServerMock.createKibanaRequest(); const scopedClusterClient = clusterClient.asScoped(request); @@ -87,7 +100,12 @@ describe('ClusterClient', () => { }); it('returns a distinct scoped cluster client on each call', () => { - const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const clusterClient = new ClusterClient( + createConfig(), + logger, + 'custom-type', + getAuthHeaders + ); const request = httpServerMock.createKibanaRequest(); const scopedClusterClient1 = clusterClient.asScoped(request); @@ -105,7 +123,7 @@ describe('ClusterClient', () => { }); getAuthHeaders.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({ headers: { foo: 'bar', @@ -130,7 +148,7 @@ describe('ClusterClient', () => { other: 'nope', }); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({}); clusterClient.asScoped(request); @@ -150,7 +168,7 @@ describe('ClusterClient', () => { other: 'nope', }); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({ headers: { authorization: 'override', @@ -175,7 +193,7 @@ describe('ClusterClient', () => { }); getAuthHeaders.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({}); clusterClient.asScoped(request); @@ -195,7 +213,7 @@ describe('ClusterClient', () => { const config = createConfig(); getAuthHeaders.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'my-fake-id', requestUuid: 'ignore-this-id' }, }); @@ -223,7 +241,7 @@ describe('ClusterClient', () => { foo: 'auth', }); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({}); clusterClient.asScoped(request); @@ -249,7 +267,7 @@ describe('ClusterClient', () => { }); getAuthHeaders.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({ headers: { foo: 'request' }, }); @@ -276,7 +294,7 @@ describe('ClusterClient', () => { }); getAuthHeaders.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest(); clusterClient.asScoped(request); @@ -297,7 +315,7 @@ describe('ClusterClient', () => { }); getAuthHeaders.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({ headers: { [headerKey]: 'foo' }, }); @@ -321,7 +339,7 @@ describe('ClusterClient', () => { }); getAuthHeaders.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = httpServerMock.createKibanaRequest({ headers: { foo: 'request' }, kibanaRequestState: { requestId: 'from request', requestUuid: 'ignore-this-id' }, @@ -344,7 +362,7 @@ describe('ClusterClient', () => { }); getAuthHeaders.mockReturnValue({}); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = { headers: { authorization: 'auth', @@ -368,7 +386,7 @@ describe('ClusterClient', () => { authorization: 'auth', }); - const clusterClient = new ClusterClient(config, logger, getAuthHeaders); + const clusterClient = new ClusterClient(config, logger, 'custom-type', getAuthHeaders); const request = { headers: { foo: 'bar', @@ -387,7 +405,12 @@ describe('ClusterClient', () => { describe('#close', () => { it('closes both underlying clients', async () => { - const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const clusterClient = new ClusterClient( + createConfig(), + logger, + 'custom-type', + getAuthHeaders + ); await clusterClient.close(); @@ -398,7 +421,12 @@ describe('ClusterClient', () => { it('waits for both clients to close', async (done) => { expect.assertions(4); - const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const clusterClient = new ClusterClient( + createConfig(), + logger, + 'custom-type', + getAuthHeaders + ); let internalClientClosed = false; let scopedClientClosed = false; @@ -436,7 +464,12 @@ describe('ClusterClient', () => { }); it('return a rejected promise is any client rejects', async () => { - const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const clusterClient = new ClusterClient( + createConfig(), + logger, + 'custom-type', + getAuthHeaders + ); internalClient.close.mockRejectedValue(new Error('error closing client')); @@ -446,7 +479,12 @@ describe('ClusterClient', () => { }); it('does nothing after the first call', async () => { - const clusterClient = new ClusterClient(createConfig(), logger, getAuthHeaders); + const clusterClient = new ClusterClient( + createConfig(), + logger, + 'custom-type', + getAuthHeaders + ); await clusterClient.close(); diff --git a/src/core/server/elasticsearch/client/cluster_client.ts b/src/core/server/elasticsearch/client/cluster_client.ts index 87d59e7417aa9..7e6a7f8ae53e6 100644 --- a/src/core/server/elasticsearch/client/cluster_client.ts +++ b/src/core/server/elasticsearch/client/cluster_client.ts @@ -60,10 +60,11 @@ export class ClusterClient implements ICustomClusterClient { constructor( private readonly config: ElasticsearchClientConfig, logger: Logger, + type: string, private readonly getAuthHeaders: GetAuthHeaders = noop ) { - this.asInternalUser = configureClient(config, { logger }); - this.rootScopedClient = configureClient(config, { logger, scoped: true }); + this.asInternalUser = configureClient(config, { logger, type }); + this.rootScopedClient = configureClient(config, { logger, type, scoped: true }); } asScoped(request: ScopeableRequest) { diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index 3486c210de1f9..548dc44aa4965 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -76,14 +76,14 @@ describe('configureClient', () => { }); it('calls `parseClientOptions` with the correct parameters', () => { - configureClient(config, { logger, scoped: false }); + configureClient(config, { logger, type: 'test', scoped: false }); expect(parseClientOptionsMock).toHaveBeenCalledTimes(1); expect(parseClientOptionsMock).toHaveBeenCalledWith(config, false); parseClientOptionsMock.mockClear(); - configureClient(config, { logger, scoped: true }); + configureClient(config, { logger, type: 'test', scoped: true }); expect(parseClientOptionsMock).toHaveBeenCalledTimes(1); expect(parseClientOptionsMock).toHaveBeenCalledWith(config, true); @@ -95,7 +95,7 @@ describe('configureClient', () => { }; parseClientOptionsMock.mockReturnValue(parsedOptions); - const client = configureClient(config, { logger, scoped: false }); + const client = configureClient(config, { logger, type: 'test', scoped: false }); expect(ClientMock).toHaveBeenCalledTimes(1); expect(ClientMock).toHaveBeenCalledWith(parsedOptions); @@ -103,7 +103,7 @@ describe('configureClient', () => { }); it('listens to client on `response` events', () => { - const client = configureClient(config, { logger, scoped: false }); + const client = configureClient(config, { logger, type: 'test', scoped: false }); expect(client.on).toHaveBeenCalledTimes(1); expect(client.on).toHaveBeenCalledWith('response', expect.any(Function)); @@ -122,38 +122,15 @@ describe('configureClient', () => { }, }); } - describe('does not log whrn "logQueries: false"', () => { - it('response', () => { - const client = configureClient(config, { logger, scoped: false }); - const response = createResponseWithBody({ - seq_no_primary_term: true, - query: { - term: { user: 'kimchy' }, - }, - }); - - client.emit('response', null, response); - expect(loggingSystemMock.collect(logger).debug).toHaveLength(0); - }); - - it('error', () => { - const client = configureClient(config, { logger, scoped: false }); - - const response = createApiResponse({ body: {} }); - client.emit('response', new errors.TimeoutError('message', response), response); - expect(loggingSystemMock.collect(logger).error).toHaveLength(0); + describe('logs each query', () => { + it('creates a query logger context based on the `type` parameter', () => { + configureClient(createFakeConfig(), { logger, type: 'test123' }); + expect(logger.get).toHaveBeenCalledWith('query', 'test123'); }); - }); - describe('logs each queries if `logQueries` is true', () => { it('when request body is an object', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createResponseWithBody({ seq_no_primary_term: true, @@ -169,23 +146,13 @@ describe('configureClient', () => { "200 GET /foo?hello=dolly {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}}", - Object { - "tags": Array [ - "query", - ], - }, ], ] `); }); it('when request body is a string', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createResponseWithBody( JSON.stringify({ @@ -203,23 +170,13 @@ describe('configureClient', () => { "200 GET /foo?hello=dolly {\\"seq_no_primary_term\\":true,\\"query\\":{\\"term\\":{\\"user\\":\\"kimchy\\"}}}", - Object { - "tags": Array [ - "query", - ], - }, ], ] `); }); it('when request body is a buffer', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createResponseWithBody( Buffer.from( @@ -239,23 +196,13 @@ describe('configureClient', () => { "200 GET /foo?hello=dolly [buffer]", - Object { - "tags": Array [ - "query", - ], - }, ], ] `); }); it('when request body is a readable stream', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createResponseWithBody( Readable.from( @@ -275,23 +222,13 @@ describe('configureClient', () => { "200 GET /foo?hello=dolly [stream]", - Object { - "tags": Array [ - "query", - ], - }, ], ] `); }); it('when request body is not defined', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createResponseWithBody(); @@ -301,23 +238,13 @@ describe('configureClient', () => { Array [ "200 GET /foo?hello=dolly", - Object { - "tags": Array [ - "query", - ], - }, ], ] `); }); it('properly encode queries', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createApiResponse({ body: {}, @@ -336,23 +263,13 @@ describe('configureClient', () => { Array [ "200 GET /foo?city=M%C3%BCnich", - Object { - "tags": Array [ - "query", - ], - }, ], ] `); }); - it('logs queries even in case of errors if `logQueries` is true', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + it('logs queries even in case of errors', () => { + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createApiResponse({ statusCode: 500, @@ -375,7 +292,7 @@ describe('configureClient', () => { }); client.emit('response', new errors.ResponseError(response), response); - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` Array [ Array [ "500 @@ -386,40 +303,13 @@ describe('configureClient', () => { `); }); - it('does not log queries if `logQueries` is false', () => { - const client = configureClient( - createFakeConfig({ - logQueries: false, - }), - { logger, scoped: false } - ); - - const response = createApiResponse({ - body: {}, - statusCode: 200, - params: { - method: 'GET', - path: '/foo', - }, - }); - - client.emit('response', null, response); - - expect(logger.debug).not.toHaveBeenCalled(); - }); - - it('logs error when the client emits an @elastic/elasticsearch error', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + it('logs debug when the client emits an @elastic/elasticsearch error', () => { + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createApiResponse({ body: {} }); client.emit('response', new errors.TimeoutError('message', response), response); - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` Array [ Array [ "[TimeoutError]: message", @@ -428,13 +318,8 @@ describe('configureClient', () => { `); }); - it('logs error when the client emits an ResponseError returned by elasticsearch', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + it('logs debug when the client emits an ResponseError returned by elasticsearch', () => { + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); const response = createApiResponse({ statusCode: 400, @@ -453,7 +338,7 @@ describe('configureClient', () => { }); client.emit('response', new errors.ResponseError(response), response); - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` Array [ Array [ "400 @@ -464,12 +349,7 @@ describe('configureClient', () => { }); it('logs default error info when the error response body is empty', () => { - const client = configureClient( - createFakeConfig({ - logQueries: true, - }), - { logger, scoped: false } - ); + const client = configureClient(createFakeConfig(), { logger, type: 'test', scoped: false }); let response = createApiResponse({ statusCode: 400, @@ -484,7 +364,7 @@ describe('configureClient', () => { }); client.emit('response', new errors.ResponseError(response), response); - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` Array [ Array [ "400 @@ -493,7 +373,7 @@ describe('configureClient', () => { ] `); - logger.error.mockClear(); + logger.debug.mockClear(); response = createApiResponse({ statusCode: 400, @@ -506,7 +386,7 @@ describe('configureClient', () => { }); client.emit('response', new errors.ResponseError(response), response); - expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).debug).toMatchInlineSnapshot(` Array [ Array [ "400 diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index 00cbd1958d817..bac792d1293a6 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -15,12 +15,12 @@ import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; export const configureClient = ( config: ElasticsearchClientConfig, - { logger, scoped = false }: { logger: Logger; scoped?: boolean } + { logger, type, scoped = false }: { logger: Logger; type: string; scoped?: boolean } ): Client => { const clientOptions = parseClientOptions(config, scoped); const client = new Client(clientOptions); - addLogging(client, logger, config.logQueries); + addLogging(client, logger.get('query', type)); return client; }; @@ -67,15 +67,13 @@ function getResponseMessage(event: RequestEvent): string { return `${event.statusCode}\n${params.method} ${url}${body}`; } -const addLogging = (client: Client, logger: Logger, logQueries: boolean) => { +const addLogging = (client: Client, logger: Logger) => { client.on('response', (error, event) => { - if (event && logQueries) { + if (event) { if (error) { - logger.error(getErrorMessage(error, event)); + logger.debug(getErrorMessage(error, event)); } else { - logger.debug(getResponseMessage(event), { - tags: ['query'], - }); + logger.debug(getResponseMessage(event)); } } }); diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index 803733fddb71c..e76de913a9d91 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -47,7 +47,6 @@ test('set correct defaults', () => { "http://localhost:9200", ], "ignoreVersionMismatch": false, - "logQueries": false, "password": undefined, "pingTimeout": "PT30S", "requestHeadersWhitelist": Array [ diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index b90ae2609f1e3..afda47ca8851b 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -133,6 +133,10 @@ const deprecations: ConfigDeprecationProvider = () => [ log( `Setting [${fromPath}.ssl.certificate] without [${fromPath}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.` ); + } else if (es.logQueries === true) { + log( + `Setting [${fromPath}.logQueries] is deprecated and no longer used. You should set the log level to "debug" for the "elasticsearch.queries" context in "logging.loggers" or use "logging.verbose: true".` + ); } return settings; }, @@ -164,12 +168,6 @@ export class ElasticsearchConfig { */ public readonly apiVersion: string; - /** - * Specifies whether all queries to the client should be logged (status code, - * method, query etc.). - */ - public readonly logQueries: boolean; - /** * Hosts that the client will connect to. If sniffing is enabled, this list will * be used as seeds to discover the rest of your cluster. @@ -248,7 +246,6 @@ export class ElasticsearchConfig { constructor(rawConfig: ElasticsearchConfigType) { this.ignoreVersionMismatch = rawConfig.ignoreVersionMismatch; this.apiVersion = rawConfig.apiVersion; - this.logQueries = rawConfig.logQueries; this.hosts = Array.isArray(rawConfig.hosts) ? rawConfig.hosts : [rawConfig.hosts]; this.requestHeadersWhitelist = Array.isArray(rawConfig.requestHeadersWhitelist) ? rawConfig.requestHeadersWhitelist diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index a6d966b346072..3129ccfb5a67e 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -92,14 +92,15 @@ describe('#setup', () => { // reset all mocks called during setup phase MockLegacyClusterClient.mockClear(); - const customConfig = { logQueries: true }; + const customConfig = { keepAlive: true }; const clusterClient = setupContract.legacy.createClient('some-custom-type', customConfig); expect(clusterClient).toBe(mockLegacyClusterClientInstance); expect(MockLegacyClusterClient).toHaveBeenCalledWith( expect.objectContaining(customConfig), - expect.objectContaining({ context: ['elasticsearch', 'some-custom-type'] }), + expect.objectContaining({ context: ['elasticsearch'] }), + 'some-custom-type', expect.any(Function) ); }); @@ -267,7 +268,7 @@ describe('#start', () => { // reset all mocks called during setup phase MockClusterClient.mockClear(); - const customConfig = { logQueries: true }; + const customConfig = { keepAlive: true }; const clusterClient = startContract.createClient('custom-type', customConfig); expect(clusterClient).toBe(mockClusterClientInstance); @@ -275,7 +276,8 @@ describe('#start', () => { expect(MockClusterClient).toHaveBeenCalledTimes(1); expect(MockClusterClient).toHaveBeenCalledWith( expect.objectContaining(customConfig), - expect.objectContaining({ context: ['elasticsearch', 'custom-type'] }), + expect.objectContaining({ context: ['elasticsearch'] }), + 'custom-type', expect.any(Function) ); }); @@ -286,7 +288,7 @@ describe('#start', () => { // reset all mocks called during setup phase MockClusterClient.mockClear(); - const customConfig = { logQueries: true }; + const customConfig = { keepAlive: true }; startContract.createClient('custom-type', customConfig); startContract.createClient('another-type', customConfig); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index 2d97f6e5c3121..fd3d546bb77b9 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -126,7 +126,8 @@ export class ElasticsearchService private createClusterClient(type: string, config: ElasticsearchClientConfig) { return new ClusterClient( config, - this.coreContext.logger.get('elasticsearch', type), + this.coreContext.logger.get('elasticsearch'), + type, this.getAuthHeaders ); } @@ -134,7 +135,8 @@ export class ElasticsearchService private createLegacyClusterClient(type: string, config: LegacyElasticsearchClientConfig) { return new LegacyClusterClient( config, - this.coreContext.logger.get('elasticsearch', type), + this.coreContext.logger.get('elasticsearch'), + type, this.getAuthHeaders ); } diff --git a/src/core/server/elasticsearch/legacy/cluster_client.test.ts b/src/core/server/elasticsearch/legacy/cluster_client.test.ts index 97a49cd9eb9f4..177181608bee9 100644 --- a/src/core/server/elasticsearch/legacy/cluster_client.test.ts +++ b/src/core/server/elasticsearch/legacy/cluster_client.test.ts @@ -31,11 +31,15 @@ test('#constructor creates client with parsed config', () => { const mockEsConfig = { apiVersion: 'es-version' } as any; const mockLogger = logger.get(); - const clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); + const clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type'); expect(clusterClient).toBeDefined(); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); - expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger); + expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith( + mockEsConfig, + mockLogger, + 'custom-type' + ); expect(MockClient).toHaveBeenCalledTimes(1); expect(MockClient).toHaveBeenCalledWith(mockEsClientConfig); @@ -57,7 +61,11 @@ describe('#callAsInternalUser', () => { }; MockClient.mockImplementation(() => mockEsClientInstance); - clusterClient = new LegacyClusterClient({ apiVersion: 'es-version' } as any, logger.get()); + clusterClient = new LegacyClusterClient( + { apiVersion: 'es-version' } as any, + logger.get(), + 'custom-type' + ); }); test('fails if cluster client is closed', async () => { @@ -226,7 +234,7 @@ describe('#asScoped', () => { requestHeadersWhitelist: ['one', 'two'], } as any; - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type'); jest.clearAllMocks(); }); @@ -237,10 +245,15 @@ describe('#asScoped', () => { expect(firstScopedClusterClient).toBeDefined(); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); - expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, { - auth: false, - ignoreCertAndKey: true, - }); + expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith( + mockEsConfig, + mockLogger, + 'custom-type', + { + auth: false, + ignoreCertAndKey: true, + } + ); expect(MockClient).toHaveBeenCalledTimes(1); expect(MockClient).toHaveBeenCalledWith( @@ -261,42 +274,57 @@ describe('#asScoped', () => { test('properly configures `ignoreCertAndKey` for various configurations', () => { // Config without SSL. - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type'); mockParseElasticsearchClientConfig.mockClear(); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); - expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, { - auth: false, - ignoreCertAndKey: true, - }); + expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith( + mockEsConfig, + mockLogger, + 'custom-type', + { + auth: false, + ignoreCertAndKey: true, + } + ); // Config ssl.alwaysPresentCertificate === false mockEsConfig = { ...mockEsConfig, ssl: { alwaysPresentCertificate: false } } as any; - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type'); mockParseElasticsearchClientConfig.mockClear(); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); - expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, { - auth: false, - ignoreCertAndKey: true, - }); + expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith( + mockEsConfig, + mockLogger, + 'custom-type', + { + auth: false, + ignoreCertAndKey: true, + } + ); // Config ssl.alwaysPresentCertificate === true mockEsConfig = { ...mockEsConfig, ssl: { alwaysPresentCertificate: true } } as any; - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type'); mockParseElasticsearchClientConfig.mockClear(); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1' } })); expect(mockParseElasticsearchClientConfig).toHaveBeenCalledTimes(1); - expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith(mockEsConfig, mockLogger, { - auth: false, - ignoreCertAndKey: false, - }); + expect(mockParseElasticsearchClientConfig).toHaveBeenLastCalledWith( + mockEsConfig, + mockLogger, + 'custom-type', + { + auth: false, + ignoreCertAndKey: false, + } + ); }); test('passes only filtered headers to the scoped cluster client', () => { @@ -345,7 +373,7 @@ describe('#asScoped', () => { }); test('does not fail when scope to not defined request', async () => { - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type'); clusterClient.asScoped(); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( @@ -356,7 +384,7 @@ describe('#asScoped', () => { }); test('does not fail when scope to a request without headers', async () => { - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type'); clusterClient.asScoped({} as any); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( @@ -367,7 +395,7 @@ describe('#asScoped', () => { }); test('calls getAuthHeaders and filters results for a real request', async () => { - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({ + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type', () => ({ one: '1', three: '3', })); @@ -381,7 +409,9 @@ describe('#asScoped', () => { }); test('getAuthHeaders results rewrite extends a request headers', async () => { - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({ one: 'foo' })); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type', () => ({ + one: 'foo', + })); clusterClient.asScoped(httpServerMock.createRawRequest({ headers: { one: '1', two: '2' } })); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); expect(MockScopedClusterClient).toHaveBeenCalledWith( @@ -392,7 +422,7 @@ describe('#asScoped', () => { }); test("doesn't call getAuthHeaders for a fake request", async () => { - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, () => ({})); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type', () => ({})); clusterClient.asScoped({ headers: { one: 'foo' } }); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); @@ -404,7 +434,7 @@ describe('#asScoped', () => { }); test('filters a fake request headers', async () => { - clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger); + clusterClient = new LegacyClusterClient(mockEsConfig, mockLogger, 'custom-type'); clusterClient.asScoped({ headers: { one: '1', two: '2', three: '3' } }); expect(MockScopedClusterClient).toHaveBeenCalledTimes(1); @@ -431,7 +461,8 @@ describe('#close', () => { clusterClient = new LegacyClusterClient( { apiVersion: 'es-version', requestHeadersWhitelist: [] } as any, - logger.get() + logger.get(), + 'custom-type' ); }); diff --git a/src/core/server/elasticsearch/legacy/cluster_client.ts b/src/core/server/elasticsearch/legacy/cluster_client.ts index 9cac713920331..64e1382fee201 100644 --- a/src/core/server/elasticsearch/legacy/cluster_client.ts +++ b/src/core/server/elasticsearch/legacy/cluster_client.ts @@ -121,9 +121,10 @@ export class LegacyClusterClient implements ILegacyClusterClient { constructor( private readonly config: LegacyElasticsearchClientConfig, private readonly log: Logger, + private readonly type: string, private readonly getAuthHeaders: GetAuthHeaders = noop ) { - this.client = new Client(parseElasticsearchClientConfig(config, log)); + this.client = new Client(parseElasticsearchClientConfig(config, log, type)); } /** @@ -186,7 +187,7 @@ export class LegacyClusterClient implements ILegacyClusterClient { // between all scoped client instances. if (this.scopedClient === undefined) { this.scopedClient = new Client( - parseElasticsearchClientConfig(this.config, this.log, { + parseElasticsearchClientConfig(this.config, this.log, this.type, { auth: false, ignoreCertAndKey: !this.config.ssl || !this.config.ssl.alwaysPresentCertificate, }) diff --git a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts index 5dac353c1094c..6c79f2c568caa 100644 --- a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts +++ b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.test.ts @@ -22,13 +22,13 @@ test('parses minimally specified config', () => { { apiVersion: 'master', customHeaders: { xsrf: 'something' }, - logQueries: false, sniffOnStart: false, sniffOnConnectionFault: false, hosts: ['http://localhost/elasticsearch'], requestHeadersWhitelist: [], }, - logger.get() + logger.get(), + 'custom-type' ) ).toMatchInlineSnapshot(` Object { @@ -58,7 +58,6 @@ test('parses fully specified config', () => { const elasticsearchConfig: LegacyElasticsearchClientConfig = { apiVersion: 'v7.0.0', customHeaders: { xsrf: 'something' }, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: [ @@ -84,7 +83,8 @@ test('parses fully specified config', () => { const elasticsearchClientConfig = parseElasticsearchClientConfig( elasticsearchConfig, - logger.get() + logger.get(), + 'custom-type' ); // Check that original references aren't used. @@ -163,7 +163,6 @@ test('parses config timeouts of moment.Duration type', () => { { apiVersion: 'master', customHeaders: { xsrf: 'something' }, - logQueries: false, sniffOnStart: false, sniffOnConnectionFault: false, pingTimeout: duration(100, 'ms'), @@ -172,7 +171,8 @@ test('parses config timeouts of moment.Duration type', () => { hosts: ['http://localhost:9200/elasticsearch'], requestHeadersWhitelist: [], }, - logger.get() + logger.get(), + 'custom-type' ) ).toMatchInlineSnapshot(` Object { @@ -208,7 +208,6 @@ describe('#auth', () => { { apiVersion: 'v7.0.0', customHeaders: { xsrf: 'something' }, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: ['http://user:password@localhost/elasticsearch', 'https://es.local'], @@ -217,6 +216,7 @@ describe('#auth', () => { requestHeadersWhitelist: [], }, logger.get(), + 'custom-type', { auth: false } ) ).toMatchInlineSnapshot(` @@ -260,7 +260,6 @@ describe('#auth', () => { { apiVersion: 'v7.0.0', customHeaders: { xsrf: 'something' }, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: ['https://es.local'], @@ -268,6 +267,7 @@ describe('#auth', () => { password: 'changeme', }, logger.get(), + 'custom-type', { auth: true } ) ).toMatchInlineSnapshot(` @@ -300,7 +300,6 @@ describe('#auth', () => { { apiVersion: 'v7.0.0', customHeaders: { xsrf: 'something' }, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: ['https://es.local'], @@ -308,6 +307,7 @@ describe('#auth', () => { username: 'elastic', }, logger.get(), + 'custom-type', { auth: true } ) ).toMatchInlineSnapshot(` @@ -342,13 +342,13 @@ describe('#customHeaders', () => { { apiVersion: 'master', customHeaders: { [headerKey]: 'foo' }, - logQueries: false, sniffOnStart: false, sniffOnConnectionFault: false, hosts: ['http://localhost/elasticsearch'], requestHeadersWhitelist: [], }, - logger.get() + logger.get(), + 'custom-type' ); expect(parsedConfig.hosts[0].headers).toEqual({ [headerKey]: 'foo', @@ -357,62 +357,18 @@ describe('#customHeaders', () => { }); describe('#log', () => { - test('default logger with #logQueries = false', () => { + test('default logger', () => { const parsedConfig = parseElasticsearchClientConfig( { apiVersion: 'master', customHeaders: { xsrf: 'something' }, - logQueries: false, sniffOnStart: false, sniffOnConnectionFault: false, hosts: ['http://localhost/elasticsearch'], requestHeadersWhitelist: [], }, - logger.get() - ); - - const esLogger = new parsedConfig.log(); - esLogger.error('some-error'); - esLogger.warning('some-warning'); - esLogger.trace('some-trace'); - esLogger.info('some-info'); - esLogger.debug('some-debug'); - - expect(typeof esLogger.close).toBe('function'); - - expect(loggingSystemMock.collect(logger)).toMatchInlineSnapshot(` - Object { - "debug": Array [], - "error": Array [ - Array [ - "some-error", - ], - ], - "fatal": Array [], - "info": Array [], - "log": Array [], - "trace": Array [], - "warn": Array [ - Array [ - "some-warning", - ], - ], - } - `); - }); - - test('default logger with #logQueries = true', () => { - const parsedConfig = parseElasticsearchClientConfig( - { - apiVersion: 'master', - customHeaders: { xsrf: 'something' }, - logQueries: true, - sniffOnStart: false, - sniffOnConnectionFault: false, - hosts: ['http://localhost/elasticsearch'], - requestHeadersWhitelist: [], - }, - logger.get() + logger.get(), + 'custom-type' ); const esLogger = new parsedConfig.log(); @@ -433,11 +389,6 @@ describe('#log', () => { "304 METHOD /some-path ?query=2", - Object { - "tags": Array [ - "query", - ], - }, ], ], "error": Array [ @@ -465,14 +416,14 @@ describe('#log', () => { { apiVersion: 'master', customHeaders: { xsrf: 'something' }, - logQueries: true, sniffOnStart: false, sniffOnConnectionFault: false, hosts: ['http://localhost/elasticsearch'], requestHeadersWhitelist: [], log: customLogger, }, - logger.get() + logger.get(), + 'custom-type' ); expect(parsedConfig.log).toBe(customLogger); @@ -486,14 +437,14 @@ describe('#ssl', () => { { apiVersion: 'v7.0.0', customHeaders: {}, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: ['https://es.local'], requestHeadersWhitelist: [], ssl: { verificationMode: 'none' }, }, - logger.get() + logger.get(), + 'custom-type' ) ).toMatchInlineSnapshot(` Object { @@ -527,14 +478,14 @@ describe('#ssl', () => { { apiVersion: 'v7.0.0', customHeaders: {}, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: ['https://es.local'], requestHeadersWhitelist: [], ssl: { verificationMode: 'certificate' }, }, - logger.get() + logger.get(), + 'custom-type' ); // `checkServerIdentity` shouldn't check hostname when verificationMode is certificate. @@ -576,14 +527,14 @@ describe('#ssl', () => { { apiVersion: 'v7.0.0', customHeaders: {}, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: ['https://es.local'], requestHeadersWhitelist: [], ssl: { verificationMode: 'full' }, }, - logger.get() + logger.get(), + 'custom-type' ) ).toMatchInlineSnapshot(` Object { @@ -618,14 +569,14 @@ describe('#ssl', () => { { apiVersion: 'v7.0.0', customHeaders: {}, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: ['https://es.local'], requestHeadersWhitelist: [], ssl: { verificationMode: 'misspelled' as any }, }, - logger.get() + logger.get(), + 'custom-type' ) ).toThrowErrorMatchingInlineSnapshot(`"Unknown ssl verificationMode: misspelled"`); }); @@ -636,7 +587,6 @@ describe('#ssl', () => { { apiVersion: 'v7.0.0', customHeaders: {}, - logQueries: true, sniffOnStart: true, sniffOnConnectionFault: true, hosts: ['https://es.local'], @@ -651,6 +601,7 @@ describe('#ssl', () => { }, }, logger.get(), + 'custom-type', { ignoreCertAndKey: true } ) ).toMatchInlineSnapshot(` diff --git a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts index ecd2e30097060..66b6046e4516d 100644 --- a/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts +++ b/src/core/server/elasticsearch/legacy/elasticsearch_client_config.ts @@ -29,7 +29,6 @@ export type LegacyElasticsearchClientConfig = Pick(config: T) { }; const mock: jest.Mocked['config']> = { - legacy: { globalConfig$: of(globalConfig) }, + legacy: { + globalConfig$: of(globalConfig), + get: () => globalConfig, + }, create: jest.fn().mockReturnValue(of(config)), - createIfExists: jest.fn().mockReturnValue(of(config)), + get: jest.fn().mockReturnValue(config), }; return mock; diff --git a/src/core/server/plugins/legacy_config.test.ts b/src/core/server/plugins/legacy_config.test.ts new file mode 100644 index 0000000000000..fd8234d72bd17 --- /dev/null +++ b/src/core/server/plugins/legacy_config.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { take } from 'rxjs/operators'; +import { ConfigService, Env } from '@kbn/config'; +import { getEnvOptions, rawConfigServiceMock } from '../config/mocks'; +import { getGlobalConfig, getGlobalConfig$ } from './legacy_config'; +import { REPO_ROOT } from '@kbn/utils'; +import { loggingSystemMock } from '../logging/logging_system.mock'; +import { duration } from 'moment'; +import { fromRoot } from '../utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Server } from '../server'; + +describe('Legacy config', () => { + let env: Env; + let logger: ReturnType; + + beforeEach(() => { + env = Env.createDefault(REPO_ROOT, getEnvOptions()); + logger = loggingSystemMock.create(); + }); + + const createConfigService = (rawConfig: Record = {}): ConfigService => { + const rawConfigService = rawConfigServiceMock.create({ rawConfig }); + const server = new Server(rawConfigService, env, logger); + server.setupCoreConfig(); + return server.configService; + }; + + describe('getGlobalConfig', () => { + it('should return the global config', async () => { + const configService = createConfigService(); + await configService.validate(); + + const legacyConfig = getGlobalConfig(configService); + + expect(legacyConfig).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); + }); + }); + + describe('getGlobalConfig$', () => { + it('should return an observable for the global config', async () => { + const configService = createConfigService(); + + const legacyConfig = await getGlobalConfig$(configService).pipe(take(1)).toPromise(); + + expect(legacyConfig).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); + }); + }); +}); diff --git a/src/core/server/plugins/legacy_config.ts b/src/core/server/plugins/legacy_config.ts new file mode 100644 index 0000000000000..748a1e3190640 --- /dev/null +++ b/src/core/server/plugins/legacy_config.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { map, shareReplay } from 'rxjs/operators'; +import { combineLatest, Observable } from 'rxjs'; +import { PathConfigType, config as pathConfig } from '@kbn/utils'; +import { pick, deepFreeze } from '@kbn/std'; +import { IConfigService } from '@kbn/config'; + +import { SharedGlobalConfig, SharedGlobalConfigKeys } from './types'; +import { KibanaConfigType, config as kibanaConfig } from '../kibana_config'; +import { + ElasticsearchConfigType, + config as elasticsearchConfig, +} from '../elasticsearch/elasticsearch_config'; +import { SavedObjectsConfigType, savedObjectsConfig } from '../saved_objects/saved_objects_config'; + +const createGlobalConfig = ({ + kibana, + elasticsearch, + path, + savedObjects, +}: { + kibana: KibanaConfigType; + elasticsearch: ElasticsearchConfigType; + path: PathConfigType; + savedObjects: SavedObjectsConfigType; +}): SharedGlobalConfig => { + return deepFreeze({ + kibana: pick(kibana, SharedGlobalConfigKeys.kibana), + elasticsearch: pick(elasticsearch, SharedGlobalConfigKeys.elasticsearch), + path: pick(path, SharedGlobalConfigKeys.path), + savedObjects: pick(savedObjects, SharedGlobalConfigKeys.savedObjects), + }); +}; + +export const getGlobalConfig = (configService: IConfigService): SharedGlobalConfig => { + return createGlobalConfig({ + kibana: configService.atPathSync(kibanaConfig.path), + elasticsearch: configService.atPathSync(elasticsearchConfig.path), + path: configService.atPathSync(pathConfig.path), + savedObjects: configService.atPathSync(savedObjectsConfig.path), + }); +}; + +export const getGlobalConfig$ = (configService: IConfigService): Observable => { + return combineLatest([ + configService.atPath(kibanaConfig.path), + configService.atPath(elasticsearchConfig.path), + configService.atPath(pathConfig.path), + configService.atPath(savedObjectsConfig.path), + ]).pipe( + map( + ([kibana, elasticsearch, path, savedObjects]) => + createGlobalConfig({ + kibana, + elasticsearch, + path, + savedObjects, + }), + shareReplay(1) + ) + ); +}; diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 3d212bc555828..c71102df9929b 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -17,15 +17,8 @@ import { rawConfigServiceMock, getEnvOptions } from '../config/mocks'; import { PluginManifest } from './types'; import { Server } from '../server'; import { fromRoot } from '../utils'; -import { ByteSizeValue } from '@kbn/config-schema'; - -const logger = loggingSystemMock.create(); - -let coreId: symbol; -let env: Env; -let coreContext: CoreContext; -let server: Server; -let instanceInfo: InstanceInfo; +import { schema, ByteSizeValue } from '@kbn/config-schema'; +import { ConfigService } from '@kbn/config'; function createPluginManifest(manifestProps: Partial = {}): PluginManifest { return { @@ -43,61 +36,112 @@ function createPluginManifest(manifestProps: Partial = {}): Plug } describe('createPluginInitializerContext', () => { + let logger: ReturnType; + let coreId: symbol; + let opaqueId: symbol; + let env: Env; + let coreContext: CoreContext; + let server: Server; + let instanceInfo: InstanceInfo; + beforeEach(async () => { + logger = loggingSystemMock.create(); coreId = Symbol('core'); + opaqueId = Symbol(); instanceInfo = { uuid: 'instance-uuid', }; env = Env.createDefault(REPO_ROOT, getEnvOptions()); const config$ = rawConfigServiceMock.create({ rawConfig: {} }); server = new Server(config$, env, logger); - await server.setupCoreConfig(); + server.setupCoreConfig(); coreContext = { coreId, env, logger, configService: server.configService }; }); - it('should return a globalConfig handler in the context', async () => { - const manifest = createPluginManifest(); - const opaqueId = Symbol(); - const pluginInitializerContext = createPluginInitializerContext( - coreContext, - opaqueId, - manifest, - instanceInfo - ); + describe('context.config', () => { + it('config.get() should return the plugin config synchronously', async () => { + const config$ = rawConfigServiceMock.create({ + rawConfig: { + plugin: { + foo: 'bar', + answer: 42, + }, + }, + }); + + const configService = new ConfigService(config$, env, logger); + configService.setSchema( + 'plugin', + schema.object({ + foo: schema.string(), + answer: schema.number(), + }) + ); + await configService.validate(); - expect(pluginInitializerContext.config.legacy.globalConfig$).toBeDefined(); + coreContext = { coreId, env, logger, configService }; - const configObject = await pluginInitializerContext.config.legacy.globalConfig$ - .pipe(first()) - .toPromise(); - expect(configObject).toStrictEqual({ - kibana: { - index: '.kibana', - autocompleteTerminateAfter: duration(100000), - autocompleteTimeout: duration(1000), - }, - elasticsearch: { - shardTimeout: duration(30, 's'), - requestTimeout: duration(30, 's'), - pingTimeout: duration(30, 's'), - }, - path: { data: fromRoot('data') }, - savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + const manifest = createPluginManifest({ + configPath: 'plugin', + }); + + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + + expect(pluginInitializerContext.config.get()).toEqual({ + foo: 'bar', + answer: 42, + }); + }); + + it('config.globalConfig$ should be an observable for the global config', async () => { + const manifest = createPluginManifest(); + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + + expect(pluginInitializerContext.config.legacy.globalConfig$).toBeDefined(); + + const configObject = await pluginInitializerContext.config.legacy.globalConfig$ + .pipe(first()) + .toPromise(); + expect(configObject).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); }); }); - it('allow to access the provided instance uuid', () => { - const manifest = createPluginManifest(); - const opaqueId = Symbol(); - instanceInfo = { - uuid: 'kibana-uuid', - }; - const pluginInitializerContext = createPluginInitializerContext( - coreContext, - opaqueId, - manifest, - instanceInfo - ); - expect(pluginInitializerContext.env.instanceUuid).toBe('kibana-uuid'); + describe('context.env', () => { + it('should expose the correct instance uuid', () => { + const manifest = createPluginManifest(); + instanceInfo = { + uuid: 'kibana-uuid', + }; + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + expect(pluginInitializerContext.env.instanceUuid).toBe('kibana-uuid'); + }); }); }); diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 5b0e2ee21a887..3b7dc70b9c054 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -6,27 +6,14 @@ * Public License, v 1. */ -import { map, shareReplay } from 'rxjs/operators'; -import { combineLatest } from 'rxjs'; -import { PathConfigType, config as pathConfig } from '@kbn/utils'; -import { pick, deepFreeze } from '@kbn/std'; +import { shareReplay } from 'rxjs/operators'; import type { RequestHandlerContext } from 'src/core/server'; import { CoreContext } from '../core_context'; import { PluginWrapper } from './plugin'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; -import { - PluginInitializerContext, - PluginManifest, - PluginOpaqueId, - SharedGlobalConfigKeys, -} from './types'; -import { KibanaConfigType, config as kibanaConfig } from '../kibana_config'; -import { - ElasticsearchConfigType, - config as elasticsearchConfig, -} from '../elasticsearch/elasticsearch_config'; +import { PluginInitializerContext, PluginManifest, PluginOpaqueId } from './types'; import { IRouter, RequestHandlerContextProvider } from '../http'; -import { SavedObjectsConfigType, savedObjectsConfig } from '../saved_objects/saved_objects_config'; +import { getGlobalConfig, getGlobalConfig$ } from './legacy_config'; import { CoreSetup, CoreStart } from '..'; export interface InstanceInfo { @@ -78,40 +65,19 @@ export function createPluginInitializerContext( */ config: { legacy: { - /** - * Global configuration - * Note: naming not final here, it will be renamed in a near future (https://github.com/elastic/kibana/issues/46240) - * @deprecated - */ - globalConfig$: combineLatest([ - coreContext.configService.atPath(kibanaConfig.path), - coreContext.configService.atPath(elasticsearchConfig.path), - coreContext.configService.atPath(pathConfig.path), - coreContext.configService.atPath(savedObjectsConfig.path), - ]).pipe( - map(([kibana, elasticsearch, path, savedObjects]) => - deepFreeze({ - kibana: pick(kibana, SharedGlobalConfigKeys.kibana), - elasticsearch: pick(elasticsearch, SharedGlobalConfigKeys.elasticsearch), - path: pick(path, SharedGlobalConfigKeys.path), - savedObjects: pick(savedObjects, SharedGlobalConfigKeys.savedObjects), - }) - ) - ), + globalConfig$: getGlobalConfig$(coreContext.configService), + get: () => getGlobalConfig(coreContext.configService), }, /** * Reads the subset of the config at the `configPath` defined in the plugin - * manifest and validates it against the schema in the static `schema` on - * the given `ConfigClass`. - * @param ConfigClass A class (not an instance of a class) that contains a - * static `schema` that we validate the config at the given `path` against. + * manifest. */ create() { return coreContext.configService.atPath(pluginManifest.configPath).pipe(shareReplay(1)); }, - createIfExists() { - return coreContext.configService.optionalAtPath(pluginManifest.configPath); + get() { + return coreContext.configService.atPathSync(pluginManifest.configPath); }, }, }; diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 9a1403bda3bca..dd2831f77f537 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -219,10 +219,7 @@ export class PluginsService implements CoreService { packageInfo: Readonly; instanceUuid: string; }; + /** + * {@link LoggerFactory | logger factory} instance already bound to the plugin's logging context + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * // "id: myPlugin" in `plugins/my-plugin/kibana.yaml` + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) { + * this.logger = initContext.logger.get(); + * // `logger` context: `plugins.myPlugin` + * this.mySubLogger = initContext.logger.get('sub'); // or this.logger.get('sub'); + * // `mySubLogger` context: `plugins.myPlugin.sub` + * } + * } + * ``` + */ logger: LoggerFactory; + /** + * Accessors for the plugin's configuration + */ config: { - legacy: { globalConfig$: Observable }; + /** + * Provide access to Kibana legacy configuration values. + * + * @remarks Naming not final here, it may be renamed in a near future + * @deprecated Accessing configuration values outside of the plugin's config scope is highly discouraged + */ + legacy: { + globalConfig$: Observable; + get: () => SharedGlobalConfig; + }; + /** + * Return an observable of the plugin's configuration + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * setup(core) { + * this.configSub = this.initContext.config.create().subscribe((config) => { + * this.myService.reconfigure(config); + * }); + * } + * stop() { + * this.configSub.unsubscribe(); + * } + * ``` + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * async setup(core) { + * this.config = await this.initContext.config.create().pipe(take(1)).toPromise(); + * } + * stop() { + * this.configSub.unsubscribe(); + * } + * ``` + * + * @remarks The underlying observable has a replay effect, meaning that awaiting for the first emission + * will be resolved at next tick, without risks to delay any asynchronous code's workflow. + */ create: () => Observable; - createIfExists: () => Observable; + /** + * Return the current value of the plugin's configuration synchronously. + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * setup(core) { + * const config = this.initContext.config.get(); + * // do something with the config + * } + * } + * ``` + * + * @remarks This should only be used when synchronous access is an absolute necessity, such + * as during the plugin's setup or start lifecycle. For all other usages, + * {@link create} should be used instead. + */ + get: () => T; }; } diff --git a/src/core/server/root/index.ts b/src/core/server/root/index.ts index 17cb209897c25..a918580392caa 100644 --- a/src/core/server/root/index.ts +++ b/src/core/server/root/index.ts @@ -36,7 +36,7 @@ export class Root { public async setup() { try { - await this.server.setupCoreConfig(); + this.server.setupCoreConfig(); await this.setupLogging(); this.log.debug('setting up root'); return await this.server.setup(); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 6ba652abda3d5..741f715ba6ebe 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -206,20 +206,6 @@ describe('DocumentMigrator', () => { ); }); - it('coerces the current Kibana version if it has a hyphen', () => { - const validDefinition = { - kibanaVersion: '3.2.0-SNAPSHOT', - typeRegistry: createRegistry({ - name: 'foo', - convertToMultiNamespaceTypeVersion: '3.2.0', - namespaceType: 'multiple', - }), - minimumConvertVersion: '0.0.0', - log: mockLogger, - }; - expect(() => new DocumentMigrator(validDefinition)).not.toThrowError(); - }); - it('validates convertToMultiNamespaceTypeVersion is not used on a patch version', () => { const invalidDefinition = { kibanaVersion: '3.2.3', diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index e93586ec7ce4c..e4b89a949d3cf 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -159,11 +159,10 @@ export class DocumentMigrator implements VersionedTransformer { */ constructor({ typeRegistry, - kibanaVersion: rawKibanaVersion, + kibanaVersion, minimumConvertVersion = DEFAULT_MINIMUM_CONVERT_VERSION, log, }: DocumentMigratorOptions) { - const kibanaVersion = rawKibanaVersion.split('-')[0]; // coerce a semver-like string (x.y.z-SNAPSHOT) or prerelease version (x.y.z-alpha) to a regular semver (x.y.z) validateMigrationDefinition(typeRegistry, kibanaVersion, minimumConvertVersion); this.documentMigratorOptions = { typeRegistry, kibanaVersion, log }; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 281d2ce8d03cf..dac93ff29b68f 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -31,6 +31,14 @@ const createRegistry = (types: Array>) => { }; describe('KibanaMigrator', () => { + describe('constructor', () => { + it('coerces the current Kibana version if it has a hyphen', () => { + const options = mockOptions(); + options.kibanaVersion = '3.2.1-SNAPSHOT'; + const migrator = new KibanaMigrator(options); + expect(migrator.kibanaVersion).toEqual('3.2.1'); + }); + }); describe('getActiveMappings', () => { it('returns full index mappings w/ core properties', () => { const options = mockOptions(); diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index c8bc4b2e14123..ecef84a6e297c 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -90,13 +90,12 @@ export class KibanaMigrator { }: KibanaMigratorOptions) { this.client = client; this.kibanaConfig = kibanaConfig; - this.kibanaVersion = kibanaVersion; this.savedObjectsConfig = savedObjectsConfig; this.typeRegistry = typeRegistry; this.serializer = new SavedObjectsSerializer(this.typeRegistry); this.mappingProperties = mergeTypes(this.typeRegistry.getAllTypes()); this.log = logger; - this.kibanaVersion = kibanaVersion; + this.kibanaVersion = kibanaVersion.split('-')[0]; // coerce a semver-like string (x.y.z-SNAPSHOT) or prerelease version (x.y.z-alpha) to a regular semver (x.y.z); this.documentMigrator = new DocumentMigrator({ kibanaVersion, typeRegistry, diff --git a/src/core/server/saved_objects/migrationsv2/model.test.ts b/src/core/server/saved_objects/migrationsv2/model.test.ts index d5ab85c54a728..a9aa69960b1c2 100644 --- a/src/core/server/saved_objects/migrationsv2/model.test.ts +++ b/src/core/server/saved_objects/migrationsv2/model.test.ts @@ -182,6 +182,21 @@ describe('migrations v2 model', () => { versionAlias: '.kibana_7.11.0', versionIndex: '.kibana_7.11.0_001', }; + const mappingsWithUnknownType = { + properties: { + disabled_saved_object_type: { + properties: { + value: { type: 'keyword' }, + }, + }, + }, + _meta: { + migrationMappingPropertyHashes: { + disabled_saved_object_type: '7997cf5a56cc02bdc9c93361bde732b0', + }, + }, + }; + test('INIT -> OUTDATED_DOCUMENTS_SEARCH if .kibana is already pointing to the target index', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { @@ -189,38 +204,27 @@ describe('migrations v2 model', () => { '.kibana': {}, '.kibana_7.11.0': {}, }, - mappings: { - properties: { - disabled_saved_object_type: { - properties: { - value: { type: 'keyword' }, - }, - }, - }, - _meta: { - migrationMappingPropertyHashes: { - disabled_saved_object_type: '7997cf5a56cc02bdc9c93361bde732b0', - }, - }, - }, + mappings: mappingsWithUnknownType, settings: {}, }, }); const newState = model(initState, res); expect(newState.controlState).toEqual('OUTDATED_DOCUMENTS_SEARCH'); + // This snapshot asserts that we merge the + // migrationMappingPropertyHashes of the existing index, but we leave + // the mappings for the disabled_saved_object_type untouched. There + // might be another Kibana instance that knows about this type and + // needs these mappings in place. expect(newState.targetIndexMappings).toMatchInlineSnapshot(` Object { "_meta": Object { "migrationMappingPropertyHashes": Object { + "disabled_saved_object_type": "7997cf5a56cc02bdc9c93361bde732b0", "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", }, }, "properties": Object { - "disabled_saved_object_type": Object { - "dynamic": false, - "properties": Object {}, - }, "new_saved_object_type": Object { "properties": Object { "value": Object { @@ -271,7 +275,7 @@ describe('migrations v2 model', () => { '.kibana': {}, '.kibana_7.12.0': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, '.kibana_7.11.0_001': { @@ -288,12 +292,37 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_7.invalid.0_001'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); }); test('INIT -> SET_SOURCE_WRITE_BLOCK when migrating from a v2 migrations index (>= 7.11.0)', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana_7.11.0_001': { aliases: { '.kibana': {}, '.kibana_7.11.0': {} }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, '.kibana_3': { @@ -319,6 +348,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_7.11.0_001'), targetIndex: '.kibana_7.12.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -328,7 +382,7 @@ describe('migrations v2 model', () => { aliases: { '.kibana': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -339,6 +393,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_3'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -346,7 +425,7 @@ describe('migrations v2 model', () => { const res: ResponseType<'INIT'> = Either.right({ '.kibana': { aliases: {}, - mappings: { properties: {}, _meta: {} }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -357,6 +436,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('.kibana_pre6.5.0_001'), targetIndex: '.kibana_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -366,7 +470,7 @@ describe('migrations v2 model', () => { aliases: { 'my-saved-objects': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -386,6 +490,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('my-saved-objects_3'), targetIndex: 'my-saved-objects_7.11.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); @@ -395,7 +524,7 @@ describe('migrations v2 model', () => { aliases: { 'my-saved-objects': {}, }, - mappings: { properties: {}, _meta: { migrationMappingPropertyHashes: {} } }, + mappings: mappingsWithUnknownType, settings: {}, }, }); @@ -416,6 +545,31 @@ describe('migrations v2 model', () => { sourceIndex: Option.some('my-saved-objects_7.11.0'), targetIndex: 'my-saved-objects_7.12.0_001', }); + // This snapshot asserts that we disable the unknown saved object + // type. Because it's mappings are disabled, we also don't copy the + // `_meta.migrationMappingPropertyHashes` for the disabled type. + expect(newState.targetIndexMappings).toMatchInlineSnapshot(` + Object { + "_meta": Object { + "migrationMappingPropertyHashes": Object { + "new_saved_object_type": "4a11183eee21e6fbad864f7a30b39ad0", + }, + }, + "properties": Object { + "disabled_saved_object_type": Object { + "dynamic": false, + "properties": Object {}, + }, + "new_saved_object_type": Object { + "properties": Object { + "value": Object { + "type": "text", + }, + }, + }, + }, + } + `); expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); diff --git a/src/core/server/saved_objects/migrationsv2/model.ts b/src/core/server/saved_objects/migrationsv2/model.ts index 1119edde8e268..3556bb611bb67 100644 --- a/src/core/server/saved_objects/migrationsv2/model.ts +++ b/src/core/server/saved_objects/migrationsv2/model.ts @@ -60,13 +60,13 @@ function throwBadResponse(state: State, res: any): never { * Merge the _meta.migrationMappingPropertyHashes mappings of an index with * the given target mappings. * - * @remarks Mapping updates are commutative (deeply merged) by Elasticsearch, - * except for the _meta key. The source index we're migrating from might - * contain documents created by a plugin that is disabled in the Kibana - * instance performing this migration. We merge the - * _meta.migrationMappingPropertyHashes mappings from the source index into - * the targetMappings to ensure that any `migrationPropertyHashes` for - * disabled plugins aren't lost. + * @remarks When another instance already completed a migration, the existing + * target index might contain documents and mappings created by a plugin that + * is disabled in the current Kibana instance performing this migration. + * Mapping updates are commutative (deeply merged) by Elasticsearch, except + * for the `_meta` key. By merging the `_meta.migrationMappingPropertyHashes` + * mappings from the existing target index index into the targetMappings we + * ensure that any `migrationPropertyHashes` for disabled plugins aren't lost. * * Right now we don't use these `migrationPropertyHashes` but it could be used * in the future to detect if mappings were changed. If mappings weren't @@ -209,7 +209,7 @@ export const model = (currentState: State, resW: ResponseType): // index sourceIndex: Option.none, targetIndex: `${stateP.indexPrefix}_${stateP.kibanaVersion}_001`, - targetIndexMappings: disableUnknownTypeMappingFields( + targetIndexMappings: mergeMigrationMappingPropertyHashes( stateP.targetIndexMappings, indices[aliases[stateP.currentAlias]].mappings ), @@ -242,7 +242,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'SET_SOURCE_WRITE_BLOCK', sourceIndex: Option.some(source) as Option.Some, targetIndex: target, - targetIndexMappings: mergeMigrationMappingPropertyHashes( + targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, indices[source].mappings ), @@ -279,7 +279,7 @@ export const model = (currentState: State, resW: ResponseType): controlState: 'LEGACY_SET_WRITE_BLOCK', sourceIndex: Option.some(legacyReindexTarget) as Option.Some, targetIndex: target, - targetIndexMappings: mergeMigrationMappingPropertyHashes( + targetIndexMappings: disableUnknownTypeMappingFields( stateP.targetIndexMappings, indices[stateP.legacyIndex].mappings ), diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index 6d57eaa3777e6..b85747985e523 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -44,7 +45,7 @@ export const registerBulkCreateRoute = (router: IRouter, { coreUsageData }: Rout ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/bulk_get.ts b/src/core/server/saved_objects/routes/bulk_get.ts index a260301633668..580bf26a4e529 100644 --- a/src/core/server/saved_objects/routes/bulk_get.ts +++ b/src/core/server/saved_objects/routes/bulk_get.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -28,7 +29,7 @@ export const registerBulkGetRoute = (router: IRouter, { coreUsageData }: RouteDe ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsBulkGet({ request: req }).catch(() => {}); diff --git a/src/core/server/saved_objects/routes/bulk_update.ts b/src/core/server/saved_objects/routes/bulk_update.ts index f9b8d4a2f567f..e592adc72a244 100644 --- a/src/core/server/saved_objects/routes/bulk_update.ts +++ b/src/core/server/saved_objects/routes/bulk_update.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -39,7 +40,7 @@ export const registerBulkUpdateRoute = (router: IRouter, { coreUsageData }: Rout ), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const usageStatsClient = coreUsageData.getClient(); usageStatsClient.incrementSavedObjectsBulkUpdate({ request: req }).catch(() => {}); diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index fd256abac3526..f6043ca96398d 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -43,7 +44,7 @@ export const registerCreateRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; const { diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index a7846c3dc845b..b127f64b74a0c 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -28,7 +29,7 @@ export const registerDeleteRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { force } = req.query; diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 9b40855afec2e..f064cf1ca0ec1 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -18,7 +18,7 @@ import { SavedObjectsExportByObjectOptions, SavedObjectsExportError, } from '../export'; -import { validateTypes, validateObjects } from './utils'; +import { validateTypes, validateObjects, catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { config: SavedObjectConfig; @@ -163,7 +163,7 @@ export const registerExportRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const cleaned = cleanOptions(req.body); const supportedTypes = context.core.savedObjects.typeRegistry .getImportableAndExportableTypes() diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 747070e54e5ad..c814fd310dc52 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -49,7 +50,7 @@ export const registerFindRoute = (router: IRouter, { coreUsageData }: RouteDepen }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const query = req.query; const namespaces = diff --git a/src/core/server/saved_objects/routes/get.ts b/src/core/server/saved_objects/routes/get.ts index c66a11dcf0cdd..2dd812f35cefd 100644 --- a/src/core/server/saved_objects/routes/get.ts +++ b/src/core/server/saved_objects/routes/get.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -25,7 +26,7 @@ export const registerGetRoute = (router: IRouter, { coreUsageData }: RouteDepend }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 6c4c759460ce3..5fd132acafbed 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -13,7 +13,7 @@ import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { SavedObjectsImportError } from '../import'; -import { createSavedObjectsStreamFromNdJson } from './utils'; +import { catchAndReturnBoomErrors, createSavedObjectsStreamFromNdJson } from './utils'; interface RouteDependencies { config: SavedObjectConfig; @@ -61,7 +61,7 @@ export const registerImportRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { overwrite, createNewCopies } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/migrate.ts b/src/core/server/saved_objects/routes/migrate.ts index 8b347d4725b08..7c2f4bfb06710 100644 --- a/src/core/server/saved_objects/routes/migrate.ts +++ b/src/core/server/saved_objects/routes/migrate.ts @@ -8,6 +8,7 @@ import { IRouter } from '../../http'; import { IKibanaMigrator } from '../migrations'; +import { catchAndReturnBoomErrors } from './utils'; export const registerMigrateRoute = ( router: IRouter, @@ -21,7 +22,7 @@ export const registerMigrateRoute = ( tags: ['access:migrateSavedObjects'], }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const migrator = await migratorPromise; await migrator.runMigrations({ rerun: true }); return res.ok({ diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 0cf976c30b311..6f0a3d028baf9 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -13,8 +13,7 @@ import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; import { SavedObjectConfig } from '../saved_objects_config'; import { SavedObjectsImportError } from '../import'; -import { createSavedObjectsStreamFromNdJson } from './utils'; - +import { catchAndReturnBoomErrors, createSavedObjectsStreamFromNdJson } from './utils'; interface RouteDependencies { config: SavedObjectConfig; coreUsageData: CoreUsageDataSetup; @@ -69,7 +68,7 @@ export const registerResolveImportErrorsRoute = ( }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { createNewCopies } = req.query; const usageStatsClient = coreUsageData.getClient(); diff --git a/src/core/server/saved_objects/routes/update.ts b/src/core/server/saved_objects/routes/update.ts index 17cfd438d47bf..dbc69f743df76 100644 --- a/src/core/server/saved_objects/routes/update.ts +++ b/src/core/server/saved_objects/routes/update.ts @@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { CoreUsageDataSetup } from '../../core_usage_data'; +import { catchAndReturnBoomErrors } from './utils'; interface RouteDependencies { coreUsageData: CoreUsageDataSetup; @@ -38,7 +39,7 @@ export const registerUpdateRoute = (router: IRouter, { coreUsageData }: RouteDep }), }, }, - router.handleLegacyErrors(async (context, req, res) => { + catchAndReturnBoomErrors(async (context, req, res) => { const { type, id } = req.params; const { attributes, version, references } = req.body; const options = { version, references }; diff --git a/src/core/server/saved_objects/routes/utils.test.ts b/src/core/server/saved_objects/routes/utils.test.ts index ade7b03f6a8c2..1d7e86e288b18 100644 --- a/src/core/server/saved_objects/routes/utils.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -9,6 +9,15 @@ import { createSavedObjectsStreamFromNdJson, validateTypes, validateObjects } from './utils'; import { Readable } from 'stream'; import { createPromiseFromStreams, createConcatStream } from '@kbn/utils'; +import { catchAndReturnBoomErrors } from './utils'; +import Boom from '@hapi/boom'; +import { + KibanaRequest, + RequestHandler, + RequestHandlerContext, + KibanaResponseFactory, + kibanaResponseFactory, +} from '../../'; async function readStreamToCompletion(stream: Readable) { return createPromiseFromStreams([stream, createConcatStream([])]); @@ -143,3 +152,69 @@ describe('validateObjects', () => { ).toBeUndefined(); }); }); + +describe('catchAndReturnBoomErrors', () => { + let context: RequestHandlerContext; + let request: KibanaRequest; + let response: KibanaResponseFactory; + + const createHandler = (handler: () => any): RequestHandler => () => { + return handler(); + }; + + beforeEach(() => { + context = {} as any; + request = {} as any; + response = kibanaResponseFactory; + }); + + it('should pass-though call parameters to the handler', async () => { + const handler = jest.fn(); + const wrapped = catchAndReturnBoomErrors(handler); + await wrapped(context, request, response); + expect(handler).toHaveBeenCalledWith(context, request, response); + }); + + it('should pass-though result from the handler', async () => { + const handler = createHandler(() => { + return 'handler-response'; + }); + const wrapped = catchAndReturnBoomErrors(handler); + const result = await wrapped(context, request, response); + expect(result).toBe('handler-response'); + }); + + it('should intercept and convert thrown Boom errors', async () => { + const handler = createHandler(() => { + throw Boom.notFound('not there'); + }); + const wrapped = catchAndReturnBoomErrors(handler); + const result = await wrapped(context, request, response); + expect(result.status).toBe(404); + expect(result.payload).toEqual({ + error: 'Not Found', + message: 'not there', + statusCode: 404, + }); + }); + + it('should re-throw non-Boom errors', async () => { + const handler = createHandler(() => { + throw new Error('something went bad'); + }); + const wrapped = catchAndReturnBoomErrors(handler); + await expect(wrapped(context, request, response)).rejects.toMatchInlineSnapshot( + `[Error: something went bad]` + ); + }); + + it('should re-throw Boom internal/500 errors', async () => { + const handler = createHandler(() => { + throw Boom.internal(); + }); + const wrapped = catchAndReturnBoomErrors(handler); + await expect(wrapped(context, request, response)).rejects.toMatchInlineSnapshot( + `[Error: Internal Server Error]` + ); + }); +}); diff --git a/src/core/server/saved_objects/routes/utils.ts b/src/core/server/saved_objects/routes/utils.ts index b9e7df48a4b4c..269f3f0698561 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -7,7 +7,11 @@ */ import { Readable } from 'stream'; -import { SavedObject, SavedObjectsExportResultDetails } from 'src/core/server'; +import { + RequestHandlerWrapper, + SavedObject, + SavedObjectsExportResultDetails, +} from 'src/core/server'; import { createSplitStream, createMapStream, @@ -16,6 +20,7 @@ import { createListStream, createConcatStream, } from '@kbn/utils'; +import Boom from '@hapi/boom'; export async function createSavedObjectsStreamFromNdJson(ndJsonStream: Readable) { const savedObjects = await createPromiseFromStreams([ @@ -52,3 +57,30 @@ export function validateObjects( .join(', ')}`; } } + +/** + * Catches errors thrown by saved object route handlers and returns an error + * with the payload and statusCode of the boom error. + * + * This is very close to the core `router.handleLegacyErrors` except that it + * throws internal errors (statusCode: 500) so that the internal error's + * message get logged by Core. + * + * TODO: Remove once https://github.com/elastic/kibana/issues/65291 is fixed. + */ +export const catchAndReturnBoomErrors: RequestHandlerWrapper = (handler) => { + return async (context, request, response) => { + try { + return await handler(context, request, response); + } catch (e) { + if (Boom.isBoom(e) && e.output.statusCode !== 500) { + return response.customError({ + body: e.output.payload, + statusCode: e.output.statusCode, + headers: e.output.headers as { [key: string]: string }, + }); + } + throw e; + } + }; +}; diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts index cc497ca6348b8..da1ebec2c0f7d 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts @@ -109,6 +109,27 @@ describe('savedObjectsClient/decorateEsError', () => { expect(SavedObjectsErrorHelpers.isNotFoundError(genericError)).toBe(true); }); + it('if saved objects index does not exist makes NotFound a SavedObjectsClient/generalError', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 404, + body: { + error: { + reason: + 'no such index [.kibana_8.0.0] and [require_alias] request flag is [true] and [.kibana_8.0.0] is not an alias', + }, + }, + }) + ); + expect(SavedObjectsErrorHelpers.isGeneralError(error)).toBe(false); + const genericError = decorateEsError(error); + expect(genericError.message).toEqual( + `Saved object index alias [.kibana_8.0.0] not found: Response Error` + ); + expect(genericError.output.statusCode).toBe(500); + expect(SavedObjectsErrorHelpers.isGeneralError(error)).toBe(true); + }); + it('makes BadRequest a SavedObjectsClient/BadRequest error', () => { const error = new esErrors.ResponseError( elasticsearchClientMock.createApiResponse({ statusCode: 400 }) diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.ts index 40f18c9c94c25..aabca2d602cb3 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.ts @@ -63,6 +63,12 @@ export function decorateEsError(error: EsErrors) { } if (responseErrors.isNotFound(error.statusCode)) { + const match = error?.meta?.body?.error?.reason?.match( + /no such index \[(.+)\] and \[require_alias\] request flag is \[true\] and \[.+\] is not an alias/ + ); + if (match?.length > 0) { + return SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError(error, match[1]); + } return SavedObjectsErrorHelpers.createGenericNotFoundError(); } diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index f216e72efbcf8..c348196aaba21 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -135,6 +135,19 @@ export class SavedObjectsErrorHelpers { return decorate(Boom.notFound(), CODE_NOT_FOUND, 404); } + public static createIndexAliasNotFoundError(alias: string) { + return SavedObjectsErrorHelpers.decorateIndexAliasNotFoundError(Boom.internal(), alias); + } + + public static decorateIndexAliasNotFoundError(error: Error, alias: string) { + return decorate( + error, + CODE_GENERAL_ERROR, + 500, + `Saved object index alias [${alias}] not found` + ); + } + public static isNotFoundError(error: Error | DecoratedError) { return isSavedObjectsClientError(error) && error[code] === CODE_NOT_FOUND; } @@ -185,4 +198,8 @@ export class SavedObjectsErrorHelpers { public static decorateGeneralError(error: Error, reason?: string) { return decorate(error, CODE_GENERAL_ERROR, 500, reason); } + + public static isGeneralError(error: Error | DecoratedError) { + return isSavedObjectsClientError(error) && error[code] === CODE_GENERAL_ERROR; + } } diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 216e1c4bd2d3c..68fdea0f9eb25 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -18,6 +18,7 @@ import { DocumentMigrator } from '../../migrations/core/document_migrator'; import { mockKibanaMigrator } from '../../migrations/kibana/kibana_migrator.mock'; import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { esKuery } from '../../es_query'; +import { errors as EsErrors } from '@elastic/elasticsearch'; const { nodeTypes } = esKuery; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -4341,8 +4342,14 @@ describe('SavedObjectsRepository', () => { }); it(`throws when ES is unable to find the document during update`, async () => { + const notFoundError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 404, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + elasticsearchClientMock.createErrorTransportRequestPromise(notFoundError) ); await expectNotFoundError(type, id); expect(client.update).toHaveBeenCalledTimes(1); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 2993d4234bd2e..da80971635a93 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -299,6 +299,7 @@ export class SavedObjectsRepository { refresh, body: raw._source, ...(overwrite && version ? decodeRequestVersion(version) : {}), + require_alias: true, }; const { body } = @@ -469,6 +470,7 @@ export class SavedObjectsRepository { const bulkResponse = bulkCreateParams.length ? await this.client.bulk({ refresh, + require_alias: true, body: bulkCreateParams, }) : undefined; @@ -1117,8 +1119,8 @@ export class SavedObjectsRepository { ...(Array.isArray(references) && { references }), }; - const { body, statusCode } = await this.client.update( - { + const { body } = await this.client + .update({ id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), ...getExpectedVersionProperties(version, preflightResult), @@ -1128,14 +1130,15 @@ export class SavedObjectsRepository { doc, }, _source_includes: ['namespace', 'namespaces', 'originId'], - }, - { ignore: [404] } - ); - - if (statusCode === 404) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } + require_alias: true, + }) + .catch((err) => { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + throw err; + }); const { originId } = body.get._source; let namespaces = []; @@ -1496,6 +1499,7 @@ export class SavedObjectsRepository { refresh, body: bulkUpdateParams, _source_includes: ['originId'], + require_alias: true, }) : undefined; @@ -1712,6 +1716,7 @@ export class SavedObjectsRepository { id: raw._id, index: this.getIndexForType(type), refresh, + require_alias: true, _source: 'true', body: { script: { @@ -1933,12 +1938,18 @@ export class SavedObjectsRepository { } } -function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) { +function getBulkOperationError( + error: { type: string; reason?: string; index?: string }, + type: string, + id: string +) { switch (error.type) { case 'version_conflict_engine_exception': return errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)); case 'document_missing_exception': return errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)); + case 'index_not_found_exception': + return errorContent(SavedObjectsErrorHelpers.createIndexAliasNotFoundError(error.index!)); default: return { message: error.reason || JSON.stringify(error), diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index fc90284ffe5b2..f3191c5625f8d 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -843,7 +843,7 @@ export type ElasticsearchClient = Omit & { +export type ElasticsearchClientConfig = Pick & { pingTimeout?: ElasticsearchConfig['pingTimeout'] | ClientOptions['pingTimeout']; requestTimeout?: ElasticsearchConfig['requestTimeout'] | ClientOptions['requestTimeout']; ssl?: Partial; @@ -859,7 +859,6 @@ export class ElasticsearchConfig { readonly healthCheckDelay: Duration; readonly hosts: string[]; readonly ignoreVersionMismatch: boolean; - readonly logQueries: boolean; readonly password?: string; readonly pingTimeout: Duration; readonly requestHeadersWhitelist: string[]; @@ -1531,7 +1530,7 @@ export interface LegacyCallAPIOptions { // @public @deprecated export class LegacyClusterClient implements ILegacyClusterClient { - constructor(config: LegacyElasticsearchClientConfig, log: Logger, getAuthHeaders?: GetAuthHeaders); + constructor(config: LegacyElasticsearchClientConfig, log: Logger, type: string, getAuthHeaders?: GetAuthHeaders); asScoped(request?: ScopeableRequest): ILegacyScopedClusterClient; // @deprecated callAsInternalUser: LegacyAPICaller; @@ -1553,7 +1552,7 @@ export interface LegacyConfig { } // @public @deprecated (undocumented) -export type LegacyElasticsearchClientConfig = Pick & Pick & { +export type LegacyElasticsearchClientConfig = Pick & Pick & { pingTimeout?: ElasticsearchConfig['pingTimeout'] | ConfigOptions['pingTimeout']; requestTimeout?: ElasticsearchConfig['requestTimeout'] | ConfigOptions['requestTimeout']; sniffInterval?: ElasticsearchConfig['sniffInterval'] | ConfigOptions['sniffInterval']; @@ -1841,13 +1840,13 @@ export type PluginInitializer { - // (undocumented) config: { legacy: { globalConfig$: Observable; + get: () => SharedGlobalConfig; }; create: () => Observable; - createIfExists: () => Observable; + get: () => T; }; // (undocumented) env: { @@ -1855,7 +1854,7 @@ export interface PluginInitializerContext { packageInfo: Readonly; instanceUuid: string; }; - // (undocumented) + // Warning: (ae-unresolved-link) The @link reference could not be resolved: Reexported declarations are not supported logger: LoggerFactory; // (undocumented) opaqueId: PluginOpaqueId; @@ -2336,6 +2335,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) + static createIndexAliasNotFoundError(alias: string): DecoratedError; + // (undocumented) static createInvalidVersionError(versionInput?: string): DecoratedError; // (undocumented) static createTooManyRequestsError(type: string, id: string): DecoratedError; @@ -2354,6 +2355,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static decorateGeneralError(error: Error, reason?: string): DecoratedError; // (undocumented) + static decorateIndexAliasNotFoundError(error: Error, alias: string): DecoratedError; + // (undocumented) static decorateNotAuthorizedError(error: Error, reason?: string): DecoratedError; // (undocumented) static decorateRequestEntityTooLargeError(error: Error, reason?: string): DecoratedError; @@ -2370,6 +2373,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static isForbiddenError(error: Error | DecoratedError): boolean; // (undocumented) + static isGeneralError(error: Error | DecoratedError): boolean; + // (undocumented) static isInvalidVersionError(error: Error | DecoratedError): boolean; // (undocumented) static isNotAuthorizedError(error: Error | DecoratedError): boolean; @@ -3139,5 +3144,6 @@ export const validBodyOutput: readonly ["data", "stream"]; // src/core/server/plugins/types.ts:263:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:263:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts +// src/core/server/plugins/types.ts:371:5 - (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "create" ``` diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 60f3f90428d40..cc1087a422e39 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -300,7 +300,7 @@ export class Server { ); } - public async setupCoreConfig() { + public setupCoreConfig() { const configDescriptors: Array> = [ pathConfig, cspConfig, @@ -325,7 +325,7 @@ export class Server { if (descriptor.deprecations) { this.configService.addDeprecationProvider(descriptor.path, descriptor.deprecations); } - await this.configService.setSchema(descriptor.path, descriptor.schema); + this.configService.setSchema(descriptor.path, descriptor.schema); } } } diff --git a/src/core/server/ui_settings/integration_tests/doc_exists.ts b/src/core/server/ui_settings/integration_tests/doc_exists.ts index aa6f98ddf2d03..d100b89af9609 100644 --- a/src/core/server/ui_settings/integration_tests/doc_exists.ts +++ b/src/core/server/ui_settings/integration_tests/doc_exists.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docExistsSuite() { +export const docExistsSuite = (savedObjectsIndex: string) => () => { async function setup(options: any = {}) { const { initialSettings } = options; @@ -16,7 +16,7 @@ export function docExistsSuite() { // delete the kibana index to ensure we start fresh await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { conflicts: 'proceed', query: { match_all: {} }, @@ -212,4 +212,4 @@ export function docExistsSuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/doc_missing.ts b/src/core/server/ui_settings/integration_tests/doc_missing.ts index 501976e3823f1..822ffe398b87d 100644 --- a/src/core/server/ui_settings/integration_tests/doc_missing.ts +++ b/src/core/server/ui_settings/integration_tests/doc_missing.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docMissingSuite() { +export const docMissingSuite = (savedObjectsIndex: string) => () => { // ensure the kibana index has no documents beforeEach(async () => { const { kbnServer, callCluster } = getServices(); @@ -22,7 +22,7 @@ export function docMissingSuite() { // delete all docs from kibana index to ensure savedConfig is not found await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { query: { match_all: {} }, }, @@ -136,4 +136,4 @@ export function docMissingSuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts b/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts index b2ff1b2f1d4ab..997d51e36abdc 100644 --- a/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts +++ b/src/core/server/ui_settings/integration_tests/doc_missing_and_index_read_only.ts @@ -8,7 +8,7 @@ import { getServices, chance } from './lib'; -export function docMissingAndIndexReadOnlySuite() { +export const docMissingAndIndexReadOnlySuite = (savedObjectsIndex: string) => () => { // ensure the kibana index has no documents beforeEach(async () => { const { kbnServer, callCluster } = getServices(); @@ -22,7 +22,7 @@ export function docMissingAndIndexReadOnlySuite() { // delete all docs from kibana index to ensure savedConfig is not found await callCluster('deleteByQuery', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { query: { match_all: {} }, }, @@ -30,7 +30,7 @@ export function docMissingAndIndexReadOnlySuite() { // set the index to read only await callCluster('indices.putSettings', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { index: { blocks: { @@ -42,11 +42,11 @@ export function docMissingAndIndexReadOnlySuite() { }); afterEach(async () => { - const { kbnServer, callCluster } = getServices(); + const { callCluster } = getServices(); // disable the read only block await callCluster('indices.putSettings', { - index: kbnServer.config.get('kibana.index'), + index: savedObjectsIndex, body: { index: { blocks: { @@ -142,4 +142,4 @@ export function docMissingAndIndexReadOnlySuite() { }); }); }); -} +}; diff --git a/src/core/server/ui_settings/integration_tests/index.test.ts b/src/core/server/ui_settings/integration_tests/index.test.ts index f415f1d73de7d..e27e6c4e46874 100644 --- a/src/core/server/ui_settings/integration_tests/index.test.ts +++ b/src/core/server/ui_settings/integration_tests/index.test.ts @@ -6,20 +6,25 @@ * Public License, v 1. */ +import { Env } from '@kbn/config'; +import { REPO_ROOT } from '@kbn/dev-utils'; +import { getEnvOptions } from '@kbn/config/target/mocks'; import { startServers, stopServers } from './lib'; - import { docExistsSuite } from './doc_exists'; import { docMissingSuite } from './doc_missing'; import { docMissingAndIndexReadOnlySuite } from './doc_missing_and_index_read_only'; +const kibanaVersion = Env.createDefault(REPO_ROOT, getEnvOptions()).packageInfo.version; +const savedObjectIndex = `.kibana_${kibanaVersion}_001`; + describe('uiSettings/routes', function () { jest.setTimeout(10000); beforeAll(startServers); /* eslint-disable jest/valid-describe */ - describe('doc missing', docMissingSuite); - describe('doc missing and index readonly', docMissingAndIndexReadOnlySuite); - describe('doc exists', docExistsSuite); + describe('doc missing', docMissingSuite(savedObjectIndex)); + describe('doc missing and index readonly', docMissingAndIndexReadOnlySuite(savedObjectIndex)); + describe('doc exists', docExistsSuite(savedObjectIndex)); /* eslint-enable jest/valid-describe */ afterAll(stopServers); }); diff --git a/src/core/server/ui_settings/integration_tests/lib/servers.ts b/src/core/server/ui_settings/integration_tests/lib/servers.ts index b5198b19007d0..f181272030ae1 100644 --- a/src/core/server/ui_settings/integration_tests/lib/servers.ts +++ b/src/core/server/ui_settings/integration_tests/lib/servers.ts @@ -37,9 +37,6 @@ export async function startServers() { adjustTimeout: (t) => jest.setTimeout(t), settings: { kbn: { - migrations: { - enableV2: false, - }, uiSettings: { overrides: { foo: 'bar', diff --git a/src/core/test_helpers/kbn_server.ts b/src/core/test_helpers/kbn_server.ts index 0007e1fcca0a5..6fe6819a0981a 100644 --- a/src/core/test_helpers/kbn_server.ts +++ b/src/core/test_helpers/kbn_server.ts @@ -40,7 +40,7 @@ const DEFAULTS_SETTINGS = { }, logging: { silent: true }, plugins: {}, - migrations: { skip: true }, + migrations: { skip: false }, }; const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = { diff --git a/src/dev/build/args.test.ts b/src/dev/build/args.test.ts index 584f0dfe54b74..745b9d0b910c8 100644 --- a/src/dev/build/args.test.ts +++ b/src/dev/build/args.test.ts @@ -30,8 +30,9 @@ it('build default and oss dist for current platform, without packages, by defaul "buildOssDist": true, "createArchives": true, "createDebPackage": false, - "createDockerPackage": false, - "createDockerUbiPackage": false, + "createDockerCentOS": false, + "createDockerContexts": false, + "createDockerUBI": false, "createRpmPackage": false, "downloadFreshNode": true, "isRelease": false, @@ -53,8 +54,9 @@ it('builds packages if --all-platforms is passed', () => { "buildOssDist": true, "createArchives": true, "createDebPackage": true, - "createDockerPackage": true, - "createDockerUbiPackage": true, + "createDockerCentOS": true, + "createDockerContexts": true, + "createDockerUBI": true, "createRpmPackage": true, "downloadFreshNode": true, "isRelease": false, @@ -76,8 +78,9 @@ it('limits packages if --rpm passed with --all-platforms', () => { "buildOssDist": true, "createArchives": true, "createDebPackage": false, - "createDockerPackage": false, - "createDockerUbiPackage": false, + "createDockerCentOS": false, + "createDockerContexts": false, + "createDockerUBI": false, "createRpmPackage": true, "downloadFreshNode": true, "isRelease": false, @@ -99,8 +102,9 @@ it('limits packages if --deb passed with --all-platforms', () => { "buildOssDist": true, "createArchives": true, "createDebPackage": true, - "createDockerPackage": false, - "createDockerUbiPackage": false, + "createDockerCentOS": false, + "createDockerContexts": false, + "createDockerUBI": false, "createRpmPackage": false, "downloadFreshNode": true, "isRelease": false, @@ -115,7 +119,7 @@ it('limits packages if --deb passed with --all-platforms', () => { }); it('limits packages if --docker passed with --all-platforms', () => { - expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--docker'])) + expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--docker-images'])) .toMatchInlineSnapshot(` Object { "buildOptions": Object { @@ -123,8 +127,9 @@ it('limits packages if --docker passed with --all-platforms', () => { "buildOssDist": true, "createArchives": true, "createDebPackage": false, - "createDockerPackage": true, - "createDockerUbiPackage": true, + "createDockerCentOS": true, + "createDockerContexts": false, + "createDockerUBI": true, "createRpmPackage": false, "downloadFreshNode": true, "isRelease": false, @@ -139,16 +144,24 @@ it('limits packages if --docker passed with --all-platforms', () => { }); it('limits packages if --docker passed with --skip-docker-ubi and --all-platforms', () => { - expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--docker', '--skip-docker-ubi'])) - .toMatchInlineSnapshot(` + expect( + readCliArgs([ + 'node', + 'scripts/build', + '--all-platforms', + '--docker-images', + '--skip-docker-ubi', + ]) + ).toMatchInlineSnapshot(` Object { "buildOptions": Object { "buildDefaultDist": true, "buildOssDist": true, "createArchives": true, "createDebPackage": false, - "createDockerPackage": true, - "createDockerUbiPackage": false, + "createDockerCentOS": true, + "createDockerContexts": false, + "createDockerUBI": false, "createRpmPackage": false, "downloadFreshNode": true, "isRelease": false, @@ -161,3 +174,28 @@ it('limits packages if --docker passed with --skip-docker-ubi and --all-platform } `); }); + +it('limits packages if --all-platforms passed with --skip-docker-centos', () => { + expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--skip-docker-centos'])) + .toMatchInlineSnapshot(` + Object { + "buildOptions": Object { + "buildDefaultDist": true, + "buildOssDist": true, + "createArchives": true, + "createDebPackage": true, + "createDockerCentOS": false, + "createDockerContexts": true, + "createDockerUBI": true, + "createRpmPackage": true, + "downloadFreshNode": true, + "isRelease": false, + "targetAllPlatforms": true, + "versionQualifier": "", + }, + "log": , + "showHelp": false, + "unknownFlags": Array [], + } + `); +}); diff --git a/src/dev/build/args.ts b/src/dev/build/args.ts index 5070e325f40a4..2d26d7db3a5e3 100644 --- a/src/dev/build/args.ts +++ b/src/dev/build/args.ts @@ -21,8 +21,10 @@ export function readCliArgs(argv: string[]) { 'skip-os-packages', 'rpm', 'deb', - 'docker', + 'docker-images', + 'docker-contexts', 'skip-docker-ubi', + 'skip-docker-centos', 'release', 'skip-node-download', 'verbose', @@ -42,7 +44,8 @@ export function readCliArgs(argv: string[]) { debug: true, rpm: null, deb: null, - docker: null, + 'docker-images': null, + 'docker-contexts': null, oss: null, 'version-qualifier': '', }, @@ -69,7 +72,7 @@ export function readCliArgs(argv: string[]) { // In order to build a docker image we always need // to generate all the platforms - if (flags.docker) { + if (flags['docker-images'] || flags['docker-contexts']) { flags['all-platforms'] = true; } @@ -79,7 +82,12 @@ export function readCliArgs(argv: string[]) { } // build all if no flags specified - if (flags.rpm === null && flags.deb === null && flags.docker === null) { + if ( + flags.rpm === null && + flags.deb === null && + flags['docker-images'] === null && + flags['docker-contexts'] === null + ) { return true; } @@ -95,8 +103,10 @@ export function readCliArgs(argv: string[]) { createArchives: !Boolean(flags['skip-archives']), createRpmPackage: isOsPackageDesired('rpm'), createDebPackage: isOsPackageDesired('deb'), - createDockerPackage: isOsPackageDesired('docker'), - createDockerUbiPackage: isOsPackageDesired('docker') && !Boolean(flags['skip-docker-ubi']), + createDockerCentOS: + isOsPackageDesired('docker-images') && !Boolean(flags['skip-docker-centos']), + createDockerUBI: isOsPackageDesired('docker-images') && !Boolean(flags['skip-docker-ubi']), + createDockerContexts: isOsPackageDesired('docker-contexts'), targetAllPlatforms: Boolean(flags['all-platforms']), }; diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts index 6620673829711..df4ba45517cc1 100644 --- a/src/dev/build/build_distributables.ts +++ b/src/dev/build/build_distributables.ts @@ -19,8 +19,9 @@ export interface BuildOptions { createArchives: boolean; createRpmPackage: boolean; createDebPackage: boolean; - createDockerPackage: boolean; - createDockerUbiPackage: boolean; + createDockerUBI: boolean; + createDockerCentOS: boolean; + createDockerContexts: boolean; versionQualifier: string | undefined; targetAllPlatforms: boolean; } @@ -95,12 +96,19 @@ export async function buildDistributables(log: ToolingLog, options: BuildOptions // control w/ --rpm or --skip-os-packages await run(Tasks.CreateRpmPackage); } - if (options.createDockerPackage) { - // control w/ --docker or --skip-docker-ubi or --skip-os-packages - await run(Tasks.CreateDockerPackage); - if (options.createDockerUbiPackage) { - await run(Tasks.CreateDockerUbiPackage); - } + if (options.createDockerUBI) { + // control w/ --docker-images or --skip-docker-ubi or --skip-os-packages + await run(Tasks.CreateDockerUBI); + } + + if (options.createDockerCentOS) { + // control w/ --docker-images or --skip-docker-centos or --skip-os-packages + await run(Tasks.CreateDockerCentOS); + } + + if (options.createDockerContexts) { + // control w/ --docker-contexts or --skip-os-packages + await run(Tasks.CreateDockerContexts); } /** diff --git a/src/dev/build/cli.ts b/src/dev/build/cli.ts index ca9debfdc1ae1..3e3a0a493f2d1 100644 --- a/src/dev/build/cli.ts +++ b/src/dev/build/cli.ts @@ -38,10 +38,12 @@ if (showHelp) { --skip-archives {dim Don't produce tar/zip archives} --skip-os-packages {dim Don't produce rpm/deb/docker packages} --all-platforms {dim Produce archives for all platforms, not just this one} - --rpm {dim Only build the rpm package} - --deb {dim Only build the deb package} - --docker {dim Only build the docker image} + --rpm {dim Only build the rpm packages} + --deb {dim Only build the deb packages} + --docker-images {dim Only build the Docker images} + --docker-contexts {dim Only build the Docker build contexts} --skip-docker-ubi {dim Don't build the docker ubi image} + --skip-docker-centos {dim Don't build the docker centos image} --release {dim Produce a release-ready distributable} --version-qualifier {dim Suffix version with a qualifier} --skip-node-download {dim Reuse existing downloads of node.js} diff --git a/src/dev/build/lib/version_info.test.ts b/src/dev/build/lib/integration_tests/version_info.test.ts similarity index 92% rename from src/dev/build/lib/version_info.test.ts rename to src/dev/build/lib/integration_tests/version_info.test.ts index dc0bf4ce6a833..36d052ebad937 100644 --- a/src/dev/build/lib/version_info.test.ts +++ b/src/dev/build/lib/integration_tests/version_info.test.ts @@ -6,10 +6,11 @@ * Public License, v 1. */ -import pkg from '../../../../package.json'; -import { getVersionInfo } from './version_info'; +import { kibanaPackageJSON as pkg } from '@kbn/dev-utils'; -jest.mock('./get_build_number'); +import { getVersionInfo } from '../version_info'; + +jest.mock('../get_build_number'); describe('isRelease = true', () => { it('returns unchanged package.version, build sha, and build number', async () => { diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index ba57a5f3dbfc9..fd0224d3de13b 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -8,7 +8,7 @@ import { Task } from '../../lib'; import { runFpm } from './run_fpm'; -import { runDockerGenerator, runDockerGeneratorForUBI } from './docker_generator'; +import { runDockerGenerator } from './docker_generator'; export const CreateDebPackage: Task = { description: 'Creating deb package', @@ -49,20 +49,56 @@ export const CreateRpmPackage: Task = { }, }; -export const CreateDockerPackage: Task = { - description: 'Creating docker package', +export const CreateDockerCentOS: Task = { + description: 'Creating Docker CentOS image', async run(config, log, build) { - // Builds Docker targets for default and oss - await runDockerGenerator(config, log, build); + await runDockerGenerator(config, log, build, { + ubi: false, + context: false, + architecture: 'x64', + image: true, + }); + await runDockerGenerator(config, log, build, { + ubi: false, + context: false, + architecture: 'aarch64', + image: true, + }); }, }; -export const CreateDockerUbiPackage: Task = { - description: 'Creating docker ubi package', +export const CreateDockerUBI: Task = { + description: 'Creating Docker UBI image', async run(config, log, build) { - // Builds Docker target default with ubi7 base image - await runDockerGeneratorForUBI(config, log, build); + if (!build.isOss()) { + await runDockerGenerator(config, log, build, { + ubi: true, + context: false, + architecture: 'x64', + image: true, + }); + } + }, +}; + +export const CreateDockerContexts: Task = { + description: 'Creating Docker build contexts', + + async run(config, log, build) { + await runDockerGenerator(config, log, build, { + ubi: false, + context: true, + image: false, + }); + + if (!build.isOss()) { + await runDockerGenerator(config, log, build, { + ubi: true, + context: true, + image: false, + }); + } }, }; diff --git a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts index 07a86927d5a35..4780457fe8054 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts @@ -18,7 +18,6 @@ export async function bundleDockerFiles(config: Config, log: ToolingLog, scope: log.info( `Generating kibana${scope.imageFlavor}${scope.ubiImageFlavor} docker build context bundle` ); - const dockerFilesDirName = `kibana${scope.imageFlavor}${scope.ubiImageFlavor}-${scope.version}-docker-build-context`; const dockerFilesBuildDir = resolve(scope.dockerBuildDir, dockerFilesDirName); const dockerFilesOutputDir = config.resolveFromTarget(`${dockerFilesDirName}.tar.gz`); diff --git a/src/dev/build/tasks/os_packages/docker_generator/index.ts b/src/dev/build/tasks/os_packages/docker_generator/index.ts index 1e6e6156c3ed9..229bd5242228c 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/index.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/index.ts @@ -6,4 +6,4 @@ * Public License, v 1. */ -export { runDockerGenerator, runDockerGeneratorForUBI } from './run'; +export { runDockerGenerator } from './run'; diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 30e3b60dcee83..1598f00354bf8 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -15,11 +15,17 @@ # --elasticsearch.logQueries=true kibana_vars=( + apm_oss.apmAgentConfigurationIndex + apm_oss.errorIndices + apm_oss.indexPattern + apm_oss.metricsIndices + apm_oss.onboardingIndices + apm_oss.sourcemapIndices + apm_oss.spanIndices + apm_oss.transactionIndices console.enabled console.proxyConfig console.proxyFilter - ops.cGroupOverrides.cpuPath - ops.cGroupOverrides.cpuAcctPath cpu.cgroup.path.override cpuacct.cgroup.path.override csp.rules @@ -41,10 +47,10 @@ kibana_vars=( elasticsearch.ssl.certificateAuthorities elasticsearch.ssl.key elasticsearch.ssl.keyPassphrase - elasticsearch.ssl.keystore.path elasticsearch.ssl.keystore.password - elasticsearch.ssl.truststore.path + elasticsearch.ssl.keystore.path elasticsearch.ssl.truststore.password + elasticsearch.ssl.truststore.path elasticsearch.ssl.verificationMode elasticsearch.username enterpriseSearch.accessCheckTimeout @@ -76,34 +82,42 @@ kibana_vars=( map.tilemap.options.minZoom map.tilemap.options.subdomains map.tilemap.url + migrations.batchSize + migrations.enableV2 + migrations.pollInterval + migrations.scrollDuration + migrations.skip monitoring.cluster_alerts.email_notifications.email_address monitoring.enabled monitoring.kibana.collection.enabled monitoring.kibana.collection.interval monitoring.ui.container.elasticsearch.enabled monitoring.ui.container.logstash.enabled - monitoring.ui.elasticsearch.password - monitoring.ui.elasticsearch.pingTimeout monitoring.ui.elasticsearch.hosts - monitoring.ui.elasticsearch.username monitoring.ui.elasticsearch.logFetchCount + monitoring.ui.elasticsearch.password + monitoring.ui.elasticsearch.pingTimeout monitoring.ui.elasticsearch.ssl.certificateAuthorities monitoring.ui.elasticsearch.ssl.verificationMode + monitoring.ui.elasticsearch.username monitoring.ui.enabled monitoring.ui.max_bucket_size monitoring.ui.min_interval_seconds newsfeed.enabled + ops.cGroupOverrides.cpuAcctPath + ops.cGroupOverrides.cpuPath ops.interval path.data pid.file regionmap security.showInsecureClusterWarning server.basePath - server.customResponseHeaders server.compression.enabled server.compression.referrerWhitelist server.cors server.cors.origin + server.customResponseHeaders + server.customResponseHeaders server.defaultRoute server.host server.keepAliveTimeout @@ -117,20 +131,24 @@ kibana_vars=( server.ssl.certificateAuthorities server.ssl.cipherSuites server.ssl.clientAuthentication - server.customResponseHeaders server.ssl.enabled server.ssl.key server.ssl.keyPassphrase - server.ssl.keystore.path server.ssl.keystore.password - server.ssl.truststore.path - server.ssl.truststore.password + server.ssl.keystore.path server.ssl.redirectHttpFromPort server.ssl.supportedProtocols + server.ssl.truststore.password + server.ssl.truststore.path server.xsrf.disableProtection server.xsrf.whitelist status.allowAnonymous status.v6ApiFormat + telemetry.allowChangingOptInStatus + telemetry.enabled + telemetry.optIn + telemetry.optInStatusUrl + telemetry.sendUsageFrom tilemap.options.attribution tilemap.options.maxZoom tilemap.options.minZoom @@ -142,9 +160,9 @@ kibana_vars=( xpack.actions.enabled xpack.actions.enabledActionTypes xpack.actions.preconfigured - xpack.actions.proxyUrl xpack.actions.proxyHeaders xpack.actions.proxyRejectUnauthorizedCertificates + xpack.actions.proxyUrl xpack.actions.rejectUnauthorized xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval @@ -154,37 +172,29 @@ kibana_vars=( xpack.apm.ui.enabled xpack.apm.ui.maxTraceItems xpack.apm.ui.transactionGroupBucketSize - apm_oss.apmAgentConfigurationIndex - apm_oss.indexPattern - apm_oss.errorIndices - apm_oss.onboardingIndices - apm_oss.spanIndices - apm_oss.sourcemapIndices - apm_oss.transactionIndices - apm_oss.metricsIndices xpack.canvas.enabled - xpack.code.ui.enabled xpack.code.disk.thresholdEnabled xpack.code.disk.watermarkLow - xpack.code.maxWorkspace xpack.code.indexRepoFrequencyMs - xpack.code.updateRepoFrequencyMs xpack.code.lsp.verbose - xpack.code.verbose + xpack.code.maxWorkspace xpack.code.security.enableGitCertCheck xpack.code.security.gitHostWhitelist xpack.code.security.gitProtocolWhitelist + xpack.code.ui.enabled + xpack.code.updateRepoFrequencyMs + xpack.code.verbose xpack.encryptedSavedObjects.encryptionKey xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys xpack.event_log.enabled - xpack.event_log.logEntries xpack.event_log.indexEntries + xpack.event_log.logEntries xpack.fleet.agents.elasticsearch.host xpack.fleet.agents.kibana.host xpack.fleet.agents.tlsCheckDisabled xpack.fleet.registryUrl - xpack.graph.enabled xpack.graph.canEditDrillDownUrls + xpack.graph.enabled xpack.graph.savePolicy xpack.grokdebugger.enabled xpack.infra.enabled @@ -208,28 +218,28 @@ kibana_vars=( xpack.reporting.capture.browser.chromium.disableSandbox xpack.reporting.capture.browser.chromium.inspect xpack.reporting.capture.browser.chromium.maxScreenshotDimension + xpack.reporting.capture.browser.chromium.proxy.bypass xpack.reporting.capture.browser.chromium.proxy.enabled xpack.reporting.capture.browser.chromium.proxy.server - xpack.reporting.capture.browser.chromium.proxy.bypass xpack.reporting.capture.browser.type xpack.reporting.capture.concurrency xpack.reporting.capture.loadDelay + xpack.reporting.capture.maxAttempts xpack.reporting.capture.settleTime xpack.reporting.capture.timeout + xpack.reporting.capture.timeouts.openUrl + xpack.reporting.capture.timeouts.renderComplete + xpack.reporting.capture.timeouts.waitForElements xpack.reporting.capture.viewport.height xpack.reporting.capture.viewport.width xpack.reporting.capture.zoom xpack.reporting.csv.checkForFormulas - xpack.reporting.csv.escapeFormulaValues xpack.reporting.csv.enablePanelActionDownload - xpack.reporting.csv.useByteOrderMarkEncoding + xpack.reporting.csv.escapeFormulaValues xpack.reporting.csv.maxSizeBytes xpack.reporting.csv.scroll.duration xpack.reporting.csv.scroll.size - xpack.reporting.capture.maxAttempts - xpack.reporting.capture.timeouts.openUrl - xpack.reporting.capture.timeouts.waitForElements - xpack.reporting.capture.timeouts.renderComplete + xpack.reporting.csv.useByteOrderMarkEncoding xpack.reporting.enabled xpack.reporting.encryptionKey xpack.reporting.index @@ -248,43 +258,38 @@ kibana_vars=( xpack.reporting.queue.timeout xpack.reporting.roles.allow xpack.rollup.enabled - xpack.security.audit.enabled xpack.searchprofiler.enabled - xpack.security.authc.providers + xpack.security.audit.enabled xpack.security.authc.oidc.realm - xpack.security.authc.saml.realm + xpack.security.authc.providers xpack.security.authc.saml.maxRedirectURLSize + xpack.security.authc.saml.realm xpack.security.authc.selector.enabled xpack.security.cookieName xpack.security.enabled xpack.security.encryptionKey + xpack.security.loginAssistanceMessage + xpack.security.loginHelp xpack.security.sameSiteCookies xpack.security.secureCookies - xpack.security.sessionTimeout + xpack.security.session.cleanupInterval xpack.security.session.idleTimeout xpack.security.session.lifespan - xpack.security.session.cleanupInterval - xpack.security.loginAssistanceMessage - xpack.security.loginHelp + xpack.security.sessionTimeout xpack.spaces.enabled xpack.spaces.maxSpaces xpack.task_manager.enabled + xpack.task_manager.index xpack.task_manager.max_attempts - xpack.task_manager.poll_interval xpack.task_manager.max_poll_inactivity_cycles - xpack.task_manager.request_capacity - xpack.task_manager.index xpack.task_manager.max_workers - xpack.task_manager.monitored_stats_required_freshness xpack.task_manager.monitored_aggregated_stats_refresh_rate + xpack.task_manager.monitored_stats_required_freshness xpack.task_manager.monitored_stats_running_average_window xpack.task_manager.monitored_task_execution_thresholds + xpack.task_manager.poll_interval + xpack.task_manager.request_capacity xpack.task_manager.version_conflict_threshold - telemetry.allowChangingOptInStatus - telemetry.enabled - telemetry.optIn - telemetry.optInStatusUrl - telemetry.sendUsageFrom ) longopts='' diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 26a6a9d6e4a03..c92de567cb446 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -26,19 +26,26 @@ export async function runDockerGenerator( config: Config, log: ToolingLog, build: Build, - ubi: boolean = false + flags: { + architecture?: string; + context: boolean; + image: boolean; + ubi: boolean; + } ) { // UBI var config - const baseOSImage = ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; + const baseOSImage = flags.ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; const ubiVersionTag = 'ubi8'; - const ubiImageFlavor = ubi ? `-${ubiVersionTag}` : ''; + const ubiImageFlavor = flags.ubi ? `-${ubiVersionTag}` : ''; // General docker var config const license = build.isOss() ? 'ASL 2.0' : 'Elastic License'; const imageFlavor = build.isOss() ? '-oss' : ''; const imageTag = 'docker.elastic.co/kibana/kibana'; const version = config.getBuildVersion(); - const artifactTarball = `kibana${imageFlavor}-${version}-linux-x86_64.tar.gz`; + const artifactArchitecture = flags.architecture === 'aarch64' ? 'aarch64' : 'x86_64'; + const artifactPrefix = `kibana${imageFlavor}-${version}-linux`; + const artifactTarball = `${artifactPrefix}-${artifactArchitecture}.tar.gz`; const artifactsDir = config.resolveFromTarget('.'); const dockerBuildDate = new Date().toISOString(); // That would produce oss, default and default-ubi7 @@ -47,10 +54,12 @@ export async function runDockerGenerator( 'kibana-docker', build.isOss() ? `oss` : `default${ubiImageFlavor}` ); + const imageArchitecture = flags.architecture === 'aarch64' ? '-aarch64' : ''; const dockerTargetFilename = config.resolveFromTarget( - `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker-image.tar.gz` + `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker-image${imageArchitecture}.tar.gz` ); const scope: TemplateContext = { + artifactPrefix, artifactTarball, imageFlavor, version, @@ -62,7 +71,8 @@ export async function runDockerGenerator( baseOSImage, ubiImageFlavor, dockerBuildDate, - ubi, + ubi: flags.ubi, + architecture: flags.architecture, revision: config.getBuildSha(), }; @@ -106,20 +116,23 @@ export async function runDockerGenerator( // created from the templates/build_docker_sh.template.js // and we just run that bash script await chmodAsync(`${resolve(dockerBuildDir, 'build_docker.sh')}`, '755'); - await exec(log, `./build_docker.sh`, [], { - cwd: dockerBuildDir, - level: 'info', - }); - - // Pack Dockerfiles and create a target for them - await bundleDockerFiles(config, log, scope); -} -export async function runDockerGeneratorForUBI(config: Config, log: ToolingLog, build: Build) { - // Only run ubi docker image build for default distribution - if (build.isOss()) { - return; + // Only build images on native targets + type HostArchitectureToDocker = Record; + const hostTarget: HostArchitectureToDocker = { + x64: 'x64', + arm64: 'aarch64', + }; + const buildImage = hostTarget[process.arch] === flags.architecture && flags.image; + if (buildImage) { + await exec(log, `./build_docker.sh`, [], { + cwd: dockerBuildDir, + level: 'info', + }); } - await runDockerGenerator(config, log, build, true); + // Pack Dockerfiles and create a target for them + if (flags.context) { + await bundleDockerFiles(config, log, scope); + } } diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index 4805ba0ba9bc0..8de2b5e9361e5 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -7,6 +7,7 @@ */ export interface TemplateContext { + artifactPrefix: string; artifactTarball: string; imageFlavor: string; version: string; @@ -21,4 +22,5 @@ export interface TemplateContext { usePublicArtifact?: boolean; ubi: boolean; revision: string; + architecture?: string; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile index 1a0e325a2486a..eb4708b6ac555 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile @@ -17,17 +17,19 @@ RUN {{packageManager}} install -y findutils tar gzip {{#usePublicArtifact}} RUN cd /opt && \ - curl --retry 8 -s -L -O https://artifacts.elastic.co/downloads/kibana/{{artifactTarball}} && \ + curl --retry 8 -s -L \ + --output kibana.tar.gz \ + https://artifacts.elastic.co/downloads/kibana/{{artifactPrefix}}-$(arch).tar.gz && \ cd - {{/usePublicArtifact}} {{^usePublicArtifact}} -COPY {{artifactTarball}} /opt +COPY {{artifactTarball}} /opt/kibana.tar.gz {{/usePublicArtifact}} RUN mkdir /usr/share/kibana WORKDIR /usr/share/kibana -RUN tar --strip-components=1 -zxf /opt/{{artifactTarball}} +RUN tar --strip-components=1 -zxf /opt/kibana.tar.gz # Ensure that group permissions are the same as user permissions. # This will help when relying on GID-0 to run Kibana, rather than UID-1000. # OpenShift does this, for example. @@ -51,7 +53,7 @@ EXPOSE 5601 RUN for iter in {1..10}; do \ {{packageManager}} update --setopt=tsflags=nodocs -y && \ {{packageManager}} install --setopt=tsflags=nodocs -y \ - fontconfig freetype shadow-utils libnss3.so {{#ubi}}findutils{{/ubi}} && \ + fontconfig freetype shadow-utils nss {{#ubi}}findutils{{/ubi}} && \ {{packageManager}} clean all && exit_code=0 && break || exit_code=$? && echo "{{packageManager}} error: retry $iter in 10s" && \ sleep 10; \ done; \ @@ -59,8 +61,17 @@ RUN for iter in {1..10}; do \ # Add an init process, check the checksum to make sure it's a match RUN set -e ; \ + TINI_BIN="" ; \ + case "$(arch)" in \ + aarch64) \ + TINI_BIN='tini-arm64' ; \ + ;; \ + x86_64) \ + TINI_BIN='tini-amd64' ; \ + ;; \ + *) echo >&2 "Unsupported architecture $(arch)" ; exit 1 ;; \ + esac ; \ TINI_VERSION='v0.19.0' ; \ - TINI_BIN='tini-amd64' ; \ curl --retry 8 -S -L -O "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${TINI_BIN}" ; \ curl --retry 8 -S -L -O "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${TINI_BIN}.sha256sum" ; \ sha256sum -c "${TINI_BIN}.sha256sum" ; \ diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index 5e2a0b72769fe..d896e9cfa671c 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -17,6 +17,7 @@ function generator({ dockerTargetFilename, baseOSImage, ubiImageFlavor, + architecture, }: TemplateContext) { return dedent(` #!/usr/bin/env bash diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts index a5cc2d9527cbf..f4e9d11ca9c21 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts @@ -16,6 +16,7 @@ function generator(options: TemplateContext) { const template = readFileSync(resolve(__dirname, './Dockerfile')); return Mustache.render(template.toString(), { packageManager: options.ubiImageFlavor ? 'microdnf' : 'yum', + tiniBin: options.architecture === 'aarch64' ? 'tini-arm64' : 'tini-amd64', ...options, }); } diff --git a/src/dev/ci_setup/.bazelrc-ci b/src/dev/ci_setup/.bazelrc-ci new file mode 100644 index 0000000000000..5b345d3c9e207 --- /dev/null +++ b/src/dev/ci_setup/.bazelrc-ci @@ -0,0 +1,10 @@ +# Inspired on https://github.com/angular/angular/blob/master/.circleci/bazel.linux.rc +# These options are only enabled when running on CI +# That is done by copying this file into "$HOME/.bazelrc" which loads after the .bazelrc into the workspace + +# Import and load bazelrc common settings for ci env +try-import %workspace%/src/dev/ci_setup/.bazelrc-ci.common + +# Remote cache settings for ci env +# build --google_default_credentials +# build --remote_upload_local_results=true diff --git a/src/dev/ci_setup/.bazelrc-ci.common b/src/dev/ci_setup/.bazelrc-ci.common new file mode 100644 index 0000000000000..3f58e4e03a178 --- /dev/null +++ b/src/dev/ci_setup/.bazelrc-ci.common @@ -0,0 +1,11 @@ +# Inspired on https://github.com/angular/angular/blob/master/.circleci/bazel.common.rc +# Settings in this file should be OS agnostic + +# Don't be spammy in the logs +build --noshow_progress + +# Print all the options that apply to the build. +build --announce_rc + +# More details on failures +build --verbose_failures=true diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index 61f578ba33971..e5e21e312b0dd 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -65,3 +65,8 @@ if [ "$GIT_CHANGES" ]; then echo -e "$GIT_CHANGES\n" exit 1 fi + +### +### copy .bazelrc-ci into $HOME/.bazelrc +### +cp "src/dev/ci_setup/.bazelrc-ci" "$HOME/.bazelrc"; diff --git a/src/dev/code_coverage/shell_scripts/extract_archives.sh b/src/dev/code_coverage/shell_scripts/extract_archives.sh index 14b35f8786d02..376467f9f2e55 100644 --- a/src/dev/code_coverage/shell_scripts/extract_archives.sh +++ b/src/dev/code_coverage/shell_scripts/extract_archives.sh @@ -6,7 +6,7 @@ EXTRACT_DIR=/tmp/extracted_coverage mkdir -p $EXTRACT_DIR echo "### Extracting downloaded artifacts" -for x in kibana-intake kibana-oss-tests kibana-xpack-tests; do +for x in kibana-intake x-pack-intake kibana-oss-tests kibana-xpack-tests; do tar -xzf $DOWNLOAD_DIR/coverage/${x}/kibana-coverage.tar.gz -C $EXTRACT_DIR || echo "### Error 'tarring': ${x}" done diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 1a870e3c878f7..d18e2e49d6b83 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -65,6 +65,10 @@ export const IGNORE_FILE_GLOBS = [ 'x-pack/test/fleet_api_integration/apis/fixtures/test_packages/**/*', '.teamcity/**/*', + + // Bazel default files + '**/WORKSPACE.bazel', + '**/BUILD.bazel', ]; /** diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index d1ebcfa1e8399..675b5a682f272 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,4 +18,5 @@ export const storybookAliases = { security_solution: 'x-pack/plugins/security_solution/.storybook', ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', observability: 'x-pack/plugins/observability/.storybook', + presentation: 'src/plugins/presentation_util/storybook', }; diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index e1e2a49439de3..6955365ebca3f 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -6,22 +6,22 @@ * Public License, v 1. */ -import _ from 'lodash'; import { History } from 'history'; -import { merge, Subscription } from 'rxjs'; -import React, { useEffect, useCallback, useState } from 'react'; +import { merge, Subject, Subscription } from 'rxjs'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { debounceTime, tap } from 'rxjs/operators'; import { useKibana } from '../../../kibana_react/public'; import { DashboardConstants } from '../dashboard_constants'; import { DashboardTopNav } from './top_nav/dashboard_top_nav'; import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from './types'; import { + getChangesFromAppStateForContainerState, + getDashboardContainerInput, + getFiltersSubscription, getInputSubscription, getOutputSubscription, - getFiltersSubscription, getSearchSessionIdFromURL, - getDashboardContainerInput, - getChangesFromAppStateForContainerState, } from './dashboard_app_functions'; import { useDashboardBreadcrumbs, @@ -30,11 +30,11 @@ import { useSavedDashboard, } from './hooks'; -import { removeQueryParam } from '../services/kibana_utils'; import { IndexPattern } from '../services/data'; import { EmbeddableRenderer } from '../services/embeddable'; import { DashboardContainerInput } from '.'; import { leaveConfirmStrings } from '../dashboard_strings'; +import { createQueryParamObservable, replaceUrlHashQuery } from '../../../kibana_utils/public'; export interface DashboardAppProps { history: History; @@ -59,7 +59,7 @@ export function DashboardApp({ indexPatterns: indexPatternService, } = useKibana().services; - const [lastReloadTime, setLastReloadTime] = useState(0); + const triggerRefresh$ = useMemo(() => new Subject<{ force?: boolean }>(), []); const [indexPatterns, setIndexPatterns] = useState([]); const savedDashboard = useSavedDashboard(savedDashboardId, history); @@ -68,9 +68,13 @@ export function DashboardApp({ history ); const dashboardContainer = useDashboardContainer(dashboardStateManager, history, false); + const searchSessionIdQuery$ = useMemo( + () => createQueryParamObservable(history, DashboardConstants.SEARCH_SESSION_ID), + [history] + ); const refreshDashboardContainer = useCallback( - (lastReloadRequestTime?: number) => { + (force?: boolean) => { if (!dashboardContainer || !dashboardStateManager) { return; } @@ -80,7 +84,7 @@ export function DashboardApp({ appStateDashboardInput: getDashboardContainerInput({ isEmbeddedExternally: Boolean(embedSettings), dashboardStateManager, - lastReloadRequestTime, + lastReloadRequestTime: force ? Date.now() : undefined, dashboardCapabilities, query: data.query, }), @@ -100,10 +104,35 @@ export function DashboardApp({ const shouldRefetch = Object.keys(changes).some( (changeKey) => !noRefetchKeys.includes(changeKey as keyof DashboardContainerInput) ); - if (getSearchSessionIdFromURL(history)) { - // going away from a background search results - removeQueryParam(history, DashboardConstants.SEARCH_SESSION_ID, true); - } + + const newSearchSessionId: string | undefined = (() => { + // do not update session id if this is irrelevant state change to prevent excessive searches + if (!shouldRefetch) return; + + let searchSessionIdFromURL = getSearchSessionIdFromURL(history); + if (searchSessionIdFromURL) { + if ( + data.search.session.isRestore() && + data.search.session.isCurrentSession(searchSessionIdFromURL) + ) { + // navigating away from a restored session + dashboardStateManager.kbnUrlStateStorage.kbnUrlControls.updateAsync((nextUrl) => { + if (nextUrl.includes(DashboardConstants.SEARCH_SESSION_ID)) { + return replaceUrlHashQuery(nextUrl, (query) => { + delete query[DashboardConstants.SEARCH_SESSION_ID]; + return query; + }); + } + return nextUrl; + }); + searchSessionIdFromURL = undefined; + } else { + data.search.session.restore(searchSessionIdFromURL); + } + } + + return searchSessionIdFromURL ?? data.search.session.start(); + })(); if (changes.viewMode) { setViewMode(changes.viewMode); @@ -111,8 +140,7 @@ export function DashboardApp({ dashboardContainer.updateInput({ ...changes, - // do not start a new session if this is irrelevant state change to prevent excessive searches - ...(shouldRefetch && { searchSessionId: data.search.session.start() }), + ...(newSearchSessionId && { searchSessionId: newSearchSessionId }), }); } }, @@ -159,23 +187,42 @@ export function DashboardApp({ subscriptions.add( merge( ...[timeFilter.getRefreshIntervalUpdate$(), timeFilter.getTimeUpdate$()] - ).subscribe(() => refreshDashboardContainer()) + ).subscribe(() => triggerRefresh$.next()) ); + subscriptions.add( merge( data.search.session.onRefresh$, - data.query.timefilter.timefilter.getAutoRefreshFetch$() + data.query.timefilter.timefilter.getAutoRefreshFetch$(), + searchSessionIdQuery$ ).subscribe(() => { - setLastReloadTime(() => new Date().getTime()); + triggerRefresh$.next({ force: true }); }) ); dashboardStateManager.registerChangeListener(() => { // we aren't checking dirty state because there are changes the container needs to know about // that won't make the dashboard "dirty" - like a view mode change. - refreshDashboardContainer(); + triggerRefresh$.next(); }); + // debounce `refreshDashboardContainer()` + // use `forceRefresh=true` in case at least one debounced trigger asked for it + let forceRefresh: boolean = false; + subscriptions.add( + triggerRefresh$ + .pipe( + tap((trigger) => { + forceRefresh = forceRefresh || (trigger?.force ?? false); + }), + debounceTime(50) + ) + .subscribe(() => { + refreshDashboardContainer(forceRefresh); + forceRefresh = false; + }) + ); + return () => { subscriptions.unsubscribe(); }; @@ -187,6 +234,8 @@ export function DashboardApp({ data.search.session, indexPatternService, dashboardStateManager, + searchSessionIdQuery$, + triggerRefresh$, refreshDashboardContainer, ]); @@ -216,10 +265,12 @@ export function DashboardApp({ }; }, [dashboardStateManager, dashboardContainer, onAppLeave, embeddable]); - // Refresh the dashboard container when lastReloadTime changes + // clear search session when leaving dashboard route useEffect(() => { - refreshDashboardContainer(lastReloadTime); - }, [lastReloadTime, refreshDashboardContainer]); + return () => { + data.search.session.clear(); + }; + }, [data.search.session]); return (
@@ -242,7 +293,7 @@ export function DashboardApp({ // The user can still request a reload in the query bar, even if the // query is the same, and in that case, we have to explicitly ask for // a reload, since no state changes will cause it. - setLastReloadTime(() => new Date().getTime()); + triggerRefresh$.next({ force: true }); } }} /> diff --git a/src/plugins/dashboard/public/application/dashboard_state_manager.ts b/src/plugins/dashboard/public/application/dashboard_state_manager.ts index 90706a11b8ce2..c52bd1b4d47b8 100644 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts @@ -72,7 +72,7 @@ export class DashboardStateManager { >; private readonly stateContainerChangeSub: Subscription; private readonly STATE_STORAGE_KEY = '_a'; - private readonly kbnUrlStateStorage: IKbnUrlStateStorage; + public readonly kbnUrlStateStorage: IKbnUrlStateStorage; private readonly stateSyncRef: ISyncStateRef; private readonly history: History; private readonly usageCollection: UsageCollectionSetup | undefined; @@ -596,7 +596,7 @@ export class DashboardStateManager { this.toUrlState(this.stateContainer.get()) ); // immediately forces scheduled updates and changes location - return this.kbnUrlStateStorage.flush({ replace }); + return !!this.kbnUrlStateStorage.kbnUrlControls.flush(replace); } // TODO: find nicer solution for this diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap index bce8a661634f6..faec6b4f6f24b 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -4,10 +4,16 @@ exports[`after fetch When given a title that matches multiple dashboards, filter - getTableColumns((id) => redirectTo({ destination: 'dashboard', id }), savedObjectsTagging), - [savedObjectsTagging, redirectTo] + getTableColumns( + core.application, + kbnUrlStateStorage, + core.uiSettings.get('state:storeInSessionStorage'), + savedObjectsTagging + ), + [core.application, core.uiSettings, kbnUrlStateStorage, savedObjectsTagging] ); const noItemsFragment = useMemo( @@ -99,7 +103,6 @@ export const DashboardListing = ({ (filter: string) => { let searchTerm = filter; let references: SavedObjectsFindOptionsReference[] | undefined; - if (savedObjectsTagging) { const parsed = savedObjectsTagging.ui.parseSearchQuery(filter, { useName: true, @@ -164,7 +167,9 @@ export const DashboardListing = ({ }; const getTableColumns = ( - redirectTo: (id?: string) => void, + application: ApplicationStart, + kbnUrlStateStorage: IKbnUrlStateStorage, + useHash: boolean, savedObjectsTagging?: SavedObjectsTaggingApi ) => { return [ @@ -172,9 +177,15 @@ const getTableColumns = ( field: 'title', name: dashboardListingTable.getTitleColumnName(), sortable: true, - render: (field: string, record: { id: string; title: string }) => ( + render: (field: string, record: { id: string; title: string; timeRestore: boolean }) => ( redirectTo(record.id)} + href={getDashboardListItemLink( + application, + kbnUrlStateStorage, + useHash, + record.id, + record.timeRestore + )} data-test-subj={`dashboardListingTitleLink-${record.title.split(' ').join('-')}`} > {field} diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts new file mode 100644 index 0000000000000..6dbc76803af90 --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { getDashboardListItemLink } from './get_dashboard_list_item_link'; +import { ApplicationStart } from 'kibana/public'; +import { esFilters } from '../../../../data/public'; +import { createHashHistory } from 'history'; +import { createKbnUrlStateStorage } from '../../../../kibana_utils/public'; +import { GLOBAL_STATE_STORAGE_KEY } from '../../url_generator'; + +const DASHBOARD_ID = '13823000-99b9-11ea-9eb6-d9e8adceb647'; + +const application = ({ + getUrlForApp: jest.fn((appId: string, options?: { path?: string; absolute?: boolean }) => { + return `/app/${appId}${options?.path}`; + }), +} as unknown) as ApplicationStart; + +const history = createHashHistory(); +const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + useHash: false, +}); +kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { time: { from: 'now-7d', to: 'now' } }); + +describe('listing dashboard link', () => { + test('creates a link to a dashboard without the timerange query if time is saved on the dashboard', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + true + ); + expect(url).toMatchInlineSnapshot(`"/app/dashboards#/view/${DASHBOARD_ID}?_g=()"`); + }); + + test('creates a link to a dashboard with the timerange query if time is not saved on the dashboard', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + false + ); + expect(url).toMatchInlineSnapshot( + `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(time:(from:now-7d,to:now))"` + ); + }); +}); + +describe('when global time changes', () => { + beforeEach(() => { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + time: { + from: '2021-01-05T11:45:53.375Z', + to: '2021-01-21T11:46:00.990Z', + }, + }); + }); + + test('propagates the correct time on the query', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + false + ); + expect(url).toMatchInlineSnapshot( + `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(time:(from:'2021-01-05T11:45:53.375Z',to:'2021-01-21T11:46:00.990Z'))"` + ); + }); +}); + +describe('when global refreshInterval changes', () => { + beforeEach(() => { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + refreshInterval: { pause: false, value: 300 }, + }); + }); + + test('propagates the refreshInterval on the query', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + false + ); + expect(url).toMatchInlineSnapshot( + `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(refreshInterval:(pause:!f,value:300))"` + ); + }); +}); + +describe('when global filters change', () => { + beforeEach(() => { + const filters = [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + $state: { + store: esFilters.FilterStateStore.GLOBAL_STATE, + }, + }, + ]; + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + filters, + }); + }); + + test('propagates the filters on the query', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + false + ); + expect(url).toMatchInlineSnapshot( + `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1)),('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))))"` + ); + }); +}); diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts new file mode 100644 index 0000000000000..d14638b9e231f --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { ApplicationStart } from 'kibana/public'; +import { QueryState } from '../../../../data/public'; +import { setStateToKbnUrl } from '../../../../kibana_utils/public'; +import { createDashboardEditUrl, DashboardConstants } from '../../dashboard_constants'; +import { GLOBAL_STATE_STORAGE_KEY } from '../../url_generator'; +import { IKbnUrlStateStorage } from '../../services/kibana_utils'; + +export const getDashboardListItemLink = ( + application: ApplicationStart, + kbnUrlStateStorage: IKbnUrlStateStorage, + useHash: boolean, + id: string, + timeRestore: boolean +) => { + let url = application.getUrlForApp(DashboardConstants.DASHBOARDS_ID, { + path: `#${createDashboardEditUrl(id)}`, + }); + const globalStateInUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; + + if (timeRestore) { + delete globalStateInUrl.time; + delete globalStateInUrl.refreshInterval; + } + url = setStateToKbnUrl(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url); + return url; +}; diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx new file mode 100644 index 0000000000000..d4703d14627a4 --- /dev/null +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Capabilities } from 'src/core/public'; +import { showPublicUrlSwitch } from './show_share_modal'; + +describe('showPublicUrlSwitch', () => { + test('returns false if "dashboard" app is not available', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns false if "dashboard" app is not accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + dashboard: { + show: false, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns true if "dashboard" app is not available an accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + dashboard: { + show: true, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(true); + }); +}); diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx index 660e7635eb99d..fe4f8ea411289 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx @@ -6,6 +6,7 @@ * Public License, v 1. */ +import { Capabilities } from 'src/core/public'; import { EuiCheckboxGroup } from '@elastic/eui'; import React from 'react'; import { ReactElement, useState } from 'react'; @@ -27,6 +28,14 @@ interface ShowShareModalProps { dashboardStateManager: DashboardStateManager; } +export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { + if (!anonymousUserCapabilities.dashboard) return false; + + const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardCapabilities; + + return !!dashboard.show; +}; + export function ShowShareModal({ share, anchorElement, @@ -94,7 +103,7 @@ export function ShowShareModal({ share.toggleShareContextMenu({ anchorElement, allowEmbed: true, - allowShortUrl: !dashboardCapabilities.hideWriteControls || dashboardCapabilities.createShortUrl, + allowShortUrl: dashboardCapabilities.createShortUrl, shareableUrl: setStateToKbnUrl( '_a', dashboardStateManager.getAppState(), @@ -113,5 +122,6 @@ export function ShowShareModal({ component: EmbedUrlParamExtension, }, ], + showPublicUrlSwitch, }); } diff --git a/src/plugins/dashboard/server/index.ts b/src/plugins/dashboard/server/index.ts index cc784f5f81c9e..4bd43d1cd64a9 100644 --- a/src/plugins/dashboard/server/index.ts +++ b/src/plugins/dashboard/server/index.ts @@ -25,3 +25,4 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { DashboardPluginSetup, DashboardPluginStart } from './types'; +export { findByValueEmbeddables } from './usage/find_by_value_embeddables'; diff --git a/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts b/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts new file mode 100644 index 0000000000000..3da6a8050f14c --- /dev/null +++ b/src/plugins/dashboard/server/usage/find_by_value_embeddables.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { SavedDashboardPanel730ToLatest } from '../../common'; +import { findByValueEmbeddables } from './find_by_value_embeddables'; + +const visualizationByValue = ({ + embeddableConfig: { + value: 'visualization-by-value', + }, + type: 'visualization', +} as unknown) as SavedDashboardPanel730ToLatest; + +const mapByValue = ({ + embeddableConfig: { + value: 'map-by-value', + }, + type: 'map', +} as unknown) as SavedDashboardPanel730ToLatest; + +const embeddableByRef = ({ + panelRefName: 'panel_ref_1', +} as unknown) as SavedDashboardPanel730ToLatest; + +describe('findByValueEmbeddables', () => { + it('finds the by value embeddables for the given type', async () => { + const savedObjectsResult = { + saved_objects: [ + { + attributes: { + panelsJSON: JSON.stringify([visualizationByValue, mapByValue, embeddableByRef]), + }, + }, + { + attributes: { + panelsJSON: JSON.stringify([embeddableByRef, mapByValue, visualizationByValue]), + }, + }, + ], + }; + const savedObjectClient = { find: jest.fn().mockResolvedValue(savedObjectsResult) }; + + const maps = await findByValueEmbeddables(savedObjectClient, 'map'); + + expect(maps.length).toBe(2); + expect(maps[0]).toEqual(mapByValue.embeddableConfig); + expect(maps[1]).toEqual(mapByValue.embeddableConfig); + + const visualizations = await findByValueEmbeddables(savedObjectClient, 'visualization'); + + expect(visualizations.length).toBe(2); + expect(visualizations[0]).toEqual(visualizationByValue.embeddableConfig); + expect(visualizations[1]).toEqual(visualizationByValue.embeddableConfig); + }); +}); diff --git a/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts b/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts new file mode 100644 index 0000000000000..0ae14cdcf7197 --- /dev/null +++ b/src/plugins/dashboard/server/usage/find_by_value_embeddables.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { ISavedObjectsRepository, SavedObjectAttributes } from 'kibana/server'; +import { SavedDashboardPanel730ToLatest } from '../../common'; + +export const findByValueEmbeddables = async ( + savedObjectClient: Pick, + embeddableType: string +) => { + const dashboards = await savedObjectClient.find({ + type: 'dashboard', + }); + + return dashboards.saved_objects + .map((dashboard) => { + try { + return (JSON.parse( + dashboard.attributes.panelsJSON as string + ) as unknown) as SavedDashboardPanel730ToLatest[]; + } catch (exception) { + return []; + } + }) + .flat() + .filter((panel) => (panel as Record).panelRefName === undefined) + .filter((panel) => panel.type === embeddableType) + .map((panel) => panel.embeddableConfig); +}; diff --git a/src/plugins/data/common/es_query/kuery/node_types/node_builder.test.ts b/src/plugins/data/common/es_query/kuery/node_types/node_builder.test.ts new file mode 100644 index 0000000000000..df78d68aaef48 --- /dev/null +++ b/src/plugins/data/common/es_query/kuery/node_types/node_builder.test.ts @@ -0,0 +1,280 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { nodeBuilder } from './node_builder'; +import { toElasticsearchQuery } from '../index'; + +describe('nodeBuilder', () => { + describe('is method', () => { + test('string value', () => { + const nodes = nodeBuilder.is('foo', 'bar'); + const query = toElasticsearchQuery(nodes); + expect(query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo": "bar", + }, + }, + ], + }, + } + `); + }); + + test('KueryNode value', () => { + const literalValue = { + type: 'literal' as 'literal', + value: 'bar', + }; + const nodes = nodeBuilder.is('foo', literalValue); + const query = toElasticsearchQuery(nodes); + expect(query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo": "bar", + }, + }, + ], + }, + } + `); + }); + }); + + describe('and method', () => { + test('single clause', () => { + const nodes = [nodeBuilder.is('foo', 'bar')]; + const query = toElasticsearchQuery(nodeBuilder.and(nodes)); + expect(query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo": "bar", + }, + }, + ], + }, + } + `); + }); + + test('two clauses', () => { + const nodes = [nodeBuilder.is('foo1', 'bar1'), nodeBuilder.is('foo2', 'bar2')]; + const query = toElasticsearchQuery(nodeBuilder.and(nodes)); + expect(query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo1": "bar1", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo2": "bar2", + }, + }, + ], + }, + }, + ], + }, + } + `); + }); + + test('three clauses', () => { + const nodes = [ + nodeBuilder.is('foo1', 'bar1'), + nodeBuilder.is('foo2', 'bar2'), + nodeBuilder.is('foo3', 'bar3'), + ]; + const query = toElasticsearchQuery(nodeBuilder.and(nodes)); + expect(query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo1": "bar1", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo2": "bar2", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo3": "bar3", + }, + }, + ], + }, + }, + ], + }, + } + `); + }); + }); + + describe('or method', () => { + test('single clause', () => { + const nodes = [nodeBuilder.is('foo', 'bar')]; + const query = toElasticsearchQuery(nodeBuilder.or(nodes)); + expect(query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo": "bar", + }, + }, + ], + }, + } + `); + }); + + test('two clauses', () => { + const nodes = [nodeBuilder.is('foo1', 'bar1'), nodeBuilder.is('foo2', 'bar2')]; + const query = toElasticsearchQuery(nodeBuilder.or(nodes)); + expect(query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo1": "bar1", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo2": "bar2", + }, + }, + ], + }, + }, + ], + }, + } + `); + }); + + test('three clauses', () => { + const nodes = [ + nodeBuilder.is('foo1', 'bar1'), + nodeBuilder.is('foo2', 'bar2'), + nodeBuilder.is('foo3', 'bar3'), + ]; + const query = toElasticsearchQuery(nodeBuilder.or(nodes)); + expect(query).toMatchInlineSnapshot(` + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo1": "bar1", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo2": "bar2", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "foo3": "bar3", + }, + }, + ], + }, + }, + ], + }, + } + `); + }); + }); +}); diff --git a/src/plugins/data/common/es_query/kuery/node_types/node_builder.ts b/src/plugins/data/common/es_query/kuery/node_types/node_builder.ts index a72c7f2db41a8..6da9c3aa293ef 100644 --- a/src/plugins/data/common/es_query/kuery/node_types/node_builder.ts +++ b/src/plugins/data/common/es_query/kuery/node_types/node_builder.ts @@ -16,12 +16,10 @@ export const nodeBuilder = { nodeTypes.literal.buildNode(false), ]); }, - or: ([first, ...args]: KueryNode[]): KueryNode => { - return args.length ? nodeTypes.function.buildNode('or', [first, nodeBuilder.or(args)]) : first; + or: (nodes: KueryNode[]): KueryNode => { + return nodes.length > 1 ? nodeTypes.function.buildNode('or', nodes) : nodes[0]; }, - and: ([first, ...args]: KueryNode[]): KueryNode => { - return args.length - ? nodeTypes.function.buildNode('and', [first, nodeBuilder.and(args)]) - : first; + and: (nodes: KueryNode[]): KueryNode => { + return nodes.length > 1 ? nodeTypes.function.buildNode('and', nodes) : nodes[0]; }, }; diff --git a/src/plugins/data/common/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index 328f05fac8594..08fe2b07096bb 100644 --- a/src/plugins/data/common/search/search_source/mocks.ts +++ b/src/plugins/data/common/search/search_source/mocks.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import type { MockedKeys } from '@kbn/utility-types/jest'; import { uiSettingsServiceMock } from '../../../../../core/public/mocks'; @@ -27,6 +27,7 @@ export const searchSourceInstanceMock: MockedKeys = { createChild: jest.fn().mockReturnThis(), setParent: jest.fn(), getParent: jest.fn().mockReturnThis(), + fetch$: jest.fn().mockReturnValue(of({})), fetch: jest.fn().mockResolvedValue({}), onRequestStart: jest.fn(), getSearchRequestBody: jest.fn(), diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index 6d7654c6659f2..c2a4beb9b61a5 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -51,7 +51,14 @@ describe('SearchSource', () => { let searchSource: SearchSource; beforeEach(() => { - mockSearchMethod = jest.fn().mockReturnValue(of({ rawResponse: '' })); + mockSearchMethod = jest + .fn() + .mockReturnValue( + of( + { rawResponse: { isPartial: true, isRunning: true } }, + { rawResponse: { isPartial: false, isRunning: false } } + ) + ); searchSourceDependencies = { getConfig: jest.fn(), @@ -564,6 +571,34 @@ describe('SearchSource', () => { await searchSource.fetch(options); expect(mockSearchMethod).toBeCalledTimes(1); }); + + test('should return partial results', (done) => { + searchSource = new SearchSource({ index: indexPattern }, searchSourceDependencies); + const options = {}; + + const next = jest.fn(); + const complete = () => { + expect(next).toBeCalledTimes(2); + expect(next.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "isPartial": true, + "isRunning": true, + }, + ] + `); + expect(next.mock.calls[1]).toMatchInlineSnapshot(` + Array [ + Object { + "isPartial": false, + "isRunning": false, + }, + ] + `); + done(); + }; + searchSource.fetch$(options).subscribe({ next, complete }); + }); }); describe('#serialize', () => { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 554e8385881f2..bb60f0d7b4ad4 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -60,7 +60,8 @@ import { setWith } from '@elastic/safer-lodash-set'; import { uniqueId, keyBy, pick, difference, omit, isObject, isFunction } from 'lodash'; -import { map } from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; +import { defer, from } from 'rxjs'; import { normalizeSortRequest } from './normalize_sort_request'; import { fieldWildcardFilter } from '../../../../kibana_utils/common'; import { IIndexPattern } from '../../index_patterns'; @@ -244,30 +245,35 @@ export class SearchSource { } /** - * Fetch this source and reject the returned Promise on error - * - * @async + * Fetch this source from Elasticsearch, returning an observable over the response(s) + * @param options */ - async fetch(options: ISearchOptions = {}) { + fetch$(options: ISearchOptions = {}) { const { getConfig } = this.dependencies; - await this.requestIsStarting(options); - - const searchRequest = await this.flatten(); - this.history = [searchRequest]; - - let response; - if (getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES)) { - response = await this.legacyFetch(searchRequest, options); - } else { - response = await this.fetchSearch(searchRequest, options); - } - - // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved - if ((response as any).error) { - throw new RequestFailure(null, response); - } + return defer(() => this.requestIsStarting(options)).pipe( + switchMap(() => { + const searchRequest = this.flatten(); + this.history = [searchRequest]; + + return getConfig(UI_SETTINGS.COURIER_BATCH_SEARCHES) + ? from(this.legacyFetch(searchRequest, options)) + : this.fetchSearch$(searchRequest, options); + }), + tap((response) => { + // TODO: Remove casting when https://github.com/elastic/elasticsearch-js/issues/1287 is resolved + if ((response as any).error) { + throw new RequestFailure(null, response); + } + }) + ); + } - return response; + /** + * Fetch this source and reject the returned Promise on error + * @deprecated Use fetch$ instead + */ + fetch(options: ISearchOptions = {}) { + return this.fetch$(options).toPromise(); } /** @@ -305,16 +311,16 @@ export class SearchSource { * Run a search using the search service * @return {Promise>} */ - private fetchSearch(searchRequest: SearchRequest, options: ISearchOptions) { + private fetchSearch$(searchRequest: SearchRequest, options: ISearchOptions) { const { search, getConfig, onResponse } = this.dependencies; const params = getSearchParamsFromRequest(searchRequest, { getConfig, }); - return search({ params, indexType: searchRequest.indexType }, options) - .pipe(map(({ rawResponse }) => onResponse(searchRequest, rawResponse))) - .toPromise(); + return search({ params, indexType: searchRequest.indexType }, options).pipe( + map(({ rawResponse }) => onResponse(searchRequest, rawResponse)) + ); } /** diff --git a/src/plugins/data/common/search/test_data/illegal_argument_exception.json b/src/plugins/data/common/search/test_data/illegal_argument_exception.json new file mode 100644 index 0000000000000..ae48468abc209 --- /dev/null +++ b/src/plugins/data/common/search/test_data/illegal_argument_exception.json @@ -0,0 +1,14 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "illegal_argument_exception", + "reason" : "failed to parse setting [timeout] with value [1] as a time value: unit is missing or unrecognized" + } + ], + "type" : "illegal_argument_exception", + "reason" : "failed to parse setting [timeout] with value [1] as a time value: unit is missing or unrecognized" + }, + "status" : 400 + } + \ No newline at end of file diff --git a/src/plugins/data/common/search/test_data/index_not_found_exception.json b/src/plugins/data/common/search/test_data/index_not_found_exception.json new file mode 100644 index 0000000000000..dc892d95ae397 --- /dev/null +++ b/src/plugins/data/common/search/test_data/index_not_found_exception.json @@ -0,0 +1,21 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "index_not_found_exception", + "reason" : "no such index [poop]", + "resource.type" : "index_or_alias", + "resource.id" : "poop", + "index_uuid" : "_na_", + "index" : "poop" + } + ], + "type" : "index_not_found_exception", + "reason" : "no such index [poop]", + "resource.type" : "index_or_alias", + "resource.id" : "poop", + "index_uuid" : "_na_", + "index" : "poop" + }, + "status" : 404 +} diff --git a/src/plugins/data/common/search/test_data/json_e_o_f_exception.json b/src/plugins/data/common/search/test_data/json_e_o_f_exception.json new file mode 100644 index 0000000000000..88134e1c6ea03 --- /dev/null +++ b/src/plugins/data/common/search/test_data/json_e_o_f_exception.json @@ -0,0 +1,14 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "json_e_o_f_exception", + "reason" : "Unexpected end-of-input: expected close marker for Object (start marker at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 1])\n at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 2]" + } + ], + "type" : "json_e_o_f_exception", + "reason" : "Unexpected end-of-input: expected close marker for Object (start marker at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 1])\n at [Source: (org.elasticsearch.common.io.stream.InputStreamStreamInput); line: 1, column: 2]" + }, + "status" : 400 + } + \ No newline at end of file diff --git a/src/plugins/data/common/search/test_data/parsing_exception.json b/src/plugins/data/common/search/test_data/parsing_exception.json new file mode 100644 index 0000000000000..725a847aa0e3f --- /dev/null +++ b/src/plugins/data/common/search/test_data/parsing_exception.json @@ -0,0 +1,17 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "parsing_exception", + "reason" : "[terms] query does not support [ohno]", + "line" : 4, + "col" : 17 + } + ], + "type" : "parsing_exception", + "reason" : "[terms] query does not support [ohno]", + "line" : 4, + "col" : 17 + }, + "status" : 400 +} diff --git a/src/plugins/data/common/search/test_data/resource_not_found_exception.json b/src/plugins/data/common/search/test_data/resource_not_found_exception.json new file mode 100644 index 0000000000000..7f2a3b2e6e143 --- /dev/null +++ b/src/plugins/data/common/search/test_data/resource_not_found_exception.json @@ -0,0 +1,13 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "resource_not_found_exception", + "reason" : "FlZlSXp6dkd3UXdHZjhsalVtVHBnYkEdYjNIWDhDOTZRN3ExemdmVkx4RXNQQToxMjc2ODk=" + } + ], + "type" : "resource_not_found_exception", + "reason" : "FlZlSXp6dkd3UXdHZjhsalVtVHBnYkEdYjNIWDhDOTZRN3ExemdmVkx4RXNQQToxMjc2ODk=" + }, + "status" : 404 +} diff --git a/src/plugins/data/common/search/test_data/search_phase_execution_exception.json b/src/plugins/data/common/search/test_data/search_phase_execution_exception.json new file mode 100644 index 0000000000000..ff6879f2b8960 --- /dev/null +++ b/src/plugins/data/common/search/test_data/search_phase_execution_exception.json @@ -0,0 +1,52 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "script_exception", + "reason" : "compile error", + "script_stack" : [ + "invalid", + "^---- HERE" + ], + "script" : "invalid", + "lang" : "painless", + "position" : { + "offset" : 0, + "start" : 0, + "end" : 7 + } + } + ], + "type" : "search_phase_execution_exception", + "reason" : "all shards failed", + "phase" : "query", + "grouped" : true, + "failed_shards" : [ + { + "shard" : 0, + "index" : ".kibana_11", + "node" : "b3HX8C96Q7q1zgfVLxEsPA", + "reason" : { + "type" : "script_exception", + "reason" : "compile error", + "script_stack" : [ + "invalid", + "^---- HERE" + ], + "script" : "invalid", + "lang" : "painless", + "position" : { + "offset" : 0, + "start" : 0, + "end" : 7 + }, + "caused_by" : { + "type" : "illegal_argument_exception", + "reason" : "cannot resolve symbol [invalid]" + } + } + } + ] + }, + "status" : 400 +} diff --git a/src/plugins/data/common/search/test_data/x_content_parse_exception.json b/src/plugins/data/common/search/test_data/x_content_parse_exception.json new file mode 100644 index 0000000000000..cd6e1cb2c5977 --- /dev/null +++ b/src/plugins/data/common/search/test_data/x_content_parse_exception.json @@ -0,0 +1,17 @@ +{ + "error" : { + "root_cause" : [ + { + "type" : "x_content_parse_exception", + "reason" : "[5:13] [script] failed to parse object" + } + ], + "type" : "x_content_parse_exception", + "reason" : "[5:13] [script] failed to parse object", + "caused_by" : { + "type" : "json_parse_exception", + "reason" : "Unexpected character (''' (code 39)): expected a valid value (JSON String, Number, Array, Object or token 'null', 'true' or 'false')\n at [Source: (org.elasticsearch.common.bytes.AbstractBytesReference$BytesReferenceStreamInput); line: 5, column: 24]" + } + }, + "status" : 400 +} diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 9e493f46b0781..f533af2db9672 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -2282,8 +2282,11 @@ export class SearchInterceptor { protected readonly deps: SearchInterceptorDeps; // (undocumented) protected getTimeoutMode(): TimeoutErrorMode; + // Warning: (ae-forgotten-export) The symbol "KibanaServerError" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "AbortError" needs to be exported by the entry point index.d.ts + // // (undocumented) - protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; + protected handleSearchError(e: KibanaServerError | AbortError, timeoutSignal: AbortSignal, options?: ISearchOptions): Error; // @internal protected pendingCount$: BehaviorSubject; // @internal (undocumented) @@ -2360,6 +2363,8 @@ export class SearchSource { createChild(options?: {}): SearchSource; createCopy(): SearchSource; destroy(): void; + fetch$(options?: ISearchOptions): import("rxjs").Observable>; + // @deprecated fetch(options?: ISearchOptions): Promise>; getField(field: K, recurse?: boolean): SearchSourceFields[K]; getFields(): { @@ -2451,7 +2456,7 @@ export interface SearchSourceFields { // // @public export class SearchTimeoutError extends KbnError { - constructor(err: Error, mode: TimeoutErrorMode); + constructor(err: Record, mode: TimeoutErrorMode); // (undocumented) getErrorMessage(application: ApplicationStart): JSX.Element; // (undocumented) @@ -2601,7 +2606,7 @@ export const UI_SETTINGS: { // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:138:7 - (ae-forgotten-export) The symbol "FieldAttrSet" needs to be exported by the entry point index.d.ts // src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts:169:7 - (ae-forgotten-export) The symbol "RuntimeField" needs to be exported by the entry point index.d.ts // src/plugins/data/common/search/aggs/types.ts:139:51 - (ae-forgotten-export) The symbol "AggTypesRegistryStart" needs to be exported by the entry point index.d.ts -// src/plugins/data/common/search/search_source/search_source.ts:186:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts +// src/plugins/data/common/search/search_source/search_source.ts:187:7 - (ae-forgotten-export) The symbol "SearchFieldValue" needs to be exported by the entry point index.d.ts // src/plugins/data/public/field_formats/field_formats_service.ts:56:3 - (ae-forgotten-export) The symbol "FormatFactory" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:55:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:55:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts index 19035eb8a9328..991f3063cac0a 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts @@ -90,7 +90,7 @@ describe('sync_query_state_with_url', () => { test('url is actually changed when data in services changes', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); filterManager.setFilters([gF, aF]); - kbnUrlStateStorage.flush(); // sync force location change + kbnUrlStateStorage.kbnUrlControls.flush(); // sync force location change expect(history.location.hash).toMatchInlineSnapshot( `"#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!t,index:'logstash-*',key:query,negate:!t,type:custom,value:'%7B%22match%22:%7B%22key1%22:%22value1%22%7D%7D'),query:(match:(key1:value1)))),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))"` ); @@ -126,7 +126,7 @@ describe('sync_query_state_with_url', () => { test('when url is changed, filters synced back to filterManager', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); - kbnUrlStateStorage.cancel(); // stop initial syncing pending update + kbnUrlStateStorage.kbnUrlControls.cancel(); // stop initial syncing pending update history.push(pathWithFilter); expect(filterManager.getGlobalFilters()).toHaveLength(1); stop(); diff --git a/src/plugins/data/public/search/errors/es_error.test.tsx b/src/plugins/data/public/search/errors/es_error.test.tsx index adb422c1d18e7..6a4cb9c494b4f 100644 --- a/src/plugins/data/public/search/errors/es_error.test.tsx +++ b/src/plugins/data/public/search/errors/es_error.test.tsx @@ -7,23 +7,22 @@ */ import { EsError } from './es_error'; -import { IEsError } from './types'; describe('EsError', () => { it('contains the same body as the wrapped error', () => { const error = { - body: { - attributes: { - error: { - type: 'top_level_exception_type', - reason: 'top-level reason', - }, + statusCode: 500, + message: 'nope', + attributes: { + error: { + type: 'top_level_exception_type', + reason: 'top-level reason', }, }, - } as IEsError; + } as any; const esError = new EsError(error); - expect(typeof esError.body).toEqual('object'); - expect(esError.body).toEqual(error.body); + expect(typeof esError.attributes).toEqual('object'); + expect(esError.attributes).toEqual(error.attributes); }); }); diff --git a/src/plugins/data/public/search/errors/es_error.tsx b/src/plugins/data/public/search/errors/es_error.tsx index fff06d2e1bfb6..d241eecfd8d5d 100644 --- a/src/plugins/data/public/search/errors/es_error.tsx +++ b/src/plugins/data/public/search/errors/es_error.tsx @@ -11,19 +11,19 @@ import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; import { ApplicationStart } from 'kibana/public'; import { KbnError } from '../../../../kibana_utils/common'; import { IEsError } from './types'; -import { getRootCause, getTopLevelCause } from './utils'; +import { getRootCause } from './utils'; export class EsError extends KbnError { - readonly body: IEsError['body']; + readonly attributes: IEsError['attributes']; constructor(protected readonly err: IEsError) { super('EsError'); - this.body = err.body; + this.attributes = err.attributes; } public getErrorMessage(application: ApplicationStart) { const rootCause = getRootCause(this.err)?.reason; - const topLevelCause = getTopLevelCause(this.err)?.reason; + const topLevelCause = this.attributes?.reason; const cause = rootCause ?? topLevelCause; return ( diff --git a/src/plugins/data/public/search/errors/painless_error.test.tsx b/src/plugins/data/public/search/errors/painless_error.test.tsx new file mode 100644 index 0000000000000..929f25e234a60 --- /dev/null +++ b/src/plugins/data/public/search/errors/painless_error.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { coreMock } from '../../../../../core/public/mocks'; +const startMock = coreMock.createStart(); + +import { mount } from 'enzyme'; +import { PainlessError } from './painless_error'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json'; + +describe('PainlessError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Should show reason and code', () => { + const e = new PainlessError({ + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, + }); + const component = mount(e.getErrorMessage(startMock.application)); + + const scriptElem = findTestSubject(component, 'painlessScript').getDOMNode(); + + const failedShards = e.attributes?.failed_shards![0]; + const script = failedShards!.reason.script; + expect(scriptElem.textContent).toBe(`Error executing Painless script: '${script}'`); + + const stackTraceElem = findTestSubject(component, 'painlessStackTrace').getDOMNode(); + const stackTrace = failedShards!.reason.script_stack!.join('\n'); + expect(stackTraceElem.textContent).toBe(stackTrace); + + expect(component.find('EuiButton').length).toBe(1); + }); +}); diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index 8a4248e48185b..6d11f3a16b09e 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -33,10 +33,12 @@ export class PainlessError extends EsError { return ( <> - {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', { - defaultMessage: "Error executing Painless script: '{script}'.", - values: { script: rootCause?.script }, - })} + + {i18n.translate('data.painlessError.painlessScriptedFieldErrorMessage', { + defaultMessage: "Error executing Painless script: '{script}'", + values: { script: rootCause?.script }, + })} + {painlessStack ? ( diff --git a/src/plugins/data/public/search/errors/timeout_error.tsx b/src/plugins/data/public/search/errors/timeout_error.tsx index ee2703b888bf1..6b9ce1b422481 100644 --- a/src/plugins/data/public/search/errors/timeout_error.tsx +++ b/src/plugins/data/public/search/errors/timeout_error.tsx @@ -24,7 +24,7 @@ export enum TimeoutErrorMode { */ export class SearchTimeoutError extends KbnError { public mode: TimeoutErrorMode; - constructor(err: Error, mode: TimeoutErrorMode) { + constructor(err: Record, mode: TimeoutErrorMode) { super(`Request timeout: ${JSON.stringify(err?.message)}`); this.mode = mode; } diff --git a/src/plugins/data/public/search/errors/types.ts b/src/plugins/data/public/search/errors/types.ts index d62cb311bf6a4..5806ef8676b9b 100644 --- a/src/plugins/data/public/search/errors/types.ts +++ b/src/plugins/data/public/search/errors/types.ts @@ -6,57 +6,47 @@ * Public License, v 1. */ +import { KibanaServerError } from '../../../../kibana_utils/common'; + export interface FailedShard { shard: number; index: string; node: string; - reason: { + reason: Reason; +} + +export interface Reason { + type: string; + reason: string; + script_stack?: string[]; + position?: { + offset: number; + start: number; + end: number; + }; + lang?: string; + script?: string; + caused_by?: { type: string; reason: string; - script_stack: string[]; - script: string; - lang: string; - position: { - offset: number; - start: number; - end: number; - }; - caused_by: { - type: string; - reason: string; - }; }; } -export interface IEsError { - body: { - statusCode: number; - error: string; - message: string; - attributes?: { - error?: { - root_cause?: [ - { - lang: string; - script: string; - } - ]; - type: string; - reason: string; - failed_shards: FailedShard[]; - caused_by: { - type: string; - reason: string; - phase: string; - grouped: boolean; - failed_shards: FailedShard[]; - script_stack: string[]; - }; - }; - }; - }; +export interface IEsErrorAttributes { + type: string; + reason: string; + root_cause?: Reason[]; + failed_shards?: FailedShard[]; } +export type IEsError = KibanaServerError; + +/** + * Checks if a given errors originated from Elasticsearch. + * Those params are assigned to the attributes property of an error. + * + * @param e + */ export function isEsError(e: any): e is IEsError { - return !!e.body?.attributes; + return !!e.attributes; } diff --git a/src/plugins/data/public/search/errors/utils.ts b/src/plugins/data/public/search/errors/utils.ts index d140e713f9440..7d303543a0c57 100644 --- a/src/plugins/data/public/search/errors/utils.ts +++ b/src/plugins/data/public/search/errors/utils.ts @@ -6,19 +6,15 @@ * Public License, v 1. */ -import { IEsError } from './types'; +import { FailedShard } from './types'; +import { KibanaServerError } from '../../../../kibana_utils/common'; -export function getFailedShards(err: IEsError) { - const failedShards = - err.body?.attributes?.error?.failed_shards || - err.body?.attributes?.error?.caused_by?.failed_shards; +export function getFailedShards(err: KibanaServerError): FailedShard | undefined { + const errorInfo = err.attributes; + const failedShards = errorInfo?.failed_shards || errorInfo?.caused_by?.failed_shards; return failedShards ? failedShards[0] : undefined; } -export function getTopLevelCause(err: IEsError) { - return err.body?.attributes?.error; -} - -export function getRootCause(err: IEsError) { +export function getRootCause(err: KibanaServerError) { return getFailedShards(err)?.reason; } diff --git a/src/plugins/data/public/search/search_interceptor.test.ts b/src/plugins/data/public/search/search_interceptor.test.ts index 5ae01eccdd920..bfd73951c31c4 100644 --- a/src/plugins/data/public/search/search_interceptor.test.ts +++ b/src/plugins/data/public/search/search_interceptor.test.ts @@ -12,12 +12,15 @@ import { coreMock } from '../../../../core/public/mocks'; import { IEsSearchRequest } from '../../common/search'; import { SearchInterceptor } from './search_interceptor'; import { AbortError } from '../../../kibana_utils/public'; -import { SearchTimeoutError, PainlessError, TimeoutErrorMode } from './errors'; +import { SearchTimeoutError, PainlessError, TimeoutErrorMode, EsError } from './errors'; import { searchServiceMock } from './mocks'; import { ISearchStart, ISessionService } from '.'; import { bfetchPluginMock } from '../../../bfetch/public/mocks'; import { BfetchPublicSetup } from 'src/plugins/bfetch/public'; +import * as searchPhaseException from '../../common/search/test_data/search_phase_execution_exception.json'; +import * as resourceNotFoundException from '../../common/search/test_data/resource_not_found_exception.json'; + let searchInterceptor: SearchInterceptor; let mockCoreSetup: MockedKeys; let bfetchSetup: jest.Mocked; @@ -64,15 +67,9 @@ describe('SearchInterceptor', () => { test('Renders a PainlessError', async () => { searchInterceptor.showError( new PainlessError({ - body: { - attributes: { - error: { - failed_shards: { - reason: 'bananas', - }, - }, - }, - } as any, + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, }) ); expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); @@ -161,10 +158,8 @@ describe('SearchInterceptor', () => { describe('Should handle Timeout errors', () => { test('Should throw SearchTimeoutError on server timeout AND show toast', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { @@ -177,10 +172,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show multiple times if not in a session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -198,10 +191,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show once per each session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -219,10 +210,8 @@ describe('SearchInterceptor', () => { test('Timeout error should show once in a single session', async () => { const mockResponse: any = { - result: 500, - body: { - message: 'Request timed out', - }, + statusCode: 500, + message: 'Request timed out', }; fetchMock.mockRejectedValue(mockResponse); const mockRequest: IEsSearchRequest = { @@ -240,22 +229,9 @@ describe('SearchInterceptor', () => { test('Should throw Painless error on server error with OSS format', async () => { const mockResponse: any = { - result: 500, - body: { - attributes: { - error: { - failed_shards: [ - { - reason: { - lang: 'painless', - script_stack: ['a', 'b'], - reason: 'banana', - }, - }, - ], - }, - }, - }, + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: searchPhaseException.error, }; fetchMock.mockRejectedValueOnce(mockResponse); const mockRequest: IEsSearchRequest = { @@ -265,6 +241,20 @@ describe('SearchInterceptor', () => { await expect(response.toPromise()).rejects.toThrow(PainlessError); }); + test('Should throw ES error on ES server error', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'resource_not_found_exception', + attributes: resourceNotFoundException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const mockRequest: IEsSearchRequest = { + params: {}, + }; + const response = searchInterceptor.search(mockRequest); + await expect(response.toPromise()).rejects.toThrow(EsError); + }); + test('Observable should fail if user aborts (test merged signal)', async () => { const abortController = new AbortController(); fetchMock.mockImplementationOnce((options: any) => { diff --git a/src/plugins/data/public/search/search_interceptor.ts b/src/plugins/data/public/search/search_interceptor.ts index f6ca9ef1a993d..6dfc8faea769e 100644 --- a/src/plugins/data/public/search/search_interceptor.ts +++ b/src/plugins/data/public/search/search_interceptor.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { get, memoize } from 'lodash'; +import { memoize } from 'lodash'; import { BehaviorSubject, throwError, timer, defer, from, Observable, NEVER } from 'rxjs'; import { catchError, finalize } from 'rxjs/operators'; import { PublicMethodsOf } from '@kbn/utility-types'; @@ -25,7 +25,11 @@ import { getHttpError, } from './errors'; import { toMountPoint } from '../../../kibana_react/public'; -import { AbortError, getCombinedAbortSignal } from '../../../kibana_utils/public'; +import { + AbortError, + getCombinedAbortSignal, + KibanaServerError, +} from '../../../kibana_utils/public'; import { ISessionService } from './session'; export interface SearchInterceptorDeps { @@ -87,8 +91,12 @@ export class SearchInterceptor { * @returns `Error` a search service specific error or the original error, if a specific error can't be recognized. * @internal */ - protected handleSearchError(e: any, timeoutSignal: AbortSignal, options?: ISearchOptions): Error { - if (timeoutSignal.aborted || get(e, 'body.message') === 'Request timed out') { + protected handleSearchError( + e: KibanaServerError | AbortError, + timeoutSignal: AbortSignal, + options?: ISearchOptions + ): Error { + if (timeoutSignal.aborted || e.message === 'Request timed out') { // Handle a client or a server side timeout const err = new SearchTimeoutError(e, this.getTimeoutMode()); @@ -96,7 +104,7 @@ export class SearchInterceptor { // The timeout error is shown any time a request times out, or once per session, if the request is part of a session. this.showTimeoutError(err, options?.sessionId); return err; - } else if (options?.abortSignal?.aborted) { + } else if (e instanceof AbortError) { // In the case an application initiated abort, throw the existing AbortError. return e; } else if (isEsError(e)) { @@ -106,12 +114,13 @@ export class SearchInterceptor { return new EsError(e); } } else { - return e; + return e instanceof Error ? e : new Error(e.message); } } /** * @internal + * @throws `AbortError` | `ErrorLike` */ protected runSearch( request: IKibanaSearchRequest, @@ -234,7 +243,7 @@ export class SearchInterceptor { }); this.pendingCount$.next(this.pendingCount$.getValue() + 1); return from(this.runSearch(request, { ...options, abortSignal: combinedSignal })).pipe( - catchError((e: Error) => { + catchError((e: Error | AbortError) => { return throwError(this.handleSearchError(e, timeoutSignal, options)); }), finalize(() => { diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx index 426302689c8f0..b7d9be485a303 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx @@ -84,12 +84,15 @@ function wrapQueryStringInputInContext(testProps: any, storage?: any) { ); } -describe('QueryStringInput', () => { +// FAILING: https://github.com/elastic/kibana/issues/85715 +// FAILING: https://github.com/elastic/kibana/issues/89603 +// FAILING: https://github.com/elastic/kibana/issues/89641 +describe.skip('QueryStringInput', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('Should render the given query', async () => { + it.skip('Should render the given query', async () => { const { getByText } = render( wrapQueryStringInputInContext({ query: kqlQuery, diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts index 8e66729825e39..eeef46381732e 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.test.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.test.ts @@ -6,37 +6,56 @@ * Public License, v 1. */ +import { + elasticsearchClientMock, + MockedTransportRequestPromise, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../core/server/elasticsearch/client/mocks'; import { pluginInitializerContextConfigMock } from '../../../../../core/server/mocks'; import { esSearchStrategyProvider } from './es_search_strategy'; import { SearchStrategyDependencies } from '../types'; +import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json'; +import { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { KbnServerError } from '../../../../kibana_utils/server'; + describe('ES search strategy', () => { + const successBody = { + _shards: { + total: 10, + failed: 1, + skipped: 2, + successful: 7, + }, + }; + let mockedApiCaller: MockedTransportRequestPromise; + let mockApiCaller: jest.Mock<() => MockedTransportRequestPromise>; const mockLogger: any = { debug: () => {}, }; - const mockApiCaller = jest.fn().mockResolvedValue({ - body: { - _shards: { - total: 10, - failed: 1, - skipped: 2, - successful: 7, - }, - }, - }); - const mockDeps = ({ - uiSettingsClient: { - get: () => {}, - }, - esClient: { asCurrentUser: { search: mockApiCaller } }, - } as unknown) as SearchStrategyDependencies; + function getMockedDeps(err?: Record) { + mockApiCaller = jest.fn().mockImplementation(() => { + if (err) { + mockedApiCaller = elasticsearchClientMock.createErrorTransportRequestPromise(err); + } else { + mockedApiCaller = elasticsearchClientMock.createSuccessTransportRequestPromise( + successBody, + { statusCode: 200 } + ); + } + return mockedApiCaller; + }); - const mockConfig$ = pluginInitializerContextConfigMock({}).legacy.globalConfig$; + return ({ + uiSettingsClient: { + get: () => {}, + }, + esClient: { asCurrentUser: { search: mockApiCaller } }, + } as unknown) as SearchStrategyDependencies; + } - beforeEach(() => { - mockApiCaller.mockClear(); - }); + const mockConfig$ = pluginInitializerContextConfigMock({}).legacy.globalConfig$; it('returns a strategy with `search`', async () => { const esSearch = await esSearchStrategyProvider(mockConfig$, mockLogger); @@ -48,7 +67,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*' }; await esSearchStrategyProvider(mockConfig$, mockLogger) - .search({ params }, {}, mockDeps) + .search({ params }, {}, getMockedDeps()) .subscribe(() => { expect(mockApiCaller).toBeCalled(); expect(mockApiCaller.mock.calls[0][0]).toEqual({ @@ -64,7 +83,7 @@ describe('ES search strategy', () => { const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; await esSearchStrategyProvider(mockConfig$, mockLogger) - .search({ params }, {}, mockDeps) + .search({ params }, {}, getMockedDeps()) .subscribe(() => { expect(mockApiCaller).toBeCalled(); expect(mockApiCaller.mock.calls[0][0]).toEqual({ @@ -82,13 +101,109 @@ describe('ES search strategy', () => { params: { index: 'logstash-*' }, }, {}, - mockDeps + getMockedDeps() ) .subscribe((data) => { expect(data.isRunning).toBe(false); expect(data.isPartial).toBe(false); expect(data).toHaveProperty('loaded'); expect(data).toHaveProperty('rawResponse'); + expect(mockedApiCaller.abort).not.toBeCalled(); done(); })); + + it('can be aborted', async () => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + + const abortController = new AbortController(); + abortController.abort(); + + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, { abortSignal: abortController.signal }, getMockedDeps()) + .toPromise(); + + expect(mockApiCaller).toBeCalled(); + expect(mockApiCaller.mock.calls[0][0]).toEqual({ + ...params, + track_total_hits: true, + }); + expect(mockedApiCaller.abort).toBeCalled(); + }); + + it('throws normalized error if ResponseError is thrown', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(404); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(indexNotFoundException); + done(); + } + }); + + it('throws normalized error if ElasticsearchClientError is thrown', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new ElasticsearchClientError('This is a general ESClient error'); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(500); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(undefined); + done(); + } + }); + + it('throws normalized error if ESClient throws unknown error', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + const errResponse = new Error('ESClient error'); + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ params }, {}, getMockedDeps(errResponse)) + .toPromise(); + } catch (e) { + expect(mockApiCaller).toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.statusCode).toBe(500); + expect(e.message).toBe(errResponse.message); + expect(e.errBody).toBe(undefined); + done(); + } + }); + + it('throws KbnServerError for unknown index type', async (done) => { + const params = { index: 'logstash-*', ignore_unavailable: false, timeout: '1000ms' }; + + try { + await esSearchStrategyProvider(mockConfig$, mockLogger) + .search({ indexType: 'banana', params }, {}, getMockedDeps()) + .toPromise(); + } catch (e) { + expect(mockApiCaller).not.toBeCalled(); + expect(e).toBeInstanceOf(KbnServerError); + expect(e.message).toBe('Unsupported index pattern type banana'); + expect(e.statusCode).toBe(400); + expect(e.errBody).toBe(undefined); + done(); + } + }); }); diff --git a/src/plugins/data/server/search/es_search/es_search_strategy.ts b/src/plugins/data/server/search/es_search/es_search_strategy.ts index a11bbe11f3f95..c176a50627b92 100644 --- a/src/plugins/data/server/search/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/es_search/es_search_strategy.ts @@ -15,13 +15,20 @@ import type { SearchUsage } from '../collectors'; import { getDefaultSearchParams, getShardTimeout, shimAbortSignal } from './request_utils'; import { toKibanaSearchResponse } from './response_utils'; import { searchUsageObserver } from '../collectors/usage'; -import { KbnServerError } from '../../../../kibana_utils/server'; +import { getKbnServerError, KbnServerError } from '../../../../kibana_utils/server'; export const esSearchStrategyProvider = ( config$: Observable, logger: Logger, usage?: SearchUsage ): ISearchStrategy => ({ + /** + * @param request + * @param options + * @param deps + * @throws `KbnServerError` + * @returns `Observable>` + */ search: (request, { abortSignal }, { esClient, uiSettingsClient }) => { // Only default index pattern type is supported here. // See data_enhanced for other type support. @@ -30,15 +37,19 @@ export const esSearchStrategyProvider = ( } const search = async () => { - const config = await config$.pipe(first()).toPromise(); - const params = { - ...(await getDefaultSearchParams(uiSettingsClient)), - ...getShardTimeout(config), - ...request.params, - }; - const promise = esClient.asCurrentUser.search>(params); - const { body } = await shimAbortSignal(promise, abortSignal); - return toKibanaSearchResponse(body); + try { + const config = await config$.pipe(first()).toPromise(); + const params = { + ...(await getDefaultSearchParams(uiSettingsClient)), + ...getShardTimeout(config), + ...request.params, + }; + const promise = esClient.asCurrentUser.search>(params); + const { body } = await shimAbortSignal(promise, abortSignal); + return toKibanaSearchResponse(body); + } catch (e) { + throw getKbnServerError(e); + } }; return from(search()).pipe(tap(searchUsageObserver(logger, usage))); diff --git a/src/plugins/data/server/search/routes/bsearch.ts b/src/plugins/data/server/search/routes/bsearch.ts new file mode 100644 index 0000000000000..e30b7bdaa8402 --- /dev/null +++ b/src/plugins/data/server/search/routes/bsearch.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { catchError, first, map } from 'rxjs/operators'; +import { CoreStart, KibanaRequest } from 'src/core/server'; +import { BfetchServerSetup } from 'src/plugins/bfetch/server'; +import { + IKibanaSearchRequest, + IKibanaSearchResponse, + ISearchClient, + ISearchOptions, +} from '../../../common/search'; +import { shimHitsTotal } from './shim_hits_total'; + +type GetScopedProider = (coreStart: CoreStart) => (request: KibanaRequest) => ISearchClient; + +export function registerBsearchRoute( + bfetch: BfetchServerSetup, + coreStartPromise: Promise<[CoreStart, {}, {}]>, + getScopedProvider: GetScopedProider +): void { + bfetch.addBatchProcessingRoute< + { request: IKibanaSearchRequest; options?: ISearchOptions }, + IKibanaSearchResponse + >('/internal/bsearch', (request) => { + return { + /** + * @param requestOptions + * @throws `KibanaServerError` + */ + onBatchItem: async ({ request: requestData, options }) => { + const coreStart = await coreStartPromise; + const search = getScopedProvider(coreStart[0])(request); + return search + .search(requestData, options) + .pipe( + first(), + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + catchError((err) => { + // Re-throw as object, to get attributes passed to the client + // eslint-disable-next-line no-throw-literal + throw { + message: err.message, + statusCode: err.statusCode, + attributes: err.errBody?.error, + }; + }) + ) + .toPromise(); + }, + }; + }); +} diff --git a/src/plugins/data/server/search/routes/call_msearch.ts b/src/plugins/data/server/search/routes/call_msearch.ts index 6578774f65a3c..fc30e2f29c3ef 100644 --- a/src/plugins/data/server/search/routes/call_msearch.ts +++ b/src/plugins/data/server/search/routes/call_msearch.ts @@ -8,12 +8,12 @@ import { Observable } from 'rxjs'; import { first } from 'rxjs/operators'; -import { ApiResponse } from '@elastic/elasticsearch'; import { SearchResponse } from 'elasticsearch'; import { IUiSettingsClient, IScopedClusterClient, SharedGlobalConfig } from 'src/core/server'; import type { MsearchRequestBody, MsearchResponse } from '../../../common/search/search_source'; import { shimHitsTotal } from './shim_hits_total'; +import { getKbnServerError } from '../../../../kibana_utils/server'; import { getShardTimeout, getDefaultSearchParams, shimAbortSignal } from '..'; /** @internal */ @@ -48,6 +48,9 @@ interface CallMsearchDependencies { * @internal */ export function getCallMsearch(dependencies: CallMsearchDependencies) { + /** + * @throws KbnServerError + */ return async (params: { body: MsearchRequestBody; signal?: AbortSignal; @@ -61,28 +64,29 @@ export function getCallMsearch(dependencies: CallMsearchDependencies) { // trackTotalHits is not supported by msearch const { track_total_hits: _, ...defaultParams } = await getDefaultSearchParams(uiSettings); - const body = convertRequestBody(params.body, timeout); - - const promise = shimAbortSignal( - esClient.asCurrentUser.msearch( + try { + const promise = esClient.asCurrentUser.msearch( { - body, + body: convertRequestBody(params.body, timeout), }, { querystring: defaultParams, } - ), - params.signal - ); - const response = (await promise) as ApiResponse<{ responses: Array> }>; + ); + const response = await shimAbortSignal(promise, params.signal); - return { - body: { - ...response, + return { body: { - responses: response.body.responses?.map((r: SearchResponse) => shimHitsTotal(r)), + ...response, + body: { + responses: response.body.responses?.map((r: SearchResponse) => + shimHitsTotal(r) + ), + }, }, - }, - }; + }; + } catch (e) { + throw getKbnServerError(e); + } }; } diff --git a/src/plugins/data/server/search/routes/msearch.test.ts b/src/plugins/data/server/search/routes/msearch.test.ts index 02f200d5435dd..a847931a49123 100644 --- a/src/plugins/data/server/search/routes/msearch.test.ts +++ b/src/plugins/data/server/search/routes/msearch.test.ts @@ -24,6 +24,8 @@ import { convertRequestBody } from './call_msearch'; import { registerMsearchRoute } from './msearch'; import { DataPluginStart } from '../../plugin'; import { dataPluginMock } from '../../mocks'; +import * as jsonEofException from '../../../common/search/test_data/json_e_o_f_exception.json'; +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; describe('msearch route', () => { let mockDataStart: MockedKeys; @@ -76,15 +78,52 @@ describe('msearch route', () => { }); }); - it('handler throws an error if the search throws an error', async () => { - const response = { - message: 'oh no', - body: { - error: 'oops', + it('handler returns an error response if the search throws an error', async () => { + const rejectedValue = Promise.reject( + new ResponseError({ + body: jsonEofException, + statusCode: 400, + meta: {} as any, + headers: [], + warnings: [], + }) + ); + const mockClient = { + msearch: jest.fn().mockReturnValue(rejectedValue), + }; + const mockContext = { + core: { + elasticsearch: { client: { asCurrentUser: mockClient } }, + uiSettings: { client: { get: jest.fn() } }, }, }; + const mockBody = { searches: [{ header: {}, body: {} }] }; + const mockQuery = {}; + const mockRequest = httpServerMock.createKibanaRequest({ + body: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + registerMsearchRoute(mockCoreSetup.http.createRouter(), { getStartServices, globalConfig$ }); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient.msearch).toBeCalledTimes(1); + expect(mockResponse.customError).toBeCalled(); + + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(400); + expect(error.body.message).toBe('json_e_o_f_exception'); + expect(error.body.attributes).toBe(jsonEofException.error); + }); + + it('handler returns an error response if the search throws a general error', async () => { + const rejectedValue = Promise.reject(new Error('What happened?')); const mockClient = { - msearch: jest.fn().mockReturnValue(Promise.reject(response)), + msearch: jest.fn().mockReturnValue(rejectedValue), }; const mockContext = { core: { @@ -106,11 +145,12 @@ describe('msearch route', () => { const handler = mockRouter.post.mock.calls[0][1]; await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); - expect(mockClient.msearch).toBeCalled(); + expect(mockClient.msearch).toBeCalledTimes(1); expect(mockResponse.customError).toBeCalled(); const error: any = mockResponse.customError.mock.calls[0][0]; - expect(error.body.message).toBe('oh no'); - expect(error.body.attributes.error).toBe('oops'); + expect(error.statusCode).toBe(500); + expect(error.body.message).toBe('What happened?'); + expect(error.body.attributes).toBe(undefined); }); }); diff --git a/src/plugins/data/server/search/routes/search.test.ts b/src/plugins/data/server/search/routes/search.test.ts index f47a42cf9d82b..2cde6d19e4c18 100644 --- a/src/plugins/data/server/search/routes/search.test.ts +++ b/src/plugins/data/server/search/routes/search.test.ts @@ -12,11 +12,27 @@ import { CoreSetup, RequestHandlerContext } from 'src/core/server'; import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { registerSearchRoute } from './search'; import { DataPluginStart } from '../../plugin'; +import * as searchPhaseException from '../../../common/search/test_data/search_phase_execution_exception.json'; +import * as indexNotFoundException from '../../../common/search/test_data/index_not_found_exception.json'; +import { KbnServerError } from '../../../../kibana_utils/server'; describe('Search service', () => { let mockCoreSetup: MockedKeys>; + function mockEsError(message: string, statusCode: number, attributes?: Record) { + return new KbnServerError(message, statusCode, attributes); + } + + async function runMockSearch(mockContext: any, mockRequest: any, mockResponse: any) { + registerSearchRoute(mockCoreSetup.http.createRouter()); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + } + beforeEach(() => { + jest.clearAllMocks(); mockCoreSetup = coreMock.createSetup(); }); @@ -54,11 +70,7 @@ describe('Search service', () => { }); const mockResponse = httpServerMock.createResponseFactory(); - registerSearchRoute(mockCoreSetup.http.createRouter()); - - const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const handler = mockRouter.post.mock.calls[0][1]; - await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + await runMockSearch(mockContext, mockRequest, mockResponse); expect(mockContext.search.search).toBeCalled(); expect(mockContext.search.search.mock.calls[0][0]).toStrictEqual(mockBody); @@ -68,14 +80,9 @@ describe('Search service', () => { }); }); - it('handler throws an error if the search throws an error', async () => { + it('handler returns an error response if the search throws a painless error', async () => { const rejectedValue = from( - Promise.reject({ - message: 'oh no', - body: { - error: 'oops', - }, - }) + Promise.reject(mockEsError('search_phase_execution_exception', 400, searchPhaseException)) ); const mockContext = { @@ -84,25 +91,69 @@ describe('Search service', () => { }, }; - const mockBody = { id: undefined, params: {} }; - const mockParams = { strategy: 'foo' }; const mockRequest = httpServerMock.createKibanaRequest({ - body: mockBody, - params: mockParams, + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, }); const mockResponse = httpServerMock.createResponseFactory(); - registerSearchRoute(mockCoreSetup.http.createRouter()); + await runMockSearch(mockContext, mockRequest, mockResponse); - const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; - const handler = mockRouter.post.mock.calls[0][1]; - await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + // verify error + expect(mockResponse.customError).toBeCalled(); + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(400); + expect(error.body.message).toBe('search_phase_execution_exception'); + expect(error.body.attributes).toBe(searchPhaseException.error); + }); + + it('handler returns an error response if the search throws an index not found error', async () => { + const rejectedValue = from( + Promise.reject(mockEsError('index_not_found_exception', 404, indexNotFoundException)) + ); + + const mockContext = { + search: { + search: jest.fn().mockReturnValue(rejectedValue), + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await runMockSearch(mockContext, mockRequest, mockResponse); + + expect(mockResponse.customError).toBeCalled(); + const error: any = mockResponse.customError.mock.calls[0][0]; + expect(error.statusCode).toBe(404); + expect(error.body.message).toBe('index_not_found_exception'); + expect(error.body.attributes).toBe(indexNotFoundException.error); + }); + + it('handler returns an error response if the search throws a general error', async () => { + const rejectedValue = from(Promise.reject(new Error('This is odd'))); + + const mockContext = { + search: { + search: jest.fn().mockReturnValue(rejectedValue), + }, + }; + + const mockRequest = httpServerMock.createKibanaRequest({ + body: { id: undefined, params: {} }, + params: { strategy: 'foo' }, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + await runMockSearch(mockContext, mockRequest, mockResponse); - expect(mockContext.search.search).toBeCalled(); - expect(mockContext.search.search.mock.calls[0][0]).toStrictEqual(mockBody); expect(mockResponse.customError).toBeCalled(); const error: any = mockResponse.customError.mock.calls[0][0]; - expect(error.body.message).toBe('oh no'); - expect(error.body.attributes.error).toBe('oops'); + expect(error.statusCode).toBe(500); + expect(error.body.message).toBe('This is odd'); + expect(error.body.attributes).toBe(undefined); }); }); diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index f1a6fc09ee21f..63593bbe84a08 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { BehaviorSubject, Observable } from 'rxjs'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { pick } from 'lodash'; import { CoreSetup, @@ -18,7 +18,7 @@ import { SharedGlobalConfig, StartServicesAccessor, } from 'src/core/server'; -import { catchError, first, map } from 'rxjs/operators'; +import { first } from 'rxjs/operators'; import { BfetchServerSetup } from 'src/plugins/bfetch/server'; import { ExpressionsServerSetup } from 'src/plugins/expressions/server'; import type { @@ -64,6 +64,7 @@ import { aggShardDelay } from '../../common/search/aggs/buckets/shard_delay_fn'; import { ConfigSchema } from '../../config'; import { SessionService, IScopedSessionService, ISessionService } from './session'; import { KbnServerError } from '../../../kibana_utils/server'; +import { registerBsearchRoute } from './routes/bsearch'; type StrategyMap = Record>; @@ -137,43 +138,7 @@ export class SearchService implements Plugin { ) ); - bfetch.addBatchProcessingRoute< - { request: IKibanaSearchResponse; options?: ISearchOptions }, - any - >('/internal/bsearch', (request) => { - const search = this.asScopedProvider(this.coreStart!)(request); - - return { - onBatchItem: async ({ request: requestData, options }) => { - return search - .search(requestData, options) - .pipe( - first(), - map((response) => { - return { - ...response, - ...{ - rawResponse: shimHitsTotal(response.rawResponse), - }, - }; - }), - catchError((err) => { - // eslint-disable-next-line no-throw-literal - throw { - statusCode: err.statusCode || 500, - body: { - message: err.message, - attributes: { - error: err.body?.error || err.message, - }, - }, - }; - }) - ) - .toPromise(); - }, - }; - }); + registerBsearchRoute(bfetch, core.getStartServices(), this.asScopedProvider); core.savedObjects.registerType(searchTelemetry); if (usageCollection) { @@ -285,10 +250,14 @@ export class SearchService implements Plugin { options: ISearchOptions, deps: SearchStrategyDependencies ) => { - const strategy = this.getSearchStrategy( - options.strategy - ); - return session.search(strategy, request, options, deps); + try { + const strategy = this.getSearchStrategy( + options.strategy + ); + return session.search(strategy, request, options, deps); + } catch (e) { + return throwError(e); + } }; private cancel = (id: string, options: ISearchOptions, deps: SearchStrategyDependencies) => { diff --git a/src/plugins/data/tsconfig.json b/src/plugins/data/tsconfig.json index 81bcb3b02e100..21560b1328840 100644 --- a/src/plugins/data/tsconfig.json +++ b/src/plugins/data/tsconfig.json @@ -7,7 +7,7 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts"], + "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts", "common/**/*.json"], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../bfetch/tsconfig.json" }, diff --git a/src/plugins/discover/public/application/angular/context_state.ts b/src/plugins/discover/public/application/angular/context_state.ts index 73523b218df7c..e8c2f1d397ba5 100644 --- a/src/plugins/discover/public/application/angular/context_state.ts +++ b/src/plugins/discover/public/application/angular/context_state.ts @@ -206,7 +206,7 @@ export function getState({ } }, // helper function just needed for testing - flushToUrl: (replace?: boolean) => stateStorage.flush({ replace }), + flushToUrl: (replace?: boolean) => stateStorage.kbnUrlControls.flush(replace), }; } diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 41c80a717ce75..dcf86babaa5e1 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -47,8 +47,6 @@ import { popularizeField } from '../helpers/popularize_field'; import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state'; import { addFatalError } from '../../../../kibana_legacy/public'; import { METRIC_TYPE } from '@kbn/analytics'; -import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator'; -import { getQueryParams, removeQueryParam } from '../../../../kibana_utils/public'; import { DEFAULT_COLUMNS_SETTING, MODIFY_COLUMNS_ON_SWITCH, @@ -62,6 +60,7 @@ import { getTopNavLinks } from '../components/top_nav/get_top_nav_links'; import { updateSearchSource } from '../helpers/update_search_source'; import { calcFieldCounts } from '../helpers/calc_field_counts'; import { getDefaultSort } from './doc_table/lib/get_default_sort'; +import { DiscoverSearchSessionManager } from './discover_search_session'; const services = getServices(); @@ -86,9 +85,6 @@ const fetchStatuses = { ERROR: 'error', }; -const getSearchSessionIdFromURL = (history) => - getQueryParams(history.location)[SEARCH_SESSION_ID_QUERY_PARAM]; - const app = getAngularModule(); app.config(($routeProvider) => { @@ -177,7 +173,9 @@ function discoverController($route, $scope, Promise) { const { isDefault: isDefaultType } = indexPatternsUtils; const subscriptions = new Subscription(); const refetch$ = new Subject(); + let inspectorRequest; + let isChangingIndexPattern = false; const savedSearch = $route.current.locals.savedObjects.savedSearch; $scope.searchSource = savedSearch.searchSource; $scope.indexPattern = resolveIndexPattern( @@ -195,15 +193,10 @@ function discoverController($route, $scope, Promise) { }; const history = getHistory(); - // used for restoring a search session - let isInitialSearch = true; - - // search session requested a data refresh - subscriptions.add( - data.search.session.onRefresh$.subscribe(() => { - refetch$.next(); - }) - ); + const searchSessionManager = new DiscoverSearchSessionManager({ + history, + session: data.search.session, + }); const state = getState({ getStateDefaults, @@ -255,6 +248,7 @@ function discoverController($route, $scope, Promise) { $scope.$evalAsync(async () => { if (oldStatePartial.index !== newStatePartial.index) { //in case of index pattern switch the route has currently to be reloaded, legacy + isChangingIndexPattern = true; $route.reload(); return; } @@ -351,7 +345,12 @@ function discoverController($route, $scope, Promise) { if (abortController) abortController.abort(); savedSearch.destroy(); subscriptions.unsubscribe(); - data.search.session.clear(); + if (!isChangingIndexPattern) { + // HACK: + // do not clear session when changing index pattern due to how state management around it is setup + // it will be cleared by searchSessionManager on controller reload instead + data.search.session.clear(); + } appStateUnsubscribe(); stopStateSync(); stopSyncingGlobalStateWithUrl(); @@ -475,7 +474,8 @@ function discoverController($route, $scope, Promise) { return ( config.get(SEARCH_ON_PAGE_LOAD_SETTING) || savedSearch.id !== undefined || - timefilter.getRefreshInterval().pause === false + timefilter.getRefreshInterval().pause === false || + searchSessionManager.hasSearchSessionIdInURL() ); }; @@ -486,7 +486,8 @@ function discoverController($route, $scope, Promise) { filterManager.getFetches$(), timefilter.getFetch$(), timefilter.getAutoRefreshFetch$(), - data.query.queryString.getUpdates$() + data.query.queryString.getUpdates$(), + searchSessionManager.newSearchSessionIdFromURL$ ).pipe(debounceTime(100)); subscriptions.add( @@ -512,6 +513,13 @@ function discoverController($route, $scope, Promise) { ) ); + subscriptions.add( + data.search.session.onRefresh$.subscribe(() => { + searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); + refetch$.next(); + }) + ); + $scope.changeInterval = (interval) => { if (interval) { setAppState({ interval }); @@ -591,20 +599,7 @@ function discoverController($route, $scope, Promise) { if (abortController) abortController.abort(); abortController = new AbortController(); - const searchSessionId = (() => { - const searchSessionIdFromURL = getSearchSessionIdFromURL(history); - if (searchSessionIdFromURL) { - if (isInitialSearch) { - data.search.session.restore(searchSessionIdFromURL); - isInitialSearch = false; - return searchSessionIdFromURL; - } else { - // navigating away from background search - removeQueryParam(history, SEARCH_SESSION_ID_QUERY_PARAM); - } - } - return data.search.session.start(); - })(); + const searchSessionId = searchSessionManager.getNextSearchSessionId(); $scope .updateDataSource() @@ -631,6 +626,7 @@ function discoverController($route, $scope, Promise) { $scope.handleRefresh = function (_payload, isUpdate) { if (isUpdate === false) { + searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); refetch$.next(); } }; diff --git a/src/plugins/discover/public/application/angular/discover_search_session.test.ts b/src/plugins/discover/public/application/angular/discover_search_session.test.ts new file mode 100644 index 0000000000000..abec6aedeaf5c --- /dev/null +++ b/src/plugins/discover/public/application/angular/discover_search_session.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { DiscoverSearchSessionManager } from './discover_search_session'; +import { createMemoryHistory } from 'history'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { DataPublicPluginStart } from '../../../../data/public'; + +describe('DiscoverSearchSessionManager', () => { + const history = createMemoryHistory(); + const session = dataPluginMock.createStartContract().search.session as jest.Mocked< + DataPublicPluginStart['search']['session'] + >; + const searchSessionManager = new DiscoverSearchSessionManager({ + history, + session, + }); + + beforeEach(() => { + history.push('/'); + session.start.mockReset(); + session.restore.mockReset(); + session.getSessionId.mockReset(); + session.isCurrentSession.mockReset(); + session.isRestore.mockReset(); + }); + + describe('getNextSearchSessionId', () => { + test('starts a new session', () => { + const nextId = 'id'; + session.start.mockImplementationOnce(() => nextId); + + const id = searchSessionManager.getNextSearchSessionId(); + expect(id).toEqual(nextId); + expect(session.start).toBeCalled(); + }); + + test('restores a session using query param from the URL', () => { + const nextId = 'id_from_url'; + history.push(`/?searchSessionId=${nextId}`); + + const id = searchSessionManager.getNextSearchSessionId(); + expect(id).toEqual(nextId); + expect(session.restore).toBeCalled(); + }); + + test('removes query param from the URL when navigating away from a restored session', () => { + const idFromUrl = 'id_from_url'; + history.push(`/?searchSessionId=${idFromUrl}`); + + const nextId = 'id'; + session.start.mockImplementationOnce(() => nextId); + session.isCurrentSession.mockImplementationOnce(() => true); + session.isRestore.mockImplementationOnce(() => true); + + const id = searchSessionManager.getNextSearchSessionId(); + expect(id).toEqual(nextId); + expect(session.start).toBeCalled(); + expect(history.location.search).toMatchInlineSnapshot(`""`); + }); + }); + + describe('newSearchSessionIdFromURL$', () => { + test('notifies about searchSessionId changes in the URL', () => { + const emits: Array = []; + + const sub = searchSessionManager.newSearchSessionIdFromURL$.subscribe((newId) => { + emits.push(newId); + }); + + history.push(`/?searchSessionId=id1`); + history.push(`/?searchSessionId=id1`); + session.isCurrentSession.mockImplementationOnce(() => true); + history.replace(`/?searchSessionId=id2`); // should skip current this + history.replace(`/`); + history.push(`/?searchSessionId=id1`); + history.push(`/`); + + expect(emits).toMatchInlineSnapshot(` + Array [ + "id1", + null, + "id1", + null, + ] + `); + + sub.unsubscribe(); + }); + }); +}); diff --git a/src/plugins/discover/public/application/angular/discover_search_session.ts b/src/plugins/discover/public/application/angular/discover_search_session.ts new file mode 100644 index 0000000000000..a53d7d6d2c333 --- /dev/null +++ b/src/plugins/discover/public/application/angular/discover_search_session.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { History } from 'history'; +import { filter } from 'rxjs/operators'; +import { DataPublicPluginStart } from '../../../../data/public'; +import { + createQueryParamObservable, + getQueryParams, + removeQueryParam, +} from '../../../../kibana_utils/public'; +import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator'; + +export interface DiscoverSearchSessionManagerDeps { + history: History; + session: DataPublicPluginStart['search']['session']; +} + +/** + * Helps with state management of search session and {@link SEARCH_SESSION_ID_QUERY_PARAM} in the URL + */ +export class DiscoverSearchSessionManager { + /** + * Notifies about `searchSessionId` changes in the URL, + * skips if `searchSessionId` matches current search session id + */ + readonly newSearchSessionIdFromURL$ = createQueryParamObservable( + this.deps.history, + SEARCH_SESSION_ID_QUERY_PARAM + ).pipe( + filter((searchSessionId) => { + if (!searchSessionId) return true; + return !this.deps.session.isCurrentSession(searchSessionId); + }) + ); + + constructor(private readonly deps: DiscoverSearchSessionManagerDeps) {} + + /** + * Get next session id by either starting or restoring a session. + * When navigating away from the restored session {@link SEARCH_SESSION_ID_QUERY_PARAM} is removed from the URL using history.replace + */ + getNextSearchSessionId() { + let searchSessionIdFromURL = this.getSearchSessionIdFromURL(); + if (searchSessionIdFromURL) { + if ( + this.deps.session.isRestore() && + this.deps.session.isCurrentSession(searchSessionIdFromURL) + ) { + // navigating away from a restored session + this.removeSearchSessionIdFromURL({ replace: true }); + searchSessionIdFromURL = undefined; + } else { + this.deps.session.restore(searchSessionIdFromURL); + } + } + + return searchSessionIdFromURL ?? this.deps.session.start(); + } + + /** + * Removes Discovers {@link SEARCH_SESSION_ID_QUERY_PARAM} from the URL + * @param replace - methods to change the URL + */ + removeSearchSessionIdFromURL({ replace = true }: { replace?: boolean } = { replace: true }) { + if (this.hasSearchSessionIdInURL()) { + removeQueryParam(this.deps.history, SEARCH_SESSION_ID_QUERY_PARAM, replace); + } + } + + /** + * If there is a {@link SEARCH_SESSION_ID_QUERY_PARAM} currently in the URL + */ + hasSearchSessionIdInURL(): boolean { + return !!this.getSearchSessionIdFromURL(); + } + + private getSearchSessionIdFromURL = () => + getQueryParams(this.deps.history.location)[SEARCH_SESSION_ID_QUERY_PARAM] as string | undefined; +} diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index c769e263655ab..65a8dded11092 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -200,7 +200,7 @@ export function getState({ setState(appStateContainerModified, defaultState); }, getPreviousAppState: () => previousAppState, - flushToUrl: () => stateStorage.flush(), + flushToUrl: () => stateStorage.kbnUrlControls.flush(), isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()), }; } diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx new file mode 100644 index 0000000000000..f9428e30569f7 --- /dev/null +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { mountWithIntl } from '@kbn/test/jest'; +import { DiscoverGridFlyout } from './discover_grid_flyout'; +import { esHits } from '../../../__mocks__/es_hits'; +import { createFilterManagerMock } from '../../../../../data/public/query/filter_manager/filter_manager.mock'; +import { indexPatternMock } from '../../../__mocks__/index_pattern'; +import { DiscoverServices } from '../../../build_services'; +import { DocViewsRegistry } from '../../doc_views/doc_views_registry'; +import { setDocViewsRegistry } from '../../../kibana_services'; +import { indexPatternWithTimefieldMock } from '../../../__mocks__/index_pattern_with_timefield'; + +describe('Discover flyout', function () { + setDocViewsRegistry(new DocViewsRegistry()); + + it('should be rendered correctly using an index pattern without timefield', async () => { + const onClose = jest.fn(); + const component = mountWithIntl( + + ); + + const url = findTestSubject(component, 'docTableRowAction').prop('href'); + expect(url).toMatchInlineSnapshot(`"#/doc/the-index-pattern-id/i?id=1"`); + findTestSubject(component, 'euiFlyoutCloseButton').simulate('click'); + expect(onClose).toHaveBeenCalled(); + }); + + it('should be rendered correctly using an index pattern with timefield', async () => { + const onClose = jest.fn(); + const component = mountWithIntl( + + ); + + const actions = findTestSubject(component, 'docTableRowAction'); + expect(actions.length).toBe(2); + expect(actions.first().prop('href')).toMatchInlineSnapshot( + `"#/doc/index-pattern-with-timefield-id/i?id=1"` + ); + expect(actions.last().prop('href')).toMatchInlineSnapshot( + `"#/context/index-pattern-with-timefield-id/1?_g=(filters:!())&_a=(columns:!(date),filters:!())"` + ); + findTestSubject(component, 'euiFlyoutCloseButton').simulate('click'); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts index 1b7406496bb81..4d522f47ea87f 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { showOpenSearchPanel } from './show_open_search_panel'; -import { getSharingData } from '../../helpers/get_sharing_data'; +import { getSharingData, showPublicUrlSwitch } from '../../helpers/get_sharing_data'; import { unhashUrl } from '../../../../../kibana_utils/public'; import { DiscoverServices } from '../../../build_services'; import { Adapters } from '../../../../../inspector/common/adapters'; @@ -108,6 +108,7 @@ export const getTopNavLinks = ({ title: savedSearch.title, }, isDirty: !savedSearch.id || state.isAppStateDirty(), + showPublicUrlSwitch, }); }, }; diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts index 1394ceab1dd18..ea16b81615e42 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts @@ -6,7 +6,8 @@ * Public License, v 1. */ -import { getSharingData } from './get_sharing_data'; +import { Capabilities } from 'kibana/public'; +import { getSharingData, showPublicUrlSwitch } from './get_sharing_data'; import { IUiSettingsClient } from 'kibana/public'; import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; import { indexPatternMock } from '../../__mocks__/index_pattern'; @@ -68,3 +69,44 @@ describe('getSharingData', () => { `); }); }); + +describe('showPublicUrlSwitch', () => { + test('returns false if "discover" app is not available', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns false if "discover" app is not accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + discover: { + show: false, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns true if "discover" app is not available an accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + discover: { + show: true, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(true); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts index 62478f1d2830f..1d780a5573e2a 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { IUiSettingsClient } from 'kibana/public'; +import { Capabilities, IUiSettingsClient } from 'kibana/public'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; import { getSortForSearchSource } from '../angular/doc_table'; import { SearchSource } from '../../../../data/common'; @@ -76,3 +76,19 @@ export async function getSharingData( indexPatternId: index.id, }; } + +export interface DiscoverCapabilities { + createShortUrl?: boolean; + save?: boolean; + saveQuery?: boolean; + show?: boolean; + storeSearchSession?: boolean; +} + +export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { + if (!anonymousUserCapabilities.discover) return false; + + const discover = (anonymousUserCapabilities.discover as unknown) as DiscoverCapabilities; + + return !!discover.show; +}; diff --git a/src/plugins/input_control_vis/tsconfig.json b/src/plugins/input_control_vis/tsconfig.json new file mode 100644 index 0000000000000..bef7bc394a6cc --- /dev/null +++ b/src/plugins/input_control_vis/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../kibana_react/tsconfig.json" }, + { "path": "../data/tsconfig.json"}, + { "path": "../expressions/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, + { "path": "../vis_default_editor/tsconfig.json" }, + ] +} diff --git a/src/plugins/kibana_utils/common/errors/index.ts b/src/plugins/kibana_utils/common/errors/index.ts index 354cf1d504b28..f859e0728269a 100644 --- a/src/plugins/kibana_utils/common/errors/index.ts +++ b/src/plugins/kibana_utils/common/errors/index.ts @@ -7,3 +7,4 @@ */ export * from './errors'; +export * from './types'; diff --git a/src/plugins/kibana_utils/common/errors/types.ts b/src/plugins/kibana_utils/common/errors/types.ts new file mode 100644 index 0000000000000..89e83586dc115 --- /dev/null +++ b/src/plugins/kibana_utils/common/errors/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +export interface KibanaServerError { + statusCode: number; + message: string; + attributes?: T; +} diff --git a/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md index ec27895eed666..36c7d7119ffe5 100644 --- a/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md +++ b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md @@ -96,11 +96,11 @@ setTimeout(() => { }, 0); ``` -For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` provides these advanced apis: +For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` exposes `kbnUrlStateStorage.kbnUrlControls` that exposes these advanced apis: -- `kbnUrlStateStorage.flush({replace: boolean})` - allows to synchronously apply any pending updates. - `replace` option allows to use `history.replace()` instead of `history.push()`. Returned boolean indicates if any update happened -- `kbnUrlStateStorage.cancel()` - cancels any pending updates +- `kbnUrlStateStorage.kbnUrlControls.flush({replace: boolean})` - allows to synchronously apply any pending updates. + `replace` option allows using `history.replace()` instead of `history.push()`. +- `kbnUrlStateStorage.kbnUrlControls.cancel()` - cancels any pending updates. ### Sharing one `kbnUrlStateStorage` instance diff --git a/src/plugins/kibana_utils/public/history/history_observable.test.ts b/src/plugins/kibana_utils/public/history/history_observable.test.ts new file mode 100644 index 0000000000000..818c0d7739283 --- /dev/null +++ b/src/plugins/kibana_utils/public/history/history_observable.test.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { + createHistoryObservable, + createQueryParamObservable, + createQueryParamsObservable, +} from './history_observable'; +import { createMemoryHistory, History } from 'history'; +import { ParsedQuery } from 'query-string'; + +let history: History; + +beforeEach(() => { + history = createMemoryHistory(); +}); + +test('createHistoryObservable', () => { + const obs$ = createHistoryObservable(history); + const emits: string[] = []; + obs$.subscribe(({ location }) => { + emits.push(location.pathname + location.search); + }); + + history.push('/test'); + history.push('/'); + + expect(emits.length).toEqual(2); + expect(emits).toMatchInlineSnapshot(` + Array [ + "/test", + "/", + ] + `); +}); + +test('createQueryParamsObservable', () => { + const obs$ = createQueryParamsObservable(history); + const emits: ParsedQuery[] = []; + obs$.subscribe((params) => { + emits.push(params); + }); + + history.push('/test'); + history.push('/test?foo=bar'); + history.push('/?foo=bar'); + history.push('/test?foo=bar&foo1=bar1'); + + expect(emits.length).toEqual(2); + expect(emits).toMatchInlineSnapshot(` + Array [ + Object { + "foo": "bar", + }, + Object { + "foo": "bar", + "foo1": "bar1", + }, + ] + `); +}); + +test('createQueryParamObservable', () => { + const obs$ = createQueryParamObservable(history, 'foo'); + const emits: unknown[] = []; + obs$.subscribe((param) => { + emits.push(param); + }); + + history.push('/test'); + history.push('/test?foo=bar'); + history.push('/?foo=bar'); + history.push('/test?foo=baaaar&foo1=bar1'); + history.push('/test?foo1=bar1'); + + expect(emits.length).toEqual(3); + expect(emits).toMatchInlineSnapshot(` + Array [ + "bar", + "baaaar", + null, + ] + `); +}); diff --git a/src/plugins/kibana_utils/public/history/history_observable.ts b/src/plugins/kibana_utils/public/history/history_observable.ts new file mode 100644 index 0000000000000..f02a5e340b1a0 --- /dev/null +++ b/src/plugins/kibana_utils/public/history/history_observable.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Action, History, Location } from 'history'; +import { Observable } from 'rxjs'; +import { ParsedQuery } from 'query-string'; +import deepEqual from 'fast-deep-equal'; +import { map } from 'rxjs/operators'; +import { getQueryParams } from './get_query_params'; +import { distinctUntilChangedWithInitialValue } from '../../common'; + +/** + * Convert history.listen into an observable + * @param history - {@link History} instance + */ +export function createHistoryObservable( + history: History +): Observable<{ location: Location; action: Action }> { + return new Observable((observer) => { + const unlisten = history.listen((location, action) => observer.next({ location, action })); + return () => { + unlisten(); + }; + }); +} + +/** + * Create an observable that emits every time any of query params change. + * Uses deepEqual check. + * @param history - {@link History} instance + */ +export function createQueryParamsObservable(history: History): Observable { + return createHistoryObservable(history).pipe( + map(({ location }) => ({ ...getQueryParams(location) })), + distinctUntilChangedWithInitialValue({ ...getQueryParams(history.location) }, deepEqual) + ); +} + +/** + * Create an observable that emits every time _paramKey_ changes + * @param history - {@link History} instance + * @param paramKey - query param key to observe + */ +export function createQueryParamObservable( + history: History, + paramKey: string +): Observable { + return createQueryParamsObservable(history).pipe( + map((params) => (params[paramKey] ?? null) as Param | null), + distinctUntilChangedWithInitialValue( + (getQueryParams(history.location)[paramKey] ?? null) as Param | null, + deepEqual + ) + ); +} diff --git a/src/plugins/kibana_utils/public/history/index.ts b/src/plugins/kibana_utils/public/history/index.ts index 4b1b610d560e2..b2ac9ed6c739e 100644 --- a/src/plugins/kibana_utils/public/history/index.ts +++ b/src/plugins/kibana_utils/public/history/index.ts @@ -9,3 +9,4 @@ export { removeQueryParam } from './remove_query_param'; export { redirectWhenMissing } from './redirect_when_missing'; export { getQueryParams } from './get_query_params'; +export * from './history_observable'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index fa9cf5a52371d..29936da0117c1 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -68,7 +68,14 @@ export { StopSyncStateFnType, } from './state_sync'; export { Configurable, CollectConfigProps } from './ui'; -export { removeQueryParam, redirectWhenMissing, getQueryParams } from './history'; +export { + removeQueryParam, + redirectWhenMissing, + getQueryParams, + createQueryParamsObservable, + createHistoryObservable, + createQueryParamObservable, +} from './history'; export { applyDiff } from './state_management/utils/diff_object'; export { createStartServicesGetter, StartServicesGetter } from './core/create_start_service_getter'; diff --git a/src/plugins/kibana_utils/public/state_sync/public.api.md b/src/plugins/kibana_utils/public/state_sync/public.api.md index a4dfea82cdb59..5524563c034a8 100644 --- a/src/plugins/kibana_utils/public/state_sync/public.api.md +++ b/src/plugins/kibana_utils/public/state_sync/public.api.md @@ -22,14 +22,12 @@ export const createSessionStorageStateStorage: (storage?: Storage) => ISessionSt // @public export interface IKbnUrlStateStorage extends IStateStorage { - cancel: () => void; // (undocumented) change$: (key: string) => Observable; - flush: (opts?: { - replace?: boolean; - }) => boolean; // (undocumented) get: (key: string) => State | null; + // Warning: (ae-forgotten-export) The symbol "IKbnUrlControls" needs to be exported by the entry point index.d.ts + kbnUrlControls: IKbnUrlControls; // (undocumented) set: (key: string, state: State, opts?: { replace: boolean; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts index c7f04bc9cdbe3..890de8f6ed6a1 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -255,7 +255,7 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); - urlSyncStrategy.flush(); + urlSyncStrategy.kbnUrlControls.flush(); expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( @@ -290,7 +290,7 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); - urlSyncStrategy.cancel(); + urlSyncStrategy.kbnUrlControls.cancel(); expect(history.length).toBe(startHistoryLength); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts index fbd3c3f933791..037c6f9fc666d 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts @@ -39,11 +39,11 @@ describe('KbnUrlStateStorage', () => { const key = '_s'; urlStateStorage.set(key, state); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); - expect(urlStateStorage.flush()).toBe(true); + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`); expect(urlStateStorage.get(key)).toEqual(state); - expect(urlStateStorage.flush()).toBe(false); // nothing to flush, not update + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update }); it('should cancel url updates', async () => { @@ -51,7 +51,7 @@ describe('KbnUrlStateStorage', () => { const key = '_s'; const pr = urlStateStorage.set(key, state); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); - urlStateStorage.cancel(); + urlStateStorage.kbnUrlControls.cancel(); await pr; expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); expect(urlStateStorage.get(key)).toEqual(null); @@ -215,11 +215,11 @@ describe('KbnUrlStateStorage', () => { const key = '_s'; urlStateStorage.set(key, state); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`); - expect(urlStateStorage.flush()).toBe(true); + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/#?_s=(ok:1,test:test)"`); expect(urlStateStorage.get(key)).toEqual(state); - expect(urlStateStorage.flush()).toBe(false); // nothing to flush, not update + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update }); it('should cancel url updates', async () => { @@ -227,7 +227,7 @@ describe('KbnUrlStateStorage', () => { const key = '_s'; const pr = urlStateStorage.set(key, state); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`); - urlStateStorage.cancel(); + urlStateStorage.kbnUrlControls.cancel(); await pr; expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`); expect(urlStateStorage.get(key)).toEqual(null); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts index 700420447bf4f..0935ecd20111f 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts @@ -13,6 +13,7 @@ import { IStateStorage } from './types'; import { createKbnUrlControls, getStateFromKbnUrl, + IKbnUrlControls, setStateToKbnUrl, } from '../../state_management/url'; @@ -39,16 +40,9 @@ export interface IKbnUrlStateStorage extends IStateStorage { change$: (key: string) => Observable; /** - * cancels any pending url updates + * Lower level wrapper around history library that handles batching multiple URL updates into one history change */ - cancel: () => void; - - /** - * Synchronously runs any pending url updates, returned boolean indicates if change occurred. - * @param opts: {replace? boolean} - allows to specify if push or replace should be used for flushing update - * @returns boolean - indicates if there was an update to flush - */ - flush: (opts?: { replace?: boolean }) => boolean; + kbnUrlControls: IKbnUrlControls; } /** @@ -114,11 +108,6 @@ export const createKbnUrlStateStorage = ( }), share() ), - flush: ({ replace = false }: { replace?: boolean } = {}) => { - return !!url.flush(replace); - }, - cancel() { - url.cancel(); - }, + kbnUrlControls: url, }; }; diff --git a/src/plugins/kibana_utils/server/index.ts b/src/plugins/kibana_utils/server/index.ts index f95ffe5c3d7b6..821118ea4640d 100644 --- a/src/plugins/kibana_utils/server/index.ts +++ b/src/plugins/kibana_utils/server/index.ts @@ -18,4 +18,4 @@ export { url, } from '../common'; -export { KbnServerError, reportServerError } from './report_server_error'; +export { KbnServerError, reportServerError, getKbnServerError } from './report_server_error'; diff --git a/src/plugins/kibana_utils/server/report_server_error.ts b/src/plugins/kibana_utils/server/report_server_error.ts index 664f34ca7ad51..01e80cfc7184d 100644 --- a/src/plugins/kibana_utils/server/report_server_error.ts +++ b/src/plugins/kibana_utils/server/report_server_error.ts @@ -6,23 +6,42 @@ * Public License, v 1. */ +import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { KibanaResponseFactory } from 'kibana/server'; import { KbnError } from '../common'; export class KbnServerError extends KbnError { - constructor(message: string, public readonly statusCode: number) { + public errBody?: Record; + constructor(message: string, public readonly statusCode: number, errBody?: Record) { super(message); + this.errBody = errBody; } } -export function reportServerError(res: KibanaResponseFactory, err: any) { +/** + * Formats any error thrown into a standardized `KbnServerError`. + * @param e `Error` or `ElasticsearchClientError` + * @returns `KbnServerError` + */ +export function getKbnServerError(e: Error) { + return new KbnServerError( + e.message ?? 'Unknown error', + e instanceof ResponseError ? e.statusCode : 500, + e instanceof ResponseError ? e.body : undefined + ); +} + +/** + * + * @param res Formats a `KbnServerError` into a server error response + * @param err + */ +export function reportServerError(res: KibanaResponseFactory, err: KbnServerError) { return res.customError({ statusCode: err.statusCode ?? 500, body: { message: err.message, - attributes: { - error: err.body?.error || err.message, - }, + attributes: err.errBody?.error, }, }); } diff --git a/src/plugins/maps_oss/tsconfig.json b/src/plugins/legacy_export/tsconfig.json similarity index 65% rename from src/plugins/maps_oss/tsconfig.json rename to src/plugins/legacy_export/tsconfig.json index 03c30c3c49fd3..ec006d492499e 100644 --- a/src/plugins/maps_oss/tsconfig.json +++ b/src/plugins/legacy_export/tsconfig.json @@ -7,8 +7,10 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts"], + "include": [ + "server/**/*", + ], "references": [ - { "path": "../visualizations/tsconfig.json" }, + { "path": "../../core/tsconfig.json" } ] } diff --git a/src/plugins/presentation_util/README.md b/src/plugins/presentation_util/README.md deleted file mode 100755 index 047423a0a9036..0000000000000 --- a/src/plugins/presentation_util/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# presentationUtil - -Utilities and components used by the presentation-related plugins \ No newline at end of file diff --git a/src/plugins/presentation_util/README.mdx b/src/plugins/presentation_util/README.mdx new file mode 100755 index 0000000000000..35b80e3634534 --- /dev/null +++ b/src/plugins/presentation_util/README.mdx @@ -0,0 +1,211 @@ +--- +id: presentationUtilPlugin +slug: /kibana-dev-docs/presentationPlugin +title: Presentation Utility Plugin +summary: Introduction to the Presentation Utility Plugin. +date: 2020-01-12 +tags: ['kibana', 'presentation', 'services'] +related: [] +--- + +## Introduction + +The Presentation Utility Plugin is a set of common, shared components and toolkits for solutions within the Presentation space, (e.g. Dashboards, Canvas). + +## Plugin Services Toolkit + +While Kibana provides a `useKibana` hook for use in a plugin, the number of services it provides is very large. This presents a set of difficulties: + +- a direct dependency upon the Kibana environment; +- a requirement to mock the full Kibana environment when testing or using Storybook; +- a lack of knowledge as to what services are being consumed at any given time. + +To mitigate these difficulties, the Presentation Team creates services within the plugin that then consume Kibana-provided (or other) services. This is a toolkit for creating simple services within a plugin. + +### Overview + +- A `PluginServiceFactory` is a function that will return a set of functions-- which comprise a `Service`-- given a set of parameters. +- A `PluginServiceProvider` is an object that use a factory to start, stop or provide a `Service`. +- A `PluginServiceRegistry` is a collection of providers for a given environment, (e.g. Kibana, Jest, Storybook, stub, etc). +- A `PluginServices` object uses a registry to provide services throughout the plugin. + +### Defining Services + +To start, a plugin should define a set of services it wants to provide to itself or other plugins. + + +```ts +export interface PresentationDashboardsService { + findDashboards: ( + query: string, + fields: string[] + ) => Promise>>; + findDashboardsByTitle: (title: string) => Promise>>; +} + +export interface PresentationFooService { + getFoo: () => string; + setFoo: (bar: string) => void; +} + +export interface PresentationUtilServices { + dashboards: PresentationDashboardsService; + foo: PresentationFooService; +} +``` + + +This definition will be used in the toolkit to ensure services are complete and as expected. + +### Plugin Services + +The `PluginServices` class hosts a registry of service providers from which a plugin can access its services. It uses the service definition as a generic. + +```ts +export const pluginServices = new PluginServices(); +``` + +This can be placed in the `index.ts` file of a `services` directory within your plugin. + +Once created, it simply requires a `PluginServiceRegistry` to be started and set. + +### Service Provider Registry + +Each environment in which components are used requires a `PluginServiceRegistry` to specify how the providers are started. For example, simple stubs of services require no parameters to start, (so the `StartParameters` generic remains unspecified) + + +```ts +export const providers: PluginServiceProviders = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + foo: new PluginServiceProvider(fooServiceFactory), +}; + +export const serviceRegistry = new PluginServiceRegistry(providers); +``` + + +By contrast, a registry that uses Kibana can provide `KibanaPluginServiceParams` to determine how to start its providers, so the `StartParameters` generic is given: + + +```ts +export const providers: PluginServiceProviders< + PresentationUtilServices, + KibanaPluginServiceParams +> = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + foo: new PluginServiceProvider(fooServiceFactory), +}; + +export const serviceRegistry = new PluginServiceRegistry< + PresentationUtilServices, + KibanaPluginServiceParams +>(providers); +``` + + +### Service Provider + +A `PluginServiceProvider` is a container for a Service Factory that is responsible for starting, stopping and providing a service implementation. A Service Provider doesn't change, rather the factory and the relevant `StartParameters` change. + +### Service Factories + +A Service Factory is nothing more than a function that uses `StartParameters` to return a set of functions that conforms to a portion of the `Services` specification. For each service, a factory is provided for each environment. + +Given a service definition: + +```ts +export interface PresentationFooService { + getFoo: () => string; + setFoo: (bar: string) => void; +} +``` + +a factory for a stubbed version might look like this: + +```ts +type FooServiceFactory = PluginServiceFactory; + +export const fooServiceFactory: FooServiceFactory = () => ({ + getFoo: () => 'bar', + setFoo: (bar) => { console.log(`${bar} set!`)}, +}); +``` + +and a factory for a Kibana version might look like this: + +```ts +export type FooServiceFactory = KibanaPluginServiceFactory< + PresentationFooService, + PresentationUtilPluginStart +>; + +export const fooServiceFactory: FooServiceFactory = ({ + coreStart, + startPlugins, +}) => { + // ...do something with Kibana services... + + return { + getFoo: //... + setFoo: //... + } +} +``` + +### Using Services + +Once your services and providers are defined, and you have at least one set of factories, you can use `PluginServices` to provide the services to your React components: + + +```ts +// plugin.ts +import { pluginServices } from './services'; +import { registry } from './services/kibana'; + + public async start( + coreStart: CoreStart, + startPlugins: StartDeps + ): Promise { + pluginServices.setRegistry(registry.start({ coreStart, startPlugins })); + return {}; + } +``` + + +and wrap your root React component with the `PluginServices` context: + + +```ts +import { pluginServices } from './services'; + +const ContextProvider = pluginServices.getContextProvider(), + +return( + + + {application} + + +) +``` + + +and then, consume your services using provided hooks in a component: + + +```ts +// component.ts + +import { pluginServices } from '../services'; + +export function MyComponent() { + // Retrieve all context hooks from `PluginServices`, destructuring for the one we're using + const { foo } = pluginServices.getHooks(); + + // Use the `useContext` hook to access the API. + const { getFoo } = foo.useService(); + + // ... +} +``` + diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx new file mode 100644 index 0000000000000..cb9991e216019 --- /dev/null +++ b/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; +import { action } from '@storybook/addon-actions'; + +import { DashboardPicker } from './dashboard_picker'; + +export default { + component: DashboardPicker, + title: 'Dashboard Picker', + argTypes: { + isDisabled: { + control: 'boolean', + defaultValue: false, + }, + }, +}; + +export const Example = ({ isDisabled }: { isDisabled: boolean }) => ( + +); diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.tsx index 8aaf9be6ef5c6..b156ef4ae764c 100644 --- a/src/plugins/presentation_util/public/components/dashboard_picker.tsx +++ b/src/plugins/presentation_util/public/components/dashboard_picker.tsx @@ -6,18 +6,16 @@ * Public License, v 1. */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiComboBox } from '@elastic/eui'; -import { SavedObjectsClientContract } from '../../../../core/public'; -import { DashboardSavedObject } from '../../../../plugins/dashboard/public'; +import { pluginServices } from '../services'; export interface DashboardPickerProps { onChange: (dashboard: { name: string; id: string } | null) => void; isDisabled: boolean; - savedObjectsClient: SavedObjectsClientContract; } interface DashboardOption { @@ -26,34 +24,43 @@ interface DashboardOption { } export function DashboardPicker(props: DashboardPickerProps) { - const [dashboards, setDashboards] = useState([]); + const [dashboardOptions, setDashboardOptions] = useState([]); const [isLoadingDashboards, setIsLoadingDashboards] = useState(true); const [selectedDashboard, setSelectedDashboard] = useState(null); + const [query, setQuery] = useState(''); - const { savedObjectsClient, isDisabled, onChange } = props; + const { isDisabled, onChange } = props; + const { dashboards } = pluginServices.getHooks(); + const { findDashboardsByTitle } = dashboards.useService(); - const fetchDashboards = useCallback( - async (query) => { + useEffect(() => { + // We don't want to manipulate the React state if the component has been unmounted + // while we wait for the saved objects to return. + let cleanedUp = false; + + const fetchDashboards = async () => { setIsLoadingDashboards(true); - setDashboards([]); - - const { savedObjects } = await savedObjectsClient.find({ - type: 'dashboard', - search: query ? `${query}*` : '', - searchFields: ['title'], - }); - if (savedObjects) { - setDashboards(savedObjects.map((d) => ({ value: d.id, label: d.attributes.title }))); + setDashboardOptions([]); + + const objects = await findDashboardsByTitle(query ? `${query}*` : ''); + + if (cleanedUp) { + return; + } + + if (objects) { + setDashboardOptions(objects.map((d) => ({ value: d.id, label: d.attributes.title }))); } + setIsLoadingDashboards(false); - }, - [savedObjectsClient] - ); + }; - // Initial dashboard load - useEffect(() => { - fetchDashboards(''); - }, [fetchDashboards]); + fetchDashboards(); + + return () => { + cleanedUp = true; + }; + }, [findDashboardsByTitle, query]); return ( { if (e.length) { @@ -72,7 +79,7 @@ export function DashboardPicker(props: DashboardPickerProps) { onChange(null); } }} - onSearchChange={fetchDashboards} + onSearchChange={setQuery} isDisabled={isDisabled} isLoading={isLoadingDashboards} compressed={true} diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx index 58a70c9db7dd5..7c7b12f52ab5f 100644 --- a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx @@ -9,18 +9,6 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiRadio, - EuiIconTip, - EuiPanel, - EuiSpacer, -} from '@elastic/eui'; -import { SavedObjectsClientContract } from '../../../../core/public'; import { OnSaveProps, @@ -28,9 +16,9 @@ import { SavedObjectSaveModal, } from '../../../../plugins/saved_objects/public'; -import { DashboardPicker } from './dashboard_picker'; - import './saved_object_save_modal_dashboard.scss'; +import { pluginServices } from '../services'; +import { SaveModalDashboardSelector } from './saved_object_save_modal_dashboard_selector'; interface SaveModalDocumentInfo { id?: string; @@ -38,116 +26,50 @@ interface SaveModalDocumentInfo { description?: string; } -export interface DashboardSaveModalProps { +export interface SaveModalDashboardProps { documentInfo: SaveModalDocumentInfo; objectType: string; onClose: () => void; onSave: (props: OnSaveProps & { dashboardId: string | null }) => void; - savedObjectsClient: SavedObjectsClientContract; tagOptions?: React.ReactNode | ((state: SaveModalState) => React.ReactNode); } -export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) { - const { documentInfo, savedObjectsClient, tagOptions } = props; - const initialCopyOnSave = !Boolean(documentInfo.id); +export function SavedObjectSaveModalDashboard(props: SaveModalDashboardProps) { + const { documentInfo, tagOptions, objectType, onClose } = props; + const { id: documentId } = documentInfo; + const initialCopyOnSave = !Boolean(documentId); + + const { capabilities } = pluginServices.getHooks(); + const { + canAccessDashboards, + canCreateNewDashboards, + canEditDashboards, + } = capabilities.useService(); + + const disableDashboardOptions = + !canAccessDashboards() || (!canCreateNewDashboards && !canEditDashboards); const [dashboardOption, setDashboardOption] = useState<'new' | 'existing' | null>( - documentInfo.id ? null : 'existing' + documentId || disableDashboardOptions ? null : 'existing' ); const [selectedDashboard, setSelectedDashboard] = useState<{ id: string; name: string } | null>( null ); const [copyOnSave, setCopyOnSave] = useState(initialCopyOnSave); - const renderDashboardSelect = (state: SaveModalState) => { - const isDisabled = Boolean(!state.copyOnSave && documentInfo.id); - - return ( - <> - - - - - - - } - /> - - - } - hasChildLabel={false} - > - -
- setDashboardOption('existing')} - disabled={isDisabled} - /> - -
- { - setSelectedDashboard(dash); - }} - /> -
- - - - setDashboardOption('new')} - disabled={isDisabled} - /> - - - - setDashboardOption(null)} - disabled={isDisabled} - /> -
-
-
- - ); - }; + const rightOptions = !disableDashboardOptions + ? () => ( + { + setSelectedDashboard(dash); + }} + onChange={(option) => { + setDashboardOption(option); + }} + {...{ copyOnSave, documentId, dashboardOption }} + /> + ) + : null; const onCopyOnSaveChange = (newCopyOnSave: boolean) => { setDashboardOption(null); @@ -159,7 +81,7 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) { // Don't save with a dashboard ID if we're // just updating an existing visualization - if (!(!onSaveProps.newCopyOnSave && documentInfo.id)) { + if (!(!onSaveProps.newCopyOnSave && documentId)) { if (dashboardOption === 'existing') { dashboardId = selectedDashboard?.id || null; } else { @@ -171,13 +93,14 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) { }; const saveLibraryLabel = - !copyOnSave && documentInfo.id + !copyOnSave && documentId ? i18n.translate('presentationUtil.saveModalDashboard.saveLabel', { defaultMessage: 'Save', }) : i18n.translate('presentationUtil.saveModalDashboard.saveToLibraryLabel', { defaultMessage: 'Save and add to library', }); + const saveDashboardLabel = i18n.translate( 'presentationUtil.saveModalDashboard.saveAndGoToDashboardLabel', { @@ -192,18 +115,20 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) { return ( ); } diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx new file mode 100644 index 0000000000000..2044ecdd713e1 --- /dev/null +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; + +import { StorybookParams } from '../services/storybook'; +import { SaveModalDashboardSelector } from './saved_object_save_modal_dashboard_selector'; + +export default { + component: SaveModalDashboardSelector, + title: 'Save Modal Dashboard Selector', + description: 'A selector for determining where an object will be saved after it is created.', + argTypes: { + hasDocumentId: { + control: 'boolean', + defaultValue: false, + }, + copyOnSave: { + control: 'boolean', + defaultValue: false, + }, + canCreateNewDashboards: { + control: 'boolean', + defaultValue: true, + }, + canEditDashboards: { + control: 'boolean', + defaultValue: true, + }, + }, +}; + +export function Example({ + copyOnSave, + hasDocumentId, +}: { + copyOnSave: boolean; + hasDocumentId: boolean; +} & StorybookParams) { + const [dashboardOption, setDashboardOption] = useState<'new' | 'existing' | null>('existing'); + + return ( + + ); +} diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx new file mode 100644 index 0000000000000..b1bf9ed695842 --- /dev/null +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiRadio, + EuiIconTip, + EuiPanel, + EuiSpacer, +} from '@elastic/eui'; + +import { pluginServices } from '../services'; +import { DashboardPicker, DashboardPickerProps } from './dashboard_picker'; + +import './saved_object_save_modal_dashboard.scss'; + +export interface SaveModalDashboardSelectorProps { + copyOnSave: boolean; + documentId?: string; + onSelectDashboard: DashboardPickerProps['onChange']; + + dashboardOption: 'new' | 'existing' | null; + onChange: (dashboardOption: 'new' | 'existing' | null) => void; +} + +export function SaveModalDashboardSelector(props: SaveModalDashboardSelectorProps) { + const { documentId, onSelectDashboard, dashboardOption, onChange, copyOnSave } = props; + const { capabilities } = pluginServices.getHooks(); + const { canCreateNewDashboards, canEditDashboards } = capabilities.useService(); + + const isDisabled = !copyOnSave && !!documentId; + + return ( + <> + + + + + + + } + /> + + + } + hasChildLabel={false} + > + +
+ {canEditDashboards() && ( + <> + {' '} + onChange('existing')} + disabled={isDisabled} + /> +
+ +
+ + + )} + {canCreateNewDashboards() && ( + <> + {' '} + onChange('new')} + disabled={isDisabled} + /> + + + )} + onChange(null)} + disabled={isDisabled} + /> +
+
+
+ + ); +} diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index baf40a1ea0ae4..586ddd1320641 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -10,9 +10,11 @@ import { PresentationUtilPlugin } from './plugin'; export { SavedObjectSaveModalDashboard, - DashboardSaveModalProps, + SaveModalDashboardProps, } from './components/saved_object_save_modal_dashboard'; +export { DashboardPicker } from './components/dashboard_picker'; + export function plugin() { return new PresentationUtilPlugin(); } diff --git a/src/plugins/presentation_util/public/plugin.ts b/src/plugins/presentation_util/public/plugin.ts index cbc1d0eb04e27..5d3618b034656 100644 --- a/src/plugins/presentation_util/public/plugin.ts +++ b/src/plugins/presentation_util/public/plugin.ts @@ -7,16 +7,39 @@ */ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; -import { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types'; +import { pluginServices } from './services'; +import { registry } from './services/kibana'; +import { + PresentationUtilPluginSetup, + PresentationUtilPluginStart, + PresentationUtilPluginSetupDeps, + PresentationUtilPluginStartDeps, +} from './types'; export class PresentationUtilPlugin - implements Plugin { - public setup(core: CoreSetup): PresentationUtilPluginSetup { + implements + Plugin< + PresentationUtilPluginSetup, + PresentationUtilPluginStart, + PresentationUtilPluginSetupDeps, + PresentationUtilPluginStartDeps + > { + public setup( + _coreSetup: CoreSetup, + _setupPlugins: PresentationUtilPluginSetupDeps + ): PresentationUtilPluginSetup { return {}; } - public start(core: CoreStart): PresentationUtilPluginStart { - return {}; + public async start( + coreStart: CoreStart, + startPlugins: PresentationUtilPluginStartDeps + ): Promise { + pluginServices.setRegistry(registry.start({ coreStart, startPlugins })); + + return { + ContextProvider: pluginServices.getContextProvider(), + }; } public stop() {} diff --git a/src/plugins/presentation_util/public/services/create/factory.ts b/src/plugins/presentation_util/public/services/create/factory.ts new file mode 100644 index 0000000000000..01b143e612461 --- /dev/null +++ b/src/plugins/presentation_util/public/services/create/factory.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; +import { CoreStart, AppUpdater } from 'src/core/public'; + +/** + * A factory function for creating a service. + * + * The `Service` generic determines the shape of the API being produced. + * The `StartParameters` generic determines what parameters are expected to + * create the service. + */ +export type PluginServiceFactory = (params: Parameters) => Service; + +/** + * Parameters necessary to create a Kibana-based service, (e.g. during Plugin + * startup or setup). + * + * The `Start` generic refers to the specific Plugin `TPluginsStart`. + */ +export interface KibanaPluginServiceParams { + coreStart: CoreStart; + startPlugins: Start; + appUpdater?: BehaviorSubject; +} + +/** + * A factory function for creating a Kibana-based service. + * + * The `Service` generic determines the shape of the API being produced. + * The `Setup` generic refers to the specific Plugin `TPluginsSetup`. + * The `Start` generic refers to the specific Plugin `TPluginsStart`. + */ +export type KibanaPluginServiceFactory = ( + params: KibanaPluginServiceParams +) => Service; diff --git a/src/plugins/presentation_util/public/services/create/index.ts b/src/plugins/presentation_util/public/services/create/index.ts new file mode 100644 index 0000000000000..59f1f9fd7a43b --- /dev/null +++ b/src/plugins/presentation_util/public/services/create/index.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { mapValues } from 'lodash'; + +import { PluginServiceRegistry } from './registry'; + +export { PluginServiceRegistry } from './registry'; +export { PluginServiceProvider, PluginServiceProviders } from './provider'; +export { + PluginServiceFactory, + KibanaPluginServiceFactory, + KibanaPluginServiceParams, +} from './factory'; + +/** + * `PluginServices` is a top-level class for specifying and accessing services within a plugin. + * + * A `PluginServices` object can be provided with a `PluginServiceRegistry` at any time, which will + * then be used to provide services to any component that accesses it. + * + * The `Services` generic determines the shape of all service APIs being produced. + */ +export class PluginServices { + private registry: PluginServiceRegistry | null = null; + + /** + * Supply a `PluginServiceRegistry` for the class to use to provide services and context. + * + * @param registry A setup and started `PluginServiceRegistry`. + */ + setRegistry(registry: PluginServiceRegistry | null) { + if (registry && !registry.isStarted()) { + throw new Error('Registry has not been started.'); + } + + this.registry = registry; + } + + /** + * Returns true if a registry has been provided, false otherwise. + */ + hasRegistry() { + return !!this.registry; + } + + /** + * Private getter that will enforce proper setup throughout the class. + */ + private getRegistry() { + if (!this.registry) { + throw new Error('No registry has been provided.'); + } + + return this.registry; + } + + /** + * Return the React Context Provider that will supply services. + */ + getContextProvider() { + return this.getRegistry().getContextProvider(); + } + + /** + * Return a map of React Hooks that can be used in React components. + */ + getHooks(): { [K in keyof Services]: { useService: () => Services[K] } } { + const registry = this.getRegistry(); + const providers = registry.getServiceProviders(); + + // @ts-expect-error Need to fix this; the type isn't fully understood when inferred. + return mapValues(providers, (provider) => ({ + useService: provider.getUseServiceHook(), + })); + } +} diff --git a/src/plugins/presentation_util/public/services/create/provider.tsx b/src/plugins/presentation_util/public/services/create/provider.tsx new file mode 100644 index 0000000000000..981ff1527f981 --- /dev/null +++ b/src/plugins/presentation_util/public/services/create/provider.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React, { createContext, useContext } from 'react'; +import { PluginServiceFactory } from './factory'; + +/** + * A collection of `PluginServiceProvider` objects, keyed by the `Services` API generic. + * + * The `Services` generic determines the shape of all service APIs being produced. + * The `StartParameters` generic determines what parameters are expected to + * start the service. + */ +export type PluginServiceProviders = { + [K in keyof Services]: PluginServiceProvider; +}; + +/** + * An object which uses a given factory to start, stop or provide a service. + * + * The `Service` generic determines the shape of the API being produced. + * The `StartParameters` generic determines what parameters are expected to + * start the service. + */ +export class PluginServiceProvider { + private factory: PluginServiceFactory; + private context = createContext(null); + private pluginService: Service | null = null; + public readonly Provider: React.FC = ({ children }) => { + return {children}; + }; + + constructor(factory: PluginServiceFactory) { + this.factory = factory; + this.context.displayName = 'PluginServiceContext'; + } + + /** + * Private getter that will enforce proper setup throughout the class. + */ + private getService() { + if (!this.pluginService) { + throw new Error('Service not started'); + } + return this.pluginService; + } + + /** + * Start the service. + * + * @param params Parameters used to start the service. + */ + start(params: StartParameters) { + this.pluginService = this.factory(params); + } + + /** + * Returns a function for providing a Context hook for the service. + */ + getUseServiceHook() { + return () => { + const service = useContext(this.context); + + if (!service) { + throw new Error('Provider is not set up correctly'); + } + + return service; + }; + } + + /** + * Stop the service. + */ + stop() { + this.pluginService = null; + } +} diff --git a/src/plugins/presentation_util/public/services/create/registry.tsx b/src/plugins/presentation_util/public/services/create/registry.tsx new file mode 100644 index 0000000000000..5165380780fa9 --- /dev/null +++ b/src/plugins/presentation_util/public/services/create/registry.tsx @@ -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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; +import { values } from 'lodash'; +import { PluginServiceProvider, PluginServiceProviders } from './provider'; + +/** + * A `PluginServiceRegistry` maintains a set of service providers which can be collectively + * started, stopped or retreived. + * + * The `Services` generic determines the shape of all service APIs being produced. + * The `StartParameters` generic determines what parameters are expected to + * start the service. + */ +export class PluginServiceRegistry { + private providers: PluginServiceProviders; + private _isStarted = false; + + constructor(providers: PluginServiceProviders) { + this.providers = providers; + } + + /** + * Returns true if the registry has been started, false otherwise. + */ + isStarted() { + return this._isStarted; + } + + /** + * Returns a map of `PluginServiceProvider` objects. + */ + getServiceProviders() { + if (!this._isStarted) { + throw new Error('Registry not started'); + } + return this.providers; + } + + /** + * Returns a React Context Provider for use in consuming applications. + */ + getContextProvider() { + // Collect and combine Context.Provider elements from each Service Provider into a single + // Functional Component. + const provider: React.FC = ({ children }) => ( + <> + {values>(this.getServiceProviders()).reduceRight( + (acc, serviceProvider) => { + return {acc}; + }, + children + )} + + ); + + return provider; + } + + /** + * Start the registry. + * + * @param params Parameters used to start the registry. + */ + start(params: StartParameters) { + values>(this.providers).map((serviceProvider) => + serviceProvider.start(params) + ); + this._isStarted = true; + return this; + } + + /** + * Stop the registry. + */ + stop() { + values>(this.providers).map((serviceProvider) => + serviceProvider.stop() + ); + this._isStarted = false; + return this; + } +} diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts new file mode 100644 index 0000000000000..732cc19e14763 --- /dev/null +++ b/src/plugins/presentation_util/public/services/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { SimpleSavedObject } from 'src/core/public'; +import { DashboardSavedObject } from 'src/plugins/dashboard/public'; +import { PluginServices } from './create'; +export interface PresentationDashboardsService { + findDashboards: ( + query: string, + fields: string[] + ) => Promise>>; + findDashboardsByTitle: (title: string) => Promise>>; +} + +export interface PresentationCapabilitiesService { + canAccessDashboards: () => boolean; + canCreateNewDashboards: () => boolean; + canEditDashboards: () => boolean; +} + +export interface PresentationUtilServices { + dashboards: PresentationDashboardsService; + capabilities: PresentationCapabilitiesService; +} + +export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/kibana/capabilities.ts b/src/plugins/presentation_util/public/services/kibana/capabilities.ts new file mode 100644 index 0000000000000..f36b277979358 --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/capabilities.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PresentationUtilPluginStartDeps } from '../../types'; +import { KibanaPluginServiceFactory } from '../create'; +import { PresentationCapabilitiesService } from '..'; + +export type CapabilitiesServiceFactory = KibanaPluginServiceFactory< + PresentationCapabilitiesService, + PresentationUtilPluginStartDeps +>; + +export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ coreStart }) => { + const { dashboard } = coreStart.application.capabilities; + + return { + canAccessDashboards: () => Boolean(dashboard.show), + canCreateNewDashboards: () => Boolean(dashboard.createNew), + canEditDashboards: () => !Boolean(dashboard.hideWriteControls), + }; +}; diff --git a/src/plugins/presentation_util/public/services/kibana/dashboards.ts b/src/plugins/presentation_util/public/services/kibana/dashboards.ts new file mode 100644 index 0000000000000..acfe4bd33e26a --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/dashboards.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { DashboardSavedObject } from 'src/plugins/dashboard/public'; + +import { PresentationUtilPluginStartDeps } from '../../types'; +import { KibanaPluginServiceFactory } from '../create'; +import { PresentationDashboardsService } from '..'; + +export type DashboardsServiceFactory = KibanaPluginServiceFactory< + PresentationDashboardsService, + PresentationUtilPluginStartDeps +>; + +export const dashboardsServiceFactory: DashboardsServiceFactory = ({ coreStart }) => { + const findDashboards = async (query: string = '', fields: string[] = []) => { + const { find } = coreStart.savedObjects.client; + + const { savedObjects } = await find({ + type: 'dashboard', + search: `${query}*`, + searchFields: fields, + }); + + return savedObjects; + }; + + const findDashboardsByTitle = async (title: string = '') => findDashboards(title, ['title']); + + return { + findDashboards, + findDashboardsByTitle, + }; +}; diff --git a/src/plugins/presentation_util/public/services/kibana/index.ts b/src/plugins/presentation_util/public/services/kibana/index.ts new file mode 100644 index 0000000000000..a129b0d94479f --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { dashboardsServiceFactory } from './dashboards'; +import { capabilitiesServiceFactory } from './capabilities'; +import { + PluginServiceProviders, + KibanaPluginServiceParams, + PluginServiceProvider, + PluginServiceRegistry, +} from '../create'; +import { PresentationUtilPluginStartDeps } from '../../types'; +import { PresentationUtilServices } from '..'; + +export { dashboardsServiceFactory } from './dashboards'; +export { capabilitiesServiceFactory } from './capabilities'; + +export const providers: PluginServiceProviders< + PresentationUtilServices, + KibanaPluginServiceParams +> = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + capabilities: new PluginServiceProvider(capabilitiesServiceFactory), +}; + +export const registry = new PluginServiceRegistry< + PresentationUtilServices, + KibanaPluginServiceParams +>(providers); diff --git a/src/plugins/presentation_util/public/services/storybook/capabilities.ts b/src/plugins/presentation_util/public/services/storybook/capabilities.ts new file mode 100644 index 0000000000000..5048fe50cc025 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/capabilities.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PluginServiceFactory } from '../create'; +import { StorybookParams } from '.'; +import { PresentationCapabilitiesService } from '..'; + +type CapabilitiesServiceFactory = PluginServiceFactory< + PresentationCapabilitiesService, + StorybookParams +>; + +export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ + canAccessDashboards, + canCreateNewDashboards, + canEditDashboards, +}) => { + const check = (value: boolean = true) => value; + return { + canAccessDashboards: () => check(canAccessDashboards), + canCreateNewDashboards: () => check(canCreateNewDashboards), + canEditDashboards: () => check(canEditDashboards), + }; +}; diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts new file mode 100644 index 0000000000000..536cad3a9d131 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PluginServices, PluginServiceProviders, PluginServiceProvider } from '../create'; +import { dashboardsServiceFactory } from '../stub/dashboards'; +import { capabilitiesServiceFactory } from './capabilities'; +import { PresentationUtilServices } from '..'; + +export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; +export { PresentationUtilServices } from '..'; + +export interface StorybookParams { + canAccessDashboards?: boolean; + canCreateNewDashboards?: boolean; + canEditDashboards?: boolean; +} + +export const providers: PluginServiceProviders = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + capabilities: new PluginServiceProvider(capabilitiesServiceFactory), +}; + +export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/stub/capabilities.ts b/src/plugins/presentation_util/public/services/stub/capabilities.ts new file mode 100644 index 0000000000000..33c091022421c --- /dev/null +++ b/src/plugins/presentation_util/public/services/stub/capabilities.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PluginServiceFactory } from '../create'; +import { PresentationCapabilitiesService } from '..'; + +type CapabilitiesServiceFactory = PluginServiceFactory; + +export const capabilitiesServiceFactory: CapabilitiesServiceFactory = () => ({ + canAccessDashboards: () => true, + canCreateNewDashboards: () => true, + canEditDashboards: () => true, +}); diff --git a/src/plugins/presentation_util/public/services/stub/dashboards.ts b/src/plugins/presentation_util/public/services/stub/dashboards.ts new file mode 100644 index 0000000000000..862fa4f952c1e --- /dev/null +++ b/src/plugins/presentation_util/public/services/stub/dashboards.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PluginServiceFactory } from '../create'; +import { PresentationDashboardsService } from '..'; + +// TODO (clint): Create set of dashboards to stub and return. + +type DashboardsServiceFactory = PluginServiceFactory; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const dashboardsServiceFactory: DashboardsServiceFactory = () => ({ + findDashboards: async (query: string = '', _fields: string[] = []) => { + if (!query) { + return []; + } + + await sleep(2000); + return []; + }, + findDashboardsByTitle: async (title: string) => { + if (!title) { + return []; + } + + await sleep(2000); + return []; + }, +}); diff --git a/src/plugins/presentation_util/public/services/stub/index.ts b/src/plugins/presentation_util/public/services/stub/index.ts new file mode 100644 index 0000000000000..a2bde357fd4c0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/stub/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { dashboardsServiceFactory } from './dashboards'; +import { capabilitiesServiceFactory } from './capabilities'; +import { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; +import { PresentationUtilServices } from '..'; + +export { dashboardsServiceFactory } from './dashboards'; +export { capabilitiesServiceFactory } from './capabilities'; + +export const providers: PluginServiceProviders = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + capabilities: new PluginServiceProvider(capabilitiesServiceFactory), +}; + +export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index ae5646bd9bbae..7371ebc6f736e 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -8,5 +8,12 @@ // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PresentationUtilPluginSetup {} + +export interface PresentationUtilPluginStart { + ContextProvider: React.FC; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PresentationUtilPluginSetupDeps {} // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PresentationUtilPluginStart {} +export interface PresentationUtilPluginStartDeps {} diff --git a/src/plugins/presentation_util/storybook/decorator.tsx b/src/plugins/presentation_util/storybook/decorator.tsx new file mode 100644 index 0000000000000..5f56c70a2f849 --- /dev/null +++ b/src/plugins/presentation_util/storybook/decorator.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; + +import { DecoratorFn } from '@storybook/react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { pluginServices } from '../public/services'; +import { PresentationUtilServices } from '../public/services'; +import { providers, StorybookParams } from '../public/services/storybook'; +import { PluginServiceRegistry } from '../public/services/create'; + +export const servicesContextDecorator: DecoratorFn = (story: Function, storybook) => { + const registry = new PluginServiceRegistry(providers); + pluginServices.setRegistry(registry.start(storybook.args)); + const ContextProvider = pluginServices.getContextProvider(); + + return ( + + {story()} + + ); +}; diff --git a/src/plugins/presentation_util/storybook/main.ts b/src/plugins/presentation_util/storybook/main.ts new file mode 100644 index 0000000000000..d12b98f38a03f --- /dev/null +++ b/src/plugins/presentation_util/storybook/main.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Configuration } from 'webpack'; +import { defaultConfig } from '@kbn/storybook'; +import webpackConfig from '@kbn/storybook/target/webpack.config'; + +module.exports = { + ...defaultConfig, + addons: ['@storybook/addon-essentials'], + webpackFinal: (config: Configuration) => { + return webpackConfig({ config }); + }, +}; diff --git a/src/plugins/presentation_util/storybook/manager.ts b/src/plugins/presentation_util/storybook/manager.ts new file mode 100644 index 0000000000000..e9b6a11242036 --- /dev/null +++ b/src/plugins/presentation_util/storybook/manager.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID } from '@storybook/addon-actions'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: 'Kibana Presentation Utility Storybook', + brandUrl: 'https://github.com/elastic/kibana/tree/master/src/plugins/presentation_util', + }), + showPanel: true.valueOf, + selectedPanel: PANEL_ID, +}); diff --git a/src/plugins/presentation_util/storybook/preview.tsx b/src/plugins/presentation_util/storybook/preview.tsx new file mode 100644 index 0000000000000..dfa8ad3be04e7 --- /dev/null +++ b/src/plugins/presentation_util/storybook/preview.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; +import { addDecorator } from '@storybook/react'; +import { Title, Subtitle, Description, Primary, Stories } from '@storybook/addon-docs/blocks'; + +import { servicesContextDecorator } from './decorator'; + +addDecorator(servicesContextDecorator); + +export const parameters = { + docs: { + page: () => ( + <> + + <Subtitle /> + <Description /> + <Primary /> + <Stories /> + </> + ), + }, +}; diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index 1e3756f45e953..a9657db288848 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -7,7 +7,7 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*"], + "include": ["common/**/*", "public/**/*", "storybook/**/*", "../../../typings/**/*"], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../dashboard/tsconfig.json" }, diff --git a/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx index 6702255ee2e2c..f87169d4b828a 100644 --- a/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx @@ -31,7 +31,8 @@ interface MinimalSaveModalProps { export function showSaveModal( saveModal: React.ReactElement<MinimalSaveModalProps>, - I18nContext: I18nStart['Context'] + I18nContext: I18nStart['Context'], + Wrapper?: React.FC ) { const container = document.createElement('div'); const closeModal = () => { @@ -55,5 +56,13 @@ export function showSaveModal( onClose: closeModal, }); - ReactDOM.render(<I18nContext>{element}</I18nContext>, container); + const wrappedElement = Wrapper ? ( + <I18nContext> + <Wrapper>{element}</Wrapper> + </I18nContext> + ) : ( + <I18nContext>{element}</I18nContext> + ); + + ReactDOM.render(wrappedElement, container); } diff --git a/src/plugins/saved_objects_management/common/index.ts b/src/plugins/saved_objects_management/common/index.ts index a8395e602979c..8850899e38958 100644 --- a/src/plugins/saved_objects_management/common/index.ts +++ b/src/plugins/saved_objects_management/common/index.ts @@ -6,4 +6,11 @@ * Public License, v 1. */ -export { SavedObjectRelation, SavedObjectWithMetadata, SavedObjectMetadata } from './types'; +export { + SavedObjectWithMetadata, + SavedObjectMetadata, + SavedObjectRelation, + SavedObjectRelationKind, + SavedObjectInvalidRelation, + SavedObjectGetRelationshipsResponse, +} from './types'; diff --git a/src/plugins/saved_objects_management/common/types.ts b/src/plugins/saved_objects_management/common/types.ts index 8618cf4332acf..e100dfc6b23e6 100644 --- a/src/plugins/saved_objects_management/common/types.ts +++ b/src/plugins/saved_objects_management/common/types.ts @@ -28,12 +28,26 @@ export type SavedObjectWithMetadata<T = unknown> = SavedObject<T> & { meta: SavedObjectMetadata; }; +export type SavedObjectRelationKind = 'child' | 'parent'; + /** * Represents a relation between two {@link SavedObject | saved object} */ export interface SavedObjectRelation { id: string; type: string; - relationship: 'child' | 'parent'; + relationship: SavedObjectRelationKind; meta: SavedObjectMetadata; } + +export interface SavedObjectInvalidRelation { + id: string; + type: string; + relationship: SavedObjectRelationKind; + error: string; +} + +export interface SavedObjectGetRelationshipsResponse { + relations: SavedObjectRelation[]; + invalidRelations: SavedObjectInvalidRelation[]; +} diff --git a/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts b/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts index b609fac67dac1..4454907f530fe 100644 --- a/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts +++ b/src/plugins/saved_objects_management/public/lib/get_relationships.test.ts @@ -6,6 +6,7 @@ * Public License, v 1. */ +import { SavedObjectGetRelationshipsResponse } from '../types'; import { httpServiceMock } from '../../../../core/public/mocks'; import { getRelationships } from './get_relationships'; @@ -22,13 +23,17 @@ describe('getRelationships', () => { }); it('should handle successful responses', async () => { - httpMock.get.mockResolvedValue([1, 2]); + const serverResponse: SavedObjectGetRelationshipsResponse = { + relations: [], + invalidRelations: [], + }; + httpMock.get.mockResolvedValue(serverResponse); const response = await getRelationships(httpMock, 'dashboard', '1', [ 'search', 'index-pattern', ]); - expect(response).toEqual([1, 2]); + expect(response).toEqual(serverResponse); }); it('should handle errors', async () => { diff --git a/src/plugins/saved_objects_management/public/lib/get_relationships.ts b/src/plugins/saved_objects_management/public/lib/get_relationships.ts index 0eb97e1052fa4..69aeb6fbf580b 100644 --- a/src/plugins/saved_objects_management/public/lib/get_relationships.ts +++ b/src/plugins/saved_objects_management/public/lib/get_relationships.ts @@ -8,19 +8,19 @@ import { HttpStart } from 'src/core/public'; import { get } from 'lodash'; -import { SavedObjectRelation } from '../types'; +import { SavedObjectGetRelationshipsResponse } from '../types'; export async function getRelationships( http: HttpStart, type: string, id: string, savedObjectTypes: string[] -): Promise<SavedObjectRelation[]> { +): Promise<SavedObjectGetRelationshipsResponse> { const url = `/api/kibana/management/saved_objects/relationships/${encodeURIComponent( type )}/${encodeURIComponent(id)}`; try { - return await http.get<SavedObjectRelation[]>(url, { + return await http.get<SavedObjectGetRelationshipsResponse>(url, { query: { savedObjectTypes, }, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap index 15e5cb89b622c..c39263f304249 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap @@ -28,133 +28,131 @@ exports[`Relationships should render dashboards normally 1`] = ` </EuiTitle> </EuiFlyoutHeader> <EuiFlyoutBody> - <div> - <EuiCallOut> - <p> - Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children. - </p> - </EuiCallOut> - <EuiSpacer /> - <EuiInMemoryTable - columns={ - Array [ - Object { - "align": "center", - "description": "Type of the saved object", - "field": "type", - "name": "Type", - "render": [Function], - "sortable": false, - "width": "50px", + <EuiCallOut> + <p> + Here are the saved objects related to MyDashboard. Deleting this dashboard affects its parent objects, but not its children. + </p> + </EuiCallOut> + <EuiSpacer /> + <EuiInMemoryTable + columns={ + Array [ + Object { + "align": "center", + "description": "Type of the saved object", + "field": "type", + "name": "Type", + "render": [Function], + "sortable": false, + "width": "50px", + }, + Object { + "data-test-subj": "directRelationship", + "dataType": "string", + "field": "relationship", + "name": "Direct relationship", + "render": [Function], + "sortable": false, + "width": "125px", + }, + Object { + "dataType": "string", + "description": "Title of the saved object", + "field": "meta.title", + "name": "Title", + "render": [Function], + "sortable": false, + }, + Object { + "actions": Array [ + Object { + "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", + "description": "Inspect this saved object", + "icon": "inspect", + "name": "Inspect", + "onClick": [Function], + "type": "icon", + }, + ], + "name": "Actions", + }, + ] + } + items={ + Array [ + Object { + "id": "1", + "meta": Object { + "editUrl": "/management/kibana/objects/savedVisualizations/1", + "icon": "visualizeApp", + "inAppUrl": Object { + "path": "/app/visualize#/edit/1", + "uiCapabilitiesPath": "visualize.show", + }, + "title": "My Visualization Title 1", }, + "relationship": "child", + "type": "visualization", + }, + Object { + "id": "2", + "meta": Object { + "editUrl": "/management/kibana/objects/savedVisualizations/2", + "icon": "visualizeApp", + "inAppUrl": Object { + "path": "/app/visualize#/edit/2", + "uiCapabilitiesPath": "visualize.show", + }, + "title": "My Visualization Title 2", + }, + "relationship": "child", + "type": "visualization", + }, + ] + } + pagination={true} + responsive={true} + rowProps={[Function]} + search={ + Object { + "filters": Array [ Object { - "data-test-subj": "directRelationship", - "dataType": "string", "field": "relationship", + "multiSelect": "or", "name": "Direct relationship", - "render": [Function], - "sortable": false, - "width": "125px", - }, - Object { - "dataType": "string", - "description": "Title of the saved object", - "field": "meta.title", - "name": "Title", - "render": [Function], - "sortable": false, - }, - Object { - "actions": Array [ + "options": Array [ Object { - "available": [Function], - "data-test-subj": "relationshipsTableAction-inspect", - "description": "Inspect this saved object", - "icon": "inspect", - "name": "Inspect", - "onClick": [Function], - "type": "icon", + "name": "parent", + "value": "parent", + "view": "Parent", }, - ], - "name": "Actions", - }, - ] - } - items={ - Array [ - Object { - "id": "1", - "meta": Object { - "editUrl": "/management/kibana/objects/savedVisualizations/1", - "icon": "visualizeApp", - "inAppUrl": Object { - "path": "/app/visualize#/edit/1", - "uiCapabilitiesPath": "visualize.show", + Object { + "name": "child", + "value": "child", + "view": "Child", }, - "title": "My Visualization Title 1", - }, - "relationship": "child", - "type": "visualization", + ], + "type": "field_value_selection", }, Object { - "id": "2", - "meta": Object { - "editUrl": "/management/kibana/objects/savedVisualizations/2", - "icon": "visualizeApp", - "inAppUrl": Object { - "path": "/app/visualize#/edit/2", - "uiCapabilitiesPath": "visualize.show", + "field": "type", + "multiSelect": "or", + "name": "Type", + "options": Array [ + Object { + "name": "visualization", + "value": "visualization", + "view": "visualization", }, - "title": "My Visualization Title 2", - }, - "relationship": "child", - "type": "visualization", + ], + "type": "field_value_selection", }, - ] - } - pagination={true} - responsive={true} - rowProps={[Function]} - search={ - Object { - "filters": Array [ - Object { - "field": "relationship", - "multiSelect": "or", - "name": "Direct relationship", - "options": Array [ - Object { - "name": "parent", - "value": "parent", - "view": "Parent", - }, - Object { - "name": "child", - "value": "child", - "view": "Child", - }, - ], - "type": "field_value_selection", - }, - Object { - "field": "type", - "multiSelect": "or", - "name": "Type", - "options": Array [ - Object { - "name": "visualization", - "value": "visualization", - "view": "visualization", - }, - ], - "type": "field_value_selection", - }, - ], - } + ], } - tableLayout="fixed" - /> - </div> + } + tableLayout="fixed" + /> </EuiFlyoutBody> </EuiFlyout> `; @@ -231,138 +229,315 @@ exports[`Relationships should render index patterns normally 1`] = ` </EuiTitle> </EuiFlyoutHeader> <EuiFlyoutBody> - <div> - <EuiCallOut> - <p> - Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. - </p> - </EuiCallOut> - <EuiSpacer /> - <EuiInMemoryTable - columns={ - Array [ - Object { - "align": "center", - "description": "Type of the saved object", - "field": "type", - "name": "Type", - "render": [Function], - "sortable": false, - "width": "50px", + <EuiCallOut> + <p> + Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. + </p> + </EuiCallOut> + <EuiSpacer /> + <EuiInMemoryTable + columns={ + Array [ + Object { + "align": "center", + "description": "Type of the saved object", + "field": "type", + "name": "Type", + "render": [Function], + "sortable": false, + "width": "50px", + }, + Object { + "data-test-subj": "directRelationship", + "dataType": "string", + "field": "relationship", + "name": "Direct relationship", + "render": [Function], + "sortable": false, + "width": "125px", + }, + Object { + "dataType": "string", + "description": "Title of the saved object", + "field": "meta.title", + "name": "Title", + "render": [Function], + "sortable": false, + }, + Object { + "actions": Array [ + Object { + "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", + "description": "Inspect this saved object", + "icon": "inspect", + "name": "Inspect", + "onClick": [Function], + "type": "icon", + }, + ], + "name": "Actions", + }, + ] + } + items={ + Array [ + Object { + "id": "1", + "meta": Object { + "editUrl": "/management/kibana/objects/savedSearches/1", + "icon": "search", + "inAppUrl": Object { + "path": "/app/discover#//1", + "uiCapabilitiesPath": "discover.show", + }, + "title": "My Search Title", }, + "relationship": "parent", + "type": "search", + }, + Object { + "id": "2", + "meta": Object { + "editUrl": "/management/kibana/objects/savedVisualizations/2", + "icon": "visualizeApp", + "inAppUrl": Object { + "path": "/app/visualize#/edit/2", + "uiCapabilitiesPath": "visualize.show", + }, + "title": "My Visualization Title", + }, + "relationship": "parent", + "type": "visualization", + }, + ] + } + pagination={true} + responsive={true} + rowProps={[Function]} + search={ + Object { + "filters": Array [ Object { - "data-test-subj": "directRelationship", - "dataType": "string", "field": "relationship", + "multiSelect": "or", "name": "Direct relationship", - "render": [Function], - "sortable": false, - "width": "125px", - }, - Object { - "dataType": "string", - "description": "Title of the saved object", - "field": "meta.title", - "name": "Title", - "render": [Function], - "sortable": false, - }, - Object { - "actions": Array [ + "options": Array [ + Object { + "name": "parent", + "value": "parent", + "view": "Parent", + }, Object { - "available": [Function], - "data-test-subj": "relationshipsTableAction-inspect", - "description": "Inspect this saved object", - "icon": "inspect", - "name": "Inspect", - "onClick": [Function], - "type": "icon", + "name": "child", + "value": "child", + "view": "Child", }, ], - "name": "Actions", + "type": "field_value_selection", }, - ] - } - items={ - Array [ Object { - "id": "1", - "meta": Object { - "editUrl": "/management/kibana/objects/savedSearches/1", - "icon": "search", - "inAppUrl": Object { - "path": "/app/discover#//1", - "uiCapabilitiesPath": "discover.show", + "field": "type", + "multiSelect": "or", + "name": "Type", + "options": Array [ + Object { + "name": "search", + "value": "search", + "view": "search", }, - "title": "My Search Title", - }, - "relationship": "parent", - "type": "search", - }, - Object { - "id": "2", - "meta": Object { - "editUrl": "/management/kibana/objects/savedVisualizations/2", - "icon": "visualizeApp", - "inAppUrl": Object { - "path": "/app/visualize#/edit/2", - "uiCapabilitiesPath": "visualize.show", + Object { + "name": "visualization", + "value": "visualization", + "view": "visualization", }, - "title": "My Visualization Title", - }, - "relationship": "parent", - "type": "visualization", + ], + "type": "field_value_selection", }, - ] + ], } - pagination={true} - responsive={true} - rowProps={[Function]} - search={ + } + tableLayout="fixed" + /> + </EuiFlyoutBody> +</EuiFlyout> +`; + +exports[`Relationships should render invalid relations 1`] = ` +<EuiFlyout + onClose={[MockFunction]} +> + <EuiFlyoutHeader + hasBorder={true} + > + <EuiTitle + size="m" + > + <h2> + <EuiToolTip + content="index patterns" + delay="regular" + position="top" + > + <EuiIcon + aria-label="index patterns" + size="m" + type="indexPatternApp" + /> + </EuiToolTip> +    + MyIndexPattern* + </h2> + </EuiTitle> + </EuiFlyoutHeader> + <EuiFlyoutBody> + <EuiCallOut + color="warning" + iconType="alert" + title="This saved object has some invalid relations." + /> + <EuiSpacer /> + <EuiInMemoryTable + columns={ + Array [ Object { - "filters": Array [ - Object { - "field": "relationship", - "multiSelect": "or", - "name": "Direct relationship", - "options": Array [ - Object { - "name": "parent", - "value": "parent", - "view": "Parent", - }, - Object { - "name": "child", - "value": "child", - "view": "Child", - }, - ], - "type": "field_value_selection", - }, + "data-test-subj": "relationshipsObjectType", + "description": "Type of the saved object", + "field": "type", + "name": "Type", + "sortable": false, + "width": "150px", + }, + Object { + "data-test-subj": "relationshipsObjectId", + "description": "Id of the saved object", + "field": "id", + "name": "Id", + "sortable": false, + "width": "150px", + }, + Object { + "data-test-subj": "directRelationship", + "dataType": "string", + "field": "relationship", + "name": "Direct relationship", + "render": [Function], + "sortable": false, + "width": "125px", + }, + Object { + "data-test-subj": "relationshipsError", + "description": "Error encountered with the relation", + "field": "error", + "name": "Error", + "sortable": false, + }, + ] + } + items={ + Array [ + Object { + "error": "Saved object [dashboard/1] not found", + "id": "1", + "relationship": "child", + "type": "dashboard", + }, + ] + } + pagination={true} + responsive={true} + rowProps={[Function]} + tableLayout="fixed" + /> + <EuiSpacer /> + <EuiCallOut> + <p> + Here are the saved objects related to MyIndexPattern*. Deleting this index-pattern affects its parent objects, but not its children. + </p> + </EuiCallOut> + <EuiSpacer /> + <EuiInMemoryTable + columns={ + Array [ + Object { + "align": "center", + "description": "Type of the saved object", + "field": "type", + "name": "Type", + "render": [Function], + "sortable": false, + "width": "50px", + }, + Object { + "data-test-subj": "directRelationship", + "dataType": "string", + "field": "relationship", + "name": "Direct relationship", + "render": [Function], + "sortable": false, + "width": "125px", + }, + Object { + "dataType": "string", + "description": "Title of the saved object", + "field": "meta.title", + "name": "Title", + "render": [Function], + "sortable": false, + }, + Object { + "actions": Array [ Object { - "field": "type", - "multiSelect": "or", - "name": "Type", - "options": Array [ - Object { - "name": "search", - "value": "search", - "view": "search", - }, - Object { - "name": "visualization", - "value": "visualization", - "view": "visualization", - }, - ], - "type": "field_value_selection", + "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", + "description": "Inspect this saved object", + "icon": "inspect", + "name": "Inspect", + "onClick": [Function], + "type": "icon", }, ], - } + "name": "Actions", + }, + ] + } + items={Array []} + pagination={true} + responsive={true} + rowProps={[Function]} + search={ + Object { + "filters": Array [ + Object { + "field": "relationship", + "multiSelect": "or", + "name": "Direct relationship", + "options": Array [ + Object { + "name": "parent", + "value": "parent", + "view": "Parent", + }, + Object { + "name": "child", + "value": "child", + "view": "Child", + }, + ], + "type": "field_value_selection", + }, + Object { + "field": "type", + "multiSelect": "or", + "name": "Type", + "options": Array [], + "type": "field_value_selection", + }, + ], } - tableLayout="fixed" - /> - </div> + } + tableLayout="fixed" + /> </EuiFlyoutBody> </EuiFlyout> `; @@ -395,138 +570,136 @@ exports[`Relationships should render searches normally 1`] = ` </EuiTitle> </EuiFlyoutHeader> <EuiFlyoutBody> - <div> - <EuiCallOut> - <p> - Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children. - </p> - </EuiCallOut> - <EuiSpacer /> - <EuiInMemoryTable - columns={ - Array [ - Object { - "align": "center", - "description": "Type of the saved object", - "field": "type", - "name": "Type", - "render": [Function], - "sortable": false, - "width": "50px", + <EuiCallOut> + <p> + Here are the saved objects related to MySearch. Deleting this search affects its parent objects, but not its children. + </p> + </EuiCallOut> + <EuiSpacer /> + <EuiInMemoryTable + columns={ + Array [ + Object { + "align": "center", + "description": "Type of the saved object", + "field": "type", + "name": "Type", + "render": [Function], + "sortable": false, + "width": "50px", + }, + Object { + "data-test-subj": "directRelationship", + "dataType": "string", + "field": "relationship", + "name": "Direct relationship", + "render": [Function], + "sortable": false, + "width": "125px", + }, + Object { + "dataType": "string", + "description": "Title of the saved object", + "field": "meta.title", + "name": "Title", + "render": [Function], + "sortable": false, + }, + Object { + "actions": Array [ + Object { + "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", + "description": "Inspect this saved object", + "icon": "inspect", + "name": "Inspect", + "onClick": [Function], + "type": "icon", + }, + ], + "name": "Actions", + }, + ] + } + items={ + Array [ + Object { + "id": "1", + "meta": Object { + "editUrl": "/management/kibana/indexPatterns/patterns/1", + "icon": "indexPatternApp", + "inAppUrl": Object { + "path": "/app/management/kibana/indexPatterns/patterns/1", + "uiCapabilitiesPath": "management.kibana.indexPatterns", + }, + "title": "My Index Pattern", }, + "relationship": "child", + "type": "index-pattern", + }, + Object { + "id": "2", + "meta": Object { + "editUrl": "/management/kibana/objects/savedVisualizations/2", + "icon": "visualizeApp", + "inAppUrl": Object { + "path": "/app/visualize#/edit/2", + "uiCapabilitiesPath": "visualize.show", + }, + "title": "My Visualization Title", + }, + "relationship": "parent", + "type": "visualization", + }, + ] + } + pagination={true} + responsive={true} + rowProps={[Function]} + search={ + Object { + "filters": Array [ Object { - "data-test-subj": "directRelationship", - "dataType": "string", "field": "relationship", + "multiSelect": "or", "name": "Direct relationship", - "render": [Function], - "sortable": false, - "width": "125px", - }, - Object { - "dataType": "string", - "description": "Title of the saved object", - "field": "meta.title", - "name": "Title", - "render": [Function], - "sortable": false, - }, - Object { - "actions": Array [ + "options": Array [ Object { - "available": [Function], - "data-test-subj": "relationshipsTableAction-inspect", - "description": "Inspect this saved object", - "icon": "inspect", - "name": "Inspect", - "onClick": [Function], - "type": "icon", + "name": "parent", + "value": "parent", + "view": "Parent", + }, + Object { + "name": "child", + "value": "child", + "view": "Child", }, ], - "name": "Actions", + "type": "field_value_selection", }, - ] - } - items={ - Array [ Object { - "id": "1", - "meta": Object { - "editUrl": "/management/kibana/indexPatterns/patterns/1", - "icon": "indexPatternApp", - "inAppUrl": Object { - "path": "/app/management/kibana/indexPatterns/patterns/1", - "uiCapabilitiesPath": "management.kibana.indexPatterns", + "field": "type", + "multiSelect": "or", + "name": "Type", + "options": Array [ + Object { + "name": "index-pattern", + "value": "index-pattern", + "view": "index-pattern", }, - "title": "My Index Pattern", - }, - "relationship": "child", - "type": "index-pattern", - }, - Object { - "id": "2", - "meta": Object { - "editUrl": "/management/kibana/objects/savedVisualizations/2", - "icon": "visualizeApp", - "inAppUrl": Object { - "path": "/app/visualize#/edit/2", - "uiCapabilitiesPath": "visualize.show", + Object { + "name": "visualization", + "value": "visualization", + "view": "visualization", }, - "title": "My Visualization Title", - }, - "relationship": "parent", - "type": "visualization", + ], + "type": "field_value_selection", }, - ] - } - pagination={true} - responsive={true} - rowProps={[Function]} - search={ - Object { - "filters": Array [ - Object { - "field": "relationship", - "multiSelect": "or", - "name": "Direct relationship", - "options": Array [ - Object { - "name": "parent", - "value": "parent", - "view": "Parent", - }, - Object { - "name": "child", - "value": "child", - "view": "Child", - }, - ], - "type": "field_value_selection", - }, - Object { - "field": "type", - "multiSelect": "or", - "name": "Type", - "options": Array [ - Object { - "name": "index-pattern", - "value": "index-pattern", - "view": "index-pattern", - }, - Object { - "name": "visualization", - "value": "visualization", - "view": "visualization", - }, - ], - "type": "field_value_selection", - }, - ], - } + ], } - tableLayout="fixed" - /> - </div> + } + tableLayout="fixed" + /> </EuiFlyoutBody> </EuiFlyout> `; @@ -559,133 +732,131 @@ exports[`Relationships should render visualizations normally 1`] = ` </EuiTitle> </EuiFlyoutHeader> <EuiFlyoutBody> - <div> - <EuiCallOut> - <p> - Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children. - </p> - </EuiCallOut> - <EuiSpacer /> - <EuiInMemoryTable - columns={ - Array [ - Object { - "align": "center", - "description": "Type of the saved object", - "field": "type", - "name": "Type", - "render": [Function], - "sortable": false, - "width": "50px", + <EuiCallOut> + <p> + Here are the saved objects related to MyViz. Deleting this visualization affects its parent objects, but not its children. + </p> + </EuiCallOut> + <EuiSpacer /> + <EuiInMemoryTable + columns={ + Array [ + Object { + "align": "center", + "description": "Type of the saved object", + "field": "type", + "name": "Type", + "render": [Function], + "sortable": false, + "width": "50px", + }, + Object { + "data-test-subj": "directRelationship", + "dataType": "string", + "field": "relationship", + "name": "Direct relationship", + "render": [Function], + "sortable": false, + "width": "125px", + }, + Object { + "dataType": "string", + "description": "Title of the saved object", + "field": "meta.title", + "name": "Title", + "render": [Function], + "sortable": false, + }, + Object { + "actions": Array [ + Object { + "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", + "description": "Inspect this saved object", + "icon": "inspect", + "name": "Inspect", + "onClick": [Function], + "type": "icon", + }, + ], + "name": "Actions", + }, + ] + } + items={ + Array [ + Object { + "id": "1", + "meta": Object { + "editUrl": "/management/kibana/objects/savedDashboards/1", + "icon": "dashboardApp", + "inAppUrl": Object { + "path": "/app/kibana#/dashboard/1", + "uiCapabilitiesPath": "dashboard.show", + }, + "title": "My Dashboard 1", }, + "relationship": "parent", + "type": "dashboard", + }, + Object { + "id": "2", + "meta": Object { + "editUrl": "/management/kibana/objects/savedDashboards/2", + "icon": "dashboardApp", + "inAppUrl": Object { + "path": "/app/kibana#/dashboard/2", + "uiCapabilitiesPath": "dashboard.show", + }, + "title": "My Dashboard 2", + }, + "relationship": "parent", + "type": "dashboard", + }, + ] + } + pagination={true} + responsive={true} + rowProps={[Function]} + search={ + Object { + "filters": Array [ Object { - "data-test-subj": "directRelationship", - "dataType": "string", "field": "relationship", + "multiSelect": "or", "name": "Direct relationship", - "render": [Function], - "sortable": false, - "width": "125px", - }, - Object { - "dataType": "string", - "description": "Title of the saved object", - "field": "meta.title", - "name": "Title", - "render": [Function], - "sortable": false, - }, - Object { - "actions": Array [ + "options": Array [ Object { - "available": [Function], - "data-test-subj": "relationshipsTableAction-inspect", - "description": "Inspect this saved object", - "icon": "inspect", - "name": "Inspect", - "onClick": [Function], - "type": "icon", + "name": "parent", + "value": "parent", + "view": "Parent", }, - ], - "name": "Actions", - }, - ] - } - items={ - Array [ - Object { - "id": "1", - "meta": Object { - "editUrl": "/management/kibana/objects/savedDashboards/1", - "icon": "dashboardApp", - "inAppUrl": Object { - "path": "/app/kibana#/dashboard/1", - "uiCapabilitiesPath": "dashboard.show", + Object { + "name": "child", + "value": "child", + "view": "Child", }, - "title": "My Dashboard 1", - }, - "relationship": "parent", - "type": "dashboard", + ], + "type": "field_value_selection", }, Object { - "id": "2", - "meta": Object { - "editUrl": "/management/kibana/objects/savedDashboards/2", - "icon": "dashboardApp", - "inAppUrl": Object { - "path": "/app/kibana#/dashboard/2", - "uiCapabilitiesPath": "dashboard.show", + "field": "type", + "multiSelect": "or", + "name": "Type", + "options": Array [ + Object { + "name": "dashboard", + "value": "dashboard", + "view": "dashboard", }, - "title": "My Dashboard 2", - }, - "relationship": "parent", - "type": "dashboard", + ], + "type": "field_value_selection", }, - ] + ], } - pagination={true} - responsive={true} - rowProps={[Function]} - search={ - Object { - "filters": Array [ - Object { - "field": "relationship", - "multiSelect": "or", - "name": "Direct relationship", - "options": Array [ - Object { - "name": "parent", - "value": "parent", - "view": "Parent", - }, - Object { - "name": "child", - "value": "child", - "view": "Child", - }, - ], - "type": "field_value_selection", - }, - Object { - "field": "type", - "multiSelect": "or", - "name": "Type", - "options": Array [ - Object { - "name": "dashboard", - "value": "dashboard", - "view": "dashboard", - }, - ], - "type": "field_value_selection", - }, - ], - } - } - tableLayout="fixed" - /> - </div> + } + tableLayout="fixed" + /> </EuiFlyoutBody> </EuiFlyout> `; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx index 72a4b0f2788fa..e590520193bba 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -25,36 +25,39 @@ describe('Relationships', () => { goInspectObject: () => {}, canGoInApp: () => true, basePath: httpServiceMock.createSetupContract().basePath, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'search', - id: '1', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedSearches/1', - icon: 'search', - inAppUrl: { - path: '/app/discover#//1', - uiCapabilitiesPath: 'discover.show', + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [ + { + type: 'search', + id: '1', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedSearches/1', + icon: 'search', + inAppUrl: { + path: '/app/discover#//1', + uiCapabilitiesPath: 'discover.show', + }, + title: 'My Search Title', }, - title: 'My Search Title', }, - }, - { - type: 'visualization', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', + { + type: 'visualization', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title', }, - title: 'My Visualization Title', }, - }, - ]), + ], + invalidRelations: [], + })), savedObject: { id: '1', type: 'index-pattern', @@ -92,36 +95,39 @@ describe('Relationships', () => { goInspectObject: () => {}, canGoInApp: () => true, basePath: httpServiceMock.createSetupContract().basePath, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'index-pattern', - id: '1', - relationship: 'child', - meta: { - editUrl: '/management/kibana/indexPatterns/patterns/1', - icon: 'indexPatternApp', - inAppUrl: { - path: '/app/management/kibana/indexPatterns/patterns/1', - uiCapabilitiesPath: 'management.kibana.indexPatterns', + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [ + { + type: 'index-pattern', + id: '1', + relationship: 'child', + meta: { + editUrl: '/management/kibana/indexPatterns/patterns/1', + icon: 'indexPatternApp', + inAppUrl: { + path: '/app/management/kibana/indexPatterns/patterns/1', + uiCapabilitiesPath: 'management.kibana.indexPatterns', + }, + title: 'My Index Pattern', }, - title: 'My Index Pattern', }, - }, - { - type: 'visualization', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', + { + type: 'visualization', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title', }, - title: 'My Visualization Title', }, - }, - ]), + ], + invalidRelations: [], + })), savedObject: { id: '1', type: 'search', @@ -159,36 +165,39 @@ describe('Relationships', () => { goInspectObject: () => {}, canGoInApp: () => true, basePath: httpServiceMock.createSetupContract().basePath, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'dashboard', - id: '1', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedDashboards/1', - icon: 'dashboardApp', - inAppUrl: { - path: '/app/kibana#/dashboard/1', - uiCapabilitiesPath: 'dashboard.show', + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [ + { + type: 'dashboard', + id: '1', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedDashboards/1', + icon: 'dashboardApp', + inAppUrl: { + path: '/app/kibana#/dashboard/1', + uiCapabilitiesPath: 'dashboard.show', + }, + title: 'My Dashboard 1', }, - title: 'My Dashboard 1', }, - }, - { - type: 'dashboard', - id: '2', - relationship: 'parent', - meta: { - editUrl: '/management/kibana/objects/savedDashboards/2', - icon: 'dashboardApp', - inAppUrl: { - path: '/app/kibana#/dashboard/2', - uiCapabilitiesPath: 'dashboard.show', + { + type: 'dashboard', + id: '2', + relationship: 'parent', + meta: { + editUrl: '/management/kibana/objects/savedDashboards/2', + icon: 'dashboardApp', + inAppUrl: { + path: '/app/kibana#/dashboard/2', + uiCapabilitiesPath: 'dashboard.show', + }, + title: 'My Dashboard 2', }, - title: 'My Dashboard 2', }, - }, - ]), + ], + invalidRelations: [], + })), savedObject: { id: '1', type: 'visualization', @@ -226,36 +235,39 @@ describe('Relationships', () => { goInspectObject: () => {}, canGoInApp: () => true, basePath: httpServiceMock.createSetupContract().basePath, - getRelationships: jest.fn().mockImplementation(() => [ - { - type: 'visualization', - id: '1', - relationship: 'child', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/1', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/1', - uiCapabilitiesPath: 'visualize.show', + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [ + { + type: 'visualization', + id: '1', + relationship: 'child', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/1', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/1', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title 1', }, - title: 'My Visualization Title 1', }, - }, - { - type: 'visualization', - id: '2', - relationship: 'child', - meta: { - editUrl: '/management/kibana/objects/savedVisualizations/2', - icon: 'visualizeApp', - inAppUrl: { - path: '/app/visualize#/edit/2', - uiCapabilitiesPath: 'visualize.show', + { + type: 'visualization', + id: '2', + relationship: 'child', + meta: { + editUrl: '/management/kibana/objects/savedVisualizations/2', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/2', + uiCapabilitiesPath: 'visualize.show', + }, + title: 'My Visualization Title 2', }, - title: 'My Visualization Title 2', }, - }, - ]), + ], + invalidRelations: [], + })), savedObject: { id: '1', type: 'dashboard', @@ -324,4 +336,49 @@ describe('Relationships', () => { expect(props.getRelationships).toHaveBeenCalled(); expect(component).toMatchSnapshot(); }); + + it('should render invalid relations', async () => { + const props: RelationshipsProps = { + goInspectObject: () => {}, + canGoInApp: () => true, + basePath: httpServiceMock.createSetupContract().basePath, + getRelationships: jest.fn().mockImplementation(() => ({ + relations: [], + invalidRelations: [ + { + id: '1', + type: 'dashboard', + relationship: 'child', + error: 'Saved object [dashboard/1] not found', + }, + ], + })), + savedObject: { + id: '1', + type: 'index-pattern', + attributes: {}, + references: [], + meta: { + title: 'MyIndexPattern*', + icon: 'indexPatternApp', + editUrl: '#/management/kibana/indexPatterns/patterns/1', + inAppUrl: { + path: '/management/kibana/indexPatterns/patterns/1', + uiCapabilitiesPath: 'management.kibana.indexPatterns', + }, + }, + }, + close: jest.fn(), + }; + + const component = shallowWithI18nProvider(<Relationships {...props} />); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(props.getRelationships).toHaveBeenCalled(); + expect(component).toMatchSnapshot(); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx index 2d62699b6f1f2..aee61f7bc9c7a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx @@ -26,11 +26,17 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { IBasePath } from 'src/core/public'; import { getDefaultTitle, getSavedObjectLabel } from '../../../lib'; -import { SavedObjectWithMetadata, SavedObjectRelation } from '../../../types'; +import { + SavedObjectWithMetadata, + SavedObjectRelationKind, + SavedObjectRelation, + SavedObjectInvalidRelation, + SavedObjectGetRelationshipsResponse, +} from '../../../types'; export interface RelationshipsProps { basePath: IBasePath; - getRelationships: (type: string, id: string) => Promise<SavedObjectRelation[]>; + getRelationships: (type: string, id: string) => Promise<SavedObjectGetRelationshipsResponse>; savedObject: SavedObjectWithMetadata; close: () => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; @@ -38,17 +44,47 @@ export interface RelationshipsProps { } export interface RelationshipsState { - relationships: SavedObjectRelation[]; + relations: SavedObjectRelation[]; + invalidRelations: SavedObjectInvalidRelation[]; isLoading: boolean; error?: string; } +const relationshipColumn = { + field: 'relationship', + name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnRelationshipName', { + defaultMessage: 'Direct relationship', + }), + dataType: 'string', + sortable: false, + width: '125px', + 'data-test-subj': 'directRelationship', + render: (relationship: SavedObjectRelationKind) => { + return ( + <EuiText size="s"> + {relationship === 'parent' ? ( + <FormattedMessage + id="savedObjectsManagement.objectsTable.relationships.columnRelationship.parentAsValue" + defaultMessage="Parent" + /> + ) : ( + <FormattedMessage + id="savedObjectsManagement.objectsTable.relationships.columnRelationship.childAsValue" + defaultMessage="Child" + /> + )} + </EuiText> + ); + }, +}; + export class Relationships extends Component<RelationshipsProps, RelationshipsState> { constructor(props: RelationshipsProps) { super(props); this.state = { - relationships: [], + relations: [], + invalidRelations: [], isLoading: false, error: undefined, }; @@ -70,8 +106,11 @@ export class Relationships extends Component<RelationshipsProps, RelationshipsSt this.setState({ isLoading: true }); try { - const relationships = await getRelationships(savedObject.type, savedObject.id); - this.setState({ relationships, isLoading: false, error: undefined }); + const { relations, invalidRelations } = await getRelationships( + savedObject.type, + savedObject.id + ); + this.setState({ relations, invalidRelations, isLoading: false, error: undefined }); } catch (err) { this.setState({ error: err.message, isLoading: false }); } @@ -99,9 +138,83 @@ export class Relationships extends Component<RelationshipsProps, RelationshipsSt ); } - renderRelationships() { - const { goInspectObject, savedObject, basePath } = this.props; - const { relationships, isLoading, error } = this.state; + renderInvalidRelationship() { + const { invalidRelations } = this.state; + if (!invalidRelations.length) { + return null; + } + + const columns = [ + { + field: 'type', + name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnTypeName', { + defaultMessage: 'Type', + }), + width: '150px', + description: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnTypeDescription', + { defaultMessage: 'Type of the saved object' } + ), + sortable: false, + 'data-test-subj': 'relationshipsObjectType', + }, + { + field: 'id', + name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnIdName', { + defaultMessage: 'Id', + }), + width: '150px', + description: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnIdDescription', + { defaultMessage: 'Id of the saved object' } + ), + sortable: false, + 'data-test-subj': 'relationshipsObjectId', + }, + relationshipColumn, + { + field: 'error', + name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnErrorName', { + defaultMessage: 'Error', + }), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.columnErrorDescription', + { defaultMessage: 'Error encountered with the relation' } + ), + sortable: false, + 'data-test-subj': 'relationshipsError', + }, + ]; + + return ( + <> + <EuiCallOut + color="warning" + iconType="alert" + title={i18n.translate( + 'savedObjectsManagement.objectsTable.relationships.invalidRelationShip', + { + defaultMessage: 'This saved object has some invalid relations.', + } + )} + /> + <EuiSpacer /> + <EuiInMemoryTable + items={invalidRelations} + columns={columns as any} + pagination={true} + rowProps={() => ({ + 'data-test-subj': `invalidRelationshipsTableRow`, + })} + /> + <EuiSpacer /> + </> + ); + } + + renderRelationshipsTable() { + const { goInspectObject, basePath, savedObject } = this.props; + const { relations, isLoading, error } = this.state; if (error) { return this.renderError(); @@ -137,39 +250,7 @@ export class Relationships extends Component<RelationshipsProps, RelationshipsSt ); }, }, - { - field: 'relationship', - name: i18n.translate( - 'savedObjectsManagement.objectsTable.relationships.columnRelationshipName', - { defaultMessage: 'Direct relationship' } - ), - dataType: 'string', - sortable: false, - width: '125px', - 'data-test-subj': 'directRelationship', - render: (relationship: string) => { - if (relationship === 'parent') { - return ( - <EuiText size="s"> - <FormattedMessage - id="savedObjectsManagement.objectsTable.relationships.columnRelationship.parentAsValue" - defaultMessage="Parent" - /> - </EuiText> - ); - } - if (relationship === 'child') { - return ( - <EuiText size="s"> - <FormattedMessage - id="savedObjectsManagement.objectsTable.relationships.columnRelationship.childAsValue" - defaultMessage="Child" - /> - </EuiText> - ); - } - }, - }, + relationshipColumn, { field: 'meta.title', name: i18n.translate('savedObjectsManagement.objectsTable.relationships.columnTitleName', { @@ -224,7 +305,7 @@ export class Relationships extends Component<RelationshipsProps, RelationshipsSt ]; const filterTypesMap = new Map( - relationships.map((relationship) => [ + relations.map((relationship) => [ relationship.type, { value: relationship.type, @@ -277,7 +358,7 @@ export class Relationships extends Component<RelationshipsProps, RelationshipsSt }; return ( - <div> + <> <EuiCallOut> <p> {i18n.translate( @@ -296,7 +377,7 @@ export class Relationships extends Component<RelationshipsProps, RelationshipsSt </EuiCallOut> <EuiSpacer /> <EuiInMemoryTable - items={relationships} + items={relations} columns={columns as any} pagination={true} search={search} @@ -304,7 +385,7 @@ export class Relationships extends Component<RelationshipsProps, RelationshipsSt 'data-test-subj': `relationshipsTableRow`, })} /> - </div> + </> ); } @@ -328,8 +409,10 @@ export class Relationships extends Component<RelationshipsProps, RelationshipsSt </h2> </EuiTitle> </EuiFlyoutHeader> - - <EuiFlyoutBody>{this.renderRelationships()}</EuiFlyoutBody> + <EuiFlyoutBody> + {this.renderInvalidRelationship()} + {this.renderRelationshipsTable()} + </EuiFlyoutBody> </EuiFlyout> ); } diff --git a/src/plugins/saved_objects_management/public/types.ts b/src/plugins/saved_objects_management/public/types.ts index 37f239227475d..cdfa3c43e5af2 100644 --- a/src/plugins/saved_objects_management/public/types.ts +++ b/src/plugins/saved_objects_management/public/types.ts @@ -6,4 +6,11 @@ * Public License, v 1. */ -export { SavedObjectMetadata, SavedObjectWithMetadata, SavedObjectRelation } from '../common'; +export { + SavedObjectMetadata, + SavedObjectWithMetadata, + SavedObjectRelationKind, + SavedObjectRelation, + SavedObjectInvalidRelation, + SavedObjectGetRelationshipsResponse, +} from '../common'; diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts index 631faf0c23c98..416be7d7e7426 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts @@ -6,10 +6,35 @@ * Public License, v 1. */ +import type { SavedObject, SavedObjectError } from 'src/core/types'; +import type { SavedObjectsFindResponse } from 'src/core/server'; import { findRelationships } from './find_relationships'; import { managementMock } from '../services/management.mock'; import { savedObjectsClientMock } from '../../../../core/server/mocks'; +const createObj = (parts: Partial<SavedObject<any>>): SavedObject<any> => ({ + id: 'id', + type: 'type', + attributes: {}, + references: [], + ...parts, +}); + +const createFindResponse = (objs: SavedObject[]): SavedObjectsFindResponse => ({ + saved_objects: objs.map((obj) => ({ ...obj, score: 1 })), + total: objs.length, + per_page: 20, + page: 1, +}); + +const createError = (parts: Partial<SavedObjectError>): SavedObjectError => ({ + error: 'error', + message: 'message', + metadata: {}, + statusCode: 404, + ...parts, +}); + describe('findRelationships', () => { let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>; let managementService: ReturnType<typeof managementMock.create>; @@ -19,7 +44,7 @@ describe('findRelationships', () => { managementService = managementMock.create(); }); - it('returns the child and parent references of the object', async () => { + it('calls the savedObjectClient APIs with the correct parameters', async () => { const type = 'dashboard'; const id = 'some-id'; const references = [ @@ -36,46 +61,35 @@ describe('findRelationships', () => { ]; const referenceTypes = ['some-type', 'another-type']; - savedObjectsClient.get.mockResolvedValue({ - id, - type, - attributes: {}, - references, - }); - + savedObjectsClient.get.mockResolvedValue( + createObj({ + id, + type, + references, + }) + ); savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [ - { + createObj({ type: 'some-type', id: 'ref-1', - attributes: {}, - references: [], - }, - { + }), + createObj({ type: 'another-type', id: 'ref-2', - attributes: {}, - references: [], - }, + }), ], }); - - savedObjectsClient.find.mockResolvedValue({ - saved_objects: [ - { + savedObjectsClient.find.mockResolvedValue( + createFindResponse([ + createObj({ type: 'parent-type', id: 'parent-id', - attributes: {}, - score: 1, - references: [], - }, - ], - total: 1, - per_page: 20, - page: 1, - }); + }), + ]) + ); - const relationships = await findRelationships({ + await findRelationships({ type, id, size: 20, @@ -101,8 +115,63 @@ describe('findRelationships', () => { perPage: 20, type: referenceTypes, }); + }); + + it('returns the child and parent references of the object', async () => { + const type = 'dashboard'; + const id = 'some-id'; + const references = [ + { + type: 'some-type', + id: 'ref-1', + name: 'ref 1', + }, + { + type: 'another-type', + id: 'ref-2', + name: 'ref 2', + }, + ]; + const referenceTypes = ['some-type', 'another-type']; + + savedObjectsClient.get.mockResolvedValue( + createObj({ + id, + type, + references, + }) + ); + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + createObj({ + type: 'some-type', + id: 'ref-1', + }), + createObj({ + type: 'another-type', + id: 'ref-2', + }), + ], + }); + savedObjectsClient.find.mockResolvedValue( + createFindResponse([ + createObj({ + type: 'parent-type', + id: 'parent-id', + }), + ]) + ); + + const { relations, invalidRelations } = await findRelationships({ + type, + id, + size: 20, + client: savedObjectsClient, + referenceTypes, + savedObjectsManagement: managementService, + }); - expect(relationships).toEqual([ + expect(relations).toEqual([ { id: 'ref-1', relationship: 'child', @@ -122,6 +191,70 @@ describe('findRelationships', () => { meta: expect.any(Object), }, ]); + expect(invalidRelations).toHaveLength(0); + }); + + it('returns the invalid relations', async () => { + const type = 'dashboard'; + const id = 'some-id'; + const references = [ + { + type: 'some-type', + id: 'ref-1', + name: 'ref 1', + }, + { + type: 'another-type', + id: 'ref-2', + name: 'ref 2', + }, + ]; + const referenceTypes = ['some-type', 'another-type']; + + savedObjectsClient.get.mockResolvedValue( + createObj({ + id, + type, + references, + }) + ); + const ref1Error = createError({ message: 'Not found' }); + savedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + createObj({ + type: 'some-type', + id: 'ref-1', + error: ref1Error, + }), + createObj({ + type: 'another-type', + id: 'ref-2', + }), + ], + }); + savedObjectsClient.find.mockResolvedValue(createFindResponse([])); + + const { relations, invalidRelations } = await findRelationships({ + type, + id, + size: 20, + client: savedObjectsClient, + referenceTypes, + savedObjectsManagement: managementService, + }); + + expect(relations).toEqual([ + { + id: 'ref-2', + relationship: 'child', + type: 'another-type', + meta: expect.any(Object), + }, + ]); + + expect(invalidRelations).toEqual([ + { type: 'some-type', id: 'ref-1', relationship: 'child', error: ref1Error.message }, + ]); }); it('uses the management service to consolidate the relationship objects', async () => { @@ -144,32 +277,24 @@ describe('findRelationships', () => { uiCapabilitiesPath: 'uiCapabilitiesPath', }); - savedObjectsClient.get.mockResolvedValue({ - id, - type, - attributes: {}, - references, - }); - + savedObjectsClient.get.mockResolvedValue( + createObj({ + id, + type, + references, + }) + ); savedObjectsClient.bulkGet.mockResolvedValue({ saved_objects: [ - { + createObj({ type: 'some-type', id: 'ref-1', - attributes: {}, - references: [], - }, + }), ], }); + savedObjectsClient.find.mockResolvedValue(createFindResponse([])); - savedObjectsClient.find.mockResolvedValue({ - saved_objects: [], - total: 0, - per_page: 20, - page: 1, - }); - - const relationships = await findRelationships({ + const { relations } = await findRelationships({ type, id, size: 20, @@ -183,7 +308,7 @@ describe('findRelationships', () => { expect(managementService.getEditUrl).toHaveBeenCalledTimes(1); expect(managementService.getInAppUrl).toHaveBeenCalledTimes(1); - expect(relationships).toEqual([ + expect(relations).toEqual([ { id: 'ref-1', relationship: 'child', diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.ts index 0ceef484196a3..bc6568e73c4e2 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.ts @@ -9,7 +9,11 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { injectMetaAttributes } from './inject_meta_attributes'; import { ISavedObjectsManagement } from '../services'; -import { SavedObjectRelation, SavedObjectWithMetadata } from '../types'; +import { + SavedObjectInvalidRelation, + SavedObjectWithMetadata, + SavedObjectGetRelationshipsResponse, +} from '../types'; export async function findRelationships({ type, @@ -25,17 +29,19 @@ export async function findRelationships({ client: SavedObjectsClientContract; referenceTypes: string[]; savedObjectsManagement: ISavedObjectsManagement; -}): Promise<SavedObjectRelation[]> { +}): Promise<SavedObjectGetRelationshipsResponse> { const { references = [] } = await client.get(type, id); // Use a map to avoid duplicates, it does happen but have a different "name" in the reference - const referencedToBulkGetOpts = new Map( - references.map((ref) => [`${ref.type}:${ref.id}`, { id: ref.id, type: ref.type }]) - ); + const childrenReferences = [ + ...new Map( + references.map((ref) => [`${ref.type}:${ref.id}`, { id: ref.id, type: ref.type }]) + ).values(), + ]; const [childReferencesResponse, parentReferencesResponse] = await Promise.all([ - referencedToBulkGetOpts.size > 0 - ? client.bulkGet([...referencedToBulkGetOpts.values()]) + childrenReferences.length > 0 + ? client.bulkGet(childrenReferences) : Promise.resolve({ saved_objects: [] }), client.find({ hasReference: { type, id }, @@ -44,28 +50,37 @@ export async function findRelationships({ }), ]); - return childReferencesResponse.saved_objects - .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) - .map(extractCommonProperties) - .map( - (obj) => - ({ - ...obj, - relationship: 'child', - } as SavedObjectRelation) - ) - .concat( - parentReferencesResponse.saved_objects - .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) - .map(extractCommonProperties) - .map( - (obj) => - ({ - ...obj, - relationship: 'parent', - } as SavedObjectRelation) - ) - ); + const invalidRelations: SavedObjectInvalidRelation[] = childReferencesResponse.saved_objects + .filter((obj) => Boolean(obj.error)) + .map((obj) => ({ + id: obj.id, + type: obj.type, + relationship: 'child', + error: obj.error!.message, + })); + + const relations = [ + ...childReferencesResponse.saved_objects + .filter((obj) => !obj.error) + .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) + .map(extractCommonProperties) + .map((obj) => ({ + ...obj, + relationship: 'child' as const, + })), + ...parentReferencesResponse.saved_objects + .map((obj) => injectMetaAttributes(obj, savedObjectsManagement)) + .map(extractCommonProperties) + .map((obj) => ({ + ...obj, + relationship: 'parent' as const, + })), + ]; + + return { + relations, + invalidRelations, + }; } function extractCommonProperties(savedObject: SavedObjectWithMetadata) { diff --git a/src/plugins/saved_objects_management/server/routes/relationships.ts b/src/plugins/saved_objects_management/server/routes/relationships.ts index 3a52c973fde8d..5417ff2926120 100644 --- a/src/plugins/saved_objects_management/server/routes/relationships.ts +++ b/src/plugins/saved_objects_management/server/routes/relationships.ts @@ -38,7 +38,7 @@ export const registerRelationshipsRoute = ( ? req.query.savedObjectTypes : [req.query.savedObjectTypes]; - const relations = await findRelationships({ + const findRelationsResponse = await findRelationships({ type, id, client, @@ -48,7 +48,7 @@ export const registerRelationshipsRoute = ( }); return res.ok({ - body: relations, + body: findRelationsResponse, }); }) ); diff --git a/src/plugins/saved_objects_management/server/types.ts b/src/plugins/saved_objects_management/server/types.ts index 710bb5db7d1cb..562970d2d2dcd 100644 --- a/src/plugins/saved_objects_management/server/types.ts +++ b/src/plugins/saved_objects_management/server/types.ts @@ -12,4 +12,11 @@ export interface SavedObjectsManagementPluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface SavedObjectsManagementPluginStart {} -export { SavedObjectMetadata, SavedObjectWithMetadata, SavedObjectRelation } from '../common'; +export { + SavedObjectMetadata, + SavedObjectWithMetadata, + SavedObjectRelationKind, + SavedObjectRelation, + SavedObjectInvalidRelation, + SavedObjectGetRelationshipsResponse, +} from '../common'; diff --git a/src/plugins/security_oss/public/plugin.mock.ts b/src/plugins/security_oss/public/plugin.mock.ts index 53d96b9c7a303..8fe8d56ea6576 100644 --- a/src/plugins/security_oss/public/plugin.mock.ts +++ b/src/plugins/security_oss/public/plugin.mock.ts @@ -7,6 +7,7 @@ */ import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { InsecureClusterServiceStart } from './insecure_cluster_service'; import { mockInsecureClusterService } from './insecure_cluster_service/insecure_cluster_service.mock'; import { SecurityOssPluginSetup, SecurityOssPluginStart } from './plugin'; @@ -18,7 +19,11 @@ export const mockSecurityOssPlugin = { }, createStart: () => { return { - insecureCluster: mockInsecureClusterService.createStart(), + insecureCluster: mockInsecureClusterService.createStart() as jest.Mocked<InsecureClusterServiceStart>, + anonymousAccess: { + getAccessURLParameters: jest.fn().mockResolvedValue(null), + getCapabilities: jest.fn().mockResolvedValue({}), + }, } as DeeplyMockedKeys<SecurityOssPluginStart>; }, }; diff --git a/src/plugins/share/kibana.json b/src/plugins/share/kibana.json index 7760ea321992d..8b1d28b1606d4 100644 --- a/src/plugins/share/kibana.json +++ b/src/plugins/share/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredBundles": ["kibanaUtils"] + "requiredBundles": ["kibanaUtils"], + "optionalPlugins": ["securityOss"] } diff --git a/src/plugins/share/public/components/__snapshots__/url_panel_content.test.tsx.snap b/src/plugins/share/public/components/__snapshots__/url_panel_content.test.tsx.snap index 9a7191519131c..e883b550fde04 100644 --- a/src/plugins/share/public/components/__snapshots__/url_panel_content.test.tsx.snap +++ b/src/plugins/share/public/components/__snapshots__/url_panel_content.test.tsx.snap @@ -115,49 +115,68 @@ exports[`share url panel content render 1`] = ` /> </EuiFormRow> <EuiFormRow - data-test-subj="createShortUrl" describedByIds={Array []} display="row" fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} + label={ + <FormattedMessage + defaultMessage="URL" + id="share.urlPanel.urlGroupTitle" + values={Object {}} + /> + } labelType="label" > - <EuiFlexGroup - gutterSize="none" - responsive={false} + <EuiSpacer + size="s" + /> + <EuiFormRow + data-test-subj="createShortUrl" + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + labelType="label" > - <EuiFlexItem - grow={false} + <EuiFlexGroup + gutterSize="none" + responsive={false} > - <EuiSwitch - checked={false} - data-test-subj="useShortUrl" - label={ - <FormattedMessage - defaultMessage="Short URL" - id="share.urlPanel.shortUrlLabel" - values={Object {}} - /> - } - onChange={[Function]} - /> - </EuiFlexItem> - <EuiFlexItem - grow={false} - > - <EuiIconTip - content={ - <FormattedMessage - defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." - id="share.urlPanel.shortUrlHelpText" - values={Object {}} - /> - } - position="bottom" - /> - </EuiFlexItem> - </EuiFlexGroup> + <EuiFlexItem + grow={false} + > + <EuiSwitch + checked={false} + data-test-subj="useShortUrl" + label={ + <FormattedMessage + defaultMessage="Short URL" + id="share.urlPanel.shortUrlLabel" + values={Object {}} + /> + } + onChange={[Function]} + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <EuiIconTip + content={ + <FormattedMessage + defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." + id="share.urlPanel.shortUrlHelpText" + values={Object {}} + /> + } + position="bottom" + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> </EuiFormRow> <EuiSpacer size="m" @@ -277,49 +296,68 @@ exports[`share url panel content should enable saved object export option when o /> </EuiFormRow> <EuiFormRow - data-test-subj="createShortUrl" describedByIds={Array []} display="row" fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} + label={ + <FormattedMessage + defaultMessage="URL" + id="share.urlPanel.urlGroupTitle" + values={Object {}} + /> + } labelType="label" > - <EuiFlexGroup - gutterSize="none" - responsive={false} + <EuiSpacer + size="s" + /> + <EuiFormRow + data-test-subj="createShortUrl" + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + labelType="label" > - <EuiFlexItem - grow={false} - > - <EuiSwitch - checked={false} - data-test-subj="useShortUrl" - label={ - <FormattedMessage - defaultMessage="Short URL" - id="share.urlPanel.shortUrlLabel" - values={Object {}} - /> - } - onChange={[Function]} - /> - </EuiFlexItem> - <EuiFlexItem - grow={false} + <EuiFlexGroup + gutterSize="none" + responsive={false} > - <EuiIconTip - content={ - <FormattedMessage - defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." - id="share.urlPanel.shortUrlHelpText" - values={Object {}} - /> - } - position="bottom" - /> - </EuiFlexItem> - </EuiFlexGroup> + <EuiFlexItem + grow={false} + > + <EuiSwitch + checked={false} + data-test-subj="useShortUrl" + label={ + <FormattedMessage + defaultMessage="Short URL" + id="share.urlPanel.shortUrlLabel" + values={Object {}} + /> + } + onChange={[Function]} + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <EuiIconTip + content={ + <FormattedMessage + defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." + id="share.urlPanel.shortUrlHelpText" + values={Object {}} + /> + } + position="bottom" + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> </EuiFormRow> <EuiSpacer size="m" @@ -438,6 +476,25 @@ exports[`share url panel content should hide short url section when allowShortUr } /> </EuiFormRow> + <EuiFormRow + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + label={ + <FormattedMessage + defaultMessage="URL" + id="share.urlPanel.urlGroupTitle" + values={Object {}} + /> + } + labelType="label" + > + <EuiSpacer + size="s" + /> + </EuiFormRow> <EuiSpacer size="m" /> @@ -569,49 +626,68 @@ exports[`should show url param extensions 1`] = ` /> </EuiFormRow> <EuiFormRow - data-test-subj="createShortUrl" describedByIds={Array []} display="row" fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} + label={ + <FormattedMessage + defaultMessage="URL" + id="share.urlPanel.urlGroupTitle" + values={Object {}} + /> + } labelType="label" > - <EuiFlexGroup - gutterSize="none" - responsive={false} + <EuiSpacer + size="s" + /> + <EuiFormRow + data-test-subj="createShortUrl" + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + labelType="label" > - <EuiFlexItem - grow={false} + <EuiFlexGroup + gutterSize="none" + responsive={false} > - <EuiSwitch - checked={false} - data-test-subj="useShortUrl" - label={ - <FormattedMessage - defaultMessage="Short URL" - id="share.urlPanel.shortUrlLabel" - values={Object {}} - /> - } - onChange={[Function]} - /> - </EuiFlexItem> - <EuiFlexItem - grow={false} - > - <EuiIconTip - content={ - <FormattedMessage - defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." - id="share.urlPanel.shortUrlHelpText" - values={Object {}} - /> - } - position="bottom" - /> - </EuiFlexItem> - </EuiFlexGroup> + <EuiFlexItem + grow={false} + > + <EuiSwitch + checked={false} + data-test-subj="useShortUrl" + label={ + <FormattedMessage + defaultMessage="Short URL" + id="share.urlPanel.shortUrlLabel" + values={Object {}} + /> + } + onChange={[Function]} + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <EuiIconTip + content={ + <FormattedMessage + defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." + id="share.urlPanel.shortUrlHelpText" + values={Object {}} + /> + } + position="bottom" + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> </EuiFormRow> <EuiSpacer size="m" diff --git a/src/plugins/share/public/components/share_context_menu.tsx b/src/plugins/share/public/components/share_context_menu.tsx index 5b21c449b6a9f..ff2b411d63809 100644 --- a/src/plugins/share/public/components/share_context_menu.tsx +++ b/src/plugins/share/public/components/share_context_menu.tsx @@ -13,9 +13,11 @@ import { i18n } from '@kbn/i18n'; import { EuiContextMenu, EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { HttpStart } from 'kibana/public'; +import type { Capabilities } from 'src/core/public'; import { UrlPanelContent } from './url_panel_content'; import { ShareMenuItem, ShareContextMenuPanelItem, UrlParamExtension } from '../types'; +import type { SecurityOssPluginStart } from '../../../security_oss/public'; interface Props { allowEmbed: boolean; @@ -29,6 +31,8 @@ interface Props { basePath: string; post: HttpStart['post']; embedUrlParamExtensions?: UrlParamExtension[]; + anonymousAccess?: SecurityOssPluginStart['anonymousAccess']; + showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; } export class ShareContextMenu extends Component<Props> { @@ -62,6 +66,8 @@ export class ShareContextMenu extends Component<Props> { basePath={this.props.basePath} post={this.props.post} shareableUrl={this.props.shareableUrl} + anonymousAccess={this.props.anonymousAccess} + showPublicUrlSwitch={this.props.showPublicUrlSwitch} /> ), }; @@ -91,6 +97,8 @@ export class ShareContextMenu extends Component<Props> { post={this.props.post} shareableUrl={this.props.shareableUrl} urlParamExtensions={this.props.embedUrlParamExtensions} + anonymousAccess={this.props.anonymousAccess} + showPublicUrlSwitch={this.props.showPublicUrlSwitch} /> ), }; diff --git a/src/plugins/share/public/components/url_panel_content.tsx b/src/plugins/share/public/components/url_panel_content.tsx index 5901d2452e9aa..ca9025f242b78 100644 --- a/src/plugins/share/public/components/url_panel_content.tsx +++ b/src/plugins/share/public/components/url_panel_content.tsx @@ -28,9 +28,11 @@ import { format as formatUrl, parse as parseUrl } from 'url'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { HttpStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; +import type { Capabilities } from 'src/core/public'; import { shortenUrl } from '../lib/url_shortener'; import { UrlParamExtension } from '../types'; +import type { SecurityOssPluginStart } from '../../../security_oss/public'; interface Props { allowShortUrl: boolean; @@ -41,6 +43,8 @@ interface Props { basePath: string; post: HttpStart['post']; urlParamExtensions?: UrlParamExtension[]; + anonymousAccess?: SecurityOssPluginStart['anonymousAccess']; + showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; } export enum ExportUrlAsType { @@ -57,10 +61,13 @@ interface UrlParams { interface State { exportUrlAs: ExportUrlAsType; useShortUrl: boolean; + usePublicUrl: boolean; isCreatingShortUrl: boolean; url?: string; shortUrlErrorMsg?: string; urlParams?: UrlParams; + anonymousAccessParameters: Record<string, string> | null; + showPublicUrlSwitch: boolean; } export class UrlPanelContent extends Component<Props, State> { @@ -75,8 +82,11 @@ export class UrlPanelContent extends Component<Props, State> { this.state = { exportUrlAs: ExportUrlAsType.EXPORT_URL_AS_SNAPSHOT, useShortUrl: false, + usePublicUrl: false, isCreatingShortUrl: false, url: '', + anonymousAccessParameters: null, + showPublicUrlSwitch: false, }; } @@ -91,6 +101,41 @@ export class UrlPanelContent extends Component<Props, State> { this.setUrl(); window.addEventListener('hashchange', this.resetUrl, false); + + if (this.props.anonymousAccess) { + (async () => { + const anonymousAccessParameters = await this.props.anonymousAccess!.getAccessURLParameters(); + + if (!this.mounted) { + return; + } + + if (!anonymousAccessParameters) { + return; + } + + let showPublicUrlSwitch: boolean = false; + + if (this.props.showPublicUrlSwitch) { + const anonymousUserCapabilities = await this.props.anonymousAccess!.getCapabilities(); + + if (!this.mounted) { + return; + } + + try { + showPublicUrlSwitch = this.props.showPublicUrlSwitch!(anonymousUserCapabilities); + } catch { + showPublicUrlSwitch = false; + } + } + + this.setState({ + anonymousAccessParameters, + showPublicUrlSwitch, + }); + })(); + } } public render() { @@ -99,7 +144,16 @@ export class UrlPanelContent extends Component<Props, State> { <EuiForm className="kbnShareContextMenu__finalPanel" data-test-subj="shareUrlForm"> {this.renderExportAsRadioGroup()} {this.renderUrlParamExtensions()} - {this.renderShortUrlSwitch()} + + <EuiFormRow + label={<FormattedMessage id="share.urlPanel.urlGroupTitle" defaultMessage="URL" />} + > + <> + <EuiSpacer size={'s'} /> + {this.renderShortUrlSwitch()} + {this.renderPublicUrlSwitch()} + </> + </EuiFormRow> <EuiSpacer size="m" /> @@ -150,10 +204,10 @@ export class UrlPanelContent extends Component<Props, State> { }; private updateUrlParams = (url: string) => { - const embedUrl = this.props.isEmbedded ? this.makeUrlEmbeddable(url) : url; - const extendUrl = this.state.urlParams ? this.getUrlParamExtensions(embedUrl) : embedUrl; + url = this.props.isEmbedded ? this.makeUrlEmbeddable(url) : url; + url = this.state.urlParams ? this.getUrlParamExtensions(url) : url; - return extendUrl; + return url; }; private getSavedObjectUrl = () => { @@ -206,6 +260,20 @@ export class UrlPanelContent extends Component<Props, State> { return `${url}${embedParam}`; }; + private addUrlAnonymousAccessParameters = (url: string): string => { + if (!this.state.anonymousAccessParameters || !this.state.usePublicUrl) { + return url; + } + + const parsedUrl = new URL(url); + + for (const [name, value] of Object.entries(this.state.anonymousAccessParameters)) { + parsedUrl.searchParams.set(name, value); + } + + return parsedUrl.toString(); + }; + private getUrlParamExtensions = (url: string): string => { const { urlParams } = this.state; return urlParams @@ -232,7 +300,8 @@ export class UrlPanelContent extends Component<Props, State> { }; private setUrl = () => { - let url; + let url: string | undefined; + if (this.state.exportUrlAs === ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT) { url = this.getSavedObjectUrl(); } else if (this.state.useShortUrl) { @@ -241,6 +310,10 @@ export class UrlPanelContent extends Component<Props, State> { url = this.getSnapshotUrl(); } + if (url) { + url = this.addUrlAnonymousAccessParameters(url); + } + if (this.props.isEmbedded) { url = this.makeIframeTag(url); } @@ -269,6 +342,14 @@ export class UrlPanelContent extends Component<Props, State> { this.createShortUrl(); }; + private handlePublicUrlChange = () => { + this.setState(({ usePublicUrl }) => { + return { + usePublicUrl: !usePublicUrl, + }; + }, this.setUrl); + }; + private createShortUrl = async () => { this.setState({ isCreatingShortUrl: true, @@ -280,33 +361,38 @@ export class UrlPanelContent extends Component<Props, State> { basePath: this.props.basePath, post: this.props.post, }); - if (this.mounted) { - this.shortUrlCache = shortUrl; - this.setState( - { - isCreatingShortUrl: false, - useShortUrl: true, - }, - this.setUrl - ); + + if (!this.mounted) { + return; } + + this.shortUrlCache = shortUrl; + this.setState( + { + isCreatingShortUrl: false, + useShortUrl: true, + }, + this.setUrl + ); } catch (fetchError) { - if (this.mounted) { - this.shortUrlCache = undefined; - this.setState( - { - useShortUrl: false, - isCreatingShortUrl: false, - shortUrlErrorMsg: i18n.translate('share.urlPanel.unableCreateShortUrlErrorMessage', { - defaultMessage: 'Unable to create short URL. Error: {errorMessage}', - values: { - errorMessage: fetchError.message, - }, - }), - }, - this.setUrl - ); + if (!this.mounted) { + return; } + + this.shortUrlCache = undefined; + this.setState( + { + useShortUrl: false, + isCreatingShortUrl: false, + shortUrlErrorMsg: i18n.translate('share.urlPanel.unableCreateShortUrlErrorMessage', { + defaultMessage: 'Unable to create short URL. Error: {errorMessage}', + values: { + errorMessage: fetchError.message, + }, + }), + }, + this.setUrl + ); } }; @@ -421,6 +507,36 @@ export class UrlPanelContent extends Component<Props, State> { ); }; + private renderPublicUrlSwitch = () => { + if (!this.state.anonymousAccessParameters || !this.state.showPublicUrlSwitch) { + return null; + } + + const switchLabel = ( + <FormattedMessage id="share.urlPanel.publicUrlLabel" defaultMessage="Public URL" /> + ); + const switchComponent = ( + <EuiSwitch + label={switchLabel} + checked={this.state.usePublicUrl} + onChange={this.handlePublicUrlChange} + data-test-subj="usePublicUrl" + /> + ); + const tipContent = ( + <FormattedMessage + id="share.urlPanel.publicUrlHelpText" + defaultMessage="Use public URL to share with anyone. It enables one-step anonymous access by removing the login prompt." + /> + ); + + return ( + <EuiFormRow data-test-subj="createPublicUrl"> + {this.renderWithIconTip(switchComponent, tipContent)} + </EuiFormRow> + ); + }; + private renderUrlParamExtensions = (): ReactElement | void => { if (!this.props.urlParamExtensions) { return; diff --git a/src/plugins/share/public/plugin.test.ts b/src/plugins/share/public/plugin.test.ts index 2b85564ee9ef9..5a3b335115e0d 100644 --- a/src/plugins/share/public/plugin.test.ts +++ b/src/plugins/share/public/plugin.test.ts @@ -10,6 +10,7 @@ import { registryMock, managerMock } from './plugin.test.mocks'; import { SharePlugin } from './plugin'; import { CoreStart } from 'kibana/public'; import { coreMock } from '../../../core/public/mocks'; +import { mockSecurityOssPlugin } from '../../security_oss/public/mocks'; describe('SharePlugin', () => { beforeEach(() => { @@ -21,14 +22,20 @@ describe('SharePlugin', () => { describe('setup', () => { test('wires up and returns registry', async () => { const coreSetup = coreMock.createSetup(); - const setup = await new SharePlugin().setup(coreSetup); + const plugins = { + securityOss: mockSecurityOssPlugin.createSetup(), + }; + const setup = await new SharePlugin().setup(coreSetup, plugins); expect(registryMock.setup).toHaveBeenCalledWith(); expect(setup.register).toBeDefined(); }); test('registers redirect app', async () => { const coreSetup = coreMock.createSetup(); - await new SharePlugin().setup(coreSetup); + const plugins = { + securityOss: mockSecurityOssPlugin.createSetup(), + }; + await new SharePlugin().setup(coreSetup, plugins); expect(coreSetup.application.register).toHaveBeenCalledWith( expect.objectContaining({ id: 'short_url_redirect', @@ -40,13 +47,22 @@ describe('SharePlugin', () => { describe('start', () => { test('wires up and returns show function, but not registry', async () => { const coreSetup = coreMock.createSetup(); + const pluginsSetup = { + securityOss: mockSecurityOssPlugin.createSetup(), + }; const service = new SharePlugin(); - await service.setup(coreSetup); - const start = await service.start({} as CoreStart); + await service.setup(coreSetup, pluginsSetup); + const pluginsStart = { + securityOss: mockSecurityOssPlugin.createStart(), + }; + const start = await service.start({} as CoreStart, pluginsStart); expect(registryMock.start).toHaveBeenCalled(); expect(managerMock.start).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ getShareMenuItems: expect.any(Function) }) + expect.objectContaining({ + getShareMenuItems: expect.any(Function), + }), + expect.anything() ); expect(start.toggleShareContextMenu).toBeDefined(); }); diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 55baf72cc4520..26fa1c9113f21 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -10,6 +10,7 @@ import './index.scss'; import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { ShareMenuManager, ShareMenuManagerStart } from './services'; +import type { SecurityOssPluginSetup, SecurityOssPluginStart } from '../../security_oss/public'; import { ShareMenuRegistry, ShareMenuRegistrySetup } from './services'; import { createShortUrlRedirectApp } from './services/short_url_redirect_app'; import { @@ -18,12 +19,20 @@ import { UrlGeneratorsStart, } from './url_generators/url_generator_service'; +export interface ShareSetupDependencies { + securityOss?: SecurityOssPluginSetup; +} + +export interface ShareStartDependencies { + securityOss?: SecurityOssPluginStart; +} + export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> { private readonly shareMenuRegistry = new ShareMenuRegistry(); private readonly shareContextMenu = new ShareMenuManager(); private readonly urlGeneratorsService = new UrlGeneratorsService(); - public setup(core: CoreSetup): SharePluginSetup { + public setup(core: CoreSetup, plugins: ShareSetupDependencies): SharePluginSetup { core.application.register(createShortUrlRedirectApp(core, window.location)); return { ...this.shareMenuRegistry.setup(), @@ -31,9 +40,13 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> { }; } - public start(core: CoreStart): SharePluginStart { + public start(core: CoreStart, plugins: ShareStartDependencies): SharePluginStart { return { - ...this.shareContextMenu.start(core, this.shareMenuRegistry.start()), + ...this.shareContextMenu.start( + core, + this.shareMenuRegistry.start(), + plugins.securityOss?.anonymousAccess + ), urlGenerators: this.urlGeneratorsService.start(core), }; } diff --git a/src/plugins/share/public/services/share_menu_manager.tsx b/src/plugins/share/public/services/share_menu_manager.tsx index cc3649d33d876..7284be6a8719c 100644 --- a/src/plugins/share/public/services/share_menu_manager.tsx +++ b/src/plugins/share/public/services/share_menu_manager.tsx @@ -15,13 +15,18 @@ import { CoreStart, HttpStart } from 'kibana/public'; import { ShareContextMenu } from '../components/share_context_menu'; import { ShareMenuItem, ShowShareMenuOptions } from '../types'; import { ShareMenuRegistryStart } from './share_menu_registry'; +import type { SecurityOssPluginStart } from '../../../security_oss/public'; export class ShareMenuManager { private isOpen = false; private container = document.createElement('div'); - start(core: CoreStart, shareRegistry: ShareMenuRegistryStart) { + start( + core: CoreStart, + shareRegistry: ShareMenuRegistryStart, + anonymousAccess?: SecurityOssPluginStart['anonymousAccess'] + ) { return { /** * Collects share menu items from registered providers and mounts the share context menu under @@ -35,6 +40,7 @@ export class ShareMenuManager { menuItems, post: core.http.post, basePath: core.http.basePath.get(), + anonymousAccess, }); }, }; @@ -57,10 +63,13 @@ export class ShareMenuManager { post, basePath, embedUrlParamExtensions, + anonymousAccess, + showPublicUrlSwitch, }: ShowShareMenuOptions & { menuItems: ShareMenuItem[]; post: HttpStart['post']; basePath: string; + anonymousAccess?: SecurityOssPluginStart['anonymousAccess']; }) { if (this.isOpen) { this.onClose(); @@ -92,6 +101,8 @@ export class ShareMenuManager { post={post} basePath={basePath} embedUrlParamExtensions={embedUrlParamExtensions} + anonymousAccess={anonymousAccess} + showPublicUrlSwitch={showPublicUrlSwitch} /> </EuiWrappingPopover> </I18nProvider> diff --git a/src/plugins/share/public/types.ts b/src/plugins/share/public/types.ts index 88bb51389b001..31c9631571d35 100644 --- a/src/plugins/share/public/types.ts +++ b/src/plugins/share/public/types.ts @@ -9,6 +9,7 @@ import { ComponentType } from 'react'; import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; +import type { Capabilities } from 'src/core/public'; /** * @public @@ -35,6 +36,7 @@ export interface ShareContext { sharingData: { [key: string]: unknown }; isDirty: boolean; onClose: () => void; + showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; } /** diff --git a/src/plugins/share/tsconfig.json b/src/plugins/share/tsconfig.json index a6318af602b4d..985066915f1dd 100644 --- a/src/plugins/share/tsconfig.json +++ b/src/plugins/share/tsconfig.json @@ -10,6 +10,7 @@ "include": ["common/**/*", "public/**/*", "server/**/*"], "references": [ { "path": "../../core/tsconfig.json" }, - { "path": "../../plugins/kibana_utils/tsconfig.json" } + { "path": "../kibana_utils/tsconfig.json" }, + { "path": "../security_oss/tsconfig.json" } ] } diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js index 1dab41566da67..f66c011d1f928 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js @@ -114,7 +114,7 @@ export function MathAgg(props) { values={{ link: ( <EuiLink - href="https://github.com/elastic/tinymath/blob/master/docs/functions.md" + href="https://github.com/elastic/kibana/blob/master/packages/kbn-tinymath/docs/functions.md" target="_blank" > <FormattedMessage diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index 5339266a47448..415133f711061 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -26,15 +26,6 @@ export const config: PluginConfigDescriptor<VisTypeTimeseriesConfig> = { schema: configSchema, }; -export { - AbstractSearchStrategy, - ReqFacade, -} from './lib/search_strategies/strategies/abstract_search_strategy'; - -export { VisPayload } from '../common/types'; - -export { DefaultSearchCapabilities } from './lib/search_strategies/default_search_capabilities'; - export function plugin(initializerContext: PluginInitializerContext) { return new VisTypeTimeseriesPlugin(initializerContext); } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.test.ts similarity index 95% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.test.ts index d4e3064747ab0..105bfd53ce739 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.test.ts @@ -7,8 +7,8 @@ */ import { DefaultSearchCapabilities } from './default_search_capabilities'; -import { ReqFacade } from './strategies/abstract_search_strategy'; -import { VisPayload } from '../../../common/types'; +import type { ReqFacade } from '../strategies/abstract_search_strategy'; +import type { VisPayload } from '../../../../common/types'; describe('DefaultSearchCapabilities', () => { let defaultSearchCapabilities: DefaultSearchCapabilities; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.ts similarity index 89% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.ts index 1755e25138e8f..996efce4ce66e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.ts @@ -11,10 +11,10 @@ import { convertIntervalToUnit, parseInterval, getSuitableUnit, -} from '../vis_data/helpers/unit_to_seconds'; -import { RESTRICTIONS_KEYS } from '../../../common/ui_restrictions'; -import { ReqFacade } from './strategies/abstract_search_strategy'; -import { VisPayload } from '../../../common/types'; +} from '../../vis_data/helpers/unit_to_seconds'; +import { RESTRICTIONS_KEYS } from '../../../../common/ui_restrictions'; +import type { ReqFacade } from '../strategies/abstract_search_strategy'; +import type { VisPayload } from '../../../../common/types'; const getTimezoneFromRequest = (request: ReqFacade<VisPayload>) => { return request.payload.timerange.timezone; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.test.ts similarity index 92% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.test.ts index 6c30895635fe5..443b700386c15 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.test.ts @@ -1,12 +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; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ + import { Unit } from '@elastic/datemath'; import { RollupSearchCapabilities } from './rollup_search_capabilities'; -import { ReqFacade, VisPayload } from '../../../../../src/plugins/vis_type_timeseries/server'; +import type { VisPayload } from '../../../../common/types'; +import type { ReqFacade } from '../strategies/abstract_search_strategy'; describe('Rollup Search Capabilities', () => { const testTimeZone = 'time_zone'; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.ts similarity index 86% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.ts index 015a371bd2a35..787a8ff1b2051 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.ts @@ -1,16 +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; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ + import { get, has } from 'lodash'; -import { leastCommonInterval, isCalendarInterval } from './lib/interval_helper'; +import { leastCommonInterval, isCalendarInterval } from '../lib/interval_helper'; + +import { DefaultSearchCapabilities } from './default_search_capabilities'; -import { - ReqFacade, - DefaultSearchCapabilities, - VisPayload, -} from '../../../../../src/plugins/vis_type_timeseries/server'; +import type { VisPayload } from '../../../../common/types'; +import type { ReqFacade } from '../strategies/abstract_search_strategy'; export class RollupSearchCapabilities extends DefaultSearchCapabilities { rollupIndex: string; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/index.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/index.ts index 7dd7bfe780b52..2df6f002481b5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/index.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/index.ts @@ -7,3 +7,11 @@ */ export { SearchStrategyRegistry } from './search_strategy_registry'; +export { DefaultSearchCapabilities } from './capabilities/default_search_capabilities'; + +export { + AbstractSearchStrategy, + ReqFacade, + RollupSearchStrategy, + DefaultSearchStrategy, +} from './strategies'; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.test.ts similarity index 94% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.test.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.test.ts index 31baeadce6527..158c1d74964b3 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.test.ts @@ -1,8 +1,11 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ + import { isCalendarInterval, leastCommonInterval } from './interval_helper'; describe('interval_helper', () => { diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.ts similarity index 74% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.ts index 91d73cecdf401..f4ac715b5b0f2 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.ts @@ -1,7 +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; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ import dateMath from '@elastic/datemath'; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index 81bf8920c54fc..21b746656c043 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -5,14 +5,13 @@ * compliance with, at your election, the Elastic License or the Server Side * Public License, v 1. */ +import { get } from 'lodash'; +import { RequestFacade, SearchStrategyRegistry } from './search_strategy_registry'; +import { AbstractSearchStrategy, DefaultSearchStrategy } from './strategies'; +import { DefaultSearchCapabilities } from './capabilities/default_search_capabilities'; -import { SearchStrategyRegistry } from './search_strategy_registry'; -// @ts-ignore -import { AbstractSearchStrategy } from './strategies/abstract_search_strategy'; -// @ts-ignore -import { DefaultSearchStrategy } from './strategies/default_search_strategy'; -// @ts-ignore -import { DefaultSearchCapabilities } from './default_search_capabilities'; +const getPrivateField = <T>(registry: SearchStrategyRegistry, field: string) => + get(registry, field) as T; class MockSearchStrategy extends AbstractSearchStrategy { checkForViability() { @@ -28,23 +27,21 @@ describe('SearchStrategyRegister', () => { beforeAll(() => { registry = new SearchStrategyRegistry(); + registry.addStrategy(new DefaultSearchStrategy()); }); test('should init strategies register', () => { - expect( - registry.addStrategy({} as AbstractSearchStrategy)[0] instanceof DefaultSearchStrategy - ).toBe(true); + expect(getPrivateField(registry, 'strategies')).toHaveLength(1); }); test('should not add a strategy if it is not an instance of AbstractSearchStrategy', () => { const addedStrategies = registry.addStrategy({} as AbstractSearchStrategy); expect(addedStrategies.length).toEqual(1); - expect(addedStrategies[0] instanceof DefaultSearchStrategy).toBe(true); }); test('should return a DefaultSearchStrategy instance', async () => { - const req = {}; + const req = {} as RequestFacade; const indexPattern = '*'; const { searchStrategy, capabilities } = (await registry.getViableStrategy(req, indexPattern))!; @@ -62,7 +59,7 @@ describe('SearchStrategyRegister', () => { }); test('should return a MockSearchStrategy instance', async () => { - const req = {}; + const req = {} as RequestFacade; const indexPattern = '*'; const anotherSearchStrategy = new MockSearchStrategy(); registry.addStrategy(anotherSearchStrategy); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts index 9e7272f14f146..f3bf854f00ef4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts @@ -6,23 +6,15 @@ * Public License, v 1. */ -import { AbstractSearchStrategy } from './strategies/abstract_search_strategy'; -// @ts-ignore -import { DefaultSearchStrategy } from './strategies/default_search_strategy'; -// @ts-ignore import { extractIndexPatterns } from '../../../common/extract_index_patterns'; - -export type RequestFacade = any; - import { PanelSchema } from '../../../common/types'; +import { AbstractSearchStrategy, ReqFacade } from './strategies'; + +export type RequestFacade = ReqFacade<any>; export class SearchStrategyRegistry { private strategies: AbstractSearchStrategy[] = []; - constructor() { - this.addStrategy(new DefaultSearchStrategy()); - } - public addStrategy(searchStrategy: AbstractSearchStrategy) { if (searchStrategy instanceof AbstractSearchStrategy) { this.strategies.unshift(searchStrategy); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts similarity index 72% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index a4fc48ccc6266..97876ec2579f0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -7,29 +7,35 @@ */ import { from } from 'rxjs'; -import { AbstractSearchStrategy } from './abstract_search_strategy'; +import { AbstractSearchStrategy, ReqFacade } from './abstract_search_strategy'; +import type { VisPayload } from '../../../../common/types'; +import type { IFieldType } from '../../../../../data/common'; + +class FooSearchStrategy extends AbstractSearchStrategy {} describe('AbstractSearchStrategy', () => { - let abstractSearchStrategy; - let req; - let mockedFields; - let indexPattern; + let abstractSearchStrategy: AbstractSearchStrategy; + let req: ReqFacade; + let mockedFields: IFieldType[]; + let indexPattern: string; beforeEach(() => { mockedFields = []; - req = { + req = ({ payload: {}, pre: { indexPatternsFetcher: { getFieldsForWildcard: jest.fn().mockReturnValue(mockedFields), }, }, - getIndexPatternsService: jest.fn(() => ({ - find: jest.fn(() => []), - })), - }; + getIndexPatternsService: jest.fn(() => + Promise.resolve({ + find: jest.fn(() => []), + }) + ), + } as unknown) as ReqFacade<VisPayload>; - abstractSearchStrategy = new AbstractSearchStrategy(); + abstractSearchStrategy = new FooSearchStrategy(); }); test('should init an AbstractSearchStrategy instance', () => { @@ -42,7 +48,7 @@ describe('AbstractSearchStrategy', () => { const fields = await abstractSearchStrategy.getFieldsForWildcard(req, indexPattern); expect(fields).toEqual(mockedFields); - expect(req.pre.indexPatternsFetcher.getFieldsForWildcard).toHaveBeenCalledWith({ + expect(req.pre.indexPatternsFetcher!.getFieldsForWildcard).toHaveBeenCalledWith({ pattern: indexPattern, metaFields: [], fieldCapsOptions: { allow_no_indices: true }, @@ -54,7 +60,7 @@ describe('AbstractSearchStrategy', () => { const searchFn = jest.fn().mockReturnValue(from(Promise.resolve({}))); const responses = await abstractSearchStrategy.search( - { + ({ payload: { searchSession: { sessionId: '1', @@ -65,7 +71,7 @@ describe('AbstractSearchStrategy', () => { requestContext: { search: { search: searchFn }, }, - }, + } as unknown) as ReqFacade<VisPayload>, searches ); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 966daca87a208..bf7088145f347 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -8,12 +8,13 @@ import type { FakeRequest, IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server'; +import { indexPatterns } from '../../../../../data/server'; + import type { Framework } from '../../../plugin'; import type { IndexPatternsFetcher, IFieldType } from '../../../../../data/server'; import type { VisPayload } from '../../../../common/types'; import type { IndexPatternsService } from '../../../../../data/common'; -import { indexPatterns } from '../../../../../data/server'; -import { SanitizedFieldType } from '../../../../common/types'; +import type { SanitizedFieldType } from '../../../../common/types'; import type { VisTypeTimeseriesRequestHandlerContext } from '../../../types'; /** diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts index c6f7474ed86bf..00dbf17945011 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts @@ -7,8 +7,8 @@ */ import { DefaultSearchStrategy } from './default_search_strategy'; -import { ReqFacade } from './abstract_search_strategy'; -import { VisPayload } from '../../../../common/types'; +import type { ReqFacade } from './abstract_search_strategy'; +import type { VisPayload } from '../../../../common/types'; describe('DefaultSearchStrategy', () => { let defaultSearchStrategy: DefaultSearchStrategy; @@ -20,7 +20,6 @@ describe('DefaultSearchStrategy', () => { }); test('should init an DefaultSearchStrategy instance', () => { - expect(defaultSearchStrategy.name).toBe('default'); expect(defaultSearchStrategy.checkForViability).toBeDefined(); expect(defaultSearchStrategy.search).toBeDefined(); expect(defaultSearchStrategy.getFieldsForWildcard).toBeDefined(); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index 803926ad58c50..791ff4efd3936 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -7,12 +7,10 @@ */ import { AbstractSearchStrategy, ReqFacade } from './abstract_search_strategy'; -import { DefaultSearchCapabilities } from '../default_search_capabilities'; +import { DefaultSearchCapabilities } from '../capabilities/default_search_capabilities'; import { VisPayload } from '../../../../common/types'; export class DefaultSearchStrategy extends AbstractSearchStrategy { - name = 'default'; - checkForViability(req: ReqFacade<VisPayload>) { return Promise.resolve({ isViable: true, diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/index.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/index.ts new file mode 100644 index 0000000000000..953624e476dc8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export { AbstractSearchStrategy, ReqFacade } from './abstract_search_strategy'; +export { DefaultSearchStrategy } from './default_search_strategy'; +export { RollupSearchStrategy } from './rollup_search_strategy'; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts similarity index 90% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts index e3fbe2daa3756..8e5c2fdabca5d 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts @@ -1,13 +1,17 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ + import { RollupSearchStrategy } from './rollup_search_strategy'; -import type { ReqFacade, VisPayload } from '../../../../../src/plugins/vis_type_timeseries/server'; -jest.mock('../../../../../src/plugins/vis_type_timeseries/server', () => { - const actual = jest.requireActual('../../../../../src/plugins/vis_type_timeseries/server'); +import type { VisPayload } from '../../../../common/types'; +import type { ReqFacade } from './abstract_search_strategy'; + +jest.mock('./abstract_search_strategy', () => { class AbstractSearchStrategyMock { getFieldsForWildcard() { return [ @@ -23,7 +27,6 @@ jest.mock('../../../../../src/plugins/vis_type_timeseries/server', () => { } return { - ...actual, AbstractSearchStrategy: AbstractSearchStrategyMock, }; }); @@ -52,7 +55,7 @@ describe('Rollup Search Strategy', () => { test('should create instance of RollupSearchRequest', () => { const rollupSearchStrategy = new RollupSearchStrategy(); - expect(rollupSearchStrategy.name).toBe('rollup'); + expect(rollupSearchStrategy).toBeDefined(); }); describe('checkForViability', () => { diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts similarity index 81% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index 60fa51d0995db..5b5a1bd5db79e 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -1,17 +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; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ -import { - AbstractSearchStrategy, - ReqFacade, - VisPayload, -} from '../../../../../src/plugins/vis_type_timeseries/server'; -import { getCapabilitiesForRollupIndices } from '../../../../../src/plugins/data/server'; +import { ReqFacade, AbstractSearchStrategy } from './abstract_search_strategy'; +import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; +import type { VisPayload } from '../../../../common/types'; -import { RollupSearchCapabilities } from './rollup_search_capabilities'; +import { getCapabilitiesForRollupIndices } from '../../../../../data/server'; const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); @@ -19,8 +18,6 @@ const isIndexPatternValid = (indexPattern: string) => indexPattern && typeof indexPattern === 'string' && !isIndexPatternContainsWildcard(indexPattern); export class RollupSearchStrategy extends AbstractSearchStrategy { - name = 'rollup'; - async search(req: ReqFacade<VisPayload>, bodies: any[]) { return super.search(req, bodies, 'rollup'); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts index d94362e681642..d13efcfe37149 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts @@ -6,7 +6,11 @@ * Public License, v 1. */ -import { AbstractSearchStrategy, DefaultSearchCapabilities, ReqFacade } from '../../..'; +import { + AbstractSearchStrategy, + DefaultSearchCapabilities, + ReqFacade, +} from '../../search_strategies'; export const createFieldsFetcher = ( req: ReqFacade, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts index d97e948551b1a..a20df9145d987 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts @@ -8,7 +8,8 @@ import moment from 'moment'; import { getTimerange } from './get_timerange'; -import { ReqFacade, VisPayload } from '../../..'; +import type { ReqFacade } from '../../search_strategies'; +import type { VisPayload } from '../../../../common/types'; describe('getTimerange(req)', () => { test('should return a moment object for to and from', () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts index b690ad0fb0325..2797839988ded 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts @@ -7,7 +7,8 @@ */ import { utc } from 'moment'; -import { ReqFacade, VisPayload } from '../../..'; +import type { ReqFacade } from '../../search_strategies'; +import type { VisPayload } from '../../../../common/types'; export const getTimerange = (req: ReqFacade<VisPayload>) => { const { min, max } = req.payload.timerange; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index 06ff882190ce5..bcb158ebfe2bb 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { DefaultSearchCapabilities } from '../../../search_strategies/default_search_capabilities'; +import { DefaultSearchCapabilities } from '../../../search_strategies/capabilities/default_search_capabilities'; import { dateHistogram } from './date_histogram'; import { UI_SETTINGS } from '../../../../../../data/common'; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js index 9d3296e76727e..1ea197976fb4f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js @@ -12,7 +12,7 @@ import { getDefaultDecoration } from '../../helpers/get_default_decoration'; import { getSiblingAggValue } from '../../helpers/get_sibling_agg_value'; import { getSplits } from '../../helpers/get_splits'; import { mapBucket } from '../../helpers/map_bucket'; -import { evaluate } from 'tinymath'; +import { evaluate } from '@kbn/tinymath'; export function mathAgg(resp, panel, series, meta, extractFields) { return (next) => async (results) => { diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index adcd7e8bbf0d5..43b61f37ba3d3 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -23,10 +23,15 @@ import { PluginStart } from '../../data/server'; import { visDataRoutes } from './routes/vis'; // @ts-ignore import { fieldsRoutes } from './routes/fields'; -import { SearchStrategyRegistry } from './lib/search_strategies'; import { uiSettings } from './ui_settings'; import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRouter } from './types'; +import { + SearchStrategyRegistry, + DefaultSearchStrategy, + RollupSearchStrategy, +} from './lib/search_strategies'; + export interface LegacySetup { server: Server; } @@ -45,7 +50,6 @@ export interface VisTypeTimeseriesSetup { fakeRequest: FakeRequest, options: GetVisDataOptions ) => ReturnType<GetVisData>; - addSearchStrategy: SearchStrategyRegistry['addStrategy']; } export interface Framework { @@ -76,6 +80,9 @@ export class VisTypeTimeseriesPlugin implements Plugin<VisTypeTimeseriesSetup> { const searchStrategyRegistry = new SearchStrategyRegistry(); + searchStrategyRegistry.addStrategy(new DefaultSearchStrategy()); + searchStrategyRegistry.addStrategy(new RollupSearchStrategy()); + const framework: Framework = { core, plugins, @@ -97,7 +104,6 @@ export class VisTypeTimeseriesPlugin implements Plugin<VisTypeTimeseriesSetup> { ) => { return await getVisData(requestContext, { ...fakeRequest, body: options }, framework); }, - addSearchStrategy: searchStrategyRegistry.addStrategy.bind(searchStrategyRegistry), }; } diff --git a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap index 8b813ee06b1b3..c70c4406a34f2 100644 --- a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap +++ b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap @@ -1,7 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`VegaVisualizations VegaVisualization - basics should show vega blank rectangle on top of a map (vegamap) 1`] = `"<div class=\\"vgaVis__view leaflet-container leaflet-grab leaflet-touch-drag\\" style=\\"height: 100%; position: relative;\\" tabindex=\\"0\\"><div class=\\"leaflet-pane leaflet-map-pane\\" style=\\"left: 0px; top: 0px;\\"><div class=\\"leaflet-pane leaflet-tile-pane\\"></div><div class=\\"leaflet-pane leaflet-shadow-pane\\"></div><div class=\\"leaflet-pane leaflet-overlay-pane\\"><div class=\\"leaflet-vega-container\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\" style=\\"left: 0px; top: 0px; cursor: default;\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"0\\" height=\\"0\\" viewBox=\\"0 0 0 0\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(0,0)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-rect role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"rect mark container\\"><path d=\\"M0,0h0v0h0Z\\" fill=\\"#0f0\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div></div><div class=\\"leaflet-pane leaflet-marker-pane\\"></div><div class=\\"leaflet-pane leaflet-tooltip-pane\\"></div><div class=\\"leaflet-pane leaflet-popup-pane\\"></div></div><div class=\\"leaflet-control-container\\"><div class=\\"leaflet-top leaflet-left\\"><div class=\\"leaflet-control-zoom leaflet-bar leaflet-control\\"><a class=\\"leaflet-control-zoom-in\\" href=\\"#\\" title=\\"Zoom in\\" role=\\"button\\" aria-label=\\"Zoom in\\">+</a><a class=\\"leaflet-control-zoom-out\\" href=\\"#\\" title=\\"Zoom out\\" role=\\"button\\" aria-label=\\"Zoom out\\">−</a></div></div><div class=\\"leaflet-top leaflet-right\\"></div><div class=\\"leaflet-bottom leaflet-left\\"></div><div class=\\"leaflet-bottom leaflet-right\\"><div class=\\"leaflet-control-attribution leaflet-control\\"></div></div></div></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`; - exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"<div class=\\"vgaVis__view\\" style=\\"height: 100%; cursor: default;\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"512\\" height=\\"512\\" viewBox=\\"0 0 512 512\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(0,0)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h512v512h-512Z\\"></path><g><g class=\\"mark-group role-scope\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-area role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"area mark container\\"><path d=\\"M0,512C18.962962962962962,512,37.925925925925924,512,56.888888888888886,512C75.85185185185185,512,94.81481481481481,512,113.77777777777777,512C132.74074074074073,512,151.7037037037037,512,170.66666666666666,512C189.62962962962962,512,208.59259259259258,512,227.55555555555554,512C246.5185185185185,512,265.48148148148147,512,284.44444444444446,512C303.4074074074074,512,322.3703703703704,512,341.3333333333333,512C360.29629629629625,512,379.25925925925924,512,398.2222222222222,512C417.18518518518516,512,436.1481481481481,512,455.1111111111111,512C474.0740740740741,512,493.037037037037,512,512,512L512,355.2C493.037037037037,324.79999999999995,474.0740740740741,294.4,455.1111111111111,294.4C436.1481481481481,294.4,417.18518518518516,457.6,398.2222222222222,457.6C379.25925925925924,457.6,360.29629629629625,233.60000000000002,341.3333333333333,233.60000000000002C322.3703703703704,233.60000000000002,303.4074074074074,435.2,284.44444444444446,435.2C265.48148148148147,435.2,246.5185185185185,345.6,227.55555555555554,345.6C208.59259259259258,345.6,189.62962962962962,451.2,170.66666666666666,451.2C151.7037037037037,451.2,132.74074074074073,252.8,113.77777777777777,252.8C94.81481481481481,252.8,75.85185185185185,346.1333333333333,56.888888888888886,374.4C37.925925925925924,402.66666666666663,18.962962962962962,412.5333333333333,0,422.4Z\\" fill=\\"#54B399\\" fill-opacity=\\"1\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-area role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"area mark container\\"><path d=\\"M0,422.4C18.962962962962962,412.5333333333333,37.925925925925924,402.66666666666663,56.888888888888886,374.4C75.85185185185185,346.1333333333333,94.81481481481481,252.8,113.77777777777777,252.8C132.74074074074073,252.8,151.7037037037037,451.2,170.66666666666666,451.2C189.62962962962962,451.2,208.59259259259258,345.6,227.55555555555554,345.6C246.5185185185185,345.6,265.48148148148147,435.2,284.44444444444446,435.2C303.4074074074074,435.2,322.3703703703704,233.60000000000002,341.3333333333333,233.60000000000002C360.29629629629625,233.60000000000002,379.25925925925924,457.6,398.2222222222222,457.6C417.18518518518516,457.6,436.1481481481481,294.4,455.1111111111111,294.4C474.0740740740741,294.4,493.037037037037,324.79999999999995,512,355.2L512,307.2C493.037037037037,275.2,474.0740740740741,243.2,455.1111111111111,243.2C436.1481481481481,243.2,417.18518518518516,371.2,398.2222222222222,371.2C379.25925925925924,371.2,360.29629629629625,22.399999999999977,341.3333333333333,22.399999999999977C322.3703703703704,22.399999999999977,303.4074074074074,278.4,284.44444444444446,278.4C265.48148148148147,278.4,246.5185185185185,204.8,227.55555555555554,192C208.59259259259258,179.20000000000002,189.62962962962962,185.6,170.66666666666666,172.8C151.7037037037037,160.00000000000003,132.74074074074073,83.19999999999999,113.77777777777777,83.19999999999999C94.81481481481481,83.19999999999999,75.85185185185185,83.19999999999999,56.888888888888886,83.19999999999999C37.925925925925924,83.19999999999999,18.962962962962962,164.79999999999998,0,246.39999999999998Z\\" fill=\\"#6092C0\\" fill-opacity=\\"1\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`; exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"<ul class=\\"vgaVis__messages\\"><li class=\\"vgaVis__message vgaVis__message--warn\\"><pre class=\\"vgaVis__messageCode\\">\\"width\\" and \\"height\\" params are ignored because \\"autosize\\" is enabled. Set \\"autosize\\": \\"none\\" to disable</pre></li></ul><div class=\\"vgaVis__view\\" style=\\"height: 100%; cursor: default;\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"0\\" height=\\"0\\" viewBox=\\"0 0 0 0\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(7,7)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0.5,0.5h0v0h0Z\\" fill=\\"transparent\\" stroke=\\"#ddd\\"></path><g><g class=\\"mark-line role-mark marks\\" role=\\"graphics-object\\" aria-roledescription=\\"line mark container\\"><path aria-label=\\"key: Dec 11, 2017; doc_count: 0\\" role=\\"graphics-symbol\\" aria-roledescription=\\"line mark\\" d=\\"M0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0\\" stroke=\\"#54B399\\" stroke-width=\\"2\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`; diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 376ef84de23c3..c18a7d4dfcfbd 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -17,17 +17,18 @@ import { setData, setInjectedVars, setUISettings, - setMapsLegacyConfig, setInjectedMetadata, + setMapServiceSettings, } from './services'; import { createVegaFn } from './vega_fn'; import { createVegaTypeDefinition } from './vega_type'; -import { IServiceSettings } from '../../maps_legacy/public'; +import { IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; import { ConfigSchema } from '../config'; import { getVegaInspectorView } from './vega_inspector'; import { getVegaVisRenderer } from './vega_vis_renderer'; +import { MapServiceSettings } from './vega_view/vega_map_view/map_service_settings'; /** @internal */ export interface VegaVisualizationDependencies { @@ -44,7 +45,7 @@ export interface VegaPluginSetupDependencies { visualizations: VisualizationsSetup; inspector: InspectorSetup; data: DataPublicPluginSetup; - mapsLegacy: any; + mapsLegacy: MapsLegacyPluginSetup; } /** @internal */ @@ -68,8 +69,12 @@ export class VegaPlugin implements Plugin<Promise<void>, void> { enableExternalUrls: this.initializerContext.config.get().enableExternalUrls, emsTileLayerId: core.injectedMetadata.getInjectedVar('emsTileLayerId', true), }); + setUISettings(core.uiSettings); - setMapsLegacyConfig(mapsLegacy.config); + + setMapServiceSettings( + new MapServiceSettings(mapsLegacy.config, this.initializerContext.env.packageInfo.version) + ); const visualizationDependencies: Readonly<VegaVisualizationDependencies> = { core, diff --git a/src/plugins/vis_type_vega/public/services.ts b/src/plugins/vis_type_vega/public/services.ts index 157e355f93434..3e5d890c39ff4 100644 --- a/src/plugins/vis_type_vega/public/services.ts +++ b/src/plugins/vis_type_vega/public/services.ts @@ -10,7 +10,7 @@ import { CoreStart, NotificationsStart, IUiSettingsClient } from 'src/core/publi import { DataPublicPluginStart } from '../../data/public'; import { createGetterSetter } from '../../kibana_utils/public'; -import { MapsLegacyConfig } from '../../maps_legacy/config'; +import { MapServiceSettings } from './vega_view/vega_map_view/map_service_settings'; export const [getData, setData] = createGetterSetter<DataPublicPluginStart>('Data'); @@ -24,13 +24,14 @@ export const [getInjectedMetadata, setInjectedMetadata] = createGetterSetter< CoreStart['injectedMetadata'] >('InjectedMetadata'); +export const [ + getMapServiceSettings, + setMapServiceSettings, +] = createGetterSetter<MapServiceSettings>('MapServiceSettings'); + export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ enableExternalUrls: boolean; emsTileLayerId: unknown; }>('InjectedVars'); -export const [getMapsLegacyConfig, setMapsLegacyConfig] = createGetterSetter<MapsLegacyConfig>( - 'MapsLegacyConfig' -); - export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; diff --git a/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json b/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json index 9100de38ae387..a7e3b9dc7e024 100644 --- a/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json +++ b/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json @@ -1,7 +1,7 @@ { "$schema": "https://vega.github.io/schema/vega/v5.json", "config": { - "kibana": { "renderer": "svg", "type": "map", "mapStyle": false} + "kibana": { "type": "map", "mapStyle": "default", "latitude": 25, "longitude": -70, "zoom": 3} }, "width": 512, "height": 512, diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts index d63288745986c..15132483b3659 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts @@ -18,12 +18,21 @@ interface VegaViewParams { serviceSettings: IServiceSettings; filterManager: DataPublicPluginStart['query']['filterManager']; timefilter: DataPublicPluginStart['query']['timefilter']['timefilter']; - // findIndex: (index: string) => Promise<...>; } export class VegaBaseView { constructor(params: VegaViewParams); init(): Promise<void>; onError(error: any): void; + onWarn(error: any): void; + setView(map: any): void; + setDebugValues(view: any, spec: any, vlspec: any): void; + _addDestroyHandler(handler: Function): void; + destroy(): Promise<void>; + + _$container: any; + _parser: any; + _vegaViewConfig: any; + _serviceSettings: any; } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 6971adaa55ec3..7c3915955419f 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -160,8 +160,6 @@ export class VegaBaseView { createViewConfig() { const config = { - // eslint-disable-next-line import/namespace - logLevel: vega.Warn, // note: eslint has a false positive here renderer: this._parser.renderer, }; @@ -189,6 +187,13 @@ export class VegaBaseView { }; config.loader = loader; + const logger = vega.logger(vega.Warn); + + logger.warn = this.onWarn.bind(this); + logger.error = this.onError.bind(this); + + config.logger = logger; + return config; } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js b/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js deleted file mode 100644 index bf91b50ed9cf6..0000000000000 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { KibanaMapLayer } from '../../../maps_legacy/public'; - -export class VegaMapLayer extends KibanaMapLayer { - constructor(spec, options, leaflet) { - super(); - - // Used by super.getAttributions() - this._attribution = options.attribution; - delete options.attribution; - this._leafletLayer = leaflet.vega(spec, options); - } - - getVegaView() { - return this._leafletLayer._view; - } - - getVegaSpec() { - return this._leafletLayer._spec; - } -} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js deleted file mode 100644 index 693045edeb7d0..0000000000000 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { vega } from '../lib/vega'; -import { VegaBaseView } from './vega_base_view'; -import { VegaMapLayer } from './vega_map_layer'; -import { getMapsLegacyConfig, getUISettings } from '../services'; -import { lazyLoadMapsLegacyModules, TMS_IN_YML_ID } from '../../../maps_legacy/public'; - -const isUserConfiguredTmsLayer = ({ tilemap }) => Boolean(tilemap.url); - -export class VegaMapView extends VegaBaseView { - constructor(opts) { - super(opts); - } - - async getMapStyleOptions() { - const isDarkMode = getUISettings().get('theme:darkMode'); - const mapsLegacyConfig = getMapsLegacyConfig(); - const tmsServices = await this._serviceSettings.getTMSServices(); - const mapConfig = this._parser.mapConfig; - - let mapStyle; - - if (mapConfig.mapStyle !== 'default') { - mapStyle = mapConfig.mapStyle; - } else { - if (isUserConfiguredTmsLayer(mapsLegacyConfig)) { - mapStyle = TMS_IN_YML_ID; - } else { - mapStyle = mapsLegacyConfig.emsTileLayerId.bright; - } - } - - const mapOptions = tmsServices.find((s) => s.id === mapStyle); - - if (!mapOptions) { - this.onWarn( - i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', { - defaultMessage: '{mapStyleParam} was not found', - values: { mapStyleParam: `"mapStyle":${mapStyle}` }, - }) - ); - return null; - } - - return { - ...mapOptions, - ...(await this._serviceSettings.getAttributesForTMSLayer(mapOptions, true, isDarkMode)), - }; - } - - async _initViewCustomizations() { - const mapConfig = this._parser.mapConfig; - let baseMapOpts; - let limitMinZ = 0; - let limitMaxZ = 25; - - // In some cases, Vega may be initialized twice, e.g. after awaiting... - if (!this._$container) return; - - if (mapConfig.mapStyle !== false) { - baseMapOpts = await this.getMapStyleOptions(); - - if (baseMapOpts) { - limitMinZ = baseMapOpts.minZoom; - limitMaxZ = baseMapOpts.maxZoom; - } - } - - const validate = (name, value, dflt, min, max) => { - if (value === undefined) { - value = dflt; - } else if (value < min) { - this.onWarn( - i18n.translate('visTypeVega.mapView.resettingPropertyToMinValueWarningMessage', { - defaultMessage: 'Resetting {name} to {min}', - values: { name: `"${name}"`, min }, - }) - ); - value = min; - } else if (value > max) { - this.onWarn( - i18n.translate('visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage', { - defaultMessage: 'Resetting {name} to {max}', - values: { name: `"${name}"`, max }, - }) - ); - value = max; - } - return value; - }; - - let minZoom = validate('minZoom', mapConfig.minZoom, limitMinZ, limitMinZ, limitMaxZ); - let maxZoom = validate('maxZoom', mapConfig.maxZoom, limitMaxZ, limitMinZ, limitMaxZ); - if (minZoom > maxZoom) { - this.onWarn( - i18n.translate('visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage', { - defaultMessage: '{minZoomPropertyName} and {maxZoomPropertyName} have been swapped', - values: { - minZoomPropertyName: '"minZoom"', - maxZoomPropertyName: '"maxZoom"', - }, - }) - ); - [minZoom, maxZoom] = [maxZoom, minZoom]; - } - const zoom = validate('zoom', mapConfig.zoom, 2, minZoom, maxZoom); - - // let maxBounds = null; - // if (mapConfig.maxBounds) { - // const b = mapConfig.maxBounds; - // eslint-disable-next-line no-undef - // maxBounds = L.latLngBounds(L.latLng(b[1], b[0]), L.latLng(b[3], b[2])); - // } - - const modules = await lazyLoadMapsLegacyModules(); - - this._kibanaMap = new modules.KibanaMap(this._$container.get(0), { - zoom, - minZoom, - maxZoom, - center: [mapConfig.latitude, mapConfig.longitude], - zoomControl: mapConfig.zoomControl, - scrollWheelZoom: mapConfig.scrollWheelZoom, - }); - - if (baseMapOpts) { - this._kibanaMap.setBaseLayer({ - baseLayerType: 'tms', - options: baseMapOpts, - }); - } - - const vegaMapLayer = new VegaMapLayer( - this._parser.spec, - { - vega, - bindingsContainer: this._$controls.get(0), - delayRepaint: mapConfig.delayRepaint, - viewConfig: this._vegaViewConfig, - onWarning: this.onWarn.bind(this), - onError: this.onError.bind(this), - }, - modules.L - ); - - this._kibanaMap.addLayer(vegaMapLayer); - - this._addDestroyHandler(() => { - this._kibanaMap.removeLayer(vegaMapLayer); - if (baseMapOpts) { - this._kibanaMap.setBaseLayer(null); - } - this._kibanaMap.destroy(); - }); - - const vegaView = vegaMapLayer.getVegaView(); - await this.setView(vegaView); - this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec); - } -} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts new file mode 100644 index 0000000000000..ced1dc1bdc217 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { TMS_IN_YML_ID } from '../../../../maps_legacy/public'; + +export const vegaLayerId = 'vega'; +export const userConfiguredLayerId = TMS_IN_YML_ID; +export const defaultMapConfig = { + maxZoom: 20, + minZoom: 0, + tileSize: 256, +}; + +export const defaultMabBoxStyle = { + /** + * according to the MapBox documentation that value should be '8' + * @see (https://docs.mapbox.com/mapbox-gl-js/style-spec/root/#version) + */ + version: 8, + sources: {}, + layers: [], +}; + +export const defaultProjection = { + name: 'projection', + type: 'mercator', + scale: { signal: '512*pow(2,zoom)/2/PI' }, + rotate: [{ signal: '-longitude' }, 0, 0], + center: [0, { signal: 'latitude' }], + translate: [{ signal: 'width/2' }, { signal: 'height/2' }], + fit: false, +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts new file mode 100644 index 0000000000000..c0ca7f04810d0 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export { initTmsRasterLayer } from './tms_raster_layer'; +export { initVegaLayer } from './vega_layer'; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts new file mode 100644 index 0000000000000..ea74a48dc9a74 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { initTmsRasterLayer } from './tms_raster_layer'; + +type InitTmsRasterLayerParams = Parameters<typeof initTmsRasterLayer>[0]; + +type IdType = InitTmsRasterLayerParams['id']; +type MapType = InitTmsRasterLayerParams['map']; +type ContextType = InitTmsRasterLayerParams['context']; + +describe('vega_map_view/tms_raster_layer', () => { + let id: IdType; + let map: MapType; + let context: ContextType; + + beforeEach(() => { + id = 'foo_tms_layer_id'; + map = ({ + addSource: jest.fn(), + addLayer: jest.fn(), + } as unknown) as MapType; + context = { + tiles: ['http://some.tile.com/map/{z}/{x}/{y}.jpg'], + maxZoom: 10, + minZoom: 2, + tileSize: 512, + }; + }); + + test('should register a new layer', () => { + initTmsRasterLayer({ id, map, context }); + + expect(map.addLayer).toHaveBeenCalledWith({ + id: 'foo_tms_layer_id', + maxzoom: 10, + minzoom: 2, + source: 'foo_tms_layer_id', + type: 'raster', + }); + + expect(map.addSource).toHaveBeenCalledWith('foo_tms_layer_id', { + scheme: 'xyz', + tileSize: 512, + tiles: ['http://some.tile.com/map/{z}/{x}/{y}.jpg'], + type: 'raster', + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts new file mode 100644 index 0000000000000..03fdce9bd8d93 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { LayerParameters } from './types'; + +interface TMSRasterLayerContext { + tiles: string[]; + maxZoom: number; + minZoom: number; + tileSize: number; +} + +export const initTmsRasterLayer = ({ + id, + map, + context: { tiles, maxZoom, minZoom, tileSize }, +}: LayerParameters<TMSRasterLayerContext>) => { + map.addSource(id, { + type: 'raster', + tiles, + tileSize, + scheme: 'xyz', + }); + + map.addLayer({ + id, + type: 'raster', + source: id, + maxzoom: maxZoom, + minzoom: minZoom, + }); +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts new file mode 100644 index 0000000000000..1b7ac79312329 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { Map } from 'mapbox-gl'; + +export interface LayerParameters<TContext extends Record<string, any> = {}> { + id: string; + map: Map; + context: TContext; +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts new file mode 100644 index 0000000000000..97d231c5f7a6f --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { initVegaLayer } from './vega_layer'; + +type InitVegaLayerParams = Parameters<typeof initVegaLayer>[0]; + +type IdType = InitVegaLayerParams['id']; +type MapType = InitVegaLayerParams['map']; +type ContextType = InitVegaLayerParams['context']; + +describe('vega_map_view/tms_raster_layer', () => { + let id: IdType; + let map: MapType; + let context: ContextType; + + beforeEach(() => { + id = 'foo_vega_layer_id'; + map = ({ + getCanvasContainer: () => document.createElement('div'), + getCanvas: () => ({ + style: { + width: 100, + height: 100, + }, + }), + addLayer: jest.fn(), + } as unknown) as MapType; + context = { + vegaView: { + initialize: jest.fn(), + }, + updateVegaView: jest.fn(), + }; + }); + + test('should register a new custom layer', () => { + initVegaLayer({ id, map, context }); + + const calledWith = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0]; + expect(calledWith).toHaveProperty('id', 'foo_vega_layer_id'); + expect(calledWith).toHaveProperty('type', 'custom'); + }); + + test('should initialize vega container on "onAdd" hook', () => { + initVegaLayer({ id, map, context }); + const { onAdd } = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0]; + + onAdd(map); + expect(context.vegaView.initialize).toHaveBeenCalled(); + }); + + test('should update vega view on "render" hook', () => { + initVegaLayer({ id, map, context }); + const { render } = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0]; + + expect(context.updateVegaView).not.toHaveBeenCalled(); + render(); + expect(context.updateVegaView).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts new file mode 100644 index 0000000000000..a9b650fe4c58d --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { Map, CustomLayerInterface } from 'mapbox-gl'; +import type { LayerParameters } from './types'; + +// @ts-ignore +import { vega } from '../../lib/vega'; + +export interface VegaLayerContext { + vegaView: vega.View; + updateVegaView: (map: Map, view: vega.View) => void; +} + +export function initVegaLayer({ + id, + map: mapInstance, + context: { vegaView, updateVegaView }, +}: LayerParameters<VegaLayerContext>) { + const vegaLayer: CustomLayerInterface = { + id, + type: 'custom', + onAdd(map: Map) { + const mapContainer = map.getCanvasContainer(); + const mapCanvas = map.getCanvas(); + const vegaContainer = document.createElement('div'); + + vegaContainer.style.position = 'absolute'; + vegaContainer.style.top = '0px'; + vegaContainer.style.width = mapCanvas.style.width; + vegaContainer.style.height = mapCanvas.style.height; + + mapContainer.appendChild(vegaContainer); + vegaView.initialize(vegaContainer); + }, + render() { + updateVegaView(mapInstance, vegaView); + }, + }; + + mapInstance.addLayer(vegaLayer); +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts new file mode 100644 index 0000000000000..0a477e5f62a7a --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { get } from 'lodash'; +import { uiSettingsServiceMock } from 'src/core/public/mocks'; + +import { MapServiceSettings, getAttributionsForTmsService } from './map_service_settings'; +import { MapsLegacyConfig } from '../../../../maps_legacy/config'; +import { EMSClient, TMSService } from '@elastic/ems-client'; +import { setUISettings } from '../../services'; + +const getPrivateField = <T>(mapServiceSettings: MapServiceSettings, privateField: string) => + get(mapServiceSettings, privateField) as T; + +describe('vega_map_view/map_service_settings', () => { + describe('MapServiceSettings', () => { + const appVersion = '99'; + let config: MapsLegacyConfig; + let getUiSettingsMockedValue: any; + + beforeEach(() => { + config = { + emsTileLayerId: { + desaturated: 'road_map_desaturated', + dark: 'dark_map', + }, + } as MapsLegacyConfig; + setUISettings({ + ...uiSettingsServiceMock.createSetupContract(), + get: () => getUiSettingsMockedValue, + }); + }); + + test('should be able to create instance of MapServiceSettings', () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + + expect(mapServiceSettings instanceof MapServiceSettings).toBeTruthy(); + expect(mapServiceSettings.hasUserConfiguredTmsLayer()).toBeFalsy(); + expect(mapServiceSettings.defaultTmsLayer()).toBe('road_map_desaturated'); + }); + + test('should be able to set user configured base layer through config', () => { + const mapServiceSettings = new MapServiceSettings( + { + ...config, + tilemap: { + url: 'http://some.tile.com/map/{z}/{x}/{y}.jpg', + options: { + attribution: 'attribution', + minZoom: 0, + maxZoom: 4, + }, + }, + }, + appVersion + ); + + expect(mapServiceSettings.defaultTmsLayer()).toBe('TMS in config/kibana.yml'); + expect(mapServiceSettings.hasUserConfiguredTmsLayer()).toBeTruthy(); + }); + + test('should load ems client only on executing getTmsService method', async () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + + expect(getPrivateField<EMSClient>(mapServiceSettings, 'emsClient')).toBeUndefined(); + + await mapServiceSettings.getTmsService('road_map'); + + expect( + getPrivateField<EMSClient>(mapServiceSettings, 'emsClient') instanceof EMSClient + ).toBeTruthy(); + }); + + test('should set isDarkMode value on executing getTmsService method', async () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + getUiSettingsMockedValue = true; + + expect(getPrivateField<EMSClient>(mapServiceSettings, 'isDarkMode')).toBeFalsy(); + + await mapServiceSettings.getTmsService('road_map'); + + expect(getPrivateField<EMSClient>(mapServiceSettings, 'isDarkMode')).toBeTruthy(); + }); + + test('getAttributionsForTmsService method should return attributes in a correct form', () => { + const tmsService = ({ + getAttributions: jest.fn(() => [ + { url: 'https://fist_attr.com', label: 'fist_attr' }, + { url: 'https://second_attr.com', label: 'second_attr' }, + ]), + } as unknown) as TMSService; + + expect(getAttributionsForTmsService(tmsService)).toMatchInlineSnapshot(` + Array [ + "<a rel=\\"noreferrer noopener\\" href=\\"https://fist_attr.com\\">fist_attr</a>", + "<a rel=\\"noreferrer noopener\\" href=\\"https://second_attr.com\\">second_attr</a>", + ] + `); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts new file mode 100644 index 0000000000000..92dfc873e2715 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import type { EMSClient, TMSService } from '@elastic/ems-client'; +import { getUISettings } from '../../services'; +import { userConfiguredLayerId } from './constants'; +import type { MapsLegacyConfig } from '../../../../maps_legacy/config'; + +type EmsClientConfig = ConstructorParameters<typeof EMSClient>[0]; + +const hasUserConfiguredTmsService = (config: MapsLegacyConfig) => Boolean(config.tilemap?.url); + +const initEmsClientAsync = async (config: Partial<EmsClientConfig>) => { + /** + * Build optimization: '@elastic/ems-client' should be loaded from a separate chunk + */ + const emsClientModule = await import('@elastic/ems-client'); + + return new emsClientModule.EMSClient({ + language: i18n.getLocale(), + appName: 'kibana', + // Wrap to avoid errors passing window fetch + fetchFunction(input: RequestInfo, init?: RequestInit) { + return fetch(input, init); + }, + ...config, + } as EmsClientConfig); +}; + +export class MapServiceSettings { + private emsClient?: EMSClient; + private isDarkMode: boolean = false; + + constructor(public config: MapsLegacyConfig, private appVersion: string) {} + + private isInitialized() { + return Boolean(this.emsClient); + } + + public hasUserConfiguredTmsLayer() { + return hasUserConfiguredTmsService(this.config); + } + + public defaultTmsLayer() { + const { dark, desaturated } = this.config.emsTileLayerId; + + if (this.hasUserConfiguredTmsLayer()) { + return userConfiguredLayerId; + } + + return this.isDarkMode ? dark : desaturated; + } + + private async initialize() { + this.isDarkMode = getUISettings().get('theme:darkMode'); + + this.emsClient = await initEmsClientAsync({ + appVersion: this.appVersion, + fileApiUrl: this.config.emsFileApiUrl, + tileApiUrl: this.config.emsTileApiUrl, + landingPageUrl: this.config.emsLandingPageUrl, + }); + } + + public async getTmsService(tmsTileLayer: string) { + if (!this.isInitialized()) { + await this.initialize(); + } + return this.emsClient?.findTMSServiceById(tmsTileLayer); + } +} + +export function getAttributionsForTmsService(tmsService: TMSService) { + return tmsService.getAttributions().map(({ label, url }) => { + const anchorTag = document.createElement('a'); + + anchorTag.textContent = label; + anchorTag.setAttribute('rel', 'noreferrer noopener'); + anchorTag.setAttribute('href', url); + + return anchorTag.outerHTML; + }); +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts new file mode 100644 index 0000000000000..921e604354b2e --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export { validateZoomSettings } from './validation_helper'; +export { injectMapPropsIntoSpec } from './vsi_helper'; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts new file mode 100644 index 0000000000000..c2eb37980b741 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.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 + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { validateZoomSettings } from './validation_helper'; + +type ValidateZoomSettingsParams = Parameters<typeof validateZoomSettings>; + +type MapConfigType = ValidateZoomSettingsParams[0]; +type LimitsType = ValidateZoomSettingsParams[1]; +type OnWarnType = ValidateZoomSettingsParams[2]; + +describe('vega_map_view/validation_helper', () => { + describe('validateZoomSettings', () => { + let mapConfig: MapConfigType; + let limits: LimitsType; + let onWarn: OnWarnType; + + beforeEach(() => { + onWarn = jest.fn(); + mapConfig = { + maxZoom: 10, + minZoom: 5, + zoom: 5, + }; + limits = { + maxZoom: 15, + minZoom: 2, + }; + }); + + test('should return validated interval', () => { + expect(validateZoomSettings(mapConfig, limits, onWarn)).toEqual({ + maxZoom: 10, + minZoom: 5, + zoom: 5, + }); + }); + + test('should return default interval in case if mapConfig not provided', () => { + mapConfig = {} as MapConfigType; + expect(validateZoomSettings(mapConfig, limits, onWarn)).toEqual({ + maxZoom: 15, + minZoom: 2, + zoom: 3, + }); + }); + + test('should reset MaxZoom if the passed value is greater than the limit', () => { + mapConfig = { + ...mapConfig, + maxZoom: 20, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "maxZoom" to 15'); + expect(result.maxZoom).toEqual(15); + }); + + test('should reset MinZoom if the passed value is greater than the limit', () => { + mapConfig = { + ...mapConfig, + minZoom: 0, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "minZoom" to 2'); + expect(result.minZoom).toEqual(2); + }); + + test('should reset Zoom if the passed value is greater than the max limit', () => { + mapConfig = { + ...mapConfig, + zoom: 45, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "zoom" to 10'); + expect(result.zoom).toEqual(10); + }); + + test('should reset Zoom if the passed value is greater than the min limit', () => { + mapConfig = { + ...mapConfig, + zoom: 0, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "zoom" to 5'); + expect(result.zoom).toEqual(5); + }); + + test('should swap min <--> max values', () => { + mapConfig = { + maxZoom: 10, + minZoom: 15, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('"minZoom" and "maxZoom" have been swapped'); + expect(result).toEqual({ maxZoom: 15, minZoom: 10, zoom: 10 }); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts new file mode 100644 index 0000000000000..5e6f45790ae2d --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +function validate( + name: string, + value: number, + defaultValue: number, + min: number, + max: number, + onWarn: (message: string) => void +) { + if (value === undefined) { + value = defaultValue; + } else if (value < min) { + onWarn( + i18n.translate('visTypeVega.mapView.resettingPropertyToMinValueWarningMessage', { + defaultMessage: 'Resetting {name} to {min}', + values: { name: `"${name}"`, min }, + }) + ); + value = min; + } else if (value > max) { + onWarn( + i18n.translate('visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage', { + defaultMessage: 'Resetting {name} to {max}', + values: { name: `"${name}"`, max }, + }) + ); + value = max; + } + return value; +} + +export function validateZoomSettings( + mapConfig: { + maxZoom: number; + minZoom: number; + zoom?: number; + }, + limits: { + maxZoom: number; + minZoom: number; + }, + onWarn: (message: any) => void +) { + const DEFAULT_ZOOM = 3; + + let { maxZoom, minZoom, zoom = DEFAULT_ZOOM } = mapConfig; + + minZoom = validate('minZoom', minZoom, limits.minZoom, limits.minZoom, limits.maxZoom, onWarn); + maxZoom = validate('maxZoom', maxZoom, limits.maxZoom, limits.minZoom, limits.maxZoom, onWarn); + + if (minZoom > maxZoom) { + onWarn( + i18n.translate('visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage', { + defaultMessage: '{minZoomPropertyName} and {maxZoomPropertyName} have been swapped', + values: { + minZoomPropertyName: '"minZoom"', + maxZoomPropertyName: '"maxZoom"', + }, + }) + ); + [minZoom, maxZoom] = [maxZoom, minZoom]; + } + + zoom = validate('zoom', zoom, DEFAULT_ZOOM, minZoom, maxZoom, onWarn); + + return { + zoom, + minZoom, + maxZoom, + }; +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts new file mode 100644 index 0000000000000..e671b9059f358 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { injectMapPropsIntoSpec } from './vsi_helper'; +import { VegaSpec } from '../../../data_model/types'; + +describe('vega_map_view/vsi_helper', () => { + describe('injectMapPropsIntoSpec', () => { + test('should inject map properties into vega spec', () => { + const spec = ({ + $schema: 'https://vega.github.io/schema/vega/v5.json', + config: { + kibana: { type: 'map', latitude: 25, longitude: -70, zoom: 3 }, + }, + } as unknown) as VegaSpec; + + expect(injectMapPropsIntoSpec(spec)).toMatchInlineSnapshot(` + Object { + "$schema": "https://vega.github.io/schema/vega/v5.json", + "autosize": "none", + "config": Object { + "kibana": Object { + "latitude": 25, + "longitude": -70, + "type": "map", + "zoom": 3, + }, + }, + "projections": Array [ + Object { + "center": Array [ + 0, + Object { + "signal": "latitude", + }, + ], + "fit": false, + "name": "projection", + "rotate": Array [ + Object { + "signal": "-longitude", + }, + 0, + 0, + ], + "scale": Object { + "signal": "512*pow(2,zoom)/2/PI", + }, + "translate": Array [ + Object { + "signal": "width/2", + }, + Object { + "signal": "height/2", + }, + ], + "type": "mercator", + }, + ], + "signals": Array [ + Object { + "name": "zoom", + }, + Object { + "name": "latitude", + }, + Object { + "name": "longitude", + }, + ], + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts new file mode 100644 index 0000000000000..0022f68637659 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +// @ts-expect-error +// eslint-disable-next-line import/no-extraneous-dependencies +import Vsi from 'vega-spec-injector'; + +import { VegaSpec } from '../../../data_model/types'; +import { defaultProjection } from '../constants'; + +export const injectMapPropsIntoSpec = (spec: VegaSpec) => { + const vsi = new Vsi(); + + vsi.overrideField(spec, 'autosize', 'none'); + vsi.addToList(spec, 'signals', ['zoom', 'latitude', 'longitude']); + vsi.addToList(spec, 'projections', [defaultProjection]); + + return spec; +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss new file mode 100644 index 0000000000000..33e63e7ef317c --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss @@ -0,0 +1,7 @@ +@import '~mapbox-gl/dist/mapbox-gl.css'; + +.vgaVis { + .mapboxgl-canvas-container { + cursor: auto; + } +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts new file mode 100644 index 0000000000000..fd176e5d20a2f --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import 'jest-canvas-mock'; + +import type { TMSService } from '@elastic/ems-client'; +import { VegaMapView } from './view'; +import { VegaViewParams } from '../vega_base_view'; +import { VegaParser } from '../../data_model/vega_parser'; +import { TimeCache } from '../../data_model/time_cache'; +import { SearchAPI } from '../../data_model/search_api'; +import vegaMap from '../../test_utils/vega_map_test.json'; +import { coreMock } from '../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { IServiceSettings } from '../../../../maps_legacy/public'; +import type { MapsLegacyConfig } from '../../../../maps_legacy/config'; +import { MapServiceSettings } from './map_service_settings'; +import { userConfiguredLayerId } from './constants'; +import { + setInjectedVars, + setData, + setNotifications, + setMapServiceSettings, + setUISettings, +} from '../../services'; + +jest.mock('../../lib/vega', () => ({ + vega: jest.requireActual('vega'), + vegaLite: jest.requireActual('vega-lite'), +})); + +jest.mock('mapbox-gl', () => ({ + Map: jest.fn().mockImplementation(() => ({ + getLayer: () => '', + removeLayer: jest.fn(), + once: (eventName: string, handler: Function) => handler(), + remove: () => jest.fn(), + getCanvas: () => ({ clientWidth: 512, clientHeight: 512 }), + getCenter: () => ({ lat: 20, lng: 20 }), + getZoom: () => 3, + addControl: jest.fn(), + addLayer: jest.fn(), + })), + MapboxOptions: jest.fn(), + NavigationControl: jest.fn(), +})); + +jest.mock('./layers', () => ({ + initVegaLayer: jest.fn(), + initTmsRasterLayer: jest.fn(), +})); + +import { initVegaLayer, initTmsRasterLayer } from './layers'; +import { Map, NavigationControl } from 'mapbox-gl'; + +describe('vega_map_view/view', () => { + describe('VegaMapView', () => { + const coreStart = coreMock.createStart(); + const dataPluginStart = dataPluginMock.createStartContract(); + const mockGetServiceSettings = async () => { + return {} as IServiceSettings; + }; + let vegaParser: VegaParser; + + setInjectedVars({ + emsTileLayerId: {}, + enableExternalUrls: true, + }); + setData(dataPluginStart); + setNotifications(coreStart.notifications); + setUISettings(coreStart.uiSettings); + + const getTmsService = jest.fn().mockReturnValue(({ + getVectorStyleSheet: () => ({ + version: 8, + sources: {}, + layers: [], + }), + getMaxZoom: async () => 20, + getMinZoom: async () => 0, + getAttributions: () => [{ url: 'tms_attributions' }], + } as unknown) as TMSService); + const config = { + tilemap: { + url: 'test', + options: { + attribution: 'tilemap-attribution', + minZoom: 0, + maxZoom: 20, + }, + }, + } as MapsLegacyConfig; + + function setMapService(defaultTmsLayer: string) { + setMapServiceSettings(({ + getTmsService, + defaultTmsLayer: () => defaultTmsLayer, + config, + } as unknown) as MapServiceSettings); + } + + async function createVegaMapView() { + await vegaParser.parseAsync(); + return new VegaMapView({ + vegaParser, + filterManager: dataPluginStart.query.filterManager, + timefilter: dataPluginStart.query.timefilter.timefilter, + fireEvent: (event: any) => {}, + parentEl: document.createElement('div'), + } as VegaViewParams); + } + + beforeEach(() => { + vegaParser = new VegaParser( + JSON.stringify(vegaMap), + new SearchAPI({ + search: dataPluginStart.search, + uiSettings: coreStart.uiSettings, + injectedMetadata: coreStart.injectedMetadata, + }), + new TimeCache(dataPluginStart.query.timefilter.timefilter, 0), + {}, + mockGetServiceSettings + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should be added TmsRasterLayer and do not use tmsService if mapStyle is "user_configured"', async () => { + setMapService(userConfiguredLayerId); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + const { longitude, latitude, scrollWheelZoom } = vegaMapView._parser.mapConfig; + expect(Map).toHaveBeenCalledWith({ + style: { + version: 8, + sources: {}, + layers: [], + }, + customAttribution: 'tilemap-attribution', + container: vegaMapView._$container.get(0), + minZoom: 0, + maxZoom: 20, + zoom: 3, + scrollZoom: scrollWheelZoom, + center: [longitude, latitude], + }); + expect(getTmsService).not.toHaveBeenCalled(); + expect(initTmsRasterLayer).toHaveBeenCalled(); + expect(initVegaLayer).toHaveBeenCalled(); + }); + + test('should not be added TmsRasterLayer and use tmsService if mapStyle is not "user_configured"', async () => { + setMapService('road_map_desaturated'); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + const { longitude, latitude, scrollWheelZoom } = vegaMapView._parser.mapConfig; + expect(Map).toHaveBeenCalledWith({ + style: { + version: 8, + sources: {}, + layers: [], + }, + customAttribution: ['<a rel="noreferrer noopener" href="tms_attributions"></a>'], + container: vegaMapView._$container.get(0), + minZoom: 0, + maxZoom: 20, + zoom: 3, + scrollZoom: scrollWheelZoom, + center: [longitude, latitude], + }); + expect(getTmsService).toHaveBeenCalled(); + expect(initTmsRasterLayer).not.toHaveBeenCalled(); + expect(initVegaLayer).toHaveBeenCalled(); + }); + + test('should be added NavigationControl', async () => { + setMapService('road_map_desaturated'); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + expect(NavigationControl).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts new file mode 100644 index 0000000000000..6a31eb0b37833 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { Map, Style, NavigationControl, MapboxOptions } from 'mapbox-gl'; + +import { initTmsRasterLayer, initVegaLayer } from './layers'; +import { VegaBaseView } from '../vega_base_view'; +import { getMapServiceSettings } from '../../services'; +import { getAttributionsForTmsService } from './map_service_settings'; +import type { MapServiceSettings } from './map_service_settings'; + +import { + defaultMapConfig, + defaultMabBoxStyle, + userConfiguredLayerId, + vegaLayerId, +} from './constants'; + +import { validateZoomSettings, injectMapPropsIntoSpec } from './utils'; + +// @ts-expect-error +import { vega } from '../../lib/vega'; + +import './vega_map_view.scss'; + +async function updateVegaView(mapBoxInstance: Map, vegaView: vega.View) { + const mapCanvas = mapBoxInstance.getCanvas(); + const { lat, lng } = mapBoxInstance.getCenter(); + let shouldRender = false; + + const sendSignal = (sig: string, value: any) => { + if (vegaView.signal(sig) !== value) { + vegaView.signal(sig, value); + shouldRender = true; + } + }; + + sendSignal('width', mapCanvas.clientWidth); + sendSignal('height', mapCanvas.clientHeight); + sendSignal('latitude', lat); + sendSignal('longitude', lng); + sendSignal('zoom', mapBoxInstance.getZoom()); + + if (shouldRender) { + await vegaView.runAsync(); + } +} + +export class VegaMapView extends VegaBaseView { + private mapServiceSettings: MapServiceSettings = getMapServiceSettings(); + private mapStyle = this.getMapStyle(); + + private getMapStyle() { + const { mapStyle } = this._parser.mapConfig; + + return mapStyle === 'default' ? this.mapServiceSettings.defaultTmsLayer() : mapStyle; + } + + private get shouldShowZoomControl() { + return Boolean(this._parser.mapConfig.zoomControl); + } + + private getMapParams(defaults: { maxZoom: number; minZoom: number }): Partial<MapboxOptions> { + const { longitude, latitude, scrollWheelZoom } = this._parser.mapConfig; + const zoomSettings = validateZoomSettings(this._parser.mapConfig, defaults, this.onWarn); + + return { + ...zoomSettings, + center: [longitude, latitude], + scrollZoom: scrollWheelZoom, + }; + } + + private async initMapContainer(vegaView: vega.View) { + let style: Style = defaultMabBoxStyle; + let customAttribution: MapboxOptions['customAttribution'] = []; + const zoomSettings = { + minZoom: defaultMapConfig.minZoom, + maxZoom: defaultMapConfig.maxZoom, + }; + + if (this.mapStyle && this.mapStyle !== userConfiguredLayerId) { + const tmsService = await this.mapServiceSettings.getTmsService(this.mapStyle); + + if (!tmsService) { + this.onWarn( + i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', { + defaultMessage: '{mapStyleParam} was not found', + values: { mapStyleParam: `"mapStyle":${this.mapStyle}` }, + }) + ); + return; + } + zoomSettings.maxZoom = (await tmsService.getMaxZoom()) ?? defaultMapConfig.maxZoom; + zoomSettings.minZoom = (await tmsService.getMinZoom()) ?? defaultMapConfig.minZoom; + customAttribution = getAttributionsForTmsService(tmsService); + style = (await tmsService.getVectorStyleSheet()) as Style; + } else { + customAttribution = this.mapServiceSettings.config.tilemap.options.attribution; + } + + // In some cases, Vega may be initialized twice, e.g. after awaiting... + if (!this._$container) return; + + const mapBoxInstance = new Map({ + style, + customAttribution, + container: this._$container.get(0), + ...this.getMapParams({ ...zoomSettings }), + }); + + const initMapComponents = () => { + this.initControls(mapBoxInstance); + this.initLayers(mapBoxInstance, vegaView); + + this._addDestroyHandler(() => { + if (mapBoxInstance.getLayer(vegaLayerId)) { + mapBoxInstance.removeLayer(vegaLayerId); + } + if (mapBoxInstance.getLayer(userConfiguredLayerId)) { + mapBoxInstance.removeLayer(userConfiguredLayerId); + } + mapBoxInstance.remove(); + }); + }; + + mapBoxInstance.once('load', initMapComponents); + } + + private initControls(mapBoxInstance: Map) { + if (this.shouldShowZoomControl) { + mapBoxInstance.addControl(new NavigationControl({ showCompass: false }), 'top-left'); + } + } + + private initLayers(mapBoxInstance: Map, vegaView: vega.View) { + const shouldShowUserConfiguredLayer = this.mapStyle === userConfiguredLayerId; + + if (shouldShowUserConfiguredLayer) { + const { url, options } = this.mapServiceSettings.config.tilemap; + + initTmsRasterLayer({ + id: userConfiguredLayerId, + map: mapBoxInstance, + context: { + tiles: [url!], + maxZoom: options.maxZoom ?? defaultMapConfig.maxZoom, + minZoom: options.minZoom ?? defaultMapConfig.minZoom, + tileSize: options.tileSize ?? defaultMapConfig.tileSize, + }, + }); + } + + initVegaLayer({ + id: vegaLayerId, + map: mapBoxInstance, + context: { + vegaView, + updateVegaView, + }, + }); + } + + protected async _initViewCustomizations() { + const vegaView = new vega.View( + vega.parse(injectMapPropsIntoSpec(this._parser.spec)), + this._vegaViewConfig + ); + + this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec); + this.setView(vegaView); + + await this.initMapContainer(vegaView); + } +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_view.js index 8b6ebbe9c7594..2fd7e4fd606fd 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_view.js @@ -16,8 +16,6 @@ export class VegaView extends VegaBaseView { const view = new vega.View(vega.parse(this._parser.spec), this._vegaViewConfig); - view.warn = this.onWarn.bind(this); - view.error = this.onError.bind(this); if (this._parser.useResize) this.updateVegaSize(view); view.initialize(this._$container.get(0), this._$controls.get(0)); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js index af396dbf778d2..926c03e79bff9 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.test.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -10,13 +10,10 @@ import 'jest-canvas-mock'; import $ from 'jquery'; -import 'leaflet/dist/leaflet.js'; -import 'leaflet-vega'; import { createVegaVisualization } from './vega_visualization'; import vegaliteGraph from './test_utils/vegalite_graph.json'; import vegaGraph from './test_utils/vega_graph.json'; -import vegaMapGraph from './test_utils/vega_map_test.json'; import { VegaParser } from './data_model/vega_parser'; import { SearchAPI } from './data_model/search_api'; @@ -146,32 +143,5 @@ describe('VegaVisualizations', () => { vegaVis.destroy(); } }); - - test('should show vega blank rectangle on top of a map (vegamap)', async () => { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, jest.fn()); - const vegaParser = new VegaParser( - JSON.stringify(vegaMapGraph), - new SearchAPI({ - search: dataPluginStart.search, - uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, - }), - 0, - 0, - mockGetServiceSettings - ); - await vegaParser.parseAsync(); - - mockedWidthValue = 256; - mockedHeightValue = 256; - - await vegaVis.render(vegaParser); - expect(domNode.innerHTML).toMatchSnapshot(); - } finally { - vegaVis.destroy(); - } - }); }); }); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.ts b/src/plugins/vis_type_vega/public/vega_visualization.ts index ae4d23db48ee4..14dea362bc8c5 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.ts +++ b/src/plugins/vis_type_vega/public/vega_visualization.ts @@ -13,7 +13,14 @@ import { VegaVisualizationDependencies } from './plugin'; import { getNotifications, getData } from './services'; import type { VegaView } from './vega_view/vega_view'; -export const createVegaVisualization = ({ getServiceSettings }: VegaVisualizationDependencies) => +type VegaVisType = new (el: HTMLDivElement, fireEvent: IInterpreterRenderHandlers['event']) => { + render(visData: VegaParser): Promise<void>; + destroy(): void; +}; + +export const createVegaVisualization = ({ + getServiceSettings, +}: VegaVisualizationDependencies): VegaVisType => class VegaVisualization { private readonly dataPlugin = getData(); private vegaView: InstanceType<typeof VegaView> | null = null; @@ -71,7 +78,7 @@ export const createVegaVisualization = ({ getServiceSettings }: VegaVisualizatio }; if (vegaParser.useMap) { - const { VegaMapView } = await import('./vega_view/vega_map_view'); + const { VegaMapView } = await import('./vega_view/vega_map_view/view'); this.vegaView = new VegaMapView(vegaViewParams); } else { const { VegaView: VegaViewClass } = await import('./vega_view/vega_view'); diff --git a/src/plugins/vis_type_vega/tsconfig.json b/src/plugins/vis_type_vega/tsconfig.json new file mode 100644 index 0000000000000..c013056ba4566 --- /dev/null +++ b/src/plugins/vis_type_vega/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*", + "public/**/*", + "*.ts", + // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 + "public/test_utils/vega_map_test.json" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../data/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, + { "path": "../maps_legacy/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + { "path": "../inspector/tsconfig.json" }, + { "path": "../home/tsconfig.json" }, + { "path": "../usage_collection/tsconfig.json" }, + { "path": "../kibana_utils/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" }, + { "path": "../vis_default_editor/tsconfig.json" }, + ] +} diff --git a/src/plugins/visualize/common/constants.ts b/src/plugins/visualize/common/constants.ts index b37eadd9b67e5..7e353ca86698a 100644 --- a/src/plugins/visualize/common/constants.ts +++ b/src/plugins/visualize/common/constants.ts @@ -7,3 +7,5 @@ */ export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size'; +export const STATE_STORAGE_KEY = '_a'; +export const GLOBAL_STATE_STORAGE_KEY = '_g'; diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index 7f5c7d0dc08a2..2256a7a7f550d 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -11,7 +11,8 @@ "visualizations", "embeddable", "dashboard", - "uiActions" + "uiActions", + "presentationUtil" ], "optionalPlugins": [ "home", @@ -22,7 +23,6 @@ "kibanaUtils", "kibanaReact", "home", - "presentationUtil", "discover" ] } diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 38e2b59009b38..34131ae2dc7fb 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -40,6 +40,7 @@ export const VisualizeListing = () => { savedObjectsTagging, uiSettings, visualizeCapabilities, + kbnUrlStateStorage, }, } = useKibana<VisualizeServices>(); const { pathname } = useLocation(); @@ -94,11 +95,10 @@ export const VisualizeListing = () => { ); const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]); - const tableColumns = useMemo(() => getTableColumns(application, history, savedObjectsTagging), [ - application, - history, - savedObjectsTagging, - ]); + const tableColumns = useMemo( + () => getTableColumns(application, kbnUrlStateStorage, savedObjectsTagging), + [application, kbnUrlStateStorage, savedObjectsTagging] + ); const fetchItems = useCallback( (filter) => { diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index b0d931c6c87fa..02da16c9e67ca 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -69,7 +69,6 @@ const TopNav = ({ }, [visInstance.embeddableHandler] ); - const savedObjectsClient = services.savedObjects.client; const config = useMemo(() => { if (isEmbeddableRendered) { @@ -85,7 +84,6 @@ const TopNav = ({ stateContainer, visualizationIdFromUrl, stateTransfer: services.stateTransferService, - savedObjectsClient, embeddableId, }, services @@ -104,7 +102,6 @@ const TopNav = ({ visualizationIdFromUrl, services, embeddableId, - savedObjectsClient, ]); const [indexPatterns, setIndexPatterns] = useState<IndexPattern[]>( vis.data.indexPattern ? [vis.data.indexPattern] : [] diff --git a/src/plugins/visualize/public/application/index.tsx b/src/plugins/visualize/public/application/index.tsx index 455e51a8f58d4..ae11e1de486ea 100644 --- a/src/plugins/visualize/public/application/index.tsx +++ b/src/plugins/visualize/public/application/index.tsx @@ -30,9 +30,11 @@ export const renderApp = ( const app = ( <Router history={services.history}> <KibanaContextProvider services={services}> - <services.i18n.Context> - <VisualizeApp onAppLeave={onAppLeave} /> - </services.i18n.Context> + <services.presentationUtil.ContextProvider> + <services.i18n.Context> + <VisualizeApp onAppLeave={onAppLeave} /> + </services.i18n.Context> + </services.presentationUtil.ContextProvider> </KibanaContextProvider> </Router> ); diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index d923851a68d9c..5d884889367bc 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -34,6 +34,7 @@ import { SharePluginStart } from 'src/plugins/share/public'; import { SavedObjectsStart, SavedObject } from 'src/plugins/saved_objects/public'; import { EmbeddableStart, EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import { UrlForwardingStart } from 'src/plugins/url_forwarding/public'; +import { PresentationUtilPluginStart } from 'src/plugins/presentation_util/public'; import { EventEmitter } from 'events'; import { DashboardStart } from '../../../dashboard/public'; import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; @@ -93,6 +94,7 @@ export interface VisualizeServices extends CoreStart { dashboard: DashboardStart; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; savedObjectsTagging?: SavedObjectsTaggingApi; + presentationUtil: PresentationUtilPluginStart; } export interface SavedVisInstance { diff --git a/src/plugins/visualize/public/application/utils/get_table_columns.tsx b/src/plugins/visualize/public/application/utils/get_table_columns.tsx index daa419b5f31b4..d9dafa7335671 100644 --- a/src/plugins/visualize/public/application/utils/get_table_columns.tsx +++ b/src/plugins/visualize/public/application/utils/get_table_columns.tsx @@ -7,14 +7,15 @@ */ import React from 'react'; -import { History } from 'history'; import { EuiBetaBadge, EuiButton, EuiEmptyPrompt, EuiIcon, EuiLink, EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; - import { ApplicationStart } from 'kibana/public'; +import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; import { VisualizationListItem } from 'src/plugins/visualizations/public'; import type { SavedObjectsTaggingApi } from 'src/plugins/saved_objects_tagging_oss/public'; +import { RedirectAppLinks } from '../../../../kibana_react/public'; +import { getVisualizeListItemLink } from './get_visualize_list_item_link'; const getBadge = (item: VisualizationListItem) => { if (item.stage === 'beta') { @@ -72,7 +73,7 @@ const renderItemTypeIcon = (item: VisualizationListItem) => { export const getTableColumns = ( application: ApplicationStart, - history: History, + kbnUrlStateStorage: IKbnUrlStateStorage, taggingApi?: SavedObjectsTaggingApi ) => [ { @@ -84,18 +85,14 @@ export const getTableColumns = ( render: (field: string, { editApp, editUrl, title, error }: VisualizationListItem) => // In case an error occurs i.e. the vis has wrong type, we render the vis but without the link !error ? ( - <EuiLink - onClick={() => { - if (editApp) { - application.navigateToApp(editApp, { path: editUrl }); - } else if (editUrl) { - history.push(editUrl); - } - }} - data-test-subj={`visListingTitleLink-${title.split(' ').join('-')}`} - > - {field} - </EuiLink> + <RedirectAppLinks application={application}> + <EuiLink + href={getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl)} + data-test-subj={`visListingTitleLink-${title.split(' ').join('-')}`} + > + {field} + </EuiLink> + </RedirectAppLinks> ) : ( field ), diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.test.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.test.tsx new file mode 100644 index 0000000000000..2b7706d4f9d9f --- /dev/null +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Capabilities } from 'src/core/public'; +import { showPublicUrlSwitch } from './get_top_nav_config'; + +describe('showPublicUrlSwitch', () => { + test('returns false if "visualize" app is not available', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns false if "visualize" app is not accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + visualize: { + show: false, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns true if "visualize" app is not available an accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + visualize: { + show: true, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(true); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index c4aefb397cd8a..d782937bce40a 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { Capabilities } from 'src/core/public'; import { TopNavMenuData } from 'src/plugins/navigation/public'; import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public'; import { @@ -19,7 +20,6 @@ import { } from '../../../../saved_objects/public'; import { SavedObjectSaveModalDashboard } from '../../../../presentation_util/public'; import { unhashUrl } from '../../../../kibana_utils/public'; -import { SavedObjectsClientContract } from '../../../../../core/public'; import { VisualizeServices, @@ -30,6 +30,14 @@ import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from './breadcrumbs'; import { EmbeddableStateTransfer } from '../../../../embeddable/public'; +interface VisualizeCapabilities { + createShortUrl: boolean; + delete: boolean; + save: boolean; + saveQuery: boolean; + show: boolean; +} + interface TopNavConfigParams { hasUnsavedChanges: boolean; setHasUnsavedChanges: (value: boolean) => void; @@ -41,10 +49,17 @@ interface TopNavConfigParams { stateContainer: VisualizeAppStateContainer; visualizationIdFromUrl?: string; stateTransfer: EmbeddableStateTransfer; - savedObjectsClient: SavedObjectsClientContract; embeddableId?: string; } +export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { + if (!anonymousUserCapabilities.visualize) return false; + + const visualize = (anonymousUserCapabilities.visualize as unknown) as VisualizeCapabilities; + + return !!visualize.show; +}; + export const getTopNavConfig = ( { hasUnsavedChanges, @@ -55,7 +70,6 @@ export const getTopNavConfig = ( hasUnappliedChanges, visInstance, stateContainer, - savedObjectsClient, visualizationIdFromUrl, stateTransfer, embeddableId, @@ -71,6 +85,7 @@ export const getTopNavConfig = ( i18n: { Context: I18nContext }, dashboard, savedObjectsTagging, + presentationUtil, }: VisualizeServices ) => { const { vis, embeddableHandler } = visInstance; @@ -243,6 +258,7 @@ export const getTopNavConfig = ( title: savedVis?.title, }, isDirty: hasUnappliedChanges || hasUnsavedChanges, + showPublicUrlSwitch, }); } }, @@ -379,39 +395,43 @@ export const getTopNavConfig = ( ); } - const saveModal = - !!originatingApp || - !dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables ? ( - <SavedObjectSaveModalOrigin - documentInfo={savedVis || { title: '' }} - onSave={onSave} - options={tagOptions} - getAppNameFromId={stateTransfer.getAppNameFromId} - objectType={'visualization'} - onClose={() => {}} - originatingApp={originatingApp} - returnToOriginSwitchLabel={ - originatingApp && embeddableId - ? i18n.translate('visualize.topNavMenu.updatePanel', { - defaultMessage: 'Update panel on {originatingAppName}', - values: { - originatingAppName: stateTransfer.getAppNameFromId(originatingApp), - }, - }) - : undefined - } - /> - ) : ( - <SavedObjectSaveModalDashboard - documentInfo={savedVis || { title: '' }} - onSave={onSave} - tagOptions={tagOptions} - objectType={'visualization'} - onClose={() => {}} - savedObjectsClient={savedObjectsClient} - /> - ); - showSaveModal(saveModal, I18nContext); + const useByRefFlow = + !!originatingApp || !dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables; + + const saveModal = useByRefFlow ? ( + <SavedObjectSaveModalOrigin + documentInfo={savedVis || { title: '' }} + onSave={onSave} + options={tagOptions} + getAppNameFromId={stateTransfer.getAppNameFromId} + objectType={'visualization'} + onClose={() => {}} + originatingApp={originatingApp} + returnToOriginSwitchLabel={ + originatingApp && embeddableId + ? i18n.translate('visualize.topNavMenu.updatePanel', { + defaultMessage: 'Update panel on {originatingAppName}', + values: { + originatingAppName: stateTransfer.getAppNameFromId(originatingApp), + }, + }) + : undefined + } + /> + ) : ( + <SavedObjectSaveModalDashboard + documentInfo={savedVis || { title: '' }} + onSave={onSave} + tagOptions={tagOptions} + objectType={'visualization'} + onClose={() => {}} + /> + ); + showSaveModal( + saveModal, + I18nContext, + !useByRefFlow ? presentationUtil.ContextProvider : React.Fragment + ); }, }, ] diff --git a/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.test.ts b/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.test.ts new file mode 100644 index 0000000000000..80fd1c8740f2c --- /dev/null +++ b/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { getVisualizeListItemLink } from './get_visualize_list_item_link'; +import { ApplicationStart } from 'kibana/public'; +import { createHashHistory } from 'history'; +import { createKbnUrlStateStorage } from '../../../../kibana_utils/public'; +import { esFilters } from '../../../../data/public'; +import { GLOBAL_STATE_STORAGE_KEY } from '../../../common/constants'; + +jest.mock('../../services', () => { + return { + getUISettings: () => ({ + get: jest.fn(), + }), + }; +}); + +const application = ({ + getUrlForApp: jest.fn((appId: string, options?: { path?: string; absolute?: boolean }) => { + return `/app/${appId}${options?.path}`; + }), +} as unknown) as ApplicationStart; + +const history = createHashHistory(); +const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + useHash: false, +}); +kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { time: { from: 'now-7d', to: 'now' } }); + +describe('listing item link is correct for each app', () => { + test('creates a link to classic visualization if editApp is not defined', async () => { + const editUrl = 'edit/id'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, undefined, editUrl); + expect(url).toMatchInlineSnapshot(`"/app/visualize#${editUrl}?_g=(time:(from:now-7d,to:now))"`); + }); + + test('creates a link for the app given if editApp is defined', async () => { + const editUrl = '#/edit/id'; + const editApp = 'lens'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl); + expect(url).toMatchInlineSnapshot(`"/app/${editApp}${editUrl}?_g=(time:(from:now-7d,to:now))"`); + }); + + describe('when global time changes', () => { + beforeEach(() => { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + time: { + from: '2021-01-05T11:45:53.375Z', + to: '2021-01-21T11:46:00.990Z', + }, + }); + }); + + test('it propagates the correct time on the query', async () => { + const editUrl = '#/edit/id'; + const editApp = 'lens'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl); + expect(url).toMatchInlineSnapshot( + `"/app/${editApp}${editUrl}?_g=(time:(from:'2021-01-05T11:45:53.375Z',to:'2021-01-21T11:46:00.990Z'))"` + ); + }); + }); + + describe('when global refreshInterval changes', () => { + beforeEach(() => { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + refreshInterval: { pause: false, value: 300 }, + }); + }); + + test('it propagates the refreshInterval on the query', async () => { + const editUrl = '#/edit/id'; + const editApp = 'lens'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl); + expect(url).toMatchInlineSnapshot( + `"/app/${editApp}${editUrl}?_g=(refreshInterval:(pause:!f,value:300))"` + ); + }); + }); + + describe('when global filters change', () => { + beforeEach(() => { + const filters = [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + $state: { + store: esFilters.FilterStateStore.GLOBAL_STATE, + }, + }, + ]; + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + filters, + }); + }); + + test('propagates the filters on the query', async () => { + const editUrl = '#/edit/id'; + const editApp = 'lens'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl); + expect(url).toMatchInlineSnapshot( + `"/app/${editApp}${editUrl}?_g=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1)),('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))))"` + ); + }); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.ts b/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.ts new file mode 100644 index 0000000000000..2ded3ce8c2745 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { ApplicationStart } from 'kibana/public'; +import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; +import { QueryState } from '../../../../data/public'; +import { setStateToKbnUrl } from '../../../../kibana_utils/public'; +import { getUISettings } from '../../services'; +import { GLOBAL_STATE_STORAGE_KEY } from '../../../common/constants'; +import { APP_NAME } from '../visualize_constants'; + +export const getVisualizeListItemLink = ( + application: ApplicationStart, + kbnUrlStateStorage: IKbnUrlStateStorage, + editApp: string | undefined, + editUrl: string +) => { + // for visualizations the editApp is undefined + let url = application.getUrlForApp(editApp ?? APP_NAME, { + path: editApp ? editUrl : `#${editUrl}`, + }); + const useHash = getUISettings().get('state:storeInSessionStorage'); + const globalStateInUrl = kbnUrlStateStorage.get<QueryState>(GLOBAL_STATE_STORAGE_KEY) || {}; + + url = setStateToKbnUrl<QueryState>(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url); + return url; +}; diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 111ee7b0041ed..8d02e08549663 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -20,6 +20,7 @@ import { ScopedHistory, } from 'kibana/public'; +import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { Storage, createKbnUrlTracker, @@ -62,6 +63,7 @@ export interface VisualizePluginStartDependencies { savedObjects: SavedObjectsStart; dashboard: DashboardStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + presentationUtil: PresentationUtilPluginStart; } export interface VisualizePluginSetupDependencies { @@ -204,6 +206,7 @@ export class VisualizePlugin dashboard: pluginsStart.dashboard, setHeaderActionMenu: params.setHeaderActionMenu, savedObjectsTagging: pluginsStart.savedObjectsTaggingOss?.getTaggingApi(), + presentationUtil: pluginsStart.presentationUtil, }; params.element.classList.add('visAppWrapper'); diff --git a/src/plugins/visualize/public/url_generator.ts b/src/plugins/visualize/public/url_generator.ts index 15f05106130de..57fa9b2ae4801 100644 --- a/src/plugins/visualize/public/url_generator.ts +++ b/src/plugins/visualize/public/url_generator.ts @@ -16,9 +16,7 @@ import { } from '../../data/public'; import { setStateToKbnUrl } from '../../kibana_utils/public'; import { UrlGeneratorsDefinition } from '../../share/public'; - -const STATE_STORAGE_KEY = '_a'; -const GLOBAL_STATE_STORAGE_KEY = '_g'; +import { STATE_STORAGE_KEY, GLOBAL_STATE_STORAGE_KEY } from '../common/constants'; export const VISUALIZE_APP_URL_GENERATOR = 'VISUALIZE_APP_URL_GENERATOR'; diff --git a/test/accessibility/apps/kibana_overview.ts b/test/accessibility/apps/kibana_overview.ts index c26a042b10e72..eb0b54ad07aa7 100644 --- a/test/accessibility/apps/kibana_overview.ts +++ b/test/accessibility/apps/kibana_overview.ts @@ -16,7 +16,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); before(async () => { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await PageObjects.common.navigateToApp('kibanaOverview'); }); @@ -25,7 +25,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { useActualUrl: true, }); await PageObjects.home.removeSampleDataSet('flights'); - await esArchiver.unload('empty_kibana'); }); it('Getting started view', async () => { diff --git a/test/accessibility/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index c6a194ace9c25..d29d17484486c 100644 --- a/test/accessibility/services/a11y/a11y.ts +++ b/test/accessibility/services/a11y/a11y.ts @@ -61,11 +61,7 @@ export function A11yProvider({ getService }: FtrProviderContext) { exclude: ([] as string[]) .concat(excludeTestSubj || []) .map((ts) => [testSubjectToCss(ts)]) - .concat([ - [ - '.leaflet-vega-container[role="graphics-document"][aria-roledescription="visualization"]', - ], - ]), + .concat([['[role="graphics-document"][aria-roledescription="visualization"]']]), }; } diff --git a/test/api_integration/apis/home/sample_data.ts b/test/api_integration/apis/home/sample_data.ts index 042aff1375267..ebda93b12dc20 100644 --- a/test/api_integration/apis/home/sample_data.ts +++ b/test/api_integration/apis/home/sample_data.ts @@ -11,11 +11,15 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const MILLISECOND_IN_WEEK = 1000 * 60 * 60 * 24 * 7; describe('sample data apis', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); describe('list', () => { it('should return list of sample data sets with installed status', async () => { const resp = await supertest.get(`/api/sample_data`).set('kbn-xsrf', 'kibana').expect(200); diff --git a/test/api_integration/apis/saved_objects/bulk_create.ts b/test/api_integration/apis/saved_objects/bulk_create.ts index a548172365b07..d7cdee16214a8 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.ts +++ b/test/api_integration/apis/saved_objects/bulk_create.ts @@ -97,10 +97,11 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); - it('should return 200 with individual responses', async () => + it('should return 200 with errors', async () => { + await new Promise((resolve) => setTimeout(resolve, 2000)); await supertest .post('/api/saved_objects/_bulk_create') .send(BULK_REQUESTS) @@ -109,38 +110,27 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.eql({ saved_objects: [ { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - updated_at: resp.body.saved_objects[0].updated_at, - version: resp.body.saved_objects[0].version, - attributes: { - title: 'An existing visualization', - }, - references: [], - namespaces: ['default'], - migrationVersion: { - visualization: resp.body.saved_objects[0].migrationVersion.visualization, + id: BULK_REQUESTS[0].id, + type: BULK_REQUESTS[0].type, + error: { + error: 'Internal Server Error', + message: 'An internal server error occurred', + statusCode: 500, }, - coreMigrationVersion: KIBANA_VERSION, // updated from 1.2.3 to the latest kibana version }, { - type: 'dashboard', - id: 'a01b2f57-fcfd-4864-b735-09e28f0d815e', - updated_at: resp.body.saved_objects[1].updated_at, - version: resp.body.saved_objects[1].version, - attributes: { - title: 'A great new dashboard', - }, - references: [], - namespaces: ['default'], - migrationVersion: { - dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, + id: BULK_REQUESTS[1].id, + type: BULK_REQUESTS[1].type, + error: { + error: 'Internal Server Error', + message: 'An internal server error occurred', + statusCode: 500, }, - coreMigrationVersion: KIBANA_VERSION, }, ], }); - })); + }); + }); }); }); } diff --git a/test/api_integration/apis/saved_objects/bulk_get.ts b/test/api_integration/apis/saved_objects/bulk_get.ts index 46631225f8e8a..b9536843d30c9 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.ts +++ b/test/api_integration/apis/saved_objects/bulk_get.ts @@ -108,7 +108,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return 200 with individual responses', async () => diff --git a/test/api_integration/apis/saved_objects/bulk_update.ts b/test/api_integration/apis/saved_objects/bulk_update.ts index 5a2496b6dde81..2cf3ade406a93 100644 --- a/test/api_integration/apis/saved_objects/bulk_update.ts +++ b/test/api_integration/apis/saved_objects/bulk_update.ts @@ -235,10 +235,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); - it('should return generic 404', async () => { + it('should return 200 with errors', async () => { const response = await supertest .put(`/api/saved_objects/_bulk_update`) .send([ @@ -267,9 +267,9 @@ export default function ({ getService }: FtrProviderContext) { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', error: { - statusCode: 404, - error: 'Not Found', - message: 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', }, }); @@ -277,9 +277,9 @@ export default function ({ getService }: FtrProviderContext) { id: 'be3733a0-9efe-11e7-acb3-3dab96693fab', type: 'dashboard', error: { - statusCode: 404, - error: 'Not Found', - message: 'Saved object [dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', }, }); }); diff --git a/test/api_integration/apis/saved_objects/create.ts b/test/api_integration/apis/saved_objects/create.ts index 551e082630e8f..833cb127d0023 100644 --- a/test/api_integration/apis/saved_objects/create.ts +++ b/test/api_integration/apis/saved_objects/create.ts @@ -82,10 +82,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); - it('should return 200 and create kibana index', async () => { + it('should return 500 and not auto-create saved objects index', async () => { await supertest .post(`/api/saved_objects/visualization`) .send({ @@ -93,50 +93,16 @@ export default function ({ getService }: FtrProviderContext) { title: 'My favorite vis', }, }) - .expect(200) + .expect(500) .then((resp) => { - // loose uuid validation - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'visualization', - migrationVersion: resp.body.migrationVersion, - coreMigrationVersion: KIBANA_VERSION, - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - title: 'My favorite vis', - }, - references: [], - namespaces: ['default'], + error: 'Internal Server Error', + message: 'An internal server error occurred.', + statusCode: 500, }); - expect(resp.body.migrationVersion).to.be.ok(); }); - expect((await es.indices.exists({ index: '.kibana' })).body).to.be(true); - }); - - it('result should have the latest coreMigrationVersion', async () => { - await supertest - .post(`/api/saved_objects/visualization`) - .send({ - attributes: { - title: 'My favorite vis', - }, - coreMigrationVersion: '1.2.3', - }) - .expect(200) - .then((resp) => { - expect(resp.body.coreMigrationVersion).to.eql(KIBANA_VERSION); - }); + expect((await es.indices.exists({ index: '.kibana' })).body).to.be(false); }); }); }); diff --git a/test/api_integration/apis/saved_objects/delete.ts b/test/api_integration/apis/saved_objects/delete.ts index 9ba51b4b91468..d2dd4454bdf1e 100644 --- a/test/api_integration/apis/saved_objects/delete.ts +++ b/test/api_integration/apis/saved_objects/delete.ts @@ -44,7 +44,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('returns generic 404 when kibana index is missing', async () => diff --git a/test/api_integration/apis/saved_objects/export.ts b/test/api_integration/apis/saved_objects/export.ts index a45191f24d872..c0d5430070951 100644 --- a/test/api_integration/apis/saved_objects/export.ts +++ b/test/api_integration/apis/saved_objects/export.ts @@ -534,7 +534,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return empty response', async () => { diff --git a/test/api_integration/apis/saved_objects/find.ts b/test/api_integration/apis/saved_objects/find.ts index 7aa4de86baa69..5f549dc6c5780 100644 --- a/test/api_integration/apis/saved_objects/find.ts +++ b/test/api_integration/apis/saved_objects/find.ts @@ -40,7 +40,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -137,7 +137,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -174,7 +174,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -209,7 +209,7 @@ export default function ({ getService }: FtrProviderContext) { score: 0, type: 'visualization', updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzYsMV0=', + version: 'WzIyLDJd', }, ], }); @@ -256,7 +256,7 @@ export default function ({ getService }: FtrProviderContext) { migrationVersion: resp.body.saved_objects[0].migrationVersion, coreMigrationVersion: KIBANA_VERSION, updated_at: '2017-09-21T18:51:23.794Z', - version: 'WzIsMV0=', + version: 'WzE4LDJd', }, ], }); @@ -426,11 +426,11 @@ export default function ({ getService }: FtrProviderContext) { })); }); - describe.skip('without kibana index', () => { + describe('without kibana index', () => { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return 200 with empty response', async () => diff --git a/test/api_integration/apis/saved_objects/get.ts b/test/api_integration/apis/saved_objects/get.ts index ff47b9df218dc..9bb6e32004c81 100644 --- a/test/api_integration/apis/saved_objects/get.ts +++ b/test/api_integration/apis/saved_objects/get.ts @@ -78,7 +78,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return basic 404 without mentioning index', async () => diff --git a/test/api_integration/apis/saved_objects/resolve_import_errors.ts b/test/api_integration/apis/saved_objects/resolve_import_errors.ts index 3686c46b229b1..042741476bb8e 100644 --- a/test/api_integration/apis/saved_objects/resolve_import_errors.ts +++ b/test/api_integration/apis/saved_objects/resolve_import_errors.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const es = getService('legacyEs'); describe('resolve_import_errors', () => { // mock success results including metadata @@ -34,7 +35,14 @@ export default function ({ getService }: FtrProviderContext) { describe('without kibana index', () => { // Cleanup data that got created in import - after(() => esArchiver.unload('saved_objects/basic')); + before( + async () => + // just in case the kibana server has recreated it + await es.indices.delete({ + index: '.kibana*', + ignore: [404], + }) + ); it('should return 200 and import nothing when empty parameters are passed in', async () => { await supertest @@ -51,7 +59,7 @@ export default function ({ getService }: FtrProviderContext) { }); }); - it('should return 200 and import everything when overwrite parameters contains all objects', async () => { + it('should return 200 with internal server errors', async () => { await supertest .post('/api/saved_objects/_resolve_import_errors') .field( @@ -78,12 +86,42 @@ export default function ({ getService }: FtrProviderContext) { .expect(200) .then((resp) => { expect(resp.body).to.eql({ - success: true, - successCount: 3, - successResults: [ - { ...indexPattern, overwrite: true }, - { ...visualization, overwrite: true }, - { ...dashboard, overwrite: true }, + successCount: 0, + success: false, + errors: [ + { + ...indexPattern, + ...{ title: indexPattern.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, + { + ...visualization, + ...{ title: visualization.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, + { + ...dashboard, + ...{ title: dashboard.meta.title }, + overwrite: true, + error: { + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred', + type: 'unknown', + }, + }, ], warnings: [], }); diff --git a/test/api_integration/apis/saved_objects/update.ts b/test/api_integration/apis/saved_objects/update.ts index d5346e82ce98c..da7285a430fdd 100644 --- a/test/api_integration/apis/saved_objects/update.ts +++ b/test/api_integration/apis/saved_objects/update.ts @@ -121,10 +121,10 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); - it('should return generic 404', async () => + it('should return 500', async () => await supertest .put(`/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`) .send({ @@ -132,13 +132,12 @@ export default function ({ getService }: FtrProviderContext) { title: 'My second favorite vis', }, }) - .expect(404) + .expect(500) .then((resp) => { expect(resp.body).eql({ - statusCode: 404, - error: 'Not Found', - message: - 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', + statusCode: 500, + error: 'Internal Server Error', + message: 'An internal server error occurred.', }); })); }); diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index acc01c73de674..8453b542903a4 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -42,7 +42,7 @@ export default function ({ getService }: FtrProviderContext) { { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 'WzIsMV0=', + version: 'WzE4LDJd', attributes: { title: 'Count of requests', }, @@ -184,7 +184,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return 200 with empty response', async () => diff --git a/test/api_integration/apis/saved_objects_management/get.ts b/test/api_integration/apis/saved_objects_management/get.ts index bc05d7e392bb9..70e1faa9fd22b 100644 --- a/test/api_integration/apis/saved_objects_management/get.ts +++ b/test/api_integration/apis/saved_objects_management/get.ts @@ -45,7 +45,7 @@ export default function ({ getService }: FtrProviderContext) { before( async () => // just in case the kibana server has recreated it - await es.indices.delete({ index: '.kibana' }, { ignore: [404] }) + await es.indices.delete({ index: '.kibana*' }, { ignore: [404] }) ); it('should return 404 for object that no longer exists', async () => diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index 185c6ded01de4..6dea461f790e8 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -14,23 +14,32 @@ export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const responseSchema = schema.arrayOf( - schema.object({ - id: schema.string(), - type: schema.string(), - relationship: schema.oneOf([schema.literal('parent'), schema.literal('child')]), - meta: schema.object({ - title: schema.string(), - icon: schema.string(), - editUrl: schema.string(), - inAppUrl: schema.object({ - path: schema.string(), - uiCapabilitiesPath: schema.string(), - }), - namespaceType: schema.string(), + const relationSchema = schema.object({ + id: schema.string(), + type: schema.string(), + relationship: schema.oneOf([schema.literal('parent'), schema.literal('child')]), + meta: schema.object({ + title: schema.string(), + icon: schema.string(), + editUrl: schema.string(), + inAppUrl: schema.object({ + path: schema.string(), + uiCapabilitiesPath: schema.string(), }), - }) - ); + namespaceType: schema.string(), + }), + }); + const invalidRelationSchema = schema.object({ + id: schema.string(), + type: schema.string(), + relationship: schema.oneOf([schema.literal('parent'), schema.literal('child')]), + error: schema.string(), + }); + + const responseSchema = schema.object({ + relations: schema.arrayOf(relationSchema), + invalidRelations: schema.arrayOf(invalidRelationSchema), + }); describe('relationships', () => { before(async () => { @@ -64,7 +73,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('search', '960372e0-3224-11e8-a572-ffca06da1357')) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '8963ca30-3224-11e8-a572-ffca06da1357', type: 'index-pattern', @@ -108,7 +117,7 @@ export default function ({ getService }: FtrProviderContext) { ) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '8963ca30-3224-11e8-a572-ffca06da1357', type: 'index-pattern', @@ -145,8 +154,7 @@ export default function ({ getService }: FtrProviderContext) { ]); }); - // TODO: https://github.com/elastic/kibana/issues/19713 causes this test to fail. - it.skip('should return 404 if search finds no results', async () => { + it('should return 404 if search finds no results', async () => { await supertest .get(relationshipsUrl('search', 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')) .expect(404); @@ -169,7 +177,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('dashboard', 'b70c7ae0-3224-11e8-a572-ffca06da1357')) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: 'add810b0-3224-11e8-a572-ffca06da1357', type: 'visualization', @@ -210,7 +218,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('dashboard', 'b70c7ae0-3224-11e8-a572-ffca06da1357', ['search'])) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: 'add810b0-3224-11e8-a572-ffca06da1357', type: 'visualization', @@ -246,8 +254,7 @@ export default function ({ getService }: FtrProviderContext) { ]); }); - // TODO: https://github.com/elastic/kibana/issues/19713 causes this test to fail. - it.skip('should return 404 if dashboard finds no results', async () => { + it('should return 404 if dashboard finds no results', async () => { await supertest .get(relationshipsUrl('dashboard', 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')) .expect(404); @@ -270,7 +277,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('visualization', 'a42c0580-3224-11e8-a572-ffca06da1357')) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', @@ -313,7 +320,7 @@ export default function ({ getService }: FtrProviderContext) { ) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', @@ -356,7 +363,7 @@ export default function ({ getService }: FtrProviderContext) { .get(relationshipsUrl('index-pattern', '8963ca30-3224-11e8-a572-ffca06da1357')) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', @@ -399,7 +406,7 @@ export default function ({ getService }: FtrProviderContext) { ) .expect(200); - expect(resp.body).to.eql([ + expect(resp.body.relations).to.eql([ { id: '960372e0-3224-11e8-a572-ffca06da1357', type: 'search', @@ -425,5 +432,48 @@ export default function ({ getService }: FtrProviderContext) { .expect(404); }); }); + + describe('invalid references', () => { + it('should validate the response schema', async () => { + const resp = await supertest.get(relationshipsUrl('dashboard', 'invalid-refs')).expect(200); + + expect(() => { + responseSchema.validate(resp.body); + }).not.to.throwError(); + }); + + it('should return the invalid relations', async () => { + const resp = await supertest.get(relationshipsUrl('dashboard', 'invalid-refs')).expect(200); + + expect(resp.body).to.eql({ + invalidRelations: [ + { + error: 'Saved object [visualization/invalid-vis] not found', + id: 'invalid-vis', + relationship: 'child', + type: 'visualization', + }, + ], + relations: [ + { + id: 'add810b0-3224-11e8-a572-ffca06da1357', + meta: { + editUrl: + '/management/kibana/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + icon: 'visualizeApp', + inAppUrl: { + path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', + uiCapabilitiesPath: 'visualize.show', + }, + namespaceType: 'single', + title: 'Visualization', + }, + relationship: 'child', + type: 'visualization', + }, + ], + }); + }); + }); }); } diff --git a/test/api_integration/apis/search/bsearch.ts b/test/api_integration/apis/search/bsearch.ts new file mode 100644 index 0000000000000..504680d28bf83 --- /dev/null +++ b/test/api_integration/apis/search/bsearch.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; +import request from 'superagent'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { painlessErrReq } from './painless_err_req'; +import { verifyErrorResponse } from './verify_error'; + +function parseBfetchResponse(resp: request.Response): Array<Record<string, any>> { + return resp.text + .trim() + .split('\n') + .map((item) => JSON.parse(item)); +} + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('bsearch', () => { + describe('post', () => { + it('should return 200 a single response', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + const jsonBody = JSON.parse(resp.text); + + expect(resp.status).to.be(200); + expect(jsonBody.id).to.be(0); + expect(jsonBody.result.isPartial).to.be(false); + expect(jsonBody.result.isRunning).to.be(false); + expect(jsonBody.result).to.have.property('rawResponse'); + }); + + it('should return a batch of successful resposes', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + expect(resp.status).to.be(200); + const parsedResponse = parseBfetchResponse(resp); + expect(parsedResponse).to.have.length(2); + parsedResponse.forEach((responseJson) => { + expect(responseJson.result.isPartial).to.be(false); + expect(responseJson.result.isRunning).to.be(false); + expect(responseJson.result).to.have.property('rawResponse'); + }); + }); + + it('should return error for not found strategy', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + options: { + strategy: 'wtf', + }, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 404, 'Search strategy wtf not found'); + }); + }); + + it('should return 400 when index type is provided in OSS', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + indexType: 'baad', + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 400, 'Unsupported index pattern type baad'); + }); + }); + + describe('painless', () => { + before(async () => { + await esArchiver.loadIfNeeded( + '../../../functional/fixtures/es_archiver/logstash_functional' + ); + }); + + after(async () => { + await esArchiver.unload('../../../functional/fixtures/es_archiver/logstash_functional'); + }); + it('should return 400 for Painless error', async () => { + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: painlessErrReq, + }, + ], + }); + + expect(resp.status).to.be(200); + parseBfetchResponse(resp).forEach((responseJson, i) => { + expect(responseJson.id).to.be(i); + verifyErrorResponse(responseJson.error, 400, 'search_phase_execution_exception', true); + }); + }); + }); + }); + }); +} diff --git a/test/api_integration/apis/search/index.ts b/test/api_integration/apis/search/index.ts index 2f21825d6902f..6e90bf0f22c51 100644 --- a/test/api_integration/apis/search/index.ts +++ b/test/api_integration/apis/search/index.ts @@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('search', () => { loadTestFile(require.resolve('./search')); + loadTestFile(require.resolve('./bsearch')); loadTestFile(require.resolve('./msearch')); }); } diff --git a/test/api_integration/apis/search/painless_err_req.ts b/test/api_integration/apis/search/painless_err_req.ts new file mode 100644 index 0000000000000..6fbf6565d7a9e --- /dev/null +++ b/test/api_integration/apis/search/painless_err_req.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export const painlessErrReq = { + params: { + index: 'log*', + body: { + size: 500, + fields: ['*'], + script_fields: { + invalid_scripted_field: { + script: { + source: 'invalid', + lang: 'painless', + }, + }, + }, + stored_fields: ['*'], + query: { + bool: { + filter: [ + { + match_all: {}, + }, + { + range: { + '@timestamp': { + gte: '2015-01-19T12:27:55.047Z', + lte: '2021-01-19T12:27:55.047Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + }, + }, +}; diff --git a/test/api_integration/apis/search/search.ts b/test/api_integration/apis/search/search.ts index fc13189a40753..e43c449309306 100644 --- a/test/api_integration/apis/search/search.ts +++ b/test/api_integration/apis/search/search.ts @@ -8,11 +8,22 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { painlessErrReq } from './painless_err_req'; +import { verifyErrorResponse } from './verify_error'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('search', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + await esArchiver.loadIfNeeded('../../../functional/fixtures/es_archiver/logstash_functional'); + }); + + after(async () => { + await esArchiver.unload('../../../functional/fixtures/es_archiver/logstash_functional'); + }); describe('post', () => { it('should return 200 when correctly formatted searches are provided', async () => { const resp = await supertest @@ -28,13 +39,37 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(200); + expect(resp.status).to.be(200); expect(resp.body.isPartial).to.be(false); expect(resp.body.isRunning).to.be(false); expect(resp.body).to.have.property('rawResponse'); }); - it('should return 404 when if no strategy is provided', async () => - await supertest + it('should return 200 if terminated early', async () => { + const resp = await supertest + .post(`/internal/search/es`) + .send({ + params: { + terminateAfter: 1, + index: 'log*', + size: 1000, + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(200); + + expect(resp.status).to.be(200); + expect(resp.body.isPartial).to.be(false); + expect(resp.body.isRunning).to.be(false); + expect(resp.body.rawResponse.terminated_early).to.be(true); + }); + + it('should return 404 when if no strategy is provided', async () => { + const resp = await supertest .post(`/internal/search`) .send({ body: { @@ -43,7 +78,10 @@ export default function ({ getService }: FtrProviderContext) { }, }, }) - .expect(404)); + .expect(404); + + verifyErrorResponse(resp.body, 404); + }); it('should return 404 when if unknown strategy is provided', async () => { const resp = await supertest @@ -56,6 +94,8 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(404); + + verifyErrorResponse(resp.body, 404); expect(resp.body.message).to.contain('banana not found'); }); @@ -74,11 +114,33 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); + verifyErrorResponse(resp.body, 400); + expect(resp.body.message).to.contain('Unsupported index pattern'); }); + it('should return 400 with illegal ES argument', async () => { + const resp = await supertest + .post(`/internal/search/es`) + .send({ + params: { + timeout: 1, // This should be a time range string! + index: 'log*', + size: 1000, + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(400); + + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); + }); + it('should return 400 with a bad body', async () => { - await supertest + const resp = await supertest .post(`/internal/search/es`) .send({ params: { @@ -89,16 +151,26 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); + + verifyErrorResponse(resp.body, 400, 'parsing_exception', true); + }); + + it('should return 400 for a painless error', async () => { + const resp = await supertest.post(`/internal/search/es`).send(painlessErrReq).expect(400); + + verifyErrorResponse(resp.body, 400, 'search_phase_execution_exception', true); }); }); describe('delete', () => { it('should return 404 when no search id provided', async () => { - await supertest.delete(`/internal/search/es`).send().expect(404); + const resp = await supertest.delete(`/internal/search/es`).send().expect(404); + verifyErrorResponse(resp.body, 404); }); it('should return 400 when trying a delete on a non supporting strategy', async () => { const resp = await supertest.delete(`/internal/search/es/123`).send().expect(400); + verifyErrorResponse(resp.body, 400); expect(resp.body.message).to.contain("Search strategy es doesn't support cancellations"); }); }); diff --git a/test/api_integration/apis/search/verify_error.ts b/test/api_integration/apis/search/verify_error.ts new file mode 100644 index 0000000000000..a5754ff47973e --- /dev/null +++ b/test/api_integration/apis/search/verify_error.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; + +export const verifyErrorResponse = ( + r: any, + expectedCode: number, + message?: string, + shouldHaveAttrs?: boolean +) => { + expect(r.statusCode).to.be(expectedCode); + if (message) { + expect(r.message).to.be(message); + } + if (shouldHaveAttrs) { + expect(r).to.have.property('attributes'); + expect(r.attributes).to.have.property('root_cause'); + } else { + expect(r).not.to.have.property('attributes'); + } +}; diff --git a/test/api_integration/apis/telemetry/opt_in.ts b/test/api_integration/apis/telemetry/opt_in.ts index f03b33e61965e..ba5f46c38211f 100644 --- a/test/api_integration/apis/telemetry/opt_in.ts +++ b/test/api_integration/apis/telemetry/opt_in.ts @@ -14,10 +14,13 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function optInTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); + describe('/api/telemetry/v2/optIn API', () => { let defaultAttributes: TelemetrySavedObjectAttributes; let kibanaVersion: any; before(async () => { + await esArchiver.emptyKibanaIndex(); const kibanaVersionAccessor = kibanaServer.version; kibanaVersion = await kibanaVersionAccessor.get(); defaultAttributes = diff --git a/test/api_integration/apis/telemetry/telemetry_local.ts b/test/api_integration/apis/telemetry/telemetry_local.ts index 25d29a807bdad..650846015a4a2 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.ts +++ b/test/api_integration/apis/telemetry/telemetry_local.ts @@ -177,6 +177,7 @@ export default function ({ getService }: FtrProviderContext) { describe('basic behaviour', () => { let savedObjectIds: string[] = []; before('create application usage entries', async () => { + await esArchiver.emptyKibanaIndex(); savedObjectIds = await Promise.all([ createSavedObject(), createSavedObject('appView1'), diff --git a/test/api_integration/apis/ui_counters/ui_counters.ts b/test/api_integration/apis/ui_counters/ui_counters.ts index 1cf16fe433bf9..8d60c79c9698d 100644 --- a/test/api_integration/apis/ui_counters/ui_counters.ts +++ b/test/api_integration/apis/ui_counters/ui_counters.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const createUiCounterEvent = (eventName: string, type: UiCounterMetricType, count = 1) => ({ @@ -23,6 +24,10 @@ export default function ({ getService }: FtrProviderContext) { }); describe('UI Counters API', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); + const dayDate = moment().format('DDMMYYYY'); it('stores ui counter events in savedObjects', async () => { diff --git a/test/api_integration/apis/ui_metric/ui_metric.ts b/test/api_integration/apis/ui_metric/ui_metric.ts index d330cb037d1a1..e3b3b2ec4c542 100644 --- a/test/api_integration/apis/ui_metric/ui_metric.ts +++ b/test/api_integration/apis/ui_metric/ui_metric.ts @@ -13,6 +13,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); const es = getService('es'); const createStatsMetric = ( @@ -34,6 +35,10 @@ export default function ({ getService }: FtrProviderContext) { }); describe('ui_metric savedObject data', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); + it('increments the count field in the document defined by the {app}/{action_type} path', async () => { const reportManager = new ReportManager(); const uiStatsMetric = createStatsMetric('myEvent'); diff --git a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json new file mode 100644 index 0000000000000..21d84c4b55e55 --- /dev/null +++ b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json @@ -0,0 +1,190 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "timelion-sheet:190f3e90-2ec3-11e8-ba48-69fc4e41e1f6", + "source": { + "type": "timelion-sheet", + "updated_at": "2018-03-23T17:53:30.872Z", + "timelion-sheet": { + "title": "New TimeLion Sheet", + "hits": 0, + "description": "", + "timelion_sheet": [ + ".es(*)" + ], + "timelion_interval": "auto", + "timelion_chart_height": 275, + "timelion_columns": 2, + "timelion_rows": 2, + "version": 1 + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "index-pattern:8963ca30-3224-11e8-a572-ffca06da1357", + "source": { + "type": "index-pattern", + "updated_at": "2018-03-28T01:08:34.290Z", + "index-pattern": { + "title": "saved_objects*", + "fields": "[{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "config:7.0.0-alpha1", + "source": { + "type": "config", + "updated_at": "2018-03-28T01:08:39.248Z", + "config": { + "buildNum": 8467, + "telemetry:optIn": false, + "defaultIndex": "8963ca30-3224-11e8-a572-ffca06da1357" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "search:960372e0-3224-11e8-a572-ffca06da1357", + "source": { + "type": "search", + "updated_at": "2018-03-28T01:08:55.182Z", + "search": { + "title": "OneRecord", + "description": "", + "hits": 0, + "columns": [ + "_source" + ], + "sort": [ + "_score", + "desc" + ], + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"8963ca30-3224-11e8-a572-ffca06da1357\",\"highlightAll\":true,\"version\":true,\"query\":{\"query\":\"id:3\",\"language\":\"lucene\"},\"filter\":[]}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:a42c0580-3224-11e8-a572-ffca06da1357", + "source": { + "type": "visualization", + "updated_at": "2018-03-28T01:09:18.936Z", + "visualization": { + "title": "VisualizationFromSavedSearch", + "visState": "{\"title\":\"VisualizationFromSavedSearch\",\"type\":\"table\",\"params\":{\"perPage\":10,\"showPartialRows\":false,\"showMeticsAtAllLevels\":false,\"sort\":{\"columnIndex\":null,\"direction\":null},\"showTotal\":false,\"totalFunc\":\"sum\"},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}]}", + "uiStateJSON": "{\"vis\":{\"params\":{\"sort\":{\"columnIndex\":null,\"direction\":null}}}}", + "description": "", + "savedSearchId": "960372e0-3224-11e8-a572-ffca06da1357", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "visualization:add810b0-3224-11e8-a572-ffca06da1357", + "source": { + "type": "visualization", + "updated_at": "2018-03-28T01:09:35.163Z", + "visualization": { + "title": "Visualization", + "visState": "{\"title\":\"Visualization\",\"type\":\"metric\",\"params\":{\"addTooltip\":true,\"addLegend\":false,\"type\":\"metric\",\"metric\":{\"percentageMode\":false,\"useRanges\":false,\"colorSchema\":\"Green to Red\",\"metricColorMode\":\"None\",\"colorsRange\":[{\"from\":0,\"to\":10000}],\"labels\":{\"show\":true},\"invertColors\":false,\"style\":{\"bgFill\":\"#000\",\"bgColor\":false,\"labelColor\":false,\"subText\":\"\",\"fontSize\":60}}},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}}]}", + "uiStateJSON": "{}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"8963ca30-3224-11e8-a572-ffca06da1357\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "dashboard:b70c7ae0-3224-11e8-a572-ffca06da1357", + "source": { + "type": "dashboard", + "updated_at": "2018-03-28T01:09:50.606Z", + "dashboard": { + "title": "Dashboard", + "hits": 0, + "description": "", + "panelsJSON": "[{\"gridData\":{\"w\":24,\"h\":15,\"x\":0,\"y\":0,\"i\":\"1\"},\"version\":\"7.0.0-alpha1\",\"panelIndex\":\"1\",\"type\":\"visualization\",\"id\":\"add810b0-3224-11e8-a572-ffca06da1357\",\"embeddableConfig\":{}},{\"gridData\":{\"w\":24,\"h\":15,\"x\":24,\"y\":0,\"i\":\"2\"},\"version\":\"7.0.0-alpha1\",\"panelIndex\":\"2\",\"type\":\"visualization\",\"id\":\"a42c0580-3224-11e8-a572-ffca06da1357\",\"embeddableConfig\":{}}]", + "optionsJSON": "{\"darkTheme\":false,\"useMargins\":true,\"hidePanelTitles\":false}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "dashboard:invalid-refs", + "source": { + "type": "dashboard", + "updated_at": "2018-03-28T01:09:50.606Z", + "dashboard": { + "title": "Dashboard", + "hits": 0, + "description": "", + "panelsJSON": "[]", + "optionsJSON": "{}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + } + }, + "references": [ + { + "type":"visualization", + "id": "add810b0-3224-11e8-a572-ffca06da1357", + "name": "valid-ref" + }, + { + "type":"visualization", + "id": "invalid-vis", + "name": "missing-ref" + } + ] + } + } +} diff --git a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json.gz b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json.gz deleted file mode 100644 index 0834567abb66b..0000000000000 Binary files a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/data.json.gz and /dev/null differ diff --git a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/mappings.json b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/mappings.json index c670508247b1a..6dd4d198e0f67 100644 --- a/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/mappings.json +++ b/test/api_integration/fixtures/es_archiver/management/saved_objects/relationships/mappings.json @@ -12,6 +12,20 @@ "mappings": { "dynamic": "strict", "properties": { + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, "config": { "dynamic": "true", "properties": { @@ -280,4 +294,4 @@ } } } -} \ No newline at end of file +} diff --git a/test/common/config.js b/test/common/config.js index b6d12444b7017..8a42e6c87b214 100644 --- a/test/common/config.js +++ b/test/common/config.js @@ -61,8 +61,6 @@ export default function () { ...(!!process.env.CODE_COVERAGE ? [`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'coverage')}`] : []), - // Disable v2 migrations in tests for now - '--migrations.enableV2=false', ], }, services, diff --git a/test/common/services/kibana_server/extend_es_archiver.js b/test/common/services/kibana_server/extend_es_archiver.js index 5390b43a87187..1d76bc4473767 100644 --- a/test/common/services/kibana_server/extend_es_archiver.js +++ b/test/common/services/kibana_server/extend_es_archiver.js @@ -6,7 +6,7 @@ * Public License, v 1. */ -const ES_ARCHIVER_LOAD_METHODS = ['load', 'loadIfNeeded', 'unload']; +const ES_ARCHIVER_LOAD_METHODS = ['load', 'loadIfNeeded', 'unload', 'emptyKibanaIndex']; const KIBANA_INDEX = '.kibana'; export function extendEsArchiver({ esArchiver, kibanaServer, retry, defaults }) { @@ -25,7 +25,7 @@ export function extendEsArchiver({ esArchiver, kibanaServer, retry, defaults }) const statsKeys = Object.keys(stats); const kibanaKeys = statsKeys.filter( // this also matches stats keys like '.kibana_1' and '.kibana_2,.kibana_1' - (key) => key.includes(KIBANA_INDEX) && (stats[key].created || stats[key].deleted) + (key) => key.includes(KIBANA_INDEX) && stats[key].created ); // if the kibana index was created by the esArchiver then update the uiSettings diff --git a/test/functional/apps/dashboard/dashboard_save.ts b/test/functional/apps/dashboard/dashboard_save.ts index 27cbba7db393d..e36136cd45141 100644 --- a/test/functional/apps/dashboard/dashboard_save.ts +++ b/test/functional/apps/dashboard/dashboard_save.ts @@ -12,7 +12,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header']); const listingTable = getService('listingTable'); - describe('dashboard save', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/89476 + describe.skip('dashboard save', function describeIndexTests() { this.tags('includeFirefox'); const dashboardName = 'Dashboard Save Test'; const dashboardNameEnterKey = 'Dashboard Save Test with Enter Key'; diff --git a/test/functional/apps/discover/_data_grid_doc_table.ts b/test/functional/apps/discover/_data_grid_doc_table.ts index 8481065c18466..10cdd7e866af9 100644 --- a/test/functional/apps/discover/_data_grid_doc_table.ts +++ b/test/functional/apps/discover/_data_grid_doc_table.ts @@ -33,6 +33,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); }); + after(async function () { + log.debug('reset uiSettings'); + await kibanaServer.uiSettings.replace({}); + }); + it('should show the first 50 rows by default', async function () { // with the default range the number of hits is ~14000 const rows = await dataGrid.getDocTableRows(); diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 1176dd6395d2c..bf0a027553832 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -227,7 +227,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); describe('usage of discover:searchOnPageLoad', () => { - it('should fetch data from ES initially when discover:searchOnPageLoad is false', async function () { + it('should not fetch data from ES initially when discover:searchOnPageLoad is false', async function () { await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': false }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); @@ -235,7 +235,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.discover.getNrOfFetches()).to.be(0); }); - it('should not fetch data from ES initially when discover:searchOnPageLoad is true', async function () { + it('should fetch data from ES initially when discover:searchOnPageLoad is true', async function () { await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': true }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 6e6c53ec04985..ec6c455ecc979 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -26,7 +26,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const testSubjects = getService('testSubjects'); - describe('saved queries saved objects', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/89477 + describe.skip('saved queries saved objects', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); await esArchiver.load('discover'); diff --git a/test/functional/apps/getting_started/_shakespeare.ts b/test/functional/apps/getting_started/_shakespeare.ts index 95abbf9fa8a78..5a891af0de93d 100644 --- a/test/functional/apps/getting_started/_shakespeare.ts +++ b/test/functional/apps/getting_started/_shakespeare.ts @@ -30,8 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // https://www.elastic.co/guide/en/kibana/current/tutorial-load-dataset.html - // Failing: See https://github.com/elastic/kibana/issues/82206 - describe.skip('Shakespeare', function describeIndexTests() { + describe('Shakespeare', function describeIndexTests() { // index starts on the first "count" metric at 1 // Each new metric or aggregation added to a visualization gets the next index. // So to modify a metric or aggregation tests need to keep track of the diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 438dd6f8adce2..a9fe2026112b6 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -20,8 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardExpect = getService('dashboardExpect'); const PageObjects = getPageObjects(['common', 'header', 'home', 'dashboard', 'timePicker']); - // Failing: See https://github.com/elastic/kibana/issues/89379 - describe.skip('sample data', function describeIndexTests() { + describe('sample data', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'kibana_sample_admin']); await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { diff --git a/test/functional/apps/management/_import_objects.ts b/test/functional/apps/management/_import_objects.ts index 07811c9c68e45..e18f2a7485444 100644 --- a/test/functional/apps/management/_import_objects.ts +++ b/test/functional/apps/management/_import_objects.ts @@ -23,12 +23,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const log = getService('log'); - describe('import objects', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/89478 + describe.skip('import objects', function describeIndexTests() { describe('.ndjson file', () => { beforeEach(async function () { + await esArchiver.load('management'); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); - await esArchiver.load('management'); await PageObjects.settings.clickKibanaSavedObjects(); }); @@ -212,10 +213,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('.json file', () => { beforeEach(async function () { - // delete .kibana index and then wait for Kibana to re-create it + await esArchiver.load('saved_objects_imports'); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); - await esArchiver.load('saved_objects_imports'); await PageObjects.settings.clickKibanaSavedObjects(); }); diff --git a/test/functional/apps/management/_index_pattern_filter.js b/test/functional/apps/management/_index_pattern_filter.js index eae53682b6ccf..91ea13348d611 100644 --- a/test/functional/apps/management/_index_pattern_filter.js +++ b/test/functional/apps/management/_index_pattern_filter.js @@ -12,10 +12,11 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); const PageObjects = getPageObjects(['settings']); + const esArchiver = getService('esArchiver'); describe('index pattern filter', function describeIndexTests() { before(async function () { - // delete .kibana index and then wait for Kibana to re-create it + await esArchiver.emptyKibanaIndex(); await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaIndexPatterns(); diff --git a/test/functional/apps/management/_index_patterns_empty.ts b/test/functional/apps/management/_index_patterns_empty.ts index 3b89e05d4b582..4e86de6d70653 100644 --- a/test/functional/apps/management/_index_patterns_empty.ts +++ b/test/functional/apps/management/_index_patterns_empty.ts @@ -19,7 +19,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('index pattern empty view', () => { before(async () => { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await esArchiver.unload('logstash_functional'); await esArchiver.unload('makelogs'); await kibanaServer.uiSettings.replace({}); @@ -27,7 +27,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); after(async () => { - await esArchiver.unload('empty_kibana'); await esArchiver.loadIfNeeded('makelogs'); // @ts-expect-error await es.transport.request({ diff --git a/test/functional/apps/management/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.js index 45a18d5932764..87eca2c7a5a65 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.js @@ -18,14 +18,13 @@ export default function ({ getService, getPageObjects }) { describe('mgmt saved objects', function describeIndexTests() { beforeEach(async function () { - await esArchiver.load('empty_kibana'); + await esArchiver.emptyKibanaIndex(); await esArchiver.load('discover'); await PageObjects.settings.navigateTo(); }); afterEach(async function () { await esArchiver.unload('discover'); - await esArchiver.load('empty_kibana'); }); it('should import saved objects mgmt', async function () { diff --git a/test/functional/apps/management/_scripted_fields_preview.js b/test/functional/apps/management/_scripted_fields_preview.js index 104d41b7e2a75..46619b89dfc59 100644 --- a/test/functional/apps/management/_scripted_fields_preview.js +++ b/test/functional/apps/management/_scripted_fields_preview.js @@ -13,7 +13,8 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['settings']); const SCRIPTED_FIELD_NAME = 'myScriptedField'; - describe('scripted fields preview', () => { + // FLAKY: https://github.com/elastic/kibana/issues/89475 + describe.skip('scripted fields preview', () => { before(async function () { await browser.setWindowSize(1200, 800); await PageObjects.settings.createIndexPattern(); diff --git a/test/functional/apps/management/_test_huge_fields.js b/test/functional/apps/management/_test_huge_fields.js index 2ab619276d2b9..c1fca31e695cb 100644 --- a/test/functional/apps/management/_test_huge_fields.js +++ b/test/functional/apps/management/_test_huge_fields.js @@ -20,6 +20,7 @@ export default function ({ getService, getPageObjects }) { const EXPECTED_FIELD_COUNT = '10006'; before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_testhuge_reader']); + await esArchiver.emptyKibanaIndex(); await esArchiver.loadIfNeeded('large_fields'); await PageObjects.settings.createIndexPattern('testhuge', 'date'); }); diff --git a/test/functional/apps/management/index.ts b/test/functional/apps/management/index.ts index ca89853875027..06e652f9f3e59 100644 --- a/test/functional/apps/management/index.ts +++ b/test/functional/apps/management/index.ts @@ -14,13 +14,11 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { describe('management', function () { before(async () => { await esArchiver.unload('logstash_functional'); - await esArchiver.load('empty_kibana'); await esArchiver.loadIfNeeded('makelogs'); }); after(async () => { await esArchiver.unload('makelogs'); - await esArchiver.unload('empty_kibana'); }); describe('', function () { diff --git a/test/functional/apps/saved_objects_management/index.ts b/test/functional/apps/saved_objects_management/index.ts index 9491661de73ef..5e4eaefb7e9d1 100644 --- a/test/functional/apps/saved_objects_management/index.ts +++ b/test/functional/apps/saved_objects_management/index.ts @@ -12,5 +12,6 @@ export default function savedObjectsManagementApp({ loadTestFile }: FtrProviderC describe('saved objects management', function savedObjectsManagementAppTestSuite() { this.tags('ciGroup7'); loadTestFile(require.resolve('./edit_saved_object')); + loadTestFile(require.resolve('./show_relationships')); }); } diff --git a/test/functional/apps/saved_objects_management/show_relationships.ts b/test/functional/apps/saved_objects_management/show_relationships.ts new file mode 100644 index 0000000000000..6f3fb5a4973e2 --- /dev/null +++ b/test/functional/apps/saved_objects_management/show_relationships.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'settings', 'savedObjects']); + + describe('saved objects relationships flyout', () => { + beforeEach(async () => { + await esArchiver.load('saved_objects_management/show_relationships'); + }); + + afterEach(async () => { + await esArchiver.unload('saved_objects_management/show_relationships'); + }); + + it('displays the invalid references', async () => { + await PageObjects.settings.navigateTo(); + await PageObjects.settings.clickKibanaSavedObjects(); + + const objects = await PageObjects.savedObjects.getRowTitles(); + expect(objects.includes('Dashboard with missing refs')).to.be(true); + + await PageObjects.savedObjects.clickRelationshipsByTitle('Dashboard with missing refs'); + + const invalidRelations = await PageObjects.savedObjects.getInvalidRelations(); + + expect(invalidRelations).to.eql([ + { + error: 'Saved object [visualization/missing-vis-ref] not found', + id: 'missing-vis-ref', + relationship: 'Child', + type: 'visualization', + }, + { + error: 'Saved object [dashboard/missing-dashboard-ref] not found', + id: 'missing-dashboard-ref', + relationship: 'Child', + type: 'dashboard', + }, + ]); + }); + }); +} diff --git a/test/functional/apps/timelion/_expression_typeahead.js b/test/functional/apps/timelion/_expression_typeahead.js index 744f8de15e767..3db5cb48dd38b 100644 --- a/test/functional/apps/timelion/_expression_typeahead.js +++ b/test/functional/apps/timelion/_expression_typeahead.js @@ -75,18 +75,18 @@ export default function ({ getPageObjects }) { await PageObjects.timelion.updateExpression(',split'); await PageObjects.timelion.clickSuggestion(); const suggestions = await PageObjects.timelion.getSuggestionItemsText(); - expect(suggestions.length).to.eql(51); + expect(suggestions.length).not.to.eql(0); expect(suggestions[0].includes('@message.raw')).to.eql(true); - await PageObjects.timelion.clickSuggestion(10, 2000); + await PageObjects.timelion.clickSuggestion(10); }); it('should show field suggestions for metric argument when index pattern set', async () => { await PageObjects.timelion.updateExpression(',metric'); await PageObjects.timelion.clickSuggestion(); await PageObjects.timelion.updateExpression('avg:'); - await PageObjects.timelion.clickSuggestion(0, 2000); + await PageObjects.timelion.clickSuggestion(0); const suggestions = await PageObjects.timelion.getSuggestionItemsText(); - expect(suggestions.length).to.eql(2); + expect(suggestions.length).not.to.eql(0); expect(suggestions[0].includes('avg:bytes')).to.eql(true); }); }); diff --git a/test/functional/apps/visualize/input_control_vis/input_control_range.ts b/test/functional/apps/visualize/input_control_vis/input_control_range.ts index 9b48e78246b37..613b1a162eb63 100644 --- a/test/functional/apps/visualize/input_control_vis/input_control_range.ts +++ b/test/functional/apps/visualize/input_control_vis/input_control_range.ts @@ -12,7 +12,6 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const kibanaServer = getService('kibanaServer'); const find = getService('find'); const security = getService('security'); const { visualize, visEditor } = getPageObjects(['visualize', 'visEditor']); @@ -53,7 +52,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await esArchiver.loadIfNeeded('logstash_functional'); await esArchiver.loadIfNeeded('long_window_logstash'); await esArchiver.load('visualize'); - await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); await security.testUser.restoreDefaults(); }); }); diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/data.json b/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/data.json new file mode 100644 index 0000000000000..4d5b969a3c931 --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/data.json @@ -0,0 +1,36 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "dashboard:dash-with-missing-refs", + "source": { + "dashboard": { + "title": "Dashboard with missing refs", + "hits": 0, + "description": "", + "panelsJSON": "[]", + "optionsJSON": "{}", + "version": 1, + "timeRestore": false, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{}" + } + }, + "type": "dashboard", + "references": [ + { + "type": "visualization", + "id": "missing-vis-ref", + "name": "some missing ref" + }, + { + "type": "dashboard", + "id": "missing-dashboard-ref", + "name": "some other missing ref" + } + ], + "updated_at": "2019-01-22T19:32:47.232Z" + } + } +} diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/mappings.json new file mode 100644 index 0000000000000..d53e6c96e883e --- /dev/null +++ b/test/functional/fixtures/es_archiver/saved_objects_management/show_relationships/mappings.json @@ -0,0 +1,473 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "dynamic": "strict", + "properties": { + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "apm-telemetry": { + "properties": { + "has_any_services": { + "type": "boolean" + }, + "services_per_agent": { + "properties": { + "go": { + "type": "long", + "null_value": 0 + }, + "java": { + "type": "long", + "null_value": 0 + }, + "js-base": { + "type": "long", + "null_value": 0 + }, + "nodejs": { + "type": "long", + "null_value": 0 + }, + "python": { + "type": "long", + "null_value": 0 + }, + "ruby": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "type": "text", + "index": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "accessibility:disableAnimations": { + "type": "boolean" + }, + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "telemetry:optIn": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "map": { + "properties": { + "bounds": { + "type": "geo_shape", + "tree": "quadtree" + }, + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "index-pattern": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "spaceId": { + "type": "keyword" + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + } + } +} diff --git a/test/functional/fixtures/es_archiver/visualize/data.json b/test/functional/fixtures/es_archiver/visualize/data.json index 56397351562de..66941e201e9ba 100644 --- a/test/functional/fixtures/es_archiver/visualize/data.json +++ b/test/functional/fixtures/es_archiver/visualize/data.json @@ -269,3 +269,24 @@ } } } + +{ + "type": "doc", + "value": { + "id": "visualization:VegaMap", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "description": "VegaMap", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "title": "VegaMap", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[],\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega/v5.json\\n config: {\\n kibana: {type: \\\"map\\\", latitude: 25, longitude: -70, zoom: 3}\\n }\\n data: [\\n {\\n name: table\\n url: {\\n index: kibana_sample_data_flights\\n %context%: true\\n // Uncomment to enable time filtering\\n // %timefield%: timestamp\\n body: {\\n size: 0\\n aggs: {\\n origins: {\\n terms: {field: \\\"OriginAirportID\\\", size: 10000}\\n aggs: {\\n originLocation: {\\n top_hits: {\\n size: 1\\n _source: {\\n includes: [\\\"OriginLocation\\\", \\\"Origin\\\"]\\n }\\n }\\n }\\n distinations: {\\n terms: {field: \\\"DestAirportID\\\", size: 10000}\\n aggs: {\\n destLocation: {\\n top_hits: {\\n size: 1\\n _source: {\\n includes: [\\\"DestLocation\\\"]\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n format: {property: \\\"aggregations.origins.buckets\\\"}\\n transform: [\\n {\\n type: geopoint\\n projection: projection\\n fields: [\\n originLocation.hits.hits[0]._source.OriginLocation.lon\\n originLocation.hits.hits[0]._source.OriginLocation.lat\\n ]\\n }\\n ]\\n }\\n {\\n name: selectedDatum\\n on: [\\n {trigger: \\\"!selected\\\", remove: true}\\n {trigger: \\\"selected\\\", insert: \\\"selected\\\"}\\n ]\\n }\\n ]\\n signals: [\\n {\\n name: selected\\n value: null\\n on: [\\n {events: \\\"@airport:mouseover\\\", update: \\\"datum\\\"}\\n {events: \\\"@airport:mouseout\\\", update: \\\"null\\\"}\\n ]\\n }\\n ]\\n scales: [\\n {\\n name: airportSize\\n type: linear\\n domain: {data: \\\"table\\\", field: \\\"doc_count\\\"}\\n range: [\\n {signal: \\\"zoom*zoom*0.2+1\\\"}\\n {signal: \\\"zoom*zoom*10+1\\\"}\\n ]\\n }\\n ]\\n marks: [\\n {\\n type: group\\n from: {\\n facet: {\\n name: facetedDatum\\n data: selectedDatum\\n field: distinations.buckets\\n }\\n }\\n data: [\\n {\\n name: facetDatumElems\\n source: facetedDatum\\n transform: [\\n {\\n type: geopoint\\n projection: projection\\n fields: [\\n destLocation.hits.hits[0]._source.DestLocation.lon\\n destLocation.hits.hits[0]._source.DestLocation.lat\\n ]\\n }\\n {type: \\\"formula\\\", expr: \\\"{x:parent.x, y:parent.y}\\\", as: \\\"source\\\"}\\n {type: \\\"formula\\\", expr: \\\"{x:datum.x, y:datum.y}\\\", as: \\\"target\\\"}\\n {type: \\\"linkpath\\\", shape: \\\"diagonal\\\"}\\n ]\\n }\\n ]\\n scales: [\\n {\\n name: lineThickness\\n type: log\\n clamp: true\\n range: [1, 8]\\n }\\n {\\n name: lineOpacity\\n type: log\\n clamp: true\\n range: [0.2, 0.8]\\n }\\n ]\\n marks: [\\n {\\n from: {data: \\\"facetDatumElems\\\"}\\n type: path\\n interactive: false\\n encode: {\\n update: {\\n path: {field: \\\"path\\\"}\\n stroke: {value: \\\"black\\\"}\\n strokeWidth: {scale: \\\"lineThickness\\\", field: \\\"doc_count\\\"}\\n strokeOpacity: {scale: \\\"lineOpacity\\\", field: \\\"doc_count\\\"}\\n }\\n }\\n }\\n ]\\n }\\n {\\n name: airport\\n type: symbol\\n from: {data: \\\"table\\\"}\\n encode: {\\n update: {\\n size: {scale: \\\"airportSize\\\", field: \\\"doc_count\\\"}\\n xc: {signal: \\\"datum.x\\\"}\\n yc: {signal: \\\"datum.y\\\"}\\n tooltip: {\\n signal: \\\"{title: datum.originLocation.hits.hits[0]._source.Origin + ' (' + datum.key + ')', connnections: length(datum.distinations.buckets), flights: datum.doc_count}\\\"\\n }\\n }\\n }\\n }\\n ]\\n}\"},\"title\":\"[Flights] Airport Connections (Hover Over Airport)\",\"type\":\"vega\"}" + } + } + } +} diff --git a/test/functional/page_objects/management/saved_objects_page.ts b/test/functional/page_objects/management/saved_objects_page.ts index 1cdf76ad58ef0..cf162f12df9d9 100644 --- a/test/functional/page_objects/management/saved_objects_page.ts +++ b/test/functional/page_objects/management/saved_objects_page.ts @@ -257,6 +257,22 @@ export function SavedObjectsPageProvider({ getService, getPageObjects }: FtrProv }); } + async getInvalidRelations() { + const rows = await testSubjects.findAll('invalidRelationshipsTableRow'); + return mapAsync(rows, async (row) => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const objectId = await row.findByTestSubject('relationshipsObjectId'); + const relationship = await row.findByTestSubject('directRelationship'); + const error = await row.findByTestSubject('relationshipsError'); + return { + type: await objectType.getVisibleText(), + id: await objectId.getVisibleText(), + relationship: await relationship.getVisibleText(), + error: await error.getVisibleText(), + }; + }); + } + async getTableSummary() { const table = await testSubjects.find('savedObjectsTable'); const $ = await table.parseDomContent(); diff --git a/test/functional/services/common/browser.ts b/test/functional/services/common/browser.ts index 635fde6dad720..4a7e82d5b42c0 100644 --- a/test/functional/services/common/browser.ts +++ b/test/functional/services/common/browser.ts @@ -289,13 +289,13 @@ export async function BrowserProvider({ getService }: FtrProviderContext) { } const origin = document.querySelector(arguments[0]); - const target = document.querySelector(arguments[1]); const dragStartEvent = createEvent('dragstart'); dispatchEvent(origin, dragStartEvent); setTimeout(() => { const dropEvent = createEvent('drop'); + const target = document.querySelector(arguments[1]); dispatchEvent(target, dropEvent, dragStartEvent.dataTransfer); const dragEndEvent = createEvent('dragend'); dispatchEvent(origin, dragEndEvent, dropEvent.dataTransfer); diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index e72d032f63469..52924d8c93280 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -19,6 +19,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const find = getService('find'); const retry = getService('retry'); const deployment = getService('deployment'); + const esArchiver = getService('esArchiver'); const loadingScreenNotShown = async () => expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); @@ -50,6 +51,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide describe('ui applications', function describeIndexTests() { before(async () => { + await esArchiver.emptyKibanaIndex(); await PageObjects.common.navigateToApp('foo'); }); diff --git a/test/plugin_functional/test_suites/data_plugin/index_patterns.ts b/test/plugin_functional/test_suites/data_plugin/index_patterns.ts index ba12e2df16d41..0cd53a5e1b764 100644 --- a/test/plugin_functional/test_suites/data_plugin/index_patterns.ts +++ b/test/plugin_functional/test_suites/data_plugin/index_patterns.ts @@ -12,8 +12,12 @@ import '../../plugins/core_provider_plugin/types'; export default function ({ getService }: PluginFunctionalProviderContext) { const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); describe('index patterns', function () { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); let indexPatternId = ''; it('can create an index pattern', async () => { diff --git a/test/plugin_functional/test_suites/data_plugin/session.ts b/test/plugin_functional/test_suites/data_plugin/session.ts index ac958ead321bc..5567958cfd878 100644 --- a/test/plugin_functional/test_suites/data_plugin/session.ts +++ b/test/plugin_functional/test_suites/data_plugin/session.ts @@ -42,10 +42,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide await PageObjects.header.waitUntilLoadingHasFinished(); const sessionIds = await getSessionIds(); - // Discover calls destroy on index pattern change, which explicitly closes a session - expect(sessionIds.length).to.be(2); - expect(sessionIds[0].length).to.be(0); - expect(sessionIds[1].length).not.to.be(0); + expect(sessionIds.length).to.be(1); }); it('Starts on a refresh', async () => { diff --git a/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts b/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts index 71663b19b35cb..b60e4b4a1d8b7 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/import_warnings.ts @@ -10,10 +10,15 @@ import path from 'path'; import expect from '@kbn/expect'; import { PluginFunctionalProviderContext } from '../../services'; -export default function ({ getPageObjects }: PluginFunctionalProviderContext) { +export default function ({ getPageObjects, getService }: PluginFunctionalProviderContext) { const PageObjects = getPageObjects(['common', 'settings', 'header', 'savedObjects']); + const esArchiver = getService('esArchiver'); describe('import warnings', () => { + before(async () => { + await esArchiver.emptyKibanaIndex(); + }); + beforeEach(async () => { await PageObjects.settings.navigateTo(); await PageObjects.settings.clickKibanaSavedObjects(); diff --git a/test/scripts/checks/test_projects.sh b/test/scripts/checks/test_projects.sh index 56f15f6839e9d..be3fe4c4be9d0 100755 --- a/test/scripts/checks/test_projects.sh +++ b/test/scripts/checks/test_projects.sh @@ -3,4 +3,4 @@ source src/dev/ci_setup/setup_env.sh checks-reporter-with-killswitch "Test Projects" \ - yarn kbn run test --exclude kibana --oss --skip-kibana-plugins + yarn kbn run test --exclude kibana --oss --skip-kibana-plugins --skip-missing diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index 9e387f97a016e..6e28f9c3ef56a 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -2,6 +2,12 @@ source test/scripts/jenkins_test_setup.sh +rename_coverage_file() { + test -f target/kibana-coverage/jest/coverage-final.json \ + && mv target/kibana-coverage/jest/coverage-final.json \ + target/kibana-coverage/jest/$1-coverage-final.json +} + if [[ -z "$CODE_COVERAGE" ]] ; then # Lint ./test/scripts/lint/eslint.sh @@ -28,8 +34,13 @@ if [[ -z "$CODE_COVERAGE" ]] ; then ./test/scripts/checks/test_hardening.sh else echo " -> Running jest tests with coverage" - node scripts/jest --ci --verbose --maxWorkers=6 --coverage || true; - + node scripts/jest --ci --verbose --coverage --config jest.config.oss.js || true; + rename_coverage_file "oss" + echo "" + echo "" echo " -> Running jest integration tests with coverage" - node scripts/jest_integration --ci --verbose --coverage || true; + node --max-old-space-size=8192 scripts/jest_integration --ci --verbose --coverage || true; + rename_coverage_file "oss-integration" + echo "" + echo "" fi diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh new file mode 100755 index 0000000000000..66fb5ae5370bc --- /dev/null +++ b/test/scripts/jenkins_xpack.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +source test/scripts/jenkins_test_setup.sh + +if [[ -z "$CODE_COVERAGE" ]] ; then + echo " -> Running jest tests" + + ./test/scripts/test/xpack_jest_unit.sh +else + echo " -> Build runtime for canvas" + # build runtime for canvas + echo "NODE_ENV=$NODE_ENV" + node ./x-pack/plugins/canvas/scripts/shareable_runtime + echo " -> Running jest tests with coverage" + cd x-pack + node --max-old-space-size=6144 scripts/jest --ci --verbose --maxWorkers=5 --coverage --config jest.config.js || true; + # rename file in order to be unique one + test -f ../target/kibana-coverage/jest/coverage-final.json \ + && mv ../target/kibana-coverage/jest/coverage-final.json \ + ../target/kibana-coverage/jest/xpack-coverage-final.json + echo "" + echo "" +fi diff --git a/test/scripts/test/jest_integration.sh b/test/scripts/test/jest_integration.sh index c48d9032466a3..78ed804f88430 100755 --- a/test/scripts/test/jest_integration.sh +++ b/test/scripts/test/jest_integration.sh @@ -3,4 +3,4 @@ source src/dev/ci_setup/setup_env.sh checks-reporter-with-killswitch "Jest Integration Tests" \ - node scripts/jest_integration --ci --verbose --coverage + node scripts/jest_integration --ci --verbose diff --git a/test/scripts/test/jest_unit.sh b/test/scripts/test/jest_unit.sh index 14d7268c6f36d..88c0fe528b88c 100755 --- a/test/scripts/test/jest_unit.sh +++ b/test/scripts/test/jest_unit.sh @@ -3,4 +3,4 @@ source src/dev/ci_setup/setup_env.sh checks-reporter-with-killswitch "Jest Unit Tests" \ - node scripts/jest --ci --verbose --maxWorkers=10 --coverage + node scripts/jest --config jest.config.oss.js --ci --verbose --maxWorkers=5 diff --git a/test/scripts/test/xpack_jest_unit.sh b/test/scripts/test/xpack_jest_unit.sh new file mode 100755 index 0000000000000..33b1c8a2b5183 --- /dev/null +++ b/test/scripts/test/xpack_jest_unit.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +source src/dev/ci_setup/setup_env.sh + +checks-reporter-with-killswitch "X-Pack Jest" \ + node scripts/jest x-pack --ci --verbose --maxWorkers=5 diff --git a/test/security_functional/insecure_cluster_warning.ts b/test/security_functional/insecure_cluster_warning.ts index 181c4cf2b46b7..2f7656b743a51 100644 --- a/test/security_functional/insecure_cluster_warning.ts +++ b/test/security_functional/insecure_cluster_warning.ts @@ -31,6 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await browser.setLocalStorageItem('insecureClusterWarningVisibility', ''); await esArchiver.unload('hamlet'); + await esArchiver.emptyKibanaIndex(); }); it('should not warn when the cluster contains no user data', async () => { diff --git a/test/tsconfig.json b/test/tsconfig.json index c8e6e69586ca0..4df74f526077e 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -4,7 +4,12 @@ "incremental": false, "types": ["node", "flot"] }, - "include": ["**/*", "../typings/elastic__node_crypto.d.ts", "typings/**/*", "../packages/kbn-test/types/ftr_globals/**/*"], + "include": [ + "**/*", + "../typings/elastic__node_crypto.d.ts", + "typings/**/*", + "../packages/kbn-test/types/ftr_globals/**/*" + ], "exclude": ["plugin_functional/plugins/**/*", "interpreter_functional/plugins/**/*"], "references": [ { "path": "../src/core/tsconfig.json" }, @@ -34,5 +39,6 @@ { "path": "../src/plugins/ui_actions/tsconfig.json" }, { "path": "../src/plugins/url_forwarding/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../src/plugins/legacy_export/tsconfig.json" } ] } diff --git a/test/visual_regression/config.ts b/test/visual_regression/config.ts index c4951760fc756..60219efc61e6c 100644 --- a/test/visual_regression/config.ts +++ b/test/visual_regression/config.ts @@ -15,7 +15,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), - testFiles: [require.resolve('./tests/console_app'), require.resolve('./tests/discover')], + testFiles: [ + require.resolve('./tests/console_app'), + require.resolve('./tests/discover'), + require.resolve('./tests/vega'), + ], services, diff --git a/test/visual_regression/tests/vega/index.ts b/test/visual_regression/tests/vega/index.ts new file mode 100644 index 0000000000000..6f79ee834b3dc --- /dev/null +++ b/test/visual_regression/tests/vega/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { DEFAULT_OPTIONS } from '../../services/visual_testing/visual_testing'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +// Width must be the same as visual_testing or canvas image widths will get skewed +const [SCREEN_WIDTH] = DEFAULT_OPTIONS.widths || []; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + + describe('vega app', function () { + this.tags('ciGroup6'); + + before(function () { + return browser.setWindowSize(SCREEN_WIDTH, 1000); + }); + + loadTestFile(require.resolve('./vega_map_visualization')); + }); +} diff --git a/test/visual_regression/tests/vega/vega_map_visualization.ts b/test/visual_regression/tests/vega/vega_map_visualization.ts new file mode 100644 index 0000000000000..98aad0cb87795 --- /dev/null +++ b/test/visual_regression/tests/vega/vega_map_visualization.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'visualize', 'visChart', 'visEditor', 'vegaChart']); + const visualTesting = getService('visualTesting'); + + describe('vega chart in visualize app', () => { + before(async () => { + await esArchiver.loadIfNeeded('kibana_sample_data_flights'); + await esArchiver.loadIfNeeded('visualize'); + }); + + after(async () => { + await esArchiver.unload('kibana_sample_data_flights'); + await esArchiver.unload('visualize'); + }); + + it('should show map with vega layer', async function () { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.visualize.openSavedVisualization('VegaMap'); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + await visualTesting.snapshot(); + }); + }); +} diff --git a/tsconfig.json b/tsconfig.json index 334a3febfddda..ee46e075f2df1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,14 +21,15 @@ "src/plugins/es_ui_shared/**/*", "src/plugins/expressions/**/*", "src/plugins/home/**/*", + "src/plugins/input_control_vis/**/*", "src/plugins/inspector/**/*", "src/plugins/kibana_legacy/**/*", "src/plugins/kibana_react/**/*", "src/plugins/kibana_usage_collection/**/*", "src/plugins/kibana_utils/**/*", + "src/plugins/legacy_export/**/*", "src/plugins/management/**/*", "src/plugins/maps_legacy/**/*", - "src/plugins/maps_oss/**/*", "src/plugins/navigation/**/*", "src/plugins/newsfeed/**/*", "src/plugins/region_map/**/*", @@ -54,6 +55,7 @@ "src/plugins/vis_type_timelion/**/*", "src/plugins/vis_type_timeseries/**/*", "src/plugins/vis_type_vislib/**/*", + "src/plugins/vis_type_vega/**/*", "src/plugins/vis_type_xy/**/*", "src/plugins/visualizations/**/*", "src/plugins/visualize/**/*", @@ -84,9 +86,9 @@ { "path": "./src/plugins/kibana_react/tsconfig.json" }, { "path": "./src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "./src/plugins/kibana_utils/tsconfig.json" }, + { "path": "./src/plugins/legacy_export/tsconfig.json" }, { "path": "./src/plugins/management/tsconfig.json" }, { "path": "./src/plugins/maps_legacy/tsconfig.json" }, - { "path": "./src/plugins/maps_oss/tsconfig.json" }, { "path": "./src/plugins/navigation/tsconfig.json" }, { "path": "./src/plugins/newsfeed/tsconfig.json" }, { "path": "./src/plugins/region_map/tsconfig.json" }, @@ -111,6 +113,7 @@ { "path": "./src/plugins/vis_type_timelion/tsconfig.json" }, { "path": "./src/plugins/vis_type_timeseries/tsconfig.json" }, { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, + { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index a8eecd278160c..16c5b6c116998 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -2,12 +2,12 @@ "include": [], "references": [ { "path": "./src/core/tsconfig.json" }, - { "path": "./src/plugins/telemetry_management_section/tsconfig.json" }, { "path": "./src/plugins/advanced_settings/tsconfig.json" }, { "path": "./src/plugins/apm_oss/tsconfig.json" }, { "path": "./src/plugins/bfetch/tsconfig.json" }, { "path": "./src/plugins/charts/tsconfig.json" }, { "path": "./src/plugins/console/tsconfig.json" }, + { "path": "./src/plugins/dashboard/tsconfig.json" }, { "path": "./src/plugins/data/tsconfig.json" }, { "path": "./src/plugins/dev_tools/tsconfig.json" }, { "path": "./src/plugins/discover/tsconfig.json" }, @@ -15,28 +15,27 @@ { "path": "./src/plugins/es_ui_shared/tsconfig.json" }, { "path": "./src/plugins/expressions/tsconfig.json" }, { "path": "./src/plugins/home/tsconfig.json" }, - { "path": "./src/plugins/dashboard/tsconfig.json" }, - { "path": "./src/plugins/dev_tools/tsconfig.json" }, { "path": "./src/plugins/inspector/tsconfig.json" }, { "path": "./src/plugins/kibana_legacy/tsconfig.json" }, { "path": "./src/plugins/kibana_react/tsconfig.json" }, { "path": "./src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "./src/plugins/kibana_utils/tsconfig.json" }, + { "path": "./src/plugins/legacy_export/tsconfig.json" }, { "path": "./src/plugins/management/tsconfig.json" }, { "path": "./src/plugins/maps_legacy/tsconfig.json" }, - { "path": "./src/plugins/maps_oss/tsconfig.json" }, { "path": "./src/plugins/navigation/tsconfig.json" }, { "path": "./src/plugins/newsfeed/tsconfig.json" }, + { "path": "./src/plugins/presentation_util/tsconfig.json" }, { "path": "./src/plugins/region_map/tsconfig.json" }, - { "path": "./src/plugins/saved_objects/tsconfig.json" }, { "path": "./src/plugins/saved_objects_management/tsconfig.json" }, { "path": "./src/plugins/saved_objects_tagging_oss/tsconfig.json" }, - { "path": "./src/plugins/presentation_util/tsconfig.json" }, + { "path": "./src/plugins/saved_objects/tsconfig.json" }, { "path": "./src/plugins/security_oss/tsconfig.json" }, { "path": "./src/plugins/share/tsconfig.json" }, { "path": "./src/plugins/spaces_oss/tsconfig.json" }, - { "path": "./src/plugins/telemetry/tsconfig.json" }, { "path": "./src/plugins/telemetry_collection_manager/tsconfig.json" }, + { "path": "./src/plugins/telemetry_management_section/tsconfig.json" }, + { "path": "./src/plugins/telemetry/tsconfig.json" }, { "path": "./src/plugins/tile_map/tsconfig.json" }, { "path": "./src/plugins/timelion/tsconfig.json" }, { "path": "./src/plugins/ui_actions/tsconfig.json" }, @@ -50,8 +49,9 @@ { "path": "./src/plugins/vis_type_timelion/tsconfig.json" }, { "path": "./src/plugins/vis_type_timeseries/tsconfig.json" }, { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, + { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, - { "path": "./src/plugins/visualize/tsconfig.json" }, + { "path": "./src/plugins/visualize/tsconfig.json" } ] } diff --git a/typings/index.d.ts b/typings/index.d.ts index 782cc4271a06b..8223d85d53289 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -23,3 +23,10 @@ declare module '*.svg' { // eslint-disable-next-line import/no-default-export export default content; } + +// Storybook references this module. It's @ts-ignored in the codebase but when +// built into its dist it strips that out. Add it here to avoid a type checking +// error. +// +// See https://github.com/storybookjs/storybook/issues/11684 +declare module 'react-syntax-highlighter/dist/cjs/create-element'; diff --git a/vars/githubPr.groovy b/vars/githubPr.groovy index 546a6785ac2f4..5224aa7463d79 100644 --- a/vars/githubPr.groovy +++ b/vars/githubPr.groovy @@ -182,12 +182,14 @@ def getNextCommentMessage(previousCommentInfo = [:], isFinal = false) { ## :green_heart: Build Succeeded * [continuous-integration/kibana-ci/pull-request](${env.BUILD_URL}) * Commit: ${getCommitHash()} + ${getDocsChangesLink()} """ } else if(status == 'UNSTABLE') { def message = """ ## :yellow_heart: Build succeeded, but was flaky * [continuous-integration/kibana-ci/pull-request](${env.BUILD_URL}) * Commit: ${getCommitHash()} + ${getDocsChangesLink()} """.stripIndent() def failures = retryable.getFlakyFailures() @@ -204,6 +206,7 @@ def getNextCommentMessage(previousCommentInfo = [:], isFinal = false) { * Commit: ${getCommitHash()} * [Pipeline Steps](${env.BUILD_URL}flowGraphTable) (look for red circles / failed steps) * [Interpreting CI Failures](https://www.elastic.co/guide/en/kibana/current/interpreting-ci-failures.html) + ${getDocsChangesLink()} """ } @@ -292,6 +295,21 @@ def getCommitHash() { return env.ghprbActualCommit } +def getDocsChangesLink() { + def url = "https://kibana_${env.ghprbPullId}.docs-preview.app.elstc.co/diff" + + try { + // httpRequest throws on status codes >400 and failures + httpRequest([ method: "GET", url: url ]) + return "* [Documentation Changes](${url})" + } catch (ex) { + print "Failed to reach ${url}" + buildUtils.printStacktrace(ex) + } + + return "" +} + def getFailedSteps() { return jenkinsApi.getFailedSteps()?.findAll { step -> step.displayName != 'Check out from version control' diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index e393f3a5d2150..609d8f78aeb96 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -197,6 +197,13 @@ def ingest(jobName, buildNumber, buildUrl, timestamp, previousSha, teamAssignmen def runTests() { parallel([ 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': { + withEnv([ + 'NODE_ENV=test' // Needed for jest tests only + ]) { + workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh')() + } + }, 'kibana-oss-agent' : workers.functional( 'kibana-oss-tests', { kibanaPipeline.buildOss() }, diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index e49692568cec8..3032d88c26d98 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -130,6 +130,8 @@ def functionalTestProcess(String name, String script) { def ossCiGroupProcess(ciGroup) { return functionalTestProcess("ciGroup" + ciGroup) { + sleep((ciGroup-1)*30) // smooth out CPU spikes from ES startup + withEnv([ "CI_GROUP=${ciGroup}", "JOB=kibana-ciGroup${ciGroup}", @@ -143,6 +145,7 @@ def ossCiGroupProcess(ciGroup) { def xpackCiGroupProcess(ciGroup) { return functionalTestProcess("xpack-ciGroup" + ciGroup) { + sleep((ciGroup-1)*30) // smooth out CPU spikes from ES startup withEnv([ "CI_GROUP=${ciGroup}", "JOB=xpack-kibana-ciGroup${ciGroup}", @@ -179,21 +182,20 @@ def uploadCoverageArtifacts(prefix, pattern) { def withGcsArtifactUpload(workerName, closure) { def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/${workerName}" def ARTIFACT_PATTERNS = [ - 'target/junit/**/*', 'target/kibana-*', - 'target/kibana-coverage/**/*', - 'target/kibana-security-solution/**/*.png', 'target/test-metrics/*', + 'target/kibana-security-solution/**/*.png', + 'target/junit/**/*', 'target/test-suites-ci-plan.json', - 'test/**/screenshots/diff/*.png', - 'test/**/screenshots/failure/*.png', 'test/**/screenshots/session/*.png', + 'test/**/screenshots/failure/*.png', + 'test/**/screenshots/diff/*.png', 'test/functional/failure_debug/html/*.html', - 'x-pack/test/**/screenshots/diff/*.png', - 'x-pack/test/**/screenshots/failure/*.png', 'x-pack/test/**/screenshots/session/*.png', - 'x-pack/test/functional/apps/reporting/reports/session/*.pdf', + 'x-pack/test/**/screenshots/failure/*.png', + 'x-pack/test/**/screenshots/diff/*.png', 'x-pack/test/functional/failure_debug/html/*.html', + 'x-pack/test/functional/apps/reporting/reports/session/*.pdf', ] withEnv([ @@ -445,16 +447,31 @@ def withTasks(Map params = [worker: [:]], Closure closure) { } def allCiTasks() { - withTasks { - tasks.check() - tasks.lint() - tasks.test() - tasks.functionalOss() - tasks.functionalXpack() - } + parallel([ + general: { + withTasks { + tasks.check() + tasks.lint() + tasks.test() + tasks.functionalOss() + tasks.functionalXpack() + } + }, + jest: { + workers.ci(name: 'jest', size: 'c2-8', ramDisk: true) { + scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh')() + } + }, + xpackJest: { + workers.ci(name: 'xpack-jest', size: 'c2-8', ramDisk: true) { + scriptTask('X-Pack Jest Unit Tests', 'test/scripts/test/xpack_jest_unit.sh')() + } + }, + ]) } def pipelineLibraryTests() { + return whenChanged(['vars/', '.ci/pipeline-library/']) { workers.base(size: 'flyweight', bootstrapped: false, ramDisk: false) { dir('.ci/pipeline-library') { diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 74ad1267e9355..6c4f897691136 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -30,10 +30,8 @@ def lint() { def test() { tasks([ - // These 2 tasks require isolation because of hard-coded, conflicting ports and such, so let's use Docker here + // This task requires isolation because of hard-coded, conflicting ports and such, so let's use Docker here kibanaPipeline.scriptTaskDocker('Jest Integration Tests', 'test/scripts/test/jest_integration.sh'), - - kibanaPipeline.scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh'), kibanaPipeline.scriptTask('API Integration Tests', 'test/scripts/test/api_integration.sh'), ]) } diff --git a/vars/workers.groovy b/vars/workers.groovy index dd634f3c25a32..e1684f7aadb43 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -19,6 +19,8 @@ def label(size) { return 'docker && tests-xl-highmem' case 'xxl': return 'docker && tests-xxl && gobld/machineType:custom-64-270336' + case 'c2-8': + return 'docker && linux && immutable && gobld/machineType:c2-standard-8' } error "unknown size '${size}'" diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index bfac437f3500a..f95c4286b3f26 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -38,6 +38,7 @@ "xpack.maps": ["plugins/maps"], "xpack.ml": ["plugins/ml"], "xpack.monitoring": ["plugins/monitoring"], + "xpack.osquery": ["plugins/osquery"], "xpack.painlessLab": "plugins/painless_lab", "xpack.remoteClusters": "plugins/remote_clusters", "xpack.reporting": ["plugins/reporting"], diff --git a/x-pack/build_chromium/README.md b/x-pack/build_chromium/README.md index 51c034e510024..39382620775ad 100644 --- a/x-pack/build_chromium/README.md +++ b/x-pack/build_chromium/README.md @@ -6,49 +6,65 @@ to accept a commit hash from the Chromium repository, and initialize the build environments and run the build on Mac, Windows, and Linux. ## Before you begin + If you wish to use a remote VM to build, you'll need access to our GCP account, which is where we have two machines provisioned for the Linux and Windows builds. Mac builds can be achieved locally, and are a great place to start to gain familiarity. +**NOTE:** Linux builds should be done in Ubuntu on x86 architecture. ARM builds +are created in x86. CentOS is not supported for building Chromium. + 1. Login to our GCP instance [here using your okta credentials](https://console.cloud.google.com/). 2. Click the "Compute Engine" tab. -3. Ensure that `chromium-build-linux` and `chromium-build-windows-12-beefy` are there. -4. If #3 fails, you'll have to spin up new instances. Generally, these need `n1-standard-8` types or 8 vCPUs/30 GB memory. -5. Ensure that there's enough room left on the disk. `ncdu` is a good linux util to verify what's claming space. - -## Usage +3. Find `chromium-build-linux` or `chromium-build-windows-12-beefy` and start the instance. +4. Install [Google Cloud SDK](https://cloud.google.com/sdk) locally to ssh into the GCP instance +5. System dependencies: + - 8 CPU + - 30GB memory + - 80GB free space on disk (Try `ncdu /home` to see where space is used.) + - git + - python2 (`python` must link to `python2`) + - lsb_release + - tmux is recommended in case your ssh session is interrupted +6. Copy the entire `build_chromium` directory into a GCP storage bucket, so you can copy the scripts into the instance and run them. + +## Build Script Usage ``` +# Allow our scripts to use depot_tools commands +export PATH=$HOME/chromium/depot_tools:$PATH + # Create a dedicated working directory for this directory of Python scripts. mkdir ~/chromium && cd ~/chromium + # Copy the scripts from the Kibana repo to use them conveniently in the working directory -cp -r ~/path/to/kibana/x-pack/build_chromium . -# Install the OS packages, configure the environment, download the chromium source +gsutil cp -r gs://my-bucket/build_chromium . + +# Install the OS packages, configure the environment, download the chromium source (25GB) python ./build_chromium/init.sh [arch_name] # Run the build script with the path to the chromium src directory, the git commit id -python ./build_chromium/build.py <commit_id> +python ./build_chromium/build.py <commit_id> x86 -# You can add an architecture flag for ARM +# OR You can build for ARM python ./build_chromium/build.py <commit_id> arm64 ``` +**NOTE:** The `init.py` script updates git config to make it more possible for +the Chromium repo to be cloned successfully. If checking out the Chromium fails +with "early EOF" errors, the instance could be low on memory or disk space. + ## Getting the Commit ID -Getting `<commit_id>` can be tricky. The best technique seems to be: +The `build.py` script requires a commit ID of the Chromium repo. Getting `<commit_id>` can be tricky. The best technique seems to be: 1. Create a temporary working directory and intialize yarn 2. `yarn add puppeteer # install latest puppeter` -3. Look through puppeteer's node module files to find the "chromium revision" (a custom versioning convention for Chromium). +3. Look through Puppeteer documentation and Changelogs to find information +about where the "chromium revision" is located in the Puppeteer code. The code +containing it might not be distributed in the node module. + - Example: https://github.com/puppeteer/puppeteer/blob/b549256/src/revisions.ts 4. Use `https://crrev.com` and look up the revision and find the git commit info. - -The official Chromium build process is poorly documented, and seems to have -breaking changes fairly regularly. The build pre-requisites, and the build -flags change over time, so it is likely that the scripts in this directory will -be out of date by the time we have to do another Chromium build. - -This document is an attempt to note all of the gotchas we've come across while -building, so that the next time we have to tinker here, we'll have a good -starting point. + - Example: http://crrev.com/818858 leads to the git commit e62cb7e3fc7c40548cef66cdf19d270535d9350b ## Build args @@ -114,8 +130,8 @@ The more cores the better, as the build makes effective use of each. For Linux, - Linux: - SSH in using [gcloud](https://cloud.google.com/sdk/) - - Get the ssh command in the [GCP console](https://console.cloud.google.com/) -> VM instances -> your-vm-name -> SSH -> gcloud - - Their in-browser UI is kinda sluggish, so use the commandline tool + - Get the ssh command in the [GCP console](https://console.cloud.google.com/) -> VM instances -> your-vm-name -> SSH -> "View gcloud command" + - Their in-browser UI is kinda sluggish, so use the commandline tool (Google Cloud SDK is required) - Windows: - Install Microsoft's Remote Desktop tools diff --git a/x-pack/build_chromium/build.py b/x-pack/build_chromium/build.py index 8622f4a9d4c0b..0064f48ae973f 100644 --- a/x-pack/build_chromium/build.py +++ b/x-pack/build_chromium/build.py @@ -33,10 +33,10 @@ base_version = source_version[:7].strip('.') # Set to "arm" to build for ARM on Linux -arch_name = sys.argv[2] if len(sys.argv) >= 3 else 'x64' +arch_name = sys.argv[2] if len(sys.argv) >= 3 else 'unknown' if arch_name != 'x64' and arch_name != 'arm64': - raise Exception('Unexpected architecture: ' + arch_name) + raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') print('Building Chromium ' + source_version + ' for ' + arch_name + ' from ' + src_path) print('src path: ' + src_path) diff --git a/x-pack/build_chromium/init.py b/x-pack/build_chromium/init.py index c0dd60f1cfcb0..3a2e28a884b09 100644 --- a/x-pack/build_chromium/init.py +++ b/x-pack/build_chromium/init.py @@ -8,18 +8,19 @@ # call this once the platform-specific initialization has completed. # Set to "arm" to build for ARM on Linux -arch_name = sys.argv[1] if len(sys.argv) >= 2 else 'x64' +arch_name = sys.argv[1] if len(sys.argv) >= 2 else 'undefined' build_path = path.abspath(os.curdir) src_path = path.abspath(path.join(build_path, 'chromium', 'src')) if arch_name != 'x64' and arch_name != 'arm64': - raise Exception('Unexpected architecture: ' + arch_name) + raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') # Configure git print('Configuring git globals...') runcmd('git config --global core.autocrlf false') runcmd('git config --global core.filemode false') runcmd('git config --global branch.autosetuprebase always') +runcmd('git config --global core.compression 0') # Grab Chromium's custom build tools, if they aren't already installed # (On Windows, they are installed before this Python script is run) @@ -35,13 +36,14 @@ runcmd('git pull origin master') os.chdir(original_dir) -configure_environment(arch_name, build_path, src_path) - # Fetch the Chromium source code chromium_dir = path.join(build_path, 'chromium') if not path.isdir(chromium_dir): mkdir(chromium_dir) os.chdir(chromium_dir) - runcmd('fetch chromium') + runcmd('fetch chromium --nohooks=1 --no-history=1') else: print('Directory exists: ' + chromium_dir + '. Skipping chromium fetch.') + +# This depends on having the chromium/src directory with the complete checkout +configure_environment(arch_name, build_path, src_path) diff --git a/x-pack/jest.config.js b/x-pack/jest.config.js new file mode 100644 index 0000000000000..8158987213cd2 --- /dev/null +++ b/x-pack/jest.config.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '..', + projects: ['<rootDir>/x-pack/plugins/*/jest.config.js'], +}; diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 8b6c25e1c3f24..cd97213a64dcc 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -402,6 +402,9 @@ describe('create()', () => { enabled: true, enabledActionTypes: ['some-not-ignored-action-type'], allowedHosts: ['*'], + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, }); const localActionTypeRegistryParams = { diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 67ab495fc9678..e403fd99fe985 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -14,6 +14,8 @@ const createActionsConfigMock = () => { ensureHostnameAllowed: jest.fn().mockReturnValue({}), ensureUriAllowed: jest.fn().mockReturnValue({}), ensureActionTypeEnabled: jest.fn().mockReturnValue({}), + isRejectUnauthorizedCertificatesEnabled: jest.fn().mockReturnValue(true), + getProxySettings: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 56c58054ca799..c8b771b647e0d 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -4,22 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ActionsConfigType } from './types'; +import { ActionsConfig } from './config'; import { getActionsConfigurationUtilities, AllowedHosts, EnabledActionTypes, } from './actions_config'; -const DefaultActionsConfig: ActionsConfigType = { +const defaultActionsConfig: ActionsConfig = { enabled: false, allowedHosts: [], enabledActionTypes: [], + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, }; describe('ensureUriAllowed', () => { test('returns true when "any" hostnames are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], @@ -30,7 +34,7 @@ describe('ensureUriAllowed', () => { }); test('throws when the hostname in the requested uri is not in the allowedHosts', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(() => getActionsConfigurationUtilities(config).ensureUriAllowed('https://github.com/elastic/kibana') ).toThrowErrorMatchingInlineSnapshot( @@ -39,7 +43,7 @@ describe('ensureUriAllowed', () => { }); test('throws when the uri cannot be parsed as a valid URI', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(() => getActionsConfigurationUtilities(config).ensureUriAllowed('github.com/elastic') ).toThrowErrorMatchingInlineSnapshot( @@ -48,7 +52,8 @@ describe('ensureUriAllowed', () => { }); test('returns true when the hostname in the requested uri is in the allowedHosts', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], @@ -61,7 +66,8 @@ describe('ensureUriAllowed', () => { describe('ensureHostnameAllowed', () => { test('returns true when "any" hostnames are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], @@ -72,7 +78,7 @@ describe('ensureHostnameAllowed', () => { }); test('throws when the hostname in the requested uri is not in the allowedHosts', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(() => getActionsConfigurationUtilities(config).ensureHostnameAllowed('github.com') ).toThrowErrorMatchingInlineSnapshot( @@ -81,7 +87,8 @@ describe('ensureHostnameAllowed', () => { }); test('returns true when the hostname in the requested uri is in the allowedHosts', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], @@ -94,7 +101,8 @@ describe('ensureHostnameAllowed', () => { describe('isUriAllowed', () => { test('returns true when "any" hostnames are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], @@ -105,21 +113,22 @@ describe('isUriAllowed', () => { }); test('throws when the hostname in the requested uri is not in the allowedHosts', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect( getActionsConfigurationUtilities(config).isUriAllowed('https://github.com/elastic/kibana') ).toEqual(false); }); test('throws when the uri cannot be parsed as a valid URI', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(getActionsConfigurationUtilities(config).isUriAllowed('github.com/elastic')).toEqual( false ); }); test('returns true when the hostname in the requested uri is in the allowedHosts', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], @@ -132,7 +141,8 @@ describe('isUriAllowed', () => { describe('isHostnameAllowed', () => { test('returns true when "any" hostnames are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], @@ -141,12 +151,13 @@ describe('isHostnameAllowed', () => { }); test('throws when the hostname in the requested uri is not in the allowedHosts', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(getActionsConfigurationUtilities(config).isHostnameAllowed('github.com')).toEqual(false); }); test('returns true when the hostname in the requested uri is in the allowedHosts', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], @@ -157,7 +168,8 @@ describe('isHostnameAllowed', () => { describe('isActionTypeEnabled', () => { test('returns true when "any" actionTypes are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', EnabledActionTypes.Any], @@ -166,7 +178,8 @@ describe('isActionTypeEnabled', () => { }); test('returns false when no actionType is allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: [], @@ -175,7 +188,8 @@ describe('isActionTypeEnabled', () => { }); test('returns false when the actionType is not in the enabled list', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['foo'], @@ -184,7 +198,8 @@ describe('isActionTypeEnabled', () => { }); test('returns true when the actionType is in the enabled list', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', 'foo'], @@ -195,7 +210,8 @@ describe('isActionTypeEnabled', () => { describe('ensureActionTypeEnabled', () => { test('does not throw when any actionType is allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', EnabledActionTypes.Any], @@ -204,7 +220,7 @@ describe('ensureActionTypeEnabled', () => { }); test('throws when no actionType is not allowed', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(() => getActionsConfigurationUtilities(config).ensureActionTypeEnabled('foo') ).toThrowErrorMatchingInlineSnapshot( @@ -213,7 +229,8 @@ describe('ensureActionTypeEnabled', () => { }); test('throws when actionType is not enabled', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore'], @@ -226,7 +243,8 @@ describe('ensureActionTypeEnabled', () => { }); test('does not throw when actionType is enabled', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', 'foo'], diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index ebac80e70f4a8..396f59094a2d9 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -10,8 +10,9 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfigType } from './types'; +import { ActionsConfig } from './config'; import { ActionTypeDisabledError } from './lib'; +import { ProxySettings } from './types'; export enum AllowedHosts { Any = '*', @@ -33,6 +34,8 @@ export interface ActionsConfigurationUtilities { ensureHostnameAllowed: (hostname: string) => void; ensureUriAllowed: (uri: string) => void; ensureActionTypeEnabled: (actionType: string) => void; + isRejectUnauthorizedCertificatesEnabled: () => boolean; + getProxySettings: () => undefined | ProxySettings; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -56,14 +59,14 @@ function disabledActionTypeErrorMessage(actionType: string) { }); } -function isAllowed({ allowedHosts }: ActionsConfigType, hostname: string | null): boolean { +function isAllowed({ allowedHosts }: ActionsConfig, hostname: string | null): boolean { const allowed = new Set(allowedHosts); if (allowed.has(AllowedHosts.Any)) return true; if (hostname && allowed.has(hostname)) return true; return false; } -function isHostnameAllowedInUri(config: ActionsConfigType, uri: string): boolean { +function isHostnameAllowedInUri(config: ActionsConfig, uri: string): boolean { return pipe( tryCatch(() => url.parse(uri)), map((parsedUrl) => parsedUrl.hostname), @@ -73,7 +76,7 @@ function isHostnameAllowedInUri(config: ActionsConfigType, uri: string): boolean } function isActionTypeEnabledInConfig( - { enabledActionTypes }: ActionsConfigType, + { enabledActionTypes }: ActionsConfig, actionType: string ): boolean { const enabled = new Set(enabledActionTypes); @@ -82,8 +85,20 @@ function isActionTypeEnabledInConfig( return false; } +function getProxySettingsFromConfig(config: ActionsConfig): undefined | ProxySettings { + if (!config.proxyUrl) { + return undefined; + } + + return { + proxyUrl: config.proxyUrl, + proxyHeaders: config.proxyHeaders, + proxyRejectUnauthorizedCertificates: config.proxyRejectUnauthorizedCertificates, + }; +} + export function getActionsConfigurationUtilities( - config: ActionsConfigType + config: ActionsConfig ): ActionsConfigurationUtilities { const isHostnameAllowed = curry(isAllowed)(config); const isUriAllowed = curry(isHostnameAllowedInUri)(config); @@ -92,6 +107,8 @@ export function getActionsConfigurationUtilities( isHostnameAllowed, isUriAllowed, isActionTypeEnabled, + getProxySettings: () => getProxySettingsFromConfig(config), + isRejectUnauthorizedCertificatesEnabled: () => config.rejectUnauthorized, ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { throw new Error(allowListErrorMessage(AllowListingField.URL, uri)); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 91c71a78a8ee0..5d803e504593e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -277,6 +277,16 @@ describe('execute()', () => { `); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, "content": Object { "message": "a message to you @@ -286,7 +296,6 @@ describe('execute()', () => { "subject": "the subject", }, "hasAuth": true, - "proxySettings": undefined, "routing": Object { "bcc": Array [ "jimmy@example.com", @@ -327,6 +336,16 @@ describe('execute()', () => { await actionType.executor(customExecutorOptions); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, "content": Object { "message": "a message to you @@ -336,7 +355,6 @@ describe('execute()', () => { "subject": "the subject", }, "hasAuth": false, - "proxySettings": undefined, "routing": Object { "bcc": Array [ "jimmy@example.com", diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 4afbbb3a33615..b8a3467b27b54 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -156,7 +156,7 @@ export function getActionType(params: GetActionTypeParams): EmailActionType { params: ParamsSchema, }, renderParameterTemplates, - executor: curry(executor)({ logger, publicBaseUrl }), + executor: curry(executor)({ logger, publicBaseUrl, configurationUtilities }), }; } @@ -178,7 +178,12 @@ async function executor( { logger, publicBaseUrl, - }: { logger: GetActionTypeParams['logger']; publicBaseUrl: GetActionTypeParams['publicBaseUrl'] }, + configurationUtilities, + }: { + logger: GetActionTypeParams['logger']; + publicBaseUrl: GetActionTypeParams['publicBaseUrl']; + configurationUtilities: ActionsConfigurationUtilities; + }, execOptions: EmailActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -221,8 +226,8 @@ async function executor( subject: params.subject, message: `${params.message}${EMAIL_FOOTER_DIVIDER}${footerMessage}`, }, - proxySettings: execOptions.proxySettings, hasAuth: config.hasAuth, + configurationUtilities, }; let result; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts index d701fad0e0c2f..6fdd1cb28d7bb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts @@ -72,13 +72,16 @@ export function getActionType( }), params: ExecutorParamsSchema, }, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } // action executor async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: ActionTypeExecutorOptions< JiraPublicConfigurationType, JiraSecretConfigurationType, @@ -95,7 +98,7 @@ async function executor( secrets, }, logger, - execOptions.proxySettings + configurationUtilities ); if (!api[subAction]) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index 7e8770ffbd629..aa33389303081 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -11,6 +11,7 @@ import * as utils from '../lib/axios_utils'; import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; interface ResponseError extends Error { @@ -28,6 +29,7 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); const issueTypesResponse = { data: { @@ -116,7 +118,8 @@ describe('Jira service', () => { config: { apiUrl: 'https://siem-kibana.atlassian.net/', projectKey: 'CK' }, secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, - logger + logger, + configurationUtilities ); }); @@ -132,7 +135,8 @@ describe('Jira service', () => { config: { apiUrl: null, projectKey: 'CK' }, secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -144,7 +148,8 @@ describe('Jira service', () => { config: { apiUrl: 'test.com', projectKey: null }, secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -156,7 +161,8 @@ describe('Jira service', () => { config: { apiUrl: 'test.com' }, secrets: { apiToken: '', email: 'elastic@elastic.com' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -168,7 +174,8 @@ describe('Jira service', () => { config: { apiUrl: 'test.com' }, secrets: { apiToken: '', email: undefined }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -193,6 +200,7 @@ describe('Jira service', () => { axios, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', logger, + configurationUtilities, }); }); @@ -293,6 +301,7 @@ describe('Jira service', () => { url: 'https://siem-kibana.atlassian.net/rest/api/2/issue', logger, method: 'post', + configurationUtilities, data: { fields: { summary: 'title', @@ -331,6 +340,7 @@ describe('Jira service', () => { url: 'https://siem-kibana.atlassian.net/rest/api/2/issue', logger, method: 'post', + configurationUtilities, data: { fields: { summary: 'title', @@ -424,6 +434,7 @@ describe('Jira service', () => { axios, logger, method: 'put', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', data: { fields: { @@ -510,6 +521,7 @@ describe('Jira service', () => { axios, logger, method: 'post', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1/comment', data: { body: 'comment' }, }); @@ -568,6 +580,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/capabilities', }); }); @@ -642,6 +655,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta?projectKeys=CK&expand=projects.issuetypes.fields', }); @@ -724,6 +738,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta/CK/issuetypes', }); }); @@ -807,6 +822,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta?projectKeys=CK&issuetypeIds=10006&expand=projects.issuetypes.fields', }); @@ -928,6 +944,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta/CK/issuetypes/10006', }); }); @@ -988,6 +1005,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: `https://siem-kibana.atlassian.net/rest/api/2/search?jql=project%3D%22CK%22%20and%20summary%20~%22Test%20title%22`, }); }); @@ -1032,6 +1050,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: `https://siem-kibana.atlassian.net/rest/api/2/issue/RJ-107`, }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index f5e1b2e4411e3..791bfbaf5d69b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -26,7 +26,7 @@ import { import * as i18n from './translations'; import { request, getErrorMessage } from '../lib/axios_utils'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; const VERSION = '2'; const BASE_URL = `rest/api/${VERSION}`; @@ -39,7 +39,7 @@ const createMetaCapabilities = ['list-project-issuetypes', 'list-issuetype-field export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - proxySettings?: ProxySettings + configurationUtilities: ActionsConfigurationUtilities ): ExternalService => { const { apiUrl: url, projectKey } = config as JiraPublicConfigurationType; const { apiToken, email } = secrets as JiraSecretConfigurationType; @@ -173,7 +173,7 @@ export const createExternalService = ( axios: axiosInstance, url: `${incidentUrl}/${id}`, logger, - proxySettings, + configurationUtilities, }); const { fields, ...rest } = res.data; @@ -222,7 +222,7 @@ export const createExternalService = ( data: { fields, }, - proxySettings, + configurationUtilities, }); const updatedIncident = await getIncident(res.data.id); @@ -263,7 +263,7 @@ export const createExternalService = ( url: `${incidentUrl}/${incidentId}`, logger, data: { fields }, - proxySettings, + configurationUtilities, }); const updatedIncident = await getIncident(incidentId as string); @@ -297,7 +297,7 @@ export const createExternalService = ( url: getCommentsURL(incidentId), logger, data: { body: comment.comment }, - proxySettings, + configurationUtilities, }); return { @@ -324,7 +324,7 @@ export const createExternalService = ( method: 'get', url: capabilitiesUrl, logger, - proxySettings, + configurationUtilities, }); return { ...res.data }; @@ -350,7 +350,7 @@ export const createExternalService = ( method: 'get', url: getIssueTypesOldAPIURL, logger, - proxySettings, + configurationUtilities, }); const issueTypes = res.data.projects[0]?.issuetypes ?? []; @@ -361,7 +361,7 @@ export const createExternalService = ( method: 'get', url: getIssueTypesUrl, logger, - proxySettings, + configurationUtilities, }); const issueTypes = res.data.values; @@ -389,7 +389,7 @@ export const createExternalService = ( method: 'get', url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsOldAPIURL, issueTypeId), logger, - proxySettings, + configurationUtilities, }); const fields = res.data.projects[0]?.issuetypes[0]?.fields || {}; @@ -400,7 +400,7 @@ export const createExternalService = ( method: 'get', url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsUrl, issueTypeId), logger, - proxySettings, + configurationUtilities, }); const fields = res.data.values.reduce( @@ -459,7 +459,7 @@ export const createExternalService = ( method: 'get', url: query, logger, - proxySettings, + configurationUtilities, }); return normalizeSearchResults(res.data?.issues ?? []); @@ -483,7 +483,7 @@ export const createExternalService = ( method: 'get', url: getIssueUrl, logger, - proxySettings, + configurationUtilities, }); return normalizeIssue(res.data ?? {}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts index e106b17ad223f..23e16b7463914 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts @@ -5,12 +5,15 @@ */ import axios from 'axios'; +import { Agent as HttpsAgent } from 'https'; import { Logger } from '../../../../../../src/core/server'; import { addTimeZoneToDate, request, patch, getErrorMessage } from './axios_utils'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; import { getProxyAgents } from './get_proxy_agents'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; +const configurationUtilities = actionsConfigMock.create(); jest.mock('axios'); const axiosMock = (axios as unknown) as jest.Mock; @@ -41,13 +44,14 @@ describe('request', () => { axios, url: '/test', logger, + configurationUtilities, }); expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'get', data: {}, httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: expect.any(HttpsAgent), proxy: false, }); expect(res).toEqual({ @@ -58,20 +62,17 @@ describe('request', () => { }); test('it have been called with proper proxy agent for a valid url', async () => { - const proxySettings = { + configurationUtilities.getProxySettings.mockReturnValue({ proxyRejectUnauthorizedCertificates: true, proxyUrl: 'https://localhost:1212', - }; - const { httpAgent, httpsAgent } = getProxyAgents(proxySettings, logger); + }); + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); const res = await request({ axios, url: 'http://testProxy', logger, - proxySettings: { - proxyUrl: 'https://localhost:1212', - proxyRejectUnauthorizedCertificates: true, - }, + configurationUtilities, }); expect(axiosMock).toHaveBeenCalledWith('http://testProxy', { @@ -89,21 +90,22 @@ describe('request', () => { }); test('it have been called with proper proxy agent for an invalid url', async () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: ':nope:', + proxyRejectUnauthorizedCertificates: false, + }); const res = await request({ axios, url: 'https://testProxy', logger, - proxySettings: { - proxyUrl: ':nope:', - proxyRejectUnauthorizedCertificates: false, - }, + configurationUtilities, }); expect(axiosMock).toHaveBeenCalledWith('https://testProxy', { method: 'get', data: {}, httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: expect.any(HttpsAgent), proxy: false, }); expect(res).toEqual({ @@ -114,13 +116,20 @@ describe('request', () => { }); test('it fetch correctly', async () => { - const res = await request({ axios, url: '/test', method: 'post', logger, data: { id: '123' } }); + const res = await request({ + axios, + url: '/test', + method: 'post', + logger, + data: { id: '123' }, + configurationUtilities, + }); expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'post', data: { id: '123' }, httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: expect.any(HttpsAgent), proxy: false, }); expect(res).toEqual({ @@ -140,12 +149,12 @@ describe('patch', () => { }); test('it fetch correctly', async () => { - await patch({ axios, url: '/test', data: { id: '123' }, logger }); + await patch({ axios, url: '/test', data: { id: '123' }, logger, configurationUtilities }); expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'patch', data: { id: '123' }, httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: expect.any(HttpsAgent), proxy: false, }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts index 78c6b91b57dc0..a70a452737dc6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts @@ -6,8 +6,8 @@ import { AxiosInstance, Method, AxiosResponse, AxiosBasicCredentials } from 'axios'; import { Logger } from '../../../../../../src/core/server'; -import { ProxySettings } from '../../types'; import { getProxyAgents } from './get_proxy_agents'; +import { ActionsConfigurationUtilities } from '../../actions_config'; export const request = async <T = unknown>({ axios, @@ -15,7 +15,7 @@ export const request = async <T = unknown>({ logger, method = 'get', data, - proxySettings, + configurationUtilities, ...rest }: { axios: AxiosInstance; @@ -24,12 +24,12 @@ export const request = async <T = unknown>({ method?: Method; data?: T; params?: unknown; - proxySettings?: ProxySettings; + configurationUtilities: ActionsConfigurationUtilities; headers?: Record<string, string> | null; validateStatus?: (status: number) => boolean; auth?: AxiosBasicCredentials; }): Promise<AxiosResponse> => { - const { httpAgent, httpsAgent } = getProxyAgents(proxySettings, logger); + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); return await axios(url, { ...rest, @@ -47,13 +47,13 @@ export const patch = async <T = unknown>({ url, data, logger, - proxySettings, + configurationUtilities, }: { axios: AxiosInstance; url: string; data: T; logger: Logger; - proxySettings?: ProxySettings; + configurationUtilities: ActionsConfigurationUtilities; }): Promise<AxiosResponse> => { return request({ axios, @@ -61,7 +61,7 @@ export const patch = async <T = unknown>({ logger, method: 'patch', data, - proxySettings, + configurationUtilities, }); }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.test.ts index 759ca92968263..da2ad9bb3990d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.test.ts @@ -4,41 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Agent as HttpsAgent } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; import { getProxyAgents } from './get_proxy_agents'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; describe('getProxyAgents', () => { + const configurationUtilities = actionsConfigMock.create(); + test('get agents for valid proxy URL', () => { - const { httpAgent, httpsAgent } = getProxyAgents( - { proxyUrl: 'https://someproxyhost', proxyRejectUnauthorizedCertificates: false }, - logger - ); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + }); + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); }); - test('return undefined agents for invalid proxy URL', () => { - const { httpAgent, httpsAgent } = getProxyAgents( - { proxyUrl: ':nope: not a valid URL', proxyRejectUnauthorizedCertificates: false }, - logger - ); - expect(httpAgent).toBe(undefined); - expect(httpsAgent).toBe(undefined); - }); - - test('return undefined agents for null proxy options', () => { - const { httpAgent, httpsAgent } = getProxyAgents(null, logger); + test('return default agents for invalid proxy URL', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: ':nope: not a valid URL', + proxyRejectUnauthorizedCertificates: false, + }); + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); expect(httpAgent).toBe(undefined); - expect(httpsAgent).toBe(undefined); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); }); - test('return undefined agents for undefined proxy options', () => { - const { httpAgent, httpsAgent } = getProxyAgents(undefined, logger); + test('return default agents for undefined proxy options', () => { + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); expect(httpAgent).toBe(undefined); - expect(httpsAgent).toBe(undefined); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.ts index 45f962429ad2b..a49889570f4bf 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.ts @@ -4,28 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Agent } from 'http'; +import { Agent as HttpAgent } from 'http'; +import { Agent as HttpsAgent } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; interface GetProxyAgentsResponse { - httpAgent: Agent | undefined; - httpsAgent: Agent | undefined; + httpAgent: HttpAgent | undefined; + httpsAgent: HttpsAgent | undefined; } export function getProxyAgents( - proxySettings: ProxySettings | undefined | null, + configurationUtilities: ActionsConfigurationUtilities, logger: Logger ): GetProxyAgentsResponse { - const undefinedResponse = { + const proxySettings = configurationUtilities.getProxySettings(); + const defaultResponse = { httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: new HttpsAgent({ + rejectUnauthorized: configurationUtilities.isRejectUnauthorizedCertificatesEnabled(), + }), }; if (!proxySettings) { - return undefinedResponse; + return defaultResponse; } logger.debug(`Creating proxy agents for proxy: ${proxySettings.proxyUrl}`); @@ -34,7 +38,7 @@ export function getProxyAgents( proxyUrl = new URL(proxySettings.proxyUrl); } catch (err) { logger.warn(`invalid proxy URL "${proxySettings.proxyUrl}" ignored`); - return undefinedResponse; + return defaultResponse; } const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); @@ -45,8 +49,8 @@ export function getProxyAgents( headers: proxySettings.proxyHeaders, // do not fail on invalid certs if value is false rejectUnauthorized: proxySettings.proxyRejectUnauthorizedCertificates, - }) as unknown) as Agent; - // vsCode wasn't convinced HttpsProxyAgent is an http.Agent, so we convinced it + }) as unknown) as HttpsAgent; + // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it return { httpAgent, httpsAgent }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts index d78237beb98a1..51a4e3f857153 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts @@ -6,23 +6,24 @@ import axios, { AxiosResponse } from 'axios'; import { Logger } from '../../../../../../src/core/server'; -import { Services, ProxySettings } from '../../types'; +import { Services } from '../../types'; import { request } from './axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; interface PostPagerdutyOptions { apiUrl: string; data: unknown; headers: Record<string, string>; services: Services; - proxySettings?: ProxySettings; } // post an event to pagerduty export async function postPagerduty( options: PostPagerdutyOptions, - logger: Logger + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities ): Promise<AxiosResponse> { - const { apiUrl, data, headers, proxySettings } = options; + const { apiUrl, data, headers } = options; const axiosInstance = axios.create(); return await request({ @@ -31,8 +32,8 @@ export async function postPagerduty( method: 'post', logger, data, - proxySettings, headers, + configurationUtilities, validateStatus: () => true, }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index a1c4041628bd5..bd23aba618544 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -13,6 +13,7 @@ import { sendEmail } from './send_email'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; +import { actionsConfigMock } from '../../actions_config.mock'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; @@ -136,7 +137,7 @@ describe('send_email module', () => { "port": 1025, "secure": false, "tls": Object { - "rejectUnauthorized": undefined, + "rejectUnauthorized": true, }, }, ] @@ -223,6 +224,10 @@ function getSendEmailOptions( { content = {}, routing = {}, transport = {} } = {}, proxySettings?: ProxySettings ) { + const configurationUtilities = actionsConfigMock.create(); + if (proxySettings) { + configurationUtilities.getProxySettings.mockReturnValue(proxySettings); + } return { content: { ...content, @@ -242,8 +247,8 @@ function getSendEmailOptions( user: 'elastic', password: 'changeme', }, - proxySettings, hasAuth: true, + configurationUtilities, }; } @@ -251,6 +256,10 @@ function getSendEmailOptionsNoAuth( { content = {}, routing = {}, transport = {} } = {}, proxySettings?: ProxySettings ) { + const configurationUtilities = actionsConfigMock.create(); + if (proxySettings) { + configurationUtilities.getProxySettings.mockReturnValue(proxySettings); + } return { content: { ...content, @@ -267,7 +276,7 @@ function getSendEmailOptionsNoAuth( transport: { ...transport, }, - proxySettings, hasAuth: false, + configurationUtilities, }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index f3cdf82bfe8cd..2ade4f9e9dcb4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -9,7 +9,7 @@ import nodemailer from 'nodemailer'; import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -18,9 +18,8 @@ export interface SendEmailOptions { transport: Transport; routing: Routing; content: Content; - proxySettings?: ProxySettings; - rejectUnauthorized?: boolean; hasAuth: boolean; + configurationUtilities: ActionsConfigurationUtilities; } // config validation ensures either service is set or host/port are set @@ -47,12 +46,14 @@ export interface Content { // send an email export async function sendEmail(logger: Logger, options: SendEmailOptions): Promise<unknown> { - const { transport, routing, content, proxySettings, rejectUnauthorized, hasAuth } = options; + const { transport, routing, content, configurationUtilities, hasAuth } = options; const { service, host, port, secure, user, password } = transport; const { from, to, cc, bcc } = routing; const { subject, message } = content; const transportConfig: Record<string, unknown> = {}; + const proxySettings = configurationUtilities.getProxySettings(); + const rejectUnauthorized = configurationUtilities.isRejectUnauthorizedCertificatesEnabled(); if (hasAuth && user != null && password != null) { transportConfig.auth = { diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index ccd25da2397bb..688e75aece43c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -139,7 +139,7 @@ export function getActionType({ secrets: SecretsSchema, params: ParamsSchema, }, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } @@ -166,7 +166,10 @@ function getPagerDutyApiUrl(config: ActionTypeConfigType): string { // action executor async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: PagerDutyActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -174,7 +177,6 @@ async function executor( const secrets = execOptions.secrets; const params = execOptions.params; const services = execOptions.services; - const proxySettings = execOptions.proxySettings; const apiUrl = getPagerDutyApiUrl(config); const headers = { @@ -185,7 +187,11 @@ async function executor( let response; try { - response = await postPagerduty({ apiUrl, data, headers, services, proxySettings }, logger); + response = await postPagerduty( + { apiUrl, data, headers, services }, + logger, + configurationUtilities + ); } catch (err) { const message = i18n.translate('xpack.actions.builtin.pagerduty.postingErrorMessage', { defaultMessage: 'error posting pagerduty event', diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts index fca99f81d62bd..a14a1c7d4c8af 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts @@ -63,13 +63,16 @@ export function getActionType( }), params: ExecutorParamsSchema, }, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } // action executor async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: ActionTypeExecutorOptions< ResilientPublicConfigurationType, ResilientSecretConfigurationType, @@ -86,7 +89,7 @@ async function executor( secrets, }, logger, - execOptions.proxySettings + configurationUtilities ); if (!api[subAction]) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts index 97d8b64fb6535..326f0a6ed5f8b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -12,6 +12,7 @@ import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { incidentTypes, resilientFields, severity } from './mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; @@ -28,6 +29,7 @@ axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; const now = Date.now; const TIMESTAMP = 1589391874472; +const configurationUtilities = actionsConfigMock.create(); // Incident update makes three calls to the API. // The function below mocks this calls. @@ -86,7 +88,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: 'https://resilient.elastic.co/', orgId: '201' }, secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, }, - logger + logger, + configurationUtilities ); }); @@ -155,7 +158,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: null, orgId: '201' }, secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -167,7 +171,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: 'test.com', orgId: null }, secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -179,7 +184,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: 'test.com', orgId: '201' }, secrets: { apiKeyId: '', apiKeySecret: 'secret' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -191,7 +197,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: 'test.com', orgId: '201' }, secrets: { apiKeyId: '', apiKeySecret: undefined }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -226,6 +233,7 @@ describe('IBM Resilient service', () => { params: { text_content_output_format: 'objects_convert', }, + configurationUtilities, }); }); @@ -294,6 +302,7 @@ describe('IBM Resilient service', () => { 'https://resilient.elastic.co/rest/orgs/201/incidents?text_content_output_format=objects_convert', logger, method: 'post', + configurationUtilities, data: { name: 'title', description: { @@ -367,6 +376,7 @@ describe('IBM Resilient service', () => { axios, logger, method: 'patch', + configurationUtilities, url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', data: { changes: [ @@ -480,7 +490,7 @@ describe('IBM Resilient service', () => { axios, logger, method: 'post', - proxySettings: undefined, + configurationUtilities, url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments', data: { text: { @@ -584,6 +594,7 @@ describe('IBM Resilient service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://resilient.elastic.co/rest/orgs/201/types/incident/fields', }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts index a13204f8bb1d8..ec31de4f2afd5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -24,7 +24,7 @@ import { import * as i18n from './translations'; import { getErrorMessage, request } from '../lib/axios_utils'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; const VIEW_INCIDENT_URL = `#incidents`; @@ -93,7 +93,7 @@ export const formatUpdateRequest = ({ export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - proxySettings?: ProxySettings + configurationUtilities: ActionsConfigurationUtilities ): ExternalService => { const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType; const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType; @@ -130,7 +130,7 @@ export const createExternalService = ( params: { text_content_output_format: 'objects_convert', }, - proxySettings, + configurationUtilities, }); return { ...res.data, description: res.data.description?.content ?? '' }; @@ -178,7 +178,7 @@ export const createExternalService = ( method: 'post', logger, data, - proxySettings, + configurationUtilities, }); return { @@ -208,7 +208,7 @@ export const createExternalService = ( url: `${incidentUrl}/${incidentId}`, logger, data, - proxySettings, + configurationUtilities, }); if (!res.data.success) { @@ -241,7 +241,7 @@ export const createExternalService = ( url: getCommentsURL(incidentId), logger, data: { text: { format: 'text', content: comment.comment } }, - proxySettings, + configurationUtilities, }); return { @@ -266,7 +266,7 @@ export const createExternalService = ( method: 'get', url: incidentTypesUrl, logger, - proxySettings, + configurationUtilities, }); const incidentTypes = res.data?.values ?? []; @@ -288,7 +288,7 @@ export const createExternalService = ( method: 'get', url: severityUrl, logger, - proxySettings, + configurationUtilities, }); const incidentTypes = res.data?.values ?? []; @@ -309,7 +309,7 @@ export const createExternalService = ( axios: axiosInstance, url: incidentFieldsUrl, logger, - proxySettings, + configurationUtilities, }); return res.data ?? []; } catch (error) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index 1f75d439200e3..107d86f111deb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -60,14 +60,17 @@ export function getActionType( }), params: ExecutorParamsSchema, }, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } // action executor const supportedSubActions: string[] = ['getFields', 'pushToService']; async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: ActionTypeExecutorOptions< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, @@ -84,7 +87,7 @@ async function executor( secrets, }, logger, - execOptions.proxySettings + configurationUtilities ); if (!api[subAction]) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 1a6412f9ceb5b..4ef0e7da166e6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -11,6 +11,7 @@ import * as utils from '../lib/axios_utils'; import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; import { serviceNowCommonFields } from './mocks'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; @@ -27,6 +28,7 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; const patchMock = utils.patch as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); describe('ServiceNow service', () => { let service: ExternalService; @@ -39,7 +41,8 @@ describe('ServiceNow service', () => { config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, - logger + logger, + configurationUtilities ); }); @@ -55,7 +58,8 @@ describe('ServiceNow service', () => { config: { apiUrl: null }, secrets: { username: 'admin', password: 'admin' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -67,7 +71,8 @@ describe('ServiceNow service', () => { config: { apiUrl: 'test.com' }, secrets: { username: '', password: 'admin' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -79,7 +84,8 @@ describe('ServiceNow service', () => { config: { apiUrl: 'test.com' }, secrets: { username: '', password: undefined }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -103,6 +109,7 @@ describe('ServiceNow service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', }); }); @@ -147,6 +154,7 @@ describe('ServiceNow service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/incident', method: 'post', data: { short_description: 'title', description: 'desc' }, @@ -200,6 +208,7 @@ describe('ServiceNow service', () => { expect(patchMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', data: { short_description: 'title', description: 'desc' }, }); @@ -248,6 +257,7 @@ describe('ServiceNow service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 96faf6d338b90..108fe06bcbcaa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -12,7 +12,7 @@ import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types'; import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; const API_VERSION = 'v2'; const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; @@ -24,7 +24,7 @@ const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - proxySettings?: ProxySettings + configurationUtilities: ActionsConfigurationUtilities ): ExternalService => { const { apiUrl: url } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; @@ -58,7 +58,7 @@ export const createExternalService = ( axios: axiosInstance, url: `${incidentUrl}/${id}`, logger, - proxySettings, + configurationUtilities, }); checkInstance(res); return { ...res.data.result }; @@ -75,8 +75,8 @@ export const createExternalService = ( axios: axiosInstance, url: incidentUrl, logger, - proxySettings, params, + configurationUtilities, }); checkInstance(res); return res.data.result.length > 0 ? { ...res.data.result } : undefined; @@ -93,9 +93,9 @@ export const createExternalService = ( axios: axiosInstance, url: `${incidentUrl}`, logger, - proxySettings, method: 'post', data: { ...(incident as Record<string, unknown>) }, + configurationUtilities, }); checkInstance(res); return { @@ -118,7 +118,7 @@ export const createExternalService = ( url: `${incidentUrl}/${incidentId}`, logger, data: { ...(incident as Record<string, unknown>) }, - proxySettings, + configurationUtilities, }); checkInstance(res); return { @@ -143,7 +143,7 @@ export const createExternalService = ( axios: axiosInstance, url: fieldsUrl, logger, - proxySettings, + configurationUtilities, }); checkInstance(res); return res.data.result.length > 0 ? res.data.result : []; diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index e73f8d91b0847..cfac23e624a04 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -165,10 +165,6 @@ describe('execute()', () => { config: {}, secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, - proxySettings: { - proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, - }, }); expect(response).toMatchInlineSnapshot(` Object { @@ -194,9 +190,14 @@ describe('execute()', () => { }); test('calls the mock executor with success proxy', async () => { + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + }); const actionTypeProxy = getActionType({ logger: mockedLogger, - configurationUtilities: actionsConfigMock.create(), + configurationUtilities, }); await actionTypeProxy.executor({ actionId: 'some-id', @@ -204,10 +205,6 @@ describe('execute()', () => { config: {}, secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, - proxySettings: { - proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, - }, }); expect(mockedLogger.debug).toHaveBeenCalledWith( 'IncomingWebhook was called with proxyUrl https://someproxyhost' diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index 02026eb729727..5d2c5a24b3edd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -6,7 +6,6 @@ import { URL } from 'url'; import { curry } from 'lodash'; -import { Agent } from 'http'; import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook'; @@ -56,7 +55,7 @@ export const ActionTypeId = '.slack'; export function getActionType({ logger, configurationUtilities, - executor = curry(slackExecutor)({ logger }), + executor = curry(slackExecutor)({ logger, configurationUtilities }), }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; @@ -116,7 +115,10 @@ function validateActionTypeConfig( // action executor async function slackExecutor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: SlackActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -126,15 +128,15 @@ async function slackExecutor( let result: IncomingWebhookResult; const { webhookUrl } = secrets; const { message } = params; + const proxySettings = configurationUtilities.getProxySettings(); - let httpProxyAgent: Agent | undefined; - if (execOptions.proxySettings) { - const httpProxyAgents = getProxyAgents(execOptions.proxySettings, logger); - httpProxyAgent = webhookUrl.toLowerCase().startsWith('https') - ? httpProxyAgents.httpsAgent - : httpProxyAgents.httpAgent; + const proxyAgents = getProxyAgents(configurationUtilities, logger); + const httpProxyAgent = webhookUrl.toLowerCase().startsWith('https') + ? proxyAgents.httpsAgent + : proxyAgents.httpAgent; - logger.debug(`IncomingWebhook was called with proxyUrl ${execOptions.proxySettings.proxyUrl}`); + if (proxySettings) { + logger.debug(`IncomingWebhook was called with proxyUrl ${proxySettings.proxyUrl}`); } try { diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index a9fce2f0a9ebf..4ca25013e9691 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -160,38 +160,47 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, }); expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "axios": undefined, - "data": Object { - "text": "this invocation should succeed", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from teams action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], + Object { + "axios": undefined, + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "data": Object { + "text": "this invocation should succeed", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "proxySettings": undefined, - "url": "http://example.com", - } + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "url": "http://example.com", + } `); expect(response).toMatchInlineSnapshot(` Object { @@ -211,47 +220,49 @@ describe('execute()', () => { config: {}, secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, - proxySettings: { - proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, - }, }); expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "axios": undefined, - "data": Object { - "text": "this invocation should succeed", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from teams action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], + Object { + "axios": undefined, + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "data": Object { + "text": "this invocation should succeed", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "proxySettings": Object { - "proxyRejectUnauthorizedCertificates": false, - "proxyUrl": "https://someproxyhost", - }, - "url": "http://example.com", - } + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "url": "http://example.com", + } `); expect(response).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.ts index 088e30db4e3ce..857110d2f53c4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.ts @@ -63,7 +63,7 @@ export function getActionType({ }), params: ParamsSchema, }, - executor: curry(teamsExecutor)({ logger }), + executor: curry(teamsExecutor)({ logger, configurationUtilities }), }; } @@ -95,7 +95,10 @@ function validateActionTypeConfig( // action executor async function teamsExecutor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: TeamsActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -114,7 +117,7 @@ async function teamsExecutor( url: webhookUrl, logger, data, - proxySettings: execOptions.proxySettings, + configurationUtilities, }) ); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index dbbd2a029caa9..80614e6b1336d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -279,43 +279,52 @@ describe('execute()', () => { }); expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "auth": Object { - "password": "123", - "username": "abc", - }, - "axios": undefined, - "data": "some data", - "headers": Object { - "aheader": "a value", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from webhook action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], + Object { + "auth": Object { + "password": "123", + "username": "abc", + }, + "axios": undefined, + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "data": "some data", + "headers": Object { + "aheader": "a value", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from webhook action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "proxySettings": undefined, - "url": "https://abc.def/my-webhook", - } + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "url": "https://abc.def/my-webhook", + } `); }); @@ -338,39 +347,48 @@ describe('execute()', () => { }); expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "axios": undefined, - "data": "some data", - "headers": Object { - "aheader": "a value", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from webhook action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], + Object { + "axios": undefined, + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "data": "some data", + "headers": Object { + "aheader": "a value", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from webhook action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "proxySettings": undefined, - "url": "https://abc.def/my-webhook", - } + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "url": "https://abc.def/my-webhook", + } `); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts index fa6d2663c94ab..76063deee0f5e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -94,7 +94,7 @@ export function getActionType({ params: ParamsSchema, }, renderParameterTemplates, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } @@ -138,7 +138,10 @@ function validateActionTypeConfig( // action executor export async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: WebhookActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -162,7 +165,7 @@ export async function executor( ...basicAuth, headers, data, - proxySettings: execOptions.proxySettings, + configurationUtilities, }) ); @@ -202,7 +205,7 @@ export async function executor( ); } return errorResultInvalid(actionId, message); - } else if (error.isAxiosError) { + } else if (error.code) { const message = `[${error.code}] ${error.message}`; logger.error(`error on ${actionId} webhook event: ${message}`); return errorResultRequestFailed(actionId, message); diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 6c4857bff4e81..4e59dfd099811 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -6,10 +6,9 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; import { ActionsPlugin } from './plugin'; -import { configSchema } from './config'; +import { configSchema, ActionsConfig } from './config'; import { ActionsClient as ActionsClientClass } from './actions_client'; import { ActionsAuthorization as ActionsAuthorizationClass } from './authorization/actions_authorization'; -import { ActionsConfigType } from './types'; export type ActionsClient = PublicMethodsOf<ActionsClientClass>; export type ActionsAuthorization = PublicMethodsOf<ActionsAuthorizationClass>; @@ -52,7 +51,7 @@ export { asSavedObjectExecutionSource, asHttpRequestExecutionSource } from './li export const plugin = (initContext: PluginInitializerContext) => new ActionsPlugin(initContext); -export const config: PluginConfigDescriptor<ActionsConfigType> = { +export const config: PluginConfigDescriptor<ActionsConfig> = { schema: configSchema, deprecations: ({ renameFromRoot }) => [ renameFromRoot('xpack.actions.whitelistedHosts', 'xpack.actions.allowedHosts'), diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 695613a59eff1..fa33c1226ec9e 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -12,7 +12,6 @@ import { GetServicesFunction, RawAction, PreConfiguredAction, - ProxySettings, } from '../types'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { SpacesServiceStart } from '../../../spaces/server'; @@ -33,7 +32,6 @@ export interface ActionExecutorContext { actionTypeRegistry: ActionTypeRegistryContract; eventLogger: IEventLogger; preconfiguredActions: PreConfiguredAction[]; - proxySettings?: ProxySettings; } export interface ExecuteOptions<Source = unknown> { @@ -87,7 +85,6 @@ export class ActionExecutor { eventLogger, preconfiguredActions, getActionsClientWithRequest, - proxySettings, } = this.actionExecutorContext!; const services = getServices(request); @@ -145,7 +142,6 @@ export class ActionExecutor { params: validatedParams, config: validatedConfig, secrets: validatedSecrets, - proxySettings, }); } catch (err) { rawResult = { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 1543f8d7a07ce..22400a08a2a09 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -357,15 +357,6 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi encryptedSavedObjectsClient, actionTypeRegistry: actionTypeRegistry!, preconfiguredActions, - proxySettings: - this.actionsConfig && this.actionsConfig.proxyUrl - ? { - proxyUrl: this.actionsConfig.proxyUrl, - proxyHeaders: this.actionsConfig.proxyHeaders, - proxyRejectUnauthorizedCertificates: this.actionsConfig - .proxyRejectUnauthorizedCertificates, - } - : undefined, }); const spaceIdToNamespace = (spaceId?: string) => { diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index f545c0fc96633..0bcf02c6f83ae 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -55,12 +55,6 @@ export interface ActionsPlugin { start: PluginStartContract; } -export interface ActionsConfigType { - enabled: boolean; - allowedHosts: string[]; - enabledActionTypes: string[]; -} - // the parameters passed to an action type executor function export interface ActionTypeExecutorOptions<Config, Secrets, Params> { actionId: string; @@ -68,7 +62,6 @@ export interface ActionTypeExecutorOptions<Config, Secrets, Params> { config: Config; secrets: Secrets; params: Params; - proxySettings?: ProxySettings; } export interface ActionResult<Config extends ActionTypeConfig = ActionTypeConfig> { diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index cbdfec642fa74..a17b46d7d7abe 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -15,6 +15,7 @@ export * from './alert_instance_summary'; export * from './builtin_action_groups'; export * from './disabled_action_groups'; export * from './alert_notify_when_type'; +export * from './parse_duration'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/alerts/server/authorization/__snapshots__/alerts_authorization.test.ts.snap b/x-pack/plugins/alerts/server/authorization/__snapshots__/alerts_authorization.test.ts.snap new file mode 100644 index 0000000000000..f9a28dc3eb119 --- /dev/null +++ b/x-pack/plugins/alerts/server/authorization/__snapshots__/alerts_authorization.test.ts.snap @@ -0,0 +1,316 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AlertsAuthorization getFindAuthorizationFilter creates a filter based on the privileged types 1`] = ` +Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.alertTypeId", + }, + Object { + "type": "literal", + "value": "myAppAlertType", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "alerts", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myOtherApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myAppWithSubFeature", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.alertTypeId", + }, + Object { + "type": "literal", + "value": "myOtherAppAlertType", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "alerts", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myOtherApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myAppWithSubFeature", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.alertTypeId", + }, + Object { + "type": "literal", + "value": "mySecondAppAlertType", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "alerts", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myOtherApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myAppWithSubFeature", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + ], + "function": "or", + "type": "function", +} +`; diff --git a/x-pack/plugins/alerts/server/authorization/__snapshots__/alerts_authorization_kuery.test.ts.snap b/x-pack/plugins/alerts/server/authorization/__snapshots__/alerts_authorization_kuery.test.ts.snap new file mode 100644 index 0000000000000..de01a7b27ef05 --- /dev/null +++ b/x-pack/plugins/alerts/server/authorization/__snapshots__/alerts_authorization_kuery.test.ts.snap @@ -0,0 +1,448 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`asFiltersByAlertTypeAndConsumer constructs filter for multiple alert types across authorized consumer 1`] = ` +Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.alertTypeId", + }, + Object { + "type": "literal", + "value": "myAppAlertType", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "alerts", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myOtherApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myAppWithSubFeature", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.alertTypeId", + }, + Object { + "type": "literal", + "value": "myOtherAppAlertType", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "alerts", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myOtherApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myAppWithSubFeature", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.alertTypeId", + }, + Object { + "type": "literal", + "value": "mySecondAppAlertType", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "alerts", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myOtherApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myAppWithSubFeature", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + ], + "function": "or", + "type": "function", +} +`; + +exports[`asFiltersByAlertTypeAndConsumer constructs filter for single alert type with multiple authorized consumer 1`] = ` +Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.alertTypeId", + }, + Object { + "type": "literal", + "value": "myAppAlertType", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "alerts", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myOtherApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + ], + "function": "and", + "type": "function", +} +`; + +exports[`asFiltersByAlertTypeAndConsumer constructs filter for single alert type with single authorized consumer 1`] = ` +Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.alertTypeId", + }, + Object { + "type": "literal", + "value": "myAppAlertType", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "alert.attributes.consumer", + }, + Object { + "type": "literal", + "value": "myApp", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", +} +`; diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts index a7d9421073483..fc895f3e308f4 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts @@ -6,7 +6,6 @@ import { KibanaRequest } from 'kibana/server'; import { alertTypeRegistryMock } from '../alert_type_registry.mock'; import { securityMock } from '../../../../plugins/security/server/mocks'; -import { esKuery } from '../../../../../src/plugins/data/server'; import { PluginStartContract as FeaturesStartContract, KibanaFeature, @@ -627,11 +626,17 @@ describe('AlertsAuthorization', () => { }); alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); - expect((await alertAuthorization.getFindAuthorizationFilter()).filter).toEqual( - esKuery.fromKueryExpression( - `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:myOtherAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:mySecondAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)))` - ) - ); + // TODO: once issue https://github.com/elastic/kibana/issues/89473 is + // resolved, we can start using this code again, instead of toMatchSnapshot(): + // + // expect((await alertAuthorization.getFindAuthorizationFilter()).filter).toEqual( + // esKuery.fromKueryExpression( + // `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:myOtherAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:mySecondAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)))` + // ) + // ); + + // This code is the replacement code for above + expect((await alertAuthorization.getFindAuthorizationFilter()).filter).toMatchSnapshot(); expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization_kuery.test.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization_kuery.test.ts index 8249047c0ef39..3d80ff0273db7 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization_kuery.test.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization_kuery.test.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { esKuery } from '../../../../../src/plugins/data/server'; import { RecoveredActionGroup } from '../../common'; import { asFiltersByAlertTypeAndConsumer, @@ -30,11 +29,14 @@ describe('asFiltersByAlertTypeAndConsumer', () => { }, ]) ) - ).toEqual( - esKuery.fromKueryExpression( - `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(myApp)))` - ) - ); + ).toMatchSnapshot(); + // TODO: once issue https://github.com/elastic/kibana/issues/89473 is + // resolved, we can start using this code again instead of toMatchSnapshot() + // ).toEqual( + // esKuery.fromKueryExpression( + // `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(myApp)))` + // ) + // ); }); test('constructs filter for single alert type with multiple authorized consumer', async () => { @@ -58,11 +60,14 @@ describe('asFiltersByAlertTypeAndConsumer', () => { }, ]) ) - ).toEqual( - esKuery.fromKueryExpression( - `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp)))` - ) - ); + ).toMatchSnapshot(); + // TODO: once issue https://github.com/elastic/kibana/issues/89473 is + // resolved, we can start using this code again, instead of toMatchSnapshot(): + // ).toEqual( + // esKuery.fromKueryExpression( + // `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp)))` + // ) + // ); }); test('constructs filter for multiple alert types across authorized consumer', async () => { @@ -119,11 +124,14 @@ describe('asFiltersByAlertTypeAndConsumer', () => { }, ]) ) - ).toEqual( - esKuery.fromKueryExpression( - `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:myOtherAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:mySecondAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)))` - ) - ); + ).toMatchSnapshot(); + // TODO: once issue https://github.com/elastic/kibana/issues/89473 is + // resolved, we can start using this code again, instead of toMatchSnapshot(): + // ).toEqual( + // esKuery.fromKueryExpression( + // `((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:myOtherAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:mySecondAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)))` + // ) + // ); }); }); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index a41faba2e9382..b76dd85f5ee6c 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -254,6 +254,6 @@ export const generalSettings: RawSettingDefinition[] = [ 'Used to restrict requests to certain URLs from being instrumented. This config accepts a comma-separated list of wildcard patterns of URL paths that should be ignored. When an incoming HTTP request is detected, its request path will be tested against each element in this list. For example, adding `/home/index` to this list would match and remove instrumentation from `http://localhost/home/index` as well as `http://whatever.com/home/index?value1=123`', } ), - includeAgents: ['java', 'nodejs', 'python', 'dotnet', 'ruby'], + includeAgents: ['java', 'nodejs', 'python', 'dotnet', 'ruby', 'go'], }, ]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index 4f319e4dd7016..1251f9c2f1bec 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -50,6 +50,7 @@ describe('filterByAgent', () => { 'sanitize_field_names', 'span_frames_min_duration', 'stack_trace_limit', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts index c8bbe599ca44f..3ff6686138e9a 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/VisitorBreakdownMap/useLayerList.ts @@ -16,6 +16,7 @@ import { COLOR_MAP_TYPE, FIELD_ORIGIN, LABEL_BORDER_SIZES, + SOURCE_TYPES, STYLE_TYPE, SYMBOLIZE_AS_TYPES, } from '../../../../../../maps/common/constants'; @@ -29,7 +30,7 @@ import { import { TRANSACTION_PAGE_LOAD } from '../../../../../common/transaction_types'; const ES_TERM_SOURCE_COUNTRY: ESTermSourceDescriptor = { - type: 'ES_TERM_SOURCE', + type: SOURCE_TYPES.ES_TERM_SOURCE, id: '3657625d-17b0-41ef-99ba-3a2b2938655c', indexPatternTitle: 'apm-*', term: 'client.geo.country_iso_code', @@ -46,7 +47,7 @@ const ES_TERM_SOURCE_COUNTRY: ESTermSourceDescriptor = { }; const ES_TERM_SOURCE_REGION: ESTermSourceDescriptor = { - type: 'ES_TERM_SOURCE', + type: SOURCE_TYPES.ES_TERM_SOURCE, id: 'e62a1b9c-d7ff-4fd4-a0f6-0fdc44bb9e41', indexPatternTitle: 'apm-*', term: 'client.geo.region_iso_code', diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx index ae22718af8b57..43f566a93a89d 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/CustomLinkMenuSection/index.tsx @@ -107,7 +107,6 @@ export function CustomLinkMenuSection({ </EuiFlexItem> </EuiFlexGroup> - <EuiSpacer size="s" /> <SectionSubtitle> {i18n.translate( 'xpack.apm.transactionActionMenu.customLink.subtitle', diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.test.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.test.tsx index 48c863b460482..3141dc7a5f3c6 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.test.tsx @@ -52,7 +52,7 @@ const renderTransaction = async (transaction: Record<string, any>) => { } ); - fireEvent.click(rendered.getByText('Actions')); + fireEvent.click(rendered.getByText('Investigate')); return rendered; }; @@ -289,7 +289,7 @@ describe('TransactionActionMenu component', () => { }); const component = renderTransactionActionMenuWithLicense(license); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsNotInDocument(component, ['Custom Links']); }); @@ -313,7 +313,7 @@ describe('TransactionActionMenu component', () => { { wrapper: Wrapper } ); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsNotInDocument(component, ['Custom Links']); }); @@ -330,7 +330,7 @@ describe('TransactionActionMenu component', () => { }); const component = renderTransactionActionMenuWithLicense(license); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsInDocument(component, ['Custom Links']); }); @@ -347,7 +347,7 @@ describe('TransactionActionMenu component', () => { }); const component = renderTransactionActionMenuWithLicense(license); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsInDocument(component, ['Custom Links']); }); @@ -364,7 +364,7 @@ describe('TransactionActionMenu component', () => { }); const component = renderTransactionActionMenuWithLicense(license); act(() => { - fireEvent.click(component.getByText('Actions')); + fireEvent.click(component.getByText('Investigate')); }); expectTextsInDocument(component, ['Custom Links']); act(() => { diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx index 312513db80886..22fa25f93b212 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/TransactionActionMenu.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { useLocation } from 'react-router-dom'; @@ -30,11 +30,11 @@ interface Props { function ActionMenuButton({ onClick }: { onClick: () => void }) { return ( - <EuiButtonEmpty iconType="arrowDown" iconSide="right" onClick={onClick}> + <EuiButton iconType="arrowDown" iconSide="right" onClick={onClick}> {i18n.translate('xpack.apm.transactionActionMenu.actionsButtonLabel', { - defaultMessage: 'Actions', + defaultMessage: 'Investigate', })} - </EuiButtonEmpty> + </EuiButton> ); } diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__snapshots__/TransactionActionMenu.test.tsx.snap b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__snapshots__/TransactionActionMenu.test.tsx.snap index fa6db645d28a8..ea33fb3c3df08 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__snapshots__/TransactionActionMenu.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__snapshots__/TransactionActionMenu.test.tsx.snap @@ -10,20 +10,20 @@ exports[`TransactionActionMenu component matches the snapshot 1`] = ` class="euiPopover__anchor" > <button - class="euiButtonEmpty euiButtonEmpty--primary" + class="euiButton euiButton--primary" type="button" > <span - class="euiButtonContent euiButtonContent--iconRight euiButtonEmpty__content" + class="euiButtonContent euiButtonContent--iconRight euiButton__content" > <span class="euiButtonContent__icon" data-euiicon-type="arrowDown" /> <span - class="euiButtonEmpty__text" + class="euiButton__text" > - Actions + Investigate </span> </span> </button> diff --git a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts index fae43ef148cfa..f6ddb15cbffa9 100644 --- a/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_error_rate.ts @@ -44,9 +44,7 @@ export async function getTransactionErrorRateChartPreview({ }, }; - const outcomes = getOutcomeAggregation({ - searchAggregatedTransactions: false, - }); + const outcomes = getOutcomeAggregation(); const { intervalString } = getBucketSize({ start, end, numBuckets: 20 }); diff --git a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts index 64d9ebb192eb3..9ecf201ede1b7 100644 --- a/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts +++ b/x-pack/plugins/apm/server/lib/correlations/get_correlations_for_failed_transactions/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty, omit } from 'lodash'; +import { isEmpty, omit, merge } from 'lodash'; import { EventOutcome } from '../../../../common/event_outcome'; import { processSignificantTermAggs, @@ -134,8 +134,7 @@ export async function getErrorRateTimeSeries({ extended_bounds: { min: start, max: end }, }, aggs: { - // TODO: add support for metrics - outcomes: getOutcomeAggregation({ searchAggregatedTransactions: false }), + outcomes: getOutcomeAggregation(), }, }; @@ -147,13 +146,12 @@ export async function getErrorRateTimeSeries({ }; return acc; }, - {} as Record< - string, - { + {} as { + [key: string]: { filter: AggregationOptionsByType['filter']; aggs: { timeseries: typeof timeseriesAgg }; - } - > + }; + } ); const params = { @@ -162,32 +160,25 @@ export async function getErrorRateTimeSeries({ body: { size: 0, query: { bool: { filter: backgroundFilters } }, - aggs: { - // overall aggs - timeseries: timeseriesAgg, - - // per term aggs - ...perTermAggs, - }, + aggs: merge({ timeseries: timeseriesAgg }, perTermAggs), }, }; const response = await apmEventClient.search(params); - type Agg = NonNullable<typeof response.aggregations>; + const { aggregations } = response; - if (!response.aggregations) { + if (!aggregations) { return {}; } return { overall: { timeseries: getTransactionErrorRateTimeSeries( - response.aggregations.timeseries.buckets + aggregations.timeseries.buckets ), }, significantTerms: topSigTerms.map((topSig, index) => { - // @ts-expect-error - const agg = response.aggregations[`term_${index}`] as Agg; + const agg = aggregations[`term_${index}`]!; return { ...topSig, diff --git a/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts b/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts index 876fc6b822213..2d041006e0e27 100644 --- a/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/helpers/transaction_error_rate.ts @@ -10,40 +10,21 @@ import { AggregationOptionsByType, AggregationResultOf, } from '../../../../../typings/elasticsearch/aggregations'; -import { getTransactionDurationFieldForAggregatedTransactions } from './aggregated_transactions'; -export function getOutcomeAggregation({ - searchAggregatedTransactions, -}: { - searchAggregatedTransactions: boolean; -}) { - return { - terms: { - field: EVENT_OUTCOME, - include: [EventOutcome.failure, EventOutcome.success], - }, - aggs: { - // simply using the doc count to get the number of requests is not possible for transaction metrics (histograms) - // to work around this we get the number of transactions by counting the number of latency values - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, - }; -} +export const getOutcomeAggregation = () => ({ + terms: { + field: EVENT_OUTCOME, + include: [EventOutcome.failure, EventOutcome.success], + }, +}); + +type OutcomeAggregation = ReturnType<typeof getOutcomeAggregation>; export function calculateTransactionErrorPercentage( - outcomeResponse: AggregationResultOf< - ReturnType<typeof getOutcomeAggregation>, - {} - > + outcomeResponse: AggregationResultOf<OutcomeAggregation, {}> ) { const outcomes = Object.fromEntries( - outcomeResponse.buckets.map(({ key, count }) => [key, count.value]) + outcomeResponse.buckets.map(({ key, doc_count: count }) => [key, count]) ); const failedTransactions = outcomes[EventOutcome.failure] ?? 0; @@ -56,7 +37,7 @@ export function getTransactionErrorRateTimeSeries( buckets: AggregationResultOf< { date_histogram: AggregationOptionsByType['date_histogram']; - aggs: { outcomes: ReturnType<typeof getOutcomeAggregation> }; + aggs: { outcomes: OutcomeAggregation }; }, {} >['buckets'] diff --git a/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts index 5531944fc7180..fa4bf6144fb6f 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts @@ -11,10 +11,7 @@ import { rangeFilter } from '../../../common/utils/range_filter'; import { Coordinates } from '../../../../observability/typings/common'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -import { - getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, -} from '../helpers/aggregated_transactions'; +import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; export async function getTransactionCoordinates({ setup, @@ -49,15 +46,6 @@ export async function getTransactionCoordinates({ fixed_interval: bucketSize, min_doc_count: 0, }, - aggs: { - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, }, }, }, @@ -68,7 +56,7 @@ export async function getTransactionCoordinates({ return ( aggregations?.distribution.buckets.map((bucket) => ({ x: bucket.key, - y: bucket.count.value / deltaAsMinutes, + y: bucket.doc_count / deltaAsMinutes, })) || [] ); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts index f7ca40ef1052c..173de796d47e4 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts @@ -52,8 +52,10 @@ describe('getServiceMapServiceNodeInfo', () => { apmEventClient: { search: () => Promise.resolve({ + hits: { + total: { value: 1 }, + }, aggregations: { - count: { value: 1 }, duration: { value: null }, avgCpuUsage: { value: null }, avgMemoryUsage: { value: null }, diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index 82d339686f7ec..4fe9a1a75d43f 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -162,19 +162,12 @@ async function getTransactionStats({ ), }, }, - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, }, }, }; const response = await apmEventClient.search(params); - const totalRequests = response.aggregations?.count.value ?? 0; + const totalRequests = response.hits.total.value; return { avgTransactionDuration: response.aggregations?.duration.value ?? null, diff --git a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap index 21402e4c8dac0..239b909e1572c 100644 --- a/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/services/__snapshots__/queries.test.ts.snap @@ -122,13 +122,6 @@ Array [ }, }, "outcomes": Object { - "aggs": Object { - "count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, - }, "terms": Object { "field": "event.outcome", "include": Array [ @@ -137,11 +130,6 @@ Array [ ], }, }, - "real_document_count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, "timeseries": Object { "aggs": Object { "avg_duration": Object { @@ -150,13 +138,6 @@ Array [ }, }, "outcomes": Object { - "aggs": Object { - "count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, - }, "terms": Object { "field": "event.outcome", "include": Array [ @@ -165,11 +146,6 @@ Array [ ], }, }, - "real_document_count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, }, "date_histogram": Object { "extended_bounds": Object { @@ -184,9 +160,6 @@ Array [ }, "terms": Object { "field": "transaction.type", - "order": Object { - "real_document_count": "desc", - }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts index 5880b5cbc9546..c5e5269c4409e 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_instances/get_service_instance_transaction_stats.ts @@ -30,18 +30,17 @@ export async function getServiceInstanceTransactionStats({ }: ServiceInstanceParams) { const { apmEventClient, start, end, esFilter } = setup; - const { intervalString } = getBucketSize({ start, end, numBuckets }); + const { intervalString, bucketSize } = getBucketSize({ + start, + end, + numBuckets, + }); const field = getTransactionDurationFieldForAggregatedTransactions( searchAggregatedTransactions ); const subAggs = { - count: { - value_count: { - field, - }, - }, avg_transaction_duration: { avg: { field, @@ -53,13 +52,6 @@ export async function getServiceInstanceTransactionStats({ [EVENT_OUTCOME]: EventOutcome.failure, }, }, - aggs: { - count: { - value_count: { - field, - }, - }, - }, }, }; @@ -113,12 +105,13 @@ export async function getServiceInstanceTransactionStats({ }); const deltaAsMinutes = (end - start) / 60 / 1000; + const bucketSizeInMinutes = bucketSize / 60; return ( response.aggregations?.[SERVICE_NODE_NAME].buckets.map( (serviceNodeBucket) => { const { - count, + doc_count: count, avg_transaction_duration: avgTransactionDuration, key, failures, @@ -128,17 +121,17 @@ export async function getServiceInstanceTransactionStats({ return { serviceNodeName: String(key), errorRate: { - value: failures.count.value / count.value, + value: failures.doc_count / count, timeseries: timeseries.buckets.map((dateBucket) => ({ x: dateBucket.key, - y: dateBucket.failures.count.value / dateBucket.count.value, + y: dateBucket.failures.doc_count / dateBucket.doc_count, })), }, throughput: { - value: count.value / deltaAsMinutes, + value: count / deltaAsMinutes, timeseries: timeseries.buckets.map((dateBucket) => ({ x: dateBucket.key, - y: dateBucket.count.value / deltaAsMinutes, + y: dateBucket.doc_count / bucketSizeInMinutes, })), }, latency: { diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts index 937155bc31602..745535f261673 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts @@ -17,6 +17,7 @@ import { import { ESFilter } from '../../../../../../typings/elasticsearch'; import { + getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; @@ -76,6 +77,9 @@ export async function getTimeseriesDataForTransactionGroups({ { term: { [SERVICE_NAME]: serviceName } }, { term: { [TRANSACTION_TYPE]: transactionType } }, { range: rangeFilter(start, end) }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), ...esFilter, ], }, @@ -99,10 +103,8 @@ export async function getTimeseriesDataForTransactionGroups({ }, aggs: { ...getLatencyAggregation(latencyAggregationType, field), - transaction_count: { value_count: { field } }, [EVENT_OUTCOME]: { filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - aggs: { transaction_count: { value_count: { field } } }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts index ccccf946512dd..400c896e380b4 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts @@ -99,10 +99,8 @@ export async function getTransactionGroupsForPage({ }, aggs: { ...getLatencyAggregation(latencyAggregationType, field), - transaction_count: { value_count: { field } }, [EVENT_OUTCOME]: { filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, - aggs: { transaction_count: { value_count: { field } } }, }, }, }, @@ -113,9 +111,8 @@ export async function getTransactionGroupsForPage({ const transactionGroups = response.aggregations?.transaction_groups.buckets.map((bucket) => { const errorRate = - bucket.transaction_count.value > 0 - ? (bucket[EVENT_OUTCOME].transaction_count.value ?? 0) / - bucket.transaction_count.value + bucket.doc_count > 0 + ? bucket[EVENT_OUTCOME].doc_count / bucket.doc_count : null; return { @@ -124,7 +121,7 @@ export async function getTransactionGroupsForPage({ latencyAggregationType, aggregation: bucket.latency, }), - throughput: bucket.transaction_count.value / deltaAsMinutes, + throughput: bucket.doc_count / deltaAsMinutes, errorRate, }; }) ?? []; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts index a8794e3c09a40..b0b1cb09dd784 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts @@ -52,18 +52,14 @@ export function mergeTransactionGroupData({ ...acc.throughput, timeseries: acc.throughput.timeseries.concat({ x: point.key, - y: point.transaction_count.value / deltaAsMinutes, + y: point.doc_count / deltaAsMinutes, }), }, errorRate: { ...acc.errorRate, timeseries: acc.errorRate.timeseries.concat({ x: point.key, - y: - point.transaction_count.value > 0 - ? (point[EVENT_OUTCOME].transaction_count.value ?? 0) / - point.transaction_count.value - : null, + y: point[EVENT_OUTCOME].doc_count / point.doc_count, }), }, }; diff --git a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts index 0ee7080dc0834..efbc30169d178 100644 --- a/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts +++ b/x-pack/plugins/apm/server/lib/services/get_services/get_service_transaction_stats.ts @@ -51,16 +51,9 @@ export async function getServiceTransactionStats({ }: AggregationParams) { const { apmEventClient, start, end, esFilter } = setup; - const outcomes = getOutcomeAggregation({ searchAggregatedTransactions }); + const outcomes = getOutcomeAggregation(); const metrics = { - real_document_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, avg_duration: { avg: { field: getTransactionDurationFieldForAggregatedTransactions( @@ -102,7 +95,6 @@ export async function getServiceTransactionStats({ transactionType: { terms: { field: TRANSACTION_TYPE, - order: { real_document_count: 'desc' }, }, aggs: { ...metrics, @@ -180,14 +172,14 @@ export async function getServiceTransactionStats({ }, transactionsPerMinute: { value: calculateAvgDuration({ - value: topTransactionTypeBucket.real_document_count.value, + value: topTransactionTypeBucket.doc_count, deltaAsMinutes, }), timeseries: topTransactionTypeBucket.timeseries.buckets.map( (dateBucket) => ({ x: dateBucket.key, y: calculateAvgDuration({ - value: dateBucket.real_document_count.value, + value: dateBucket.doc_count, deltaAsMinutes, }), }) diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index 29071f96e3a06..bde826a568da9 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -27,12 +27,17 @@ interface Options { type ESResponse = PromiseReturnType<typeof fetcher>; -function transform(response: ESResponse) { +function transform(response: ESResponse, options: Options) { + const { end, start } = options.setup; + const deltaAsMinutes = (end - start) / 1000 / 60; if (response.hits.total.value === 0) { return []; } const buckets = response.aggregations?.throughput.buckets ?? []; - return buckets.map(({ key: x, doc_count: y }) => ({ x, y })); + return buckets.map(({ key: x, doc_count: y }) => ({ + x, + y: y / deltaAsMinutes, + })); } async function fetcher({ @@ -82,6 +87,6 @@ async function fetcher({ export async function getThroughput(options: Options) { return { - throughput: transform(await fetcher(options)), + throughput: transform(await fetcher(options), options), }; } diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap index c678e7db711b6..89069d74bacf8 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/transaction_groups/__snapshots__/queries.test.ts.snap @@ -12,11 +12,6 @@ Array [ "aggs": Object { "transaction_groups": Object { "aggs": Object { - "count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, "transaction_type": Object { "top_hits": Object { "_source": Array [ @@ -226,11 +221,6 @@ Array [ "aggs": Object { "transaction_groups": Object { "aggs": Object { - "count": Object { - "value_count": Object { - "field": "transaction.duration.us", - }, - }, "transaction_type": Object { "top_hits": Object { "_source": Array [ diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index dfd11203b87f1..a2388dddc7fd4 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -14,7 +14,10 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { EventOutcome } from '../../../common/event_outcome'; import { rangeFilter } from '../../../common/utils/range_filter'; -import { getProcessorEventForAggregatedTransactions } from '../helpers/aggregated_transactions'; +import { + getDocumentTypeFilterForAggregatedTransactions, + getProcessorEventForAggregatedTransactions, +} from '../helpers/aggregated_transactions'; import { getBucketSize } from '../helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { @@ -55,12 +58,15 @@ export async function getErrorRate({ { terms: { [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success] }, }, + ...getDocumentTypeFilterForAggregatedTransactions( + searchAggregatedTransactions + ), ...transactionNamefilter, ...transactionTypefilter, ...esFilter, ]; - const outcomes = getOutcomeAggregation({ searchAggregatedTransactions }); + const outcomes = getOutcomeAggregation(); const params = { apm: { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts index cfd3540446172..dba58cecad79b 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_transaction_group_stats.ts @@ -66,13 +66,6 @@ export async function getCounts({ searchAggregatedTransactions, }: MetricParams) { const params = mergeRequestWithAggs(request, { - count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, transaction_type: { top_hits: { size: 1, @@ -92,7 +85,7 @@ export async function getCounts({ return { key: bucket.key as BucketKey, - count: bucket.count.value, + count: bucket.doc_count, transactionType: source.transaction.type, }; }); diff --git a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts index be374ccfe3400..8dfb0a9f65878 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/index.ts @@ -15,7 +15,6 @@ import { rangeFilter } from '../../../../common/utils/range_filter'; import { getDocumentTypeFilterForAggregatedTransactions, getProcessorEventForAggregatedTransactions, - getTransactionDurationFieldForAggregatedTransactions, } from '../../../lib/helpers/aggregated_transactions'; import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; @@ -56,10 +55,6 @@ async function searchThroughput({ filter.push({ term: { [TRANSACTION_NAME]: transactionName } }); } - const field = getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ); - const params = { apm: { events: [ @@ -82,7 +77,6 @@ async function searchThroughput({ min_doc_count: 0, extended_bounds: { min: start, max: end }, }, - aggs: { count: { value_count: { field } } }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/transform.ts b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/transform.ts index a12e36c0e9de4..7e43a0d76f70a 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/transform.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_throughput_charts/transform.ts @@ -25,7 +25,7 @@ export function getThroughputBuckets({ return { x: bucket.key, // divide by minutes - y: bucket.count.value / (bucketSize / 60), + y: bucket.doc_count / (bucketSize / 60), }; }); @@ -34,7 +34,7 @@ export function getThroughputBuckets({ resultKey === '' ? NOT_AVAILABLE_LABEL : (resultKey as string); const docCountTotal = timeseries.buckets - .map((bucket) => bucket.count.value) + .map((bucket) => bucket.doc_count) .reduce((a, b) => a + b, 0); // calculate average throughput diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts index e36644530eae8..0f200a92a41f1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts @@ -5,7 +5,7 @@ */ // @ts-expect-error no @typed def; Elastic library -import { evaluate } from 'tinymath'; +import { evaluate } from '@kbn/tinymath'; import { pivotObjectArray } from '../../../common/lib/pivot_object_array'; import { Datatable, isDatatable, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts index 58a2354b5cf38..ff5a4506ab82a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/demodata/get_demo_rows.ts @@ -5,8 +5,10 @@ */ import { cloneDeep } from 'lodash'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import ci from './ci.json'; import { DemoRows } from './demo_rows_types'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import shirts from './shirts.json'; import { getFunctionErrors } from '../../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts index 7dee587895485..a04f39c66bc26 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts @@ -5,7 +5,7 @@ */ // @ts-expect-error untyped library -import { parse } from 'tinymath'; +import { parse } from '@kbn/tinymath'; import { getFieldNames } from './pointseries/lib/get_field_names'; describe('getFieldNames', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts index f79f189f363d4..b528eb63ef2b6 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts @@ -5,7 +5,7 @@ */ // @ts-expect-error Untyped Elastic library -import { evaluate } from 'tinymath'; +import { evaluate } from '@kbn/tinymath'; import { groupBy, zipObject, omit, uniqBy } from 'lodash'; import moment from 'moment'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js index 1cbfafe8103ed..ed1f1d5e6c706 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { parse } from 'tinymath'; +import { parse } from '@kbn/tinymath'; import { getFieldType } from '../../../../../common/lib/get_field_type'; import { isColumnReference } from './is_column_reference'; import { getFieldNames } from './get_field_names'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts index aed9861e1250c..54e1adbeddd78 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts @@ -5,7 +5,7 @@ */ // @ts-expect-error untyped library -import { parse } from 'tinymath'; +import { parse } from '@kbn/tinymath'; export function isColumnReference(mathExpression: string | null): boolean { if (mathExpression == null) { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx index a9296bd9a1241..238b2edc3bd6d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/error/index.tsx @@ -12,7 +12,7 @@ import { Popover } from '../../../public/components/popover'; import { RendererStrings } from '../../../i18n'; import { RendererFactory } from '../../../types'; -interface Config { +export interface Config { error: Error; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx index bfc36932a8a07..6c1dd086c8667 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/index.tsx @@ -15,7 +15,7 @@ import { RendererStrings } from '../../../../i18n'; const { dropdownFilter: strings } = RendererStrings; -interface Config { +export interface Config { /** The column to use within the exactly function */ column: string; /** diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx index ada159e07f6ae..4933b1b4ba51d 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/table.tsx @@ -12,7 +12,7 @@ import { RendererFactory, Style, Datatable } from '../../types'; const { dropdownFilter: strings } = RendererStrings; -interface TableArguments { +export interface TableArguments { font?: Style; paginate: boolean; perPage: number; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js index 7f5fe8b2cce12..fbde9f7f63f41 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { parse } from 'tinymath'; +import { parse } from '@kbn/tinymath'; import { unquoteString } from '../../../../common/lib/unquote_string'; // break out into separate function, write unit tests first diff --git a/x-pack/plugins/canvas/common/lib/handlebars.js b/x-pack/plugins/canvas/common/lib/handlebars.js index 0b7ef38fe8f6d..ae5063e173525 100644 --- a/x-pack/plugins/canvas/common/lib/handlebars.js +++ b/x-pack/plugins/canvas/common/lib/handlebars.js @@ -5,7 +5,7 @@ */ import Hbars from 'handlebars/dist/handlebars'; -import { evaluate } from 'tinymath'; +import { evaluate } from '@kbn/tinymath'; import { pivotObjectArray } from './pivot_object_array'; // example use: {{math rows 'mean(price - cost)' 2}} diff --git a/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx b/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx index 03121e749d0dc..f26408b1200f1 100644 --- a/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx +++ b/x-pack/plugins/canvas/public/apps/export/export/export_app.component.tsx @@ -13,7 +13,7 @@ import { WorkpadPage } from '../../../components/workpad_page'; import { Link } from '../../../components/link'; import { CanvasWorkpad } from '../../../../types'; -interface Props { +export interface Props { workpad: CanvasWorkpad; selectedPageIndex: number; initializeWorkpad: () => void; diff --git a/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx b/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx index 3c2e989cc8e51..7fbdc24c112a1 100644 --- a/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx +++ b/x-pack/plugins/canvas/public/apps/home/home_app/home_app.component.tsx @@ -11,7 +11,7 @@ import { WorkpadManager } from '../../../components/workpad_manager'; // @ts-expect-error untyped local import { setDocTitle } from '../../../lib/doc_title'; -interface Props { +export interface Props { onLoad: () => void; } diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx index 981334ff8d9f2..3697d5dad2dae 100644 --- a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/workpad_telemetry.tsx @@ -46,7 +46,7 @@ interface ResolvedArgs { [keys: string]: any; } -interface ElementsLoadedTelemetryProps extends PropsFromRedux { +export interface ElementsLoadedTelemetryProps extends PropsFromRedux { workpad: Workpad; } diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx index ed000741bc542..d94802bf2a772 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx @@ -28,7 +28,7 @@ import { ComponentStrings } from '../../../i18n'; const { Asset: strings } = ComponentStrings; -interface Props { +export interface Props { /** The asset to be rendered */ asset: AssetType; /** The function to execute when the user clicks 'Create' */ diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx index 98f3d8b48829d..6c1b546b49aa1 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx @@ -33,7 +33,7 @@ import { ComponentStrings } from '../../../i18n'; const { AssetManager: strings } = ComponentStrings; -interface Props { +export interface Props { /** The assets to display within the modal */ assets: AssetType[]; /** Function to invoke when the modal is closed */ diff --git a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx index 31a75acbba4ec..9d0a5e0a9f51d 100644 --- a/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx +++ b/x-pack/plugins/canvas/public/components/confirm_modal/confirm_modal.tsx @@ -8,7 +8,7 @@ import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; import PropTypes from 'prop-types'; import React, { FunctionComponent } from 'react'; -interface Props { +export interface Props { isOpen: boolean; title?: string; message: string; diff --git a/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx b/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx index fd1dc869d60ec..da1fe8473e36d 100644 --- a/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx +++ b/x-pack/plugins/canvas/public/components/page_preview/page_preview.component.tsx @@ -10,7 +10,7 @@ import { DomPreview } from '../dom_preview'; import { PageControls } from './page_controls'; import { CanvasPage } from '../../../types'; -interface Props { +export interface Props { isWriteable: boolean; page: Pick<CanvasPage, 'id' | 'style'>; height: number; diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx index 7151e72a44780..d33ba57050d4b 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx @@ -31,7 +31,7 @@ const { Toolbar: strings } = ComponentStrings; type TrayType = 'pageManager' | 'expression'; -interface Props { +export interface Props { isWriteable: boolean; selectedElement?: CanvasElement; selectedPageNumber: number; diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx index a7424882f1072..4068272bbaf11 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.component.tsx @@ -30,7 +30,7 @@ import { ComponentStrings } from '../../../i18n'; const { WorkpadConfig: strings } = ComponentStrings; -interface Props { +export interface Props { size: { height: number; width: number; diff --git a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx index d651e649128f9..023d87c7c3565 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/refresh_control/refresh_control.component.tsx @@ -12,7 +12,7 @@ import { ToolTipShortcut } from '../../tool_tip_shortcut'; import { ComponentStrings } from '../../../../i18n'; const { WorkpadHeaderRefreshControlSettings: strings } = ComponentStrings; -interface Props { +export interface Props { doRefresh: MouseEventHandler<HTMLButtonElement>; inFlight: boolean; } diff --git a/x-pack/plugins/canvas/public/functions/filters.ts b/x-pack/plugins/canvas/public/functions/filters.ts index fdb5d69d35515..70120ccad6f54 100644 --- a/x-pack/plugins/canvas/public/functions/filters.ts +++ b/x-pack/plugins/canvas/public/functions/filters.ts @@ -15,7 +15,7 @@ import { ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from '.'; -interface Arguments { +export interface Arguments { group: string[]; ungrouped: boolean; } diff --git a/x-pack/plugins/canvas/public/functions/pie.ts b/x-pack/plugins/canvas/public/functions/pie.ts index ab3f1b932dc3c..e7cf153b9cd0f 100644 --- a/x-pack/plugins/canvas/public/functions/pie.ts +++ b/x-pack/plugins/canvas/public/functions/pie.ts @@ -61,7 +61,7 @@ export interface Pie { options: PieOptions; } -interface Arguments { +export interface Arguments { palette: PaletteOutput; seriesStyle: SeriesStyle[]; radius: number | 'auto'; diff --git a/x-pack/plugins/canvas/public/functions/plot/index.ts b/x-pack/plugins/canvas/public/functions/plot/index.ts index a4661dc3401df..79aa11cfa2d80 100644 --- a/x-pack/plugins/canvas/public/functions/plot/index.ts +++ b/x-pack/plugins/canvas/public/functions/plot/index.ts @@ -17,7 +17,7 @@ import { getTickHash } from './get_tick_hash'; import { getFunctionHelp } from '../../../i18n'; import { AxisConfig, PointSeries, Render, SeriesStyle, Legend } from '../../../types'; -interface Arguments { +export interface Arguments { seriesStyle: SeriesStyle[]; defaultStyle: SeriesStyle; palette: PaletteOutput; diff --git a/x-pack/plugins/canvas/public/functions/timelion.ts b/x-pack/plugins/canvas/public/functions/timelion.ts index 947972fa310c9..3018540e5bf8e 100644 --- a/x-pack/plugins/canvas/public/functions/timelion.ts +++ b/x-pack/plugins/canvas/public/functions/timelion.ts @@ -15,7 +15,7 @@ import { Datatable, ExpressionValueFilter } from '../../types'; import { getFunctionHelp } from '../../i18n'; import { InitializeArguments } from './'; -interface Arguments { +export interface Arguments { query: string; interval: string; from: string; diff --git a/x-pack/plugins/canvas/public/functions/to.ts b/x-pack/plugins/canvas/public/functions/to.ts index 36b2d3f9f04c6..c8ac4f714e5c4 100644 --- a/x-pack/plugins/canvas/public/functions/to.ts +++ b/x-pack/plugins/canvas/public/functions/to.ts @@ -10,7 +10,7 @@ import { ExpressionFunctionDefinition } from 'src/plugins/expressions/public'; import { getFunctionHelp, getFunctionErrors } from '../../i18n'; import { InitializeArguments } from '.'; -interface Arguments { +export interface Arguments { type: string[]; } diff --git a/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx b/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx index f4e715b1bbc49..95225cf13ff3b 100644 --- a/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx +++ b/x-pack/plugins/canvas/public/lib/template_from_react_component.tsx @@ -11,7 +11,7 @@ import { I18nProvider } from '@kbn/i18n/react'; import { ErrorBoundary } from '../components/enhance/error_boundary'; import { ArgumentHandlers } from '../../types/arguments'; -interface Props { +export interface Props { renderError: Function; } diff --git a/x-pack/plugins/canvas/server/sample_data/index.ts b/x-pack/plugins/canvas/server/sample_data/index.ts index 212d9f5132831..9c9ecb718fd5f 100644 --- a/x-pack/plugins/canvas/server/sample_data/index.ts +++ b/x-pack/plugins/canvas/server/sample_data/index.ts @@ -3,9 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import ecommerceSavedObjects from './ecommerce_saved_objects.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import flightsSavedObjects from './flights_saved_objects.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import webLogsSavedObjects from './web_logs_saved_objects.json'; import { loadSampleData } from './load_sample_data'; diff --git a/x-pack/plugins/canvas/shareable_runtime/context/actions.ts b/x-pack/plugins/canvas/shareable_runtime/context/actions.ts index 8c88afbadfd9e..a36435688505d 100644 --- a/x-pack/plugins/canvas/shareable_runtime/context/actions.ts +++ b/x-pack/plugins/canvas/shareable_runtime/context/actions.ts @@ -17,7 +17,7 @@ export enum CanvasShareableActions { SET_TOOLBAR_AUTOHIDE = 'SET_TOOLBAR_AUTOHIDE', } -interface FluxAction<T, P> { +export interface FluxAction<T, P> { type: T; payload: P; } diff --git a/x-pack/plugins/canvas/shareable_runtime/test/index.ts b/x-pack/plugins/canvas/shareable_runtime/test/index.ts index 288dd0dc3a5be..f0d2ebcc20128 100644 --- a/x-pack/plugins/canvas/shareable_runtime/test/index.ts +++ b/x-pack/plugins/canvas/shareable_runtime/test/index.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import hello from './workpads/hello.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import austin from './workpads/austin.json'; +// @ts-ignore this file is too large for TypeScript, so it is excluded from our project config import test from './workpads/test.json'; export * from './utils'; diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json new file mode 100644 index 0000000000000..3e3986082e207 --- /dev/null +++ b/x-pack/plugins/canvas/tsconfig.json @@ -0,0 +1,52 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../../typings/**/*", + "__fixtures__/**/*", + "canvas_plugin_src/**/*", + "common/**/*", + "i18n/**/*", + "public/**/*", + "server/**/*", + "shareable_runtime/**/*", + "storybook/**/*", + "tasks/mocks/*", + "types/**/*", + "**/*.json", + ], + "exclude": [ + // these files are too large and upset tsc, so we exclude them + "server/sample_data/*.json", + "canvas_plugin_src/functions/server/demodata/*.json", + "shareable_runtime/test/workpads/*.json", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/bfetch/tsconfig.json"}, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/discover/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/expressions/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/inspector/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_legacy/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/saved_objects/tsconfig.json" }, + { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/visualizations/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../lens/tsconfig.json" }, + { "path": "../maps/tsconfig.json" }, + { "path": "../reporting/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/canvas/types/state.ts b/x-pack/plugins/canvas/types/state.ts index 03bb931dc9b26..33f913563daac 100644 --- a/x-pack/plugins/canvas/types/state.ts +++ b/x-pack/plugins/canvas/types/state.ts @@ -52,7 +52,7 @@ type ExpressionType = | Style | Range; -interface ExpressionRenderable { +export interface ExpressionRenderable { state: 'ready' | 'pending'; value: Render<ExpressionType> | null; error: null; diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index b82c6de8fc363..398f73f2721a6 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -6,11 +6,12 @@ import * as rt from 'io-ts'; -import { ActionResult } from '../../../../actions/common'; +import { ActionResult, ActionType } from '../../../../actions/common'; import { UserRT } from '../user'; import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; export type ActionConnector = ActionResult; +export type ActionTypeConnector = ActionType; // TODO: we will need to add this type rt.literal('close-by-third-party') const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 40911496d6494..3a12b50cf8f68 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -14,13 +14,15 @@ import { CaseConfigureService, ConnectorMappingsService, } from '../../../services'; -import { getActions } from '../__mocks__/request_responses'; +import { getActions, getActionTypes } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; import type { CasesRequestHandlerContext } from '../../../types'; export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); + actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); + const log = loggingSystemMock.create().get('case'); const esClientMock = elasticsearchServiceMock.createClusterClient(); diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index efc3b6044a804..236deb9c7462c 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { + ActionTypeConnector, CasePostRequest, CasesConfigureRequest, ConnectorTypes, @@ -73,6 +74,49 @@ export const getActions = (): FindActionResult[] => [ }, ]; +export const getActionTypes = (): ActionTypeConnector[] => [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + export const newConfiguration: CasesConfigureRequest = { connector: { id: '456', diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts index b744a6dc04810..974ae9283dd98 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts @@ -42,10 +42,72 @@ describe('GET connectors', () => { expect(res.status).toEqual(200); const expected = getActions(); + // The first connector returned by getActions is of type .webhook and we expect to be filtered expected.shift(); expect(res.payload).toEqual(expected); }); + it('filters out connectors that are not enabled in license', async () => { + const req = httpServerMock.createKibanaRequest({ + path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, + method: 'get', + }); + + const context = await createRouteContext( + createMockSavedObjectsRepository({ + caseConfigureSavedObject: mockCaseConfigure, + caseMappingsSavedObject: mockCaseMappings, + }) + ); + + const actionsClient = context.actions.getActionsClient(); + (actionsClient.listTypes as jest.Mock).mockImplementation(() => + Promise.resolve([ + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + // User does not have a platinum license + enabledInLicense: false, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + // User does not have a platinum license + enabledInLicense: false, + }, + ]) + ); + + const res = await routeHandler(context, req, kibanaResponseFactory); + expect(res.status).toEqual(200); + expect(res.payload).toEqual([ + { + id: '456', + actionTypeId: '.jira', + name: 'Connector without isCaseOwned', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, + ]); + }); + it('it throws an error when actions client is null', async () => { const req = httpServerMock.createKibanaRequest({ path: `${CASE_CONFIGURE_CONNECTORS_URL}/_find`, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index cb88f04a9b835..cf854df9f04f2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -7,6 +7,7 @@ import Boom from '@hapi/boom'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; +import { ActionType } from '../../../../../../actions/common'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../../actions/server/types'; @@ -17,10 +18,13 @@ import { RESILIENT_ACTION_TYPE_ID, } from '../../../../../common/constants'; -const isConnectorSupported = (action: FindActionResult): boolean => +const isConnectorSupported = ( + action: FindActionResult, + actionTypes: Record<string, ActionType> +): boolean => [SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( action.actionTypeId - ); + ) && actionTypes[action.actionTypeId]?.enabledInLicense; /* * Be aware that this api will only return 20 connectors @@ -40,7 +44,14 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { throw Boom.notFound('Action client have not been found'); } - const results = (await actionsClient.getAll()).filter(isConnectorSupported); + const actionTypes = (await actionsClient.listTypes()).reduce( + (types, type) => ({ ...types, [type.id]: type }), + {} + ); + + const results = (await actionsClient.getAll()).filter((action) => + isConnectorSupported(action, actionTypes) + ); return response.ok({ body: results }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json b/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json new file mode 100644 index 0000000000000..b79a396445e3d --- /dev/null +++ b/x-pack/plugins/data_enhanced/common/search/test_data/search_phase_execution_exception.json @@ -0,0 +1,229 @@ +{ + "error": { + "root_cause": [ + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "parse_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]: [failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]]" + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + }, + { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + } + } + ], + "type": "search_phase_execution_exception", + "reason": "all shards failed", + "phase": "query", + "grouped": true, + "failed_shards": [ + { + "shard": 0, + "index": ".apm-agent-configuration", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".apm-custom-link", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".kibana-event-log-8.0.0-000001", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "parse_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]: [failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]]", + "caused_by": { + "type": "illegal_argument_exception", + "reason": "failed to parse date field [2021-01-19T12:2755.047Z] with format [strict_date_optional_time]", + "caused_by": { + "type": "date_time_parse_exception", + "reason": "Text '2021-01-19T12:2755.047Z' could not be parsed, unparsed text found at index 16" + } + } + } + }, + { + "shard": 0, + "index": ".kibana_1", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".kibana_task_manager_1", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + }, + { + "shard": 0, + "index": ".security-7", + "node": "DEfMVCg5R12TRG4CYIxUgQ", + "reason": { + "type": "script_exception", + "reason": "compile error", + "script_stack": [ + "invalid", + "^---- HERE" + ], + "script": "invalid", + "lang": "painless", + "position": { + "offset": 0, + "start": 0, + "end": 7 + }, + "caused_by": { + "type": "illegal_argument_exception", + "reason": "cannot resolve symbol [invalid]" + } + } + } + ] + }, + "status": 400 +} \ No newline at end of file diff --git a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts index 1a6fc724e2cf2..22b0f3272ff7d 100644 --- a/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/search_interceptor.test.ts @@ -9,10 +9,16 @@ import { EnhancedSearchInterceptor } from './search_interceptor'; import { CoreSetup, CoreStart } from 'kibana/public'; import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; import { AbortError } from '../../../../../src/plugins/kibana_utils/public'; -import { ISessionService, SearchTimeoutError, SearchSessionState } from 'src/plugins/data/public'; +import { + ISessionService, + SearchTimeoutError, + SearchSessionState, + PainlessError, +} from 'src/plugins/data/public'; import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks'; import { bfetchPluginMock } from '../../../../../src/plugins/bfetch/public/mocks'; import { BehaviorSubject } from 'rxjs'; +import * as xpackResourceNotFoundException from '../../common/search/test_data/search_phase_execution_exception.json'; const timeTravel = (msToRun = 0) => { jest.advanceTimersByTime(msToRun); @@ -99,6 +105,33 @@ describe('EnhancedSearchInterceptor', () => { }); }); + describe('errors', () => { + test('Should throw Painless error on server error with OSS format', async () => { + const mockResponse: any = { + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: xpackResourceNotFoundException.error, + }; + fetchMock.mockRejectedValueOnce(mockResponse); + const response = searchInterceptor.search({ + params: {}, + }); + await expect(response.toPromise()).rejects.toThrow(PainlessError); + }); + + test('Renders a PainlessError', async () => { + searchInterceptor.showError( + new PainlessError({ + statusCode: 400, + message: 'search_phase_execution_exception', + attributes: xpackResourceNotFoundException.error, + }) + ); + expect(mockCoreSetup.notifications.toasts.addDanger).toBeCalledTimes(1); + expect(mockCoreSetup.notifications.toasts.addError).not.toBeCalled(); + }); + }); + describe('search', () => { test('should resolve immediately if first call returns full result', async () => { const responses = [ @@ -342,7 +375,8 @@ describe('EnhancedSearchInterceptor', () => { { time: 10, value: { - error: 'oh no', + statusCode: 500, + message: 'oh no', id: 1, }, isError: true, @@ -364,7 +398,8 @@ describe('EnhancedSearchInterceptor', () => { await timeTravel(10); expect(error).toHaveBeenCalled(); - expect(error.mock.calls[0][0]).toBe(responses[1].value); + expect(error.mock.calls[0][0]).toBeInstanceOf(Error); + expect((error.mock.calls[0][0] as Error).message).toBe('oh no'); expect(fetchMock).toHaveBeenCalledTimes(2); expect(mockCoreSetup.http.delete).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts index 3230895da7705..b2ddd0310f8f5 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.test.ts @@ -7,6 +7,10 @@ import { enhancedEsSearchStrategyProvider } from './es_search_strategy'; import { BehaviorSubject } from 'rxjs'; import { SearchStrategyDependencies } from '../../../../../src/plugins/data/server/search'; +import { KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; +import { ElasticsearchClientError, ResponseError } from '@elastic/elasticsearch/lib/errors'; +import * as indexNotFoundException from '../../../../../src/plugins/data/common/search/test_data/index_not_found_exception.json'; +import * as xContentParseException from '../../../../../src/plugins/data/common/search/test_data/x_content_parse_exception.json'; const mockAsyncResponse = { body: { @@ -145,6 +149,54 @@ describe('ES search strategy', () => { expect(request).toHaveProperty('wait_for_completion_timeout'); expect(request).toHaveProperty('keep_alive'); }); + + it('throws normalized error if ResponseError is thrown', async () => { + const errResponse = new ResponseError({ + body: indexNotFoundException, + statusCode: 404, + headers: {}, + warnings: [], + meta: {} as any, + }); + + mockSubmitCaller.mockRejectedValue(errResponse); + + const params = { index: 'logstash-*', body: { query: {} } }; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSubmitCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(404); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(indexNotFoundException); + }); + + it('throws normalized error if Error is thrown', async () => { + const errResponse = new Error('not good'); + + mockSubmitCaller.mockRejectedValue(errResponse); + + const params = { index: 'logstash-*', body: { query: {} } }; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.search({ params }, {}, mockDeps).toPromise(); + } catch (e) { + err = e; + } + expect(mockSubmitCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); }); describe('cancel', () => { @@ -160,6 +212,33 @@ describe('ES search strategy', () => { const request = mockDeleteCaller.mock.calls[0][0]; expect(request).toEqual({ id }); }); + + it('throws normalized error on ResponseError', async () => { + const errResponse = new ResponseError({ + body: xContentParseException, + statusCode: 400, + headers: {}, + warnings: [], + meta: {} as any, + }); + mockDeleteCaller.mockRejectedValue(errResponse); + + const id = 'some_id'; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.cancel!(id, {}, mockDeps); + } catch (e) { + err = e; + } + + expect(mockDeleteCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(400); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(xContentParseException); + }); }); describe('extend', () => { @@ -176,5 +255,27 @@ describe('ES search strategy', () => { const request = mockGetCaller.mock.calls[0][0]; expect(request).toEqual({ id, keep_alive: keepAlive }); }); + + it('throws normalized error on ElasticsearchClientError', async () => { + const errResponse = new ElasticsearchClientError('something is wrong with EsClient'); + mockGetCaller.mockRejectedValue(errResponse); + + const id = 'some_other_id'; + const keepAlive = '1d'; + const esSearch = await enhancedEsSearchStrategyProvider(mockConfig$, mockLogger); + + let err: KbnServerError | undefined; + try { + await esSearch.extend!(id, keepAlive, {}, mockDeps); + } catch (e) { + err = e; + } + + expect(mockGetCaller).toBeCalled(); + expect(err).toBeInstanceOf(KbnServerError); + expect(err?.statusCode).toBe(500); + expect(err?.message).toBe(errResponse.message); + expect(err?.errBody).toBe(undefined); + }); }); }); diff --git a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts index 54ed59b30952a..694d9807b5a4d 100644 --- a/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts +++ b/x-pack/plugins/data_enhanced/server/search/es_search_strategy.ts @@ -6,7 +6,7 @@ import type { Observable } from 'rxjs'; import type { IScopedClusterClient, Logger, SharedGlobalConfig } from 'kibana/server'; -import { first, tap } from 'rxjs/operators'; +import { catchError, first, tap } from 'rxjs/operators'; import { SearchResponse } from 'elasticsearch'; import { from } from 'rxjs'; import type { @@ -33,7 +33,7 @@ import { } from './request_utils'; import { toAsyncKibanaSearchResponse } from './response_utils'; import { AsyncSearchResponse } from './types'; -import { KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; +import { getKbnServerError, KbnServerError } from '../../../../../src/plugins/kibana_utils/server'; export const enhancedEsSearchStrategyProvider = ( config$: Observable<SharedGlobalConfig>, @@ -41,7 +41,11 @@ export const enhancedEsSearchStrategyProvider = ( usage?: SearchUsage ): ISearchStrategy<IEsSearchRequest> => { async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) { - await esClient.asCurrentUser.asyncSearch.delete({ id }); + try { + await esClient.asCurrentUser.asyncSearch.delete({ id }); + } catch (e) { + throw getKbnServerError(e); + } } function asyncSearch( @@ -70,7 +74,10 @@ export const enhancedEsSearchStrategyProvider = ( return pollSearch(search, cancel, options).pipe( tap((response) => (id = response.id)), - tap(searchUsageObserver(logger, usage)) + tap(searchUsageObserver(logger, usage)), + catchError((e) => { + throw getKbnServerError(e); + }) ); } @@ -90,40 +97,72 @@ export const enhancedEsSearchStrategyProvider = ( ...params, }; - const promise = esClient.asCurrentUser.transport.request({ - method, - path, - body, - querystring, - }); + try { + const promise = esClient.asCurrentUser.transport.request({ + method, + path, + body, + querystring, + }); - const esResponse = await shimAbortSignal(promise, options?.abortSignal); - const response = esResponse.body as SearchResponse<any>; - return { - rawResponse: response, - ...getTotalLoaded(response), - }; + const esResponse = await shimAbortSignal(promise, options?.abortSignal); + const response = esResponse.body as SearchResponse<any>; + return { + rawResponse: response, + ...getTotalLoaded(response), + }; + } catch (e) { + throw getKbnServerError(e); + } } return { + /** + * @param request + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Observable<IEsSearchResponse<any>>` + * @throws `KbnServerError` + */ search: (request, options: IAsyncSearchOptions, deps) => { logger.debug(`search ${JSON.stringify(request.params) || request.id}`); + if (request.indexType && request.indexType !== 'rollup') { + throw new KbnServerError('Unknown indexType', 400); + } if (request.indexType === undefined) { return asyncSearch(request, options, deps); - } else if (request.indexType === 'rollup') { - return from(rollupSearch(request, options, deps)); } else { - throw new KbnServerError('Unknown indexType', 400); + return from(rollupSearch(request, options, deps)); } }, + /** + * @param id async search ID to cancel, as returned from _async_search API + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise<void>` + * @throws `KbnServerError` + */ cancel: async (id, options, { esClient }) => { logger.debug(`cancel ${id}`); await cancelAsyncSearch(id, esClient); }, + /** + * + * @param id async search ID to extend, as returned from _async_search API + * @param keepAlive + * @param options + * @param deps `SearchStrategyDependencies` + * @returns `Promise<void>` + * @throws `KbnServerError` + */ extend: async (id, keepAlive, options, { esClient }) => { logger.debug(`extend ${id} by ${keepAlive}`); - await esClient.asCurrentUser.asyncSearch.get({ id, keep_alive: keepAlive }); + try { + await esClient.asCurrentUser.asyncSearch.get({ id, keep_alive: keepAlive }); + } catch (e) { + throw getKbnServerError(e); + } }, }; }; diff --git a/x-pack/plugins/data_enhanced/tsconfig.json b/x-pack/plugins/data_enhanced/tsconfig.json index c4b09276880d9..29bfd71cb32b4 100644 --- a/x-pack/plugins/data_enhanced/tsconfig.json +++ b/x-pack/plugins/data_enhanced/tsconfig.json @@ -14,7 +14,8 @@ "config.ts", "../../../typings/**/*", // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 - "public/autocomplete/providers/kql_query_suggestion/__fixtures__/*.json" + "public/autocomplete/providers/kql_query_suggestion/__fixtures__/*.json", + "common/search/test_data/*.json" ], "references": [ { "path": "../../../src/core/tsconfig.json" }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx index 68906e2927a0d..22847843826da 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_layout.tsx @@ -7,6 +7,7 @@ import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { useValues, useActions } from 'kea'; +import { EuiSpacer } from '@elastic/eui'; import { KibanaLogic } from '../../../shared/kibana'; import { FlashMessages } from '../../../shared/flash_messages'; @@ -47,6 +48,7 @@ export const AnalyticsLayout: React.FC<Props> = ({ <FlashMessages /> <LogRetentionCallout type={LogRetentionOptions.Analytics} /> {children} + <EuiSpacer /> </> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts index 0901ff2737803..59e33893a18eb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.test.ts @@ -9,13 +9,14 @@ import { mockKibanaValues, mockHttpValues, mockFlashMessageHelpers, - expectedAsyncError, } from '../../../__mocks__'; jest.mock('../engine', () => ({ EngineLogic: { values: { engineName: 'test-engine' } }, })); +import { nextTick } from '@kbn/test/jest'; + import { DEFAULT_START_DATE, DEFAULT_END_DATE } from './constants'; import { AnalyticsLogic } from './'; @@ -29,6 +30,11 @@ describe('AnalyticsLogic', () => { dataLoading: true, analyticsUnavailable: false, allTags: [], + recentQueries: [], + topQueries: [], + topQueriesNoResults: [], + topQueriesNoClicks: [], + topQueriesWithClicks: [], totalQueries: 0, totalQueriesNoResults: 0, totalClicks: 0, @@ -37,6 +43,7 @@ describe('AnalyticsLogic', () => { queriesNoResultsPerDay: [], clicksPerDay: [], queriesPerDayForQuery: [], + topClicksForQuery: [], startDate: '', }; @@ -129,16 +136,7 @@ describe('AnalyticsLogic', () => { expect(AnalyticsLogic.values).toEqual({ ...DEFAULT_VALUES, dataLoading: false, - analyticsUnavailable: false, - allTags: ['some-tag'], - startDate: '1970-01-01', - totalClicks: 1000, - totalQueries: 5000, - totalQueriesNoResults: 500, - queriesPerDay: [10, 50, 100], - queriesNoResultsPerDay: [1, 2, 3], - clicksPerDay: [0, 10, 50], - // TODO: Replace this with ...MOCK_ANALYTICS_RESPONSE once all data is set + ...MOCK_ANALYTICS_RESPONSE, }); }); }); @@ -151,12 +149,7 @@ describe('AnalyticsLogic', () => { expect(AnalyticsLogic.values).toEqual({ ...DEFAULT_VALUES, dataLoading: false, - analyticsUnavailable: false, - allTags: ['some-tag'], - startDate: '1970-01-01', - totalQueriesForQuery: 50, - queriesPerDayForQuery: [25, 0, 25], - // TODO: Replace this with ...MOCK_QUERY_RESPONSE once all data is set + ...MOCK_QUERY_RESPONSE, }); }); }); @@ -176,13 +169,12 @@ describe('AnalyticsLogic', () => { }); it('should make an API call and set state based on the response', async () => { - const promise = Promise.resolve(MOCK_ANALYTICS_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ANALYTICS_RESPONSE)); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsDataLoad'); AnalyticsLogic.actions.loadAnalyticsData(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith( '/api/app_search/engines/test-engine/analytics/queries', @@ -220,25 +212,23 @@ describe('AnalyticsLogic', () => { }); it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { - const promise = Promise.resolve({ analyticsUnavailable: true }); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve({ analyticsUnavailable: true })); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadAnalyticsData(); - await promise; + await nextTick(); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); }); it('handles errors', async () => { - const promise = Promise.reject('error'); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.reject('error')); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadAnalyticsData(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('error'); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); @@ -258,13 +248,12 @@ describe('AnalyticsLogic', () => { }); it('should make an API call and set state based on the response', async () => { - const promise = Promise.resolve(MOCK_QUERY_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_QUERY_RESPONSE)); mount(); jest.spyOn(AnalyticsLogic.actions, 'onQueryDataLoad'); AnalyticsLogic.actions.loadQueryData('some-query'); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith( '/api/app_search/engines/test-engine/analytics/queries/some-query', @@ -298,25 +287,23 @@ describe('AnalyticsLogic', () => { }); it('calls onAnalyticsUnavailable if analyticsUnavailable is in response', async () => { - const promise = Promise.resolve({ analyticsUnavailable: true }); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve({ analyticsUnavailable: true })); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadQueryData('some-query'); - await promise; + await nextTick(); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); }); it('handles errors', async () => { - const promise = Promise.reject('error'); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.reject('error')); mount(); jest.spyOn(AnalyticsLogic.actions, 'onAnalyticsUnavailable'); AnalyticsLogic.actions.loadQueryData('some-query'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('error'); expect(AnalyticsLogic.actions.onAnalyticsUnavailable).toHaveBeenCalled(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts index 537de02a0fee5..0caf804ea2a08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/analytics_logic.ts @@ -62,6 +62,36 @@ export const AnalyticsLogic = kea<MakeLogicType<AnalyticsValues, AnalyticsAction onQueryDataLoad: (_, { allTags }) => allTags, }, ], + recentQueries: [ + [], + { + onAnalyticsDataLoad: (_, { recentQueries }) => recentQueries, + }, + ], + topQueries: [ + [], + { + onAnalyticsDataLoad: (_, { topQueries }) => topQueries, + }, + ], + topQueriesNoResults: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesNoResults }) => topQueriesNoResults, + }, + ], + topQueriesNoClicks: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesNoClicks }) => topQueriesNoClicks, + }, + ], + topQueriesWithClicks: [ + [], + { + onAnalyticsDataLoad: (_, { topQueriesWithClicks }) => topQueriesWithClicks, + }, + ], totalQueries: [ 0, { @@ -110,6 +140,12 @@ export const AnalyticsLogic = kea<MakeLogicType<AnalyticsValues, AnalyticsAction onQueryDataLoad: (_, { queriesPerDayForQuery }) => queriesPerDayForQuery, }, ], + topClicksForQuery: [ + [], + { + onQueryDataLoad: (_, { topClicksForQuery }) => topClicksForQuery, + }, + ], startDate: [ '', { diff --git a/x-pack/plugins/enterprise_search/common/version.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss similarity index 50% rename from x-pack/plugins/enterprise_search/common/version.ts rename to x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss index e1a990e5c4710..f3c503d4b27cb 100644 --- a/x-pack/plugins/enterprise_search/common/version.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.scss @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import SemVer from 'semver/classes/semver'; -import pkg from '../../../../package.json'; +.analyticsHeader { + flex-wrap: wrap; -export const CURRENT_VERSION = new SemVer(pkg.version as string); -export const CURRENT_MAJOR_VERSION = `${CURRENT_VERSION.major}.${CURRENT_VERSION.minor}`; + &__filters.euiPageHeaderSection { + width: 100%; + margin: $euiSizeM 0; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx index 6866a89687a74..e82c3aff70119 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_header.tsx @@ -30,6 +30,8 @@ import { AnalyticsLogic } from '../'; import { DEFAULT_START_DATE, DEFAULT_END_DATE, SERVER_DATE_FORMAT } from '../constants'; import { convertTagsToSelectOptions } from '../utils'; +import './analytics_header.scss'; + interface Props { title: string; } @@ -60,7 +62,7 @@ export const AnalyticsHeader: React.FC<Props> = ({ title }) => { const hasInvalidDateRange = startDate > endDate; return ( - <EuiPageHeader> + <EuiPageHeader className="analyticsHeader"> <EuiPageHeaderSection> <EuiFlexGroup alignItems="center" justifyContent="flexStart" responsive={false}> <EuiFlexItem grow={false}> @@ -69,13 +71,13 @@ export const AnalyticsHeader: React.FC<Props> = ({ title }) => { </EuiTitle> </EuiFlexItem> <EuiFlexItem grow={false}> - <LogRetentionTooltip type={LogRetentionOptions.Analytics} /> + <LogRetentionTooltip type={LogRetentionOptions.Analytics} position="right" /> </EuiFlexItem> </EuiFlexGroup> </EuiPageHeaderSection> - <EuiPageHeaderSection> + <EuiPageHeaderSection className="analyticsHeader__filters"> <EuiFlexGroup alignItems="center" justifyContent="flexEnd" gutterSize="m"> - <EuiFlexItem grow={false}> + <EuiFlexItem> <EuiSelect options={convertTagsToSelectOptions(allTags)} value={currentTag} @@ -87,7 +89,7 @@ export const AnalyticsHeader: React.FC<Props> = ({ title }) => { fullWidth /> </EuiFlexItem> - <EuiFlexItem grow={false}> + <EuiFlexItem> <EuiDatePickerRange startDateControl={ <EuiDatePicker diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.test.tsx new file mode 100644 index 0000000000000..34161ba80dab8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockKibanaValues } from '../../../../__mocks__'; +import '../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFieldSearch } from '@elastic/eui'; + +import { AnalyticsSearch } from './'; + +describe('AnalyticsSearch', () => { + const { navigateToUrl } = mockKibanaValues; + const preventDefault = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const wrapper = shallow(<AnalyticsSearch />); + const setSearchValue = (value: string) => + wrapper.find(EuiFieldSearch).simulate('change', { target: { value } }); + + it('renders', () => { + expect(wrapper.find(EuiFieldSearch)).toHaveLength(1); + }); + + it('updates searchValue state on input change', () => { + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual(''); + + setSearchValue('some-query'); + expect(wrapper.find(EuiFieldSearch).prop('value')).toEqual('some-query'); + }); + + it('sends the user to the query detail page on search', () => { + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(preventDefault).toHaveBeenCalled(); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some-query' + ); + }); + + it('falls back to showing the "" query if searchValue is empty', () => { + setSearchValue(''); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/%22%22' // "" gets encoded + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx new file mode 100644 index 0000000000000..fc2639d87a2f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_search.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { useValues } from 'kea'; + +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton, EuiSpacer } from '@elastic/eui'; + +import { KibanaLogic } from '../../../../shared/kibana'; +import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../routes'; +import { generateEnginePath } from '../../engine'; + +export const AnalyticsSearch: React.FC = () => { + const [searchValue, setSearchValue] = useState(''); + + const { navigateToUrl } = useValues(KibanaLogic); + const viewQueryDetails = (e: React.SyntheticEvent) => { + e.preventDefault(); + const query = searchValue || '""'; + navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })); + }; + + return ( + <form onSubmit={viewQueryDetails}> + <EuiFlexGroup alignItems="center" gutterSize="m" responsive={false}> + <EuiFlexItem> + <EuiFieldSearch + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchPlaceholder', + { defaultMessage: 'Go to search term' } + )} + fullWidth + /> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton type="submit"> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetailSearchButtonLabel', + { defaultMessage: 'View details' } + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer /> + </form> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx new file mode 100644 index 0000000000000..1814aba7497f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { AnalyticsSection } from './'; + +describe('AnalyticsSection', () => { + it('renders', () => { + const wrapper = shallow( + <AnalyticsSection title="Lorem ipsum" subtitle="Dolor sit amet."> + <div data-test-subj="HelloWorld">Test</div> + </AnalyticsSection> + ); + + expect(wrapper.find('h2').text()).toEqual('Lorem ipsum'); + expect(wrapper.find('p').text()).toEqual('Dolor sit amet.'); + expect(wrapper.find('[data-test-subj="HelloWorld"]')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx new file mode 100644 index 0000000000000..e14ef0b1f2631 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_section.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiPageContentBody, EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; + +interface Props { + title: string; + subtitle: string; +} +export const AnalyticsSection: React.FC<Props> = ({ title, subtitle, children }) => ( + <section> + <header> + <EuiTitle size="m"> + <h2>{title}</h2> + </EuiTitle> + <EuiText size="s" color="subdued"> + <p>{subtitle}</p> + </EuiText> + </header> + <EuiSpacer size="m" /> + <EuiPageContentBody>{children}</EuiPageContentBody> + </section> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx new file mode 100644 index 0000000000000..88f7e858bef62 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { AnalyticsTable } from './'; + +describe('AnalyticsTable', () => { + const { navigateToUrl } = mockKibanaValues; + + const items = [ + { + key: 'some search', + tags: ['tagA'], + searches: { doc_count: 100 }, + clicks: { doc_count: 10 }, + }, + { + key: 'another search', + tags: ['tagB'], + searches: { doc_count: 99 }, + clicks: { doc_count: 9 }, + }, + { + key: '', + tags: ['tagA', 'tagB'], + searches: { doc_count: 1 }, + clicks: { doc_count: 0 }, + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(<AnalyticsTable items={items} />); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Search term'); + expect(tableContent).toContain('some search'); + expect(tableContent).toContain('another search'); + expect(tableContent).toContain('""'); + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(4); + + expect(tableContent).toContain('Queries'); + expect(tableContent).toContain('100'); + expect(tableContent).toContain('99'); + expect(tableContent).toContain('1'); + expect(tableContent).not.toContain('Clicks'); + }); + + it('renders a clicks column if hasClicks is passed', () => { + const wrapper = mountWithIntl(<AnalyticsTable items={items} hasClicks />); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Clicks'); + expect(tableContent).toContain('10'); + expect(tableContent).toContain('9'); + expect(tableContent).toContain('0'); + }); + + it('renders an action column', () => { + const wrapper = mountWithIntl(<AnalyticsTable items={items} />); + const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); + const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); + + viewQuery.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some%20search' + ); + + editQuery.simulate('click'); + // TODO + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(<AnalyticsTable items={[]} />); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No queries were performed during this time period.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx new file mode 100644 index 0000000000000..41690dfe26e71 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/analytics_table.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { Query } from '../../types'; +import { + TERM_COLUMN_PROPS, + TAGS_COLUMN, + COUNT_COLUMN_PROPS, + ACTIONS_COLUMN, +} from './shared_columns'; + +interface Props { + items: Query[]; + hasClicks?: boolean; +} +type Columns = Array<EuiBasicTableColumn<Query>>; + +export const AnalyticsTable: React.FC<Props> = ({ items, hasClicks }) => { + const TERM_COLUMN = { + field: 'key', + ...TERM_COLUMN_PROPS, + }; + + const COUNT_COLUMNS = [ + { + field: 'searches.doc_count', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.queriesColumn', + { defaultMessage: 'Queries' } + ), + ...COUNT_COLUMN_PROPS, + }, + ]; + if (hasClicks) { + COUNT_COLUMNS.push({ + field: 'clicks.doc_count', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', { + defaultMessage: 'Clicks', + }), + ...COUNT_COLUMN_PROPS, + }); + } + + return ( + <EuiBasicTable + columns={[TERM_COLUMN, TAGS_COLUMN, ...COUNT_COLUMNS, ACTIONS_COLUMN] as Columns} + items={items} + responsive + hasActions + noItemsMessage={ + <EuiEmptyPrompt + iconType="visLine" + title={ + <h4> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesTitle', + { defaultMessage: 'No queries' } + )} + </h4> + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noQueriesDescription', + { defaultMessage: 'No queries were performed during this time period.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/index.ts new file mode 100644 index 0000000000000..99363c00caaf7 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AnalyticsTable } from './analytics_table'; +export { RecentQueriesTable } from './recent_queries_table'; +export { QueryClicksTable } from './query_clicks_table'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx new file mode 100644 index 0000000000000..5909ceec4555c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; + +import { InlineTagsList } from './inline_tags_list'; + +describe('InlineTagsList', () => { + it('renders', () => { + const wrapper = shallow(<InlineTagsList tags={['test']} />); + + expect(wrapper.find(EuiBadge)).toHaveLength(1); + expect(wrapper.find(EuiBadge).prop('children')).toEqual('test'); + }); + + it('renders >2 badges in a tooltip list', () => { + const wrapper = shallow(<InlineTagsList tags={['1', '2', '3', '4', '5']} />); + + expect(wrapper.find(EuiBadge)).toHaveLength(3); + expect(wrapper.find(EuiToolTip)).toHaveLength(1); + + expect(wrapper.find(EuiBadge).at(0).prop('children')).toEqual('1'); + expect(wrapper.find(EuiBadge).at(1).prop('children')).toEqual('2'); + expect(wrapper.find(EuiBadge).at(2).prop('children')).toEqual('and 3 more'); + expect(wrapper.find(EuiToolTip).prop('content')).toEqual('3, 4, 5'); + }); + + it('does not render with no tags', () => { + const wrapper = shallow(<InlineTagsList tags={[]} />); + + expect(wrapper.isEmptyRender()).toBe(true); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx new file mode 100644 index 0000000000000..853f04ee1aa77 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/inline_tags_list.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBadgeGroup, EuiBadge, EuiToolTip } from '@elastic/eui'; + +import { Query } from '../../types'; + +interface Props { + tags?: Query['tags']; +} +export const InlineTagsList: React.FC<Props> = ({ tags }) => { + if (!tags?.length) return null; + + const displayedTags = tags.slice(0, 2); + const tooltipTags = tags.slice(2); + + return ( + <EuiBadgeGroup> + {displayedTags.map((tag: string) => ( + <EuiBadge color="hollow" key={tag}> + {tag} + </EuiBadge> + ))} + {tooltipTags.length > 0 && ( + <EuiToolTip position="bottom" content={tooltipTags.join(', ')}> + <EuiBadge> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.moreTagsBadge', + { + defaultMessage: 'and {moreTagsCount} more', + values: { moreTagsCount: tooltipTags.length }, + } + )} + </EuiBadge> + </EuiToolTip> + )} + </EuiBadgeGroup> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx new file mode 100644 index 0000000000000..9db9c140d7f50 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mountWithIntl } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiLink, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { QueryClicksTable } from './'; + +describe('QueryClicksTable', () => { + const items = [ + { + key: 'some-document', + document: { + engine: 'some-engine', + id: 'some-document', + }, + tags: ['tagA'], + doc_count: 10, + }, + { + key: 'another-document', + document: { + engine: 'another-engine', + id: 'another-document', + }, + tags: ['tagB'], + doc_count: 5, + }, + { + key: 'deleted-document', + tags: [], + doc_count: 1, + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(<QueryClicksTable items={items} />); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Documents'); + expect(tableContent).toContain('some-document'); + expect(tableContent).toContain('another-document'); + expect(tableContent).toContain('deleted-document'); + + expect(wrapper.find(EuiLink).first().prop('href')).toEqual( + '/app/enterprise_search/engines/some-engine/documents/some-document' + ); + expect(wrapper.find(EuiLink).last().prop('href')).toEqual( + '/app/enterprise_search/engines/another-engine/documents/another-document' + ); + // deleted-document should not have a link + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(2); + + expect(tableContent).toContain('Clicks'); + expect(tableContent).toContain('10'); + expect(tableContent).toContain('5'); + expect(tableContent).toContain('1'); + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(<QueryClicksTable items={[]} />); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No clicks'); + expect(promptContent).toContain('No documents have been clicked from this query.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx new file mode 100644 index 0000000000000..e032e42eca3a6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/query_clicks_table.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { ENGINE_DOCUMENT_DETAIL_PATH } from '../../../../routes'; +import { generateEnginePath } from '../../../engine'; +import { DOCUMENTS_TITLE } from '../../../documents'; + +import { QueryClick } from '../../types'; +import { FIRST_COLUMN_PROPS, TAGS_COLUMN, COUNT_COLUMN_PROPS } from './shared_columns'; + +interface Props { + items: QueryClick[]; +} +type Columns = Array<EuiBasicTableColumn<QueryClick>>; + +export const QueryClicksTable: React.FC<Props> = ({ items }) => { + const DOCUMENT_COLUMN = { + ...FIRST_COLUMN_PROPS, + field: 'document', + name: DOCUMENTS_TITLE, + render: (document: QueryClick['document'], query: QueryClick) => { + return document ? ( + <EuiLinkTo + to={generateEnginePath(ENGINE_DOCUMENT_DETAIL_PATH, { + engineName: document.engine, + documentId: document.id, + })} + > + {document.id} + </EuiLinkTo> + ) : ( + query.key + ); + }, + }; + + const CLICKS_COLUMN = { + ...COUNT_COLUMN_PROPS, + field: 'doc_count', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.clicksColumn', { + defaultMessage: 'Clicks', + }), + }; + + return ( + <EuiBasicTable + columns={[DOCUMENT_COLUMN, TAGS_COLUMN, CLICKS_COLUMN] as Columns} + items={items} + responsive + noItemsMessage={ + <EuiEmptyPrompt + iconType="visLine" + title={ + <h4> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksTitle', + { defaultMessage: 'No clicks' } + )} + </h4> + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noClicksDescription', + { defaultMessage: 'No documents have been clicked from this query.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx new file mode 100644 index 0000000000000..261d0f75c1cee --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mountWithIntl, mockKibanaValues } from '../../../../../__mocks__'; +import '../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; +import { EuiBasicTable, EuiBadge, EuiEmptyPrompt } from '@elastic/eui'; + +import { RecentQueriesTable } from './'; + +describe('RecentQueriesTable', () => { + const { navigateToUrl } = mockKibanaValues; + + const items = [ + { + query_string: 'some search', + timestamp: '1970-01-03T12:00:00Z', + tags: ['tagA'], + document_ids: ['documentA', 'documentB'], + }, + { + query_string: 'another search', + timestamp: '1970-01-02T12:00:00Z', + tags: ['tagB'], + document_ids: ['documentC'], + }, + { + query_string: '', + timestamp: '1970-01-01T12:00:00Z', + tags: ['tagA', 'tagB'], + document_ids: ['documentA', 'documentB', 'documentC'], + }, + ]; + + it('renders', () => { + const wrapper = mountWithIntl(<RecentQueriesTable items={items} />); + const tableContent = wrapper.find(EuiBasicTable).text(); + + expect(tableContent).toContain('Search term'); + expect(tableContent).toContain('some search'); + expect(tableContent).toContain('another search'); + expect(tableContent).toContain('""'); + + expect(tableContent).toContain('Time'); + expect(tableContent).toContain('1/3/1970'); + expect(tableContent).toContain('1/2/1970'); + expect(tableContent).toContain('1/1/1970'); + + expect(tableContent).toContain('Analytics tags'); + expect(tableContent).toContain('tagA'); + expect(tableContent).toContain('tagB'); + expect(wrapper.find(EuiBadge)).toHaveLength(4); + + expect(tableContent).toContain('Results'); + expect(tableContent).toContain('2'); + expect(tableContent).toContain('1'); + expect(tableContent).toContain('3'); + }); + + it('renders an action column', () => { + const wrapper = mountWithIntl(<RecentQueriesTable items={items} />); + const viewQuery = wrapper.find('[data-test-subj="AnalyticsTableViewQueryButton"]').first(); + const editQuery = wrapper.find('[data-test-subj="AnalyticsTableEditQueryButton"]').first(); + + viewQuery.simulate('click'); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/analytics/query_detail/some%20search' + ); + + editQuery.simulate('click'); + // TODO + }); + + it('renders an empty prompt if no items are passed', () => { + const wrapper = mountWithIntl(<RecentQueriesTable items={[]} />); + const promptContent = wrapper.find(EuiEmptyPrompt).text(); + + expect(promptContent).toContain('No recent queries'); + expect(promptContent).toContain('Queries will appear here as they are received.'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx new file mode 100644 index 0000000000000..b0dc8254c084b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/recent_queries_table.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedDate, FormattedTime } from '@kbn/i18n/react'; +import { EuiBasicTable, EuiBasicTableColumn, EuiEmptyPrompt } from '@elastic/eui'; + +import { RecentQuery } from '../../types'; +import { + TERM_COLUMN_PROPS, + TAGS_COLUMN, + COUNT_COLUMN_PROPS, + ACTIONS_COLUMN, +} from './shared_columns'; + +interface Props { + items: RecentQuery[]; +} +type Columns = Array<EuiBasicTableColumn<RecentQuery>>; + +export const RecentQueriesTable: React.FC<Props> = ({ items }) => { + const TERM_COLUMN = { + ...TERM_COLUMN_PROPS, + field: 'query_string', + }; + + const TIME_COLUMN = { + field: 'timestamp', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.timeColumn', { + defaultMessage: 'Time', + }), + render: (timestamp: RecentQuery['timestamp']) => { + const date = new Date(timestamp); + return ( + <> + <FormattedDate value={date} /> <FormattedTime value={date} /> + </> + ); + }, + width: '175px', + }; + + const RESULTS_COLUMN = { + ...COUNT_COLUMN_PROPS, + field: 'document_ids', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.resultsColumn', { + defaultMessage: 'Results', + }), + render: (documents: RecentQuery['document_ids']) => documents.length, + }; + + return ( + <EuiBasicTable + columns={[TERM_COLUMN, TIME_COLUMN, TAGS_COLUMN, RESULTS_COLUMN, ACTIONS_COLUMN] as Columns} + items={items} + responsive + hasActions + noItemsMessage={ + <EuiEmptyPrompt + iconType="visLine" + title={ + <h4> + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesTitle', + { defaultMessage: 'No recent queries' } + )} + </h4> + } + body={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.empty.noRecentQueriesDescription', + { defaultMessage: 'Queries will appear here as they are received.' } + )} + /> + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx new file mode 100644 index 0000000000000..16743405e0b5e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/analytics_tables/shared_columns.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiLinkTo } from '../../../../../shared/react_router_helpers'; +import { KibanaLogic } from '../../../../../shared/kibana'; +import { ENGINE_ANALYTICS_QUERY_DETAIL_PATH } from '../../../../routes'; +import { generateEnginePath } from '../../../engine'; + +import { Query, RecentQuery } from '../../types'; +import { InlineTagsList } from './inline_tags_list'; + +/** + * Shared columns / column properties between separate analytics tables + */ + +export const FIRST_COLUMN_PROPS = { + truncateText: true, + width: '25%', + mobileOptions: { + enlarge: true, + width: '100%', + }, +}; + +export const TERM_COLUMN_PROPS = { + // Field key changes per-table + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.termColumn', { + defaultMessage: 'Search term', + }), + render: (query: Query['key']) => { + if (!query) query = '""'; + return ( + <EuiLinkTo to={generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })}> + {query} + </EuiLinkTo> + ); + }, + ...FIRST_COLUMN_PROPS, +}; + +export const ACTIONS_COLUMN = { + width: '120px', + actions: [ + { + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAction', { + defaultMessage: 'View', + }), + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.viewTooltip', + { defaultMessage: 'View query analytics' } + ), + type: 'icon', + icon: 'popout', + color: 'primary', + onClick: (item: Query | RecentQuery) => { + const { navigateToUrl } = KibanaLogic.values; + + const query = (item as Query).key || (item as RecentQuery).query_string; + navigateToUrl(generateEnginePath(ENGINE_ANALYTICS_QUERY_DETAIL_PATH, { query })); + }, + 'data-test-subj': 'AnalyticsTableViewQueryButton', + }, + { + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.editAction', { + defaultMessage: 'Edit', + }), + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.table.editTooltip', + { defaultMessage: 'Edit query analytics' } + ), + type: 'icon', + icon: 'pencil', + onClick: () => { + // TODO: CurationsLogic + }, + 'data-test-subj': 'AnalyticsTableEditQueryButton', + }, + ], +}; + +export const TAGS_COLUMN = { + field: 'tags', + name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.tagsColumn', { + defaultMessage: 'Analytics tags', + }), + truncateText: true, + render: (tags: Query['tags']) => <InlineTagsList tags={tags} />, +}; + +export const COUNT_COLUMN_PROPS = { + dataType: 'number', + width: '100px', +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts index ae9c9ca450638..ddad726b04c26 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/components/index.ts @@ -7,4 +7,7 @@ export { AnalyticsCards } from './analytics_cards'; export { AnalyticsChart } from './analytics_chart'; export { AnalyticsHeader } from './analytics_header'; +export { AnalyticsSection } from './analytics_section'; +export { AnalyticsSearch } from './analytics_search'; +export { AnalyticsTable, RecentQueriesTable, QueryClicksTable } from './analytics_tables'; export { AnalyticsUnavailable } from './analytics_unavailable'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts index a3977a0c07a80..8bee8fd4407b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/types.ts @@ -4,27 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -interface Query { - doc_count: number; +export interface Query { key: string; - clicks?: { doc_count: number }; - searches?: { doc_count: number }; tags?: string[]; + searches?: { doc_count: number }; + clicks?: { doc_count: number }; } -interface QueryClick extends Query { +export interface QueryClick extends Query { document?: { id: string; engine: string; - tags?: string[]; }; } -interface RecentQuery { - document_ids: string[]; +export interface RecentQuery { query_string: string; - tags: string[]; timestamp: string; + tags: string[]; + document_ids: string[]; } /** diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx index 06bf77d35372f..e5bff981cb000 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.test.tsx @@ -5,12 +5,19 @@ */ import { setMockValues } from '../../../../__mocks__'; +import '../../../__mocks__/engine_logic.mock'; import React from 'react'; import { shallow } from 'enzyme'; -import { AnalyticsCards, AnalyticsChart } from '../components'; -import { Analytics } from './'; +import { + AnalyticsCards, + AnalyticsChart, + AnalyticsSection, + AnalyticsTable, + RecentQueriesTable, +} from '../components'; +import { Analytics, ViewAllButton } from './analytics'; describe('Analytics overview', () => { it('renders', () => { @@ -22,10 +29,27 @@ describe('Analytics overview', () => { queriesNoResultsPerDay: [1, 2, 3], clicksPerDay: [0, 1, 5], startDate: '1970-01-01', + topQueries: [], + topQueriesNoResults: [], + topQueriesNoClicks: [], + topQueriesWithClicks: [], + recentQueries: [], }); const wrapper = shallow(<Analytics />); expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); + expect(wrapper.find(AnalyticsSection)).toHaveLength(3); + expect(wrapper.find(AnalyticsTable)).toHaveLength(4); + expect(wrapper.find(RecentQueriesTable)).toHaveLength(1); + }); + + describe('ViewAllButton', () => { + it('renders', () => { + const to = '/analytics/top_queries'; + const wrapper = shallow(<ViewAllButton to={to} />); + + expect(wrapper.prop('to')).toEqual(to); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx index d3c3bff5a2947..e6a3e1ca5809b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/analytics.tsx @@ -7,15 +7,32 @@ import React from 'react'; import { useValues } from 'kea'; -import { EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; +import { + ENGINE_ANALYTICS_TOP_QUERIES_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH, + ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH, + ENGINE_ANALYTICS_RECENT_QUERIES_PATH, +} from '../../../routes'; +import { generateEnginePath } from '../../engine'; import { ANALYTICS_TITLE, TOTAL_QUERIES, TOTAL_QUERIES_NO_RESULTS, TOTAL_CLICKS, + TOP_QUERIES, + TOP_QUERIES_NO_RESULTS, + TOP_QUERIES_WITH_CLICKS, + TOP_QUERIES_NO_CLICKS, + RECENT_QUERIES, } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSection, AnalyticsTable, RecentQueriesTable } from '../components'; import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '../'; export const Analytics: React.FC = () => { @@ -27,6 +44,11 @@ export const Analytics: React.FC = () => { queriesNoResultsPerDay, clicksPerDay, startDate, + topQueries, + topQueriesNoResults, + topQueriesWithClicks, + topQueriesNoClicks, + recentQueries, } = useValues(AnalyticsLogic); return ( @@ -72,7 +94,77 @@ export const Analytics: React.FC = () => { /> <EuiSpacer /> - <p>TODO: Analytics overview</p> + <AnalyticsSection + title={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryTablesTitle', + { defaultMessage: 'Query analytics' } + )} + subtitle={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryTablesDescription', + { + defaultMessage: + 'Gain insight into the most frequent queries, and which queries returned no results.', + } + )} + > + <EuiTitle size="s"> + <h3>{TOP_QUERIES}</h3> + </EuiTitle> + <AnalyticsTable items={topQueries.slice(0, 10)} hasClicks /> + <ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_PATH)} /> + <EuiSpacer /> + <EuiTitle size="s"> + <h3>{TOP_QUERIES_NO_RESULTS}</h3> + </EuiTitle> + <AnalyticsTable items={topQueriesNoResults.slice(0, 10)} /> + <ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_NO_RESULTS_PATH)} /> + </AnalyticsSection> + <EuiSpacer size="xl" /> + + <AnalyticsSection + title={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.clickTablesTitle', + { defaultMessage: 'Click analytics' } + )} + subtitle={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.clickTablesDescription', + { + defaultMessage: 'Discover which queries generated the most and least amount of clicks.', + } + )} + > + <EuiTitle size="s"> + <h3>{TOP_QUERIES_WITH_CLICKS}</h3> + </EuiTitle> + <AnalyticsTable items={topQueriesWithClicks.slice(0, 10)} hasClicks /> + <ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_WITH_CLICKS_PATH)} /> + <EuiSpacer /> + <EuiTitle size="s"> + <h3>{TOP_QUERIES_NO_CLICKS}</h3> + </EuiTitle> + <AnalyticsTable items={topQueriesNoClicks.slice(0, 10)} /> + <ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_TOP_QUERIES_NO_CLICKS_PATH)} /> + </AnalyticsSection> + <EuiSpacer size="xl" /> + + <AnalyticsSection + title={RECENT_QUERIES} + subtitle={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.recentQueriesDescription', + { defaultMessage: 'A view into queries happening right now.' } + )} + > + <RecentQueriesTable items={recentQueries.slice(0, 10)} /> + <ViewAllButton to={generateEnginePath(ENGINE_ANALYTICS_RECENT_QUERIES_PATH)} /> + </AnalyticsSection> </AnalyticsLayout> ); }; + +export const ViewAllButton: React.FC<{ to: string }> = ({ to }) => ( + <EuiButtonTo to={to} size="s" fullWidth> + {i18n.translate('xpack.enterpriseSearch.appSearch.engine.analytics.table.viewAllButtonLabel', { + defaultMessage: 'View all', + })} + </EuiButtonTo> +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx index 99485340f6b88..7705d342ecdce 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.test.tsx @@ -13,7 +13,7 @@ import { shallow } from 'enzyme'; import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; -import { AnalyticsCards, AnalyticsChart } from '../components'; +import { AnalyticsCards, AnalyticsChart, QueryClicksTable } from '../components'; import { QueryDetail } from './'; describe('QueryDetail', () => { @@ -41,5 +41,6 @@ describe('QueryDetail', () => { expect(wrapper.find(AnalyticsCards)).toHaveLength(1); expect(wrapper.find(AnalyticsChart)).toHaveLength(1); + expect(wrapper.find(QueryClicksTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx index 53c1dc8b845b1..d5d864f35f681 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/query_detail.tsx @@ -15,6 +15,7 @@ import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_c import { BreadcrumbTrail } from '../../../../shared/kibana_chrome/generate_breadcrumbs'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSection, QueryClicksTable } from '../components'; import { AnalyticsLogic, AnalyticsCards, AnalyticsChart, convertToChartData } from '../'; const QUERY_DETAIL_TITLE = i18n.translate( @@ -28,7 +29,9 @@ interface Props { export const QueryDetail: React.FC<Props> = ({ breadcrumbs }) => { const { query } = useParams() as { query: string }; - const { totalQueriesForQuery, queriesPerDayForQuery, startDate } = useValues(AnalyticsLogic); + const { totalQueriesForQuery, queriesPerDayForQuery, startDate, topClicksForQuery } = useValues( + AnalyticsLogic + ); return ( <AnalyticsLayout isQueryView title={`"${query}"`}> @@ -63,7 +66,18 @@ export const QueryDetail: React.FC<Props> = ({ breadcrumbs }) => { /> <EuiSpacer /> - <p>TODO: Query detail page</p> + <AnalyticsSection + title={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.tableTitle', + { defaultMessage: 'Top clicks' } + )} + subtitle={i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.analytics.queryDetail.tableDescription', + { defaultMessage: 'The documents with the most clicks resulting from this query.' } + )} + > + <QueryClicksTable items={topClicksForQuery} /> + </AnalyticsSection> </AnalyticsLayout> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx index f25b044e8a56f..efd2de9223c98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { RecentQueriesTable } from '../components'; import { RecentQueries } from './'; describe('RecentQueries', () => { it('renders', () => { + setMockValues({ recentQueries: [] }); const wrapper = shallow(<RecentQueries />); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(RecentQueriesTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx index 3510a2a0e8221..708863ba0e5c8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/recent_queries.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { RECENT_QUERIES } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, RecentQueriesTable } from '../components'; +import { AnalyticsLogic } from '../'; export const RecentQueries: React.FC = () => { + const { recentQueries } = useValues(AnalyticsLogic); + return ( <AnalyticsLayout isAnalyticsView title={RECENT_QUERIES}> - <p>TODO: Recent queries</p> + <AnalyticsSearch /> + <RecentQueriesTable items={recentQueries} /> </AnalyticsLayout> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx index 9747609aaf066..754a349c2fe94 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueries } from './'; describe('TopQueries', () => { it('renders', () => { + setMockValues({ topQueries: [] }); const wrapper = shallow(<TopQueries />); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx index 3f2867871765c..0814ba16e39dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueries: React.FC = () => { + const { topQueries } = useValues(AnalyticsLogic); + return ( <AnalyticsLayout isAnalyticsView title={TOP_QUERIES}> - <p>TODO: Top queries</p> + <AnalyticsSearch /> + <AnalyticsTable items={topQueries} hasClicks /> </AnalyticsLayout> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx index bc55753acf152..f1eb3a2f69a98 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesNoClicks } from './'; describe('TopQueriesNoClicks', () => { it('renders', () => { + setMockValues({ topQueriesNoClicks: [] }); const wrapper = shallow(<TopQueriesNoClicks />); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx index dc14c4a83bff3..283a790b61571 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_clicks.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_NO_CLICKS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesNoClicks: React.FC = () => { + const { topQueriesNoClicks } = useValues(AnalyticsLogic); + return ( <AnalyticsLayout isAnalyticsView title={TOP_QUERIES_NO_CLICKS}> - <p>TODO: Top queries with no clicks</p> + <AnalyticsSearch /> + <AnalyticsTable items={topQueriesNoClicks} hasClicks /> </AnalyticsLayout> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx index 72c718f374714..8e404e34b5f3e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesNoResults } from './'; describe('TopQueriesNoResults', () => { it('renders', () => { + setMockValues({ topQueriesNoResults: [] }); const wrapper = shallow(<TopQueriesNoResults />); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx index da8595b43859f..8a54d529b2dd0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_no_results.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_NO_RESULTS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesNoResults: React.FC = () => { + const { topQueriesNoResults } = useValues(AnalyticsLogic); + return ( <AnalyticsLayout isAnalyticsView title={TOP_QUERIES_NO_RESULTS}> - <p>TODO: Top queries with no results</p> + <AnalyticsSearch /> + <AnalyticsTable items={topQueriesNoResults} hasClicks /> </AnalyticsLayout> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx index 74e31e77974ee..714da0d8e45dd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.test.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { setMockValues } from '../../../../__mocks__'; + import React from 'react'; import { shallow } from 'enzyme'; +import { AnalyticsTable } from '../components'; import { TopQueriesWithClicks } from './'; describe('TopQueriesWithClicks', () => { it('renders', () => { + setMockValues({ topQueriesWithClicks: [] }); const wrapper = shallow(<TopQueriesWithClicks />); - expect(wrapper.isEmptyRender()).toBe(false); // TODO + expect(wrapper.find(AnalyticsTable)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx index dc6e837be61d8..73ad9e2e973d8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/analytics/views/top_queries_with_clicks.tsx @@ -5,14 +5,20 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { TOP_QUERIES_WITH_CLICKS } from '../constants'; import { AnalyticsLayout } from '../analytics_layout'; +import { AnalyticsSearch, AnalyticsTable } from '../components'; +import { AnalyticsLogic } from '../'; export const TopQueriesWithClicks: React.FC = () => { + const { topQueriesWithClicks } = useValues(AnalyticsLogic); + return ( <AnalyticsLayout isAnalyticsView title={TOP_QUERIES_WITH_CLICKS}> - <p>TODO: Top queries with clicks</p> + <AnalyticsSearch /> + <AnalyticsTable items={topQueriesWithClicks} hasClicks /> </AnalyticsLayout> ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts index cdd055fd367ef..2374bcb1b2d03 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/credentials/credentials_logic.test.ts @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; jest.mock('../../app_logic', () => ({ AppLogic: { @@ -17,9 +12,12 @@ jest.mock('../../app_logic', () => ({ values: { myRole: jest.fn(() => ({})) }, }, })); -import { AppLogic } from '../../app_logic'; +import { nextTick } from '@kbn/test/jest'; + +import { AppLogic } from '../../app_logic'; import { ApiTokenTypes } from './constants'; + import { CredentialsLogic } from './credentials_logic'; describe('CredentialsLogic', () => { @@ -1064,8 +1062,7 @@ describe('CredentialsLogic', () => { it('will call an API endpoint and set the results with the `setCredentialsData` action', async () => { mount(); jest.spyOn(CredentialsLogic.actions, 'setCredentialsData').mockImplementationOnce(() => {}); - const promise = Promise.resolve({ meta, results }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ meta, results })); CredentialsLogic.actions.fetchCredentials(2); expect(http.get).toHaveBeenCalledWith('/api/app_search/credentials', { @@ -1073,17 +1070,16 @@ describe('CredentialsLogic', () => { 'page[current]': 2, }, }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.setCredentialsData).toHaveBeenCalledWith(meta, results); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.fetchCredentials(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1095,12 +1091,11 @@ describe('CredentialsLogic', () => { jest .spyOn(CredentialsLogic.actions, 'setCredentialsDetails') .mockImplementationOnce(() => {}); - const promise = Promise.resolve(credentialsDetails); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(credentialsDetails)); CredentialsLogic.actions.fetchDetails(); expect(http.get).toHaveBeenCalledWith('/api/app_search/credentials/details'); - await promise; + await nextTick(); expect(CredentialsLogic.actions.setCredentialsDetails).toHaveBeenCalledWith( credentialsDetails ); @@ -1108,11 +1103,10 @@ describe('CredentialsLogic', () => { it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.fetchDetails(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1124,23 +1118,21 @@ describe('CredentialsLogic', () => { it('will call an API endpoint and set the results with the `onApiKeyDelete` action', async () => { mount(); jest.spyOn(CredentialsLogic.actions, 'onApiKeyDelete').mockImplementationOnce(() => {}); - const promise = Promise.resolve(); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve()); CredentialsLogic.actions.deleteApiKey(tokenName); expect(http.delete).toHaveBeenCalledWith(`/api/app_search/credentials/${tokenName}`); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiKeyDelete).toHaveBeenCalledWith(tokenName); expect(setSuccessMessage).toHaveBeenCalled(); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.deleteApiKey(tokenName); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); @@ -1156,14 +1148,13 @@ describe('CredentialsLogic', () => { activeApiToken: createdToken, }); jest.spyOn(CredentialsLogic.actions, 'onApiTokenCreateSuccess'); - const promise = Promise.resolve(createdToken); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(createdToken)); CredentialsLogic.actions.onApiTokenChange(); expect(http.post).toHaveBeenCalledWith('/api/app_search/credentials', { body: JSON.stringify(createdToken), }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiTokenCreateSuccess).toHaveBeenCalledWith(createdToken); expect(setSuccessMessage).toHaveBeenCalled(); }); @@ -1184,25 +1175,23 @@ describe('CredentialsLogic', () => { }, }); jest.spyOn(CredentialsLogic.actions, 'onApiTokenUpdateSuccess'); - const promise = Promise.resolve(updatedToken); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(updatedToken)); CredentialsLogic.actions.onApiTokenChange(); expect(http.put).toHaveBeenCalledWith('/api/app_search/credentials/test-key', { body: JSON.stringify(updatedToken), }); - await promise; + await nextTick(); expect(CredentialsLogic.actions.onApiTokenUpdateSuccess).toHaveBeenCalledWith(updatedToken); expect(setSuccessMessage).toHaveBeenCalled(); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occured'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('An error occured')); CredentialsLogic.actions.onApiTokenChange(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts index 2256d5ae7946a..e1b562d9561ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_logic.test.ts @@ -6,6 +6,7 @@ import { LogicMounter, mockHttpValues } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; import dedent from 'dedent'; jest.mock('./utils', () => ({ @@ -443,10 +444,10 @@ describe('DocumentCreationLogic', () => { }); it('should set and show summary from the returned response', async () => { - const promise = http.post.mockReturnValueOnce(Promise.resolve(mockValidResponse)); + http.post.mockReturnValueOnce(Promise.resolve(mockValidResponse)); await DocumentCreationLogic.actions.uploadDocuments({ documents: mockValidDocuments }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith(mockValidResponse); expect(DocumentCreationLogic.actions.setCreationStep).toHaveBeenCalledWith( @@ -462,7 +463,7 @@ describe('DocumentCreationLogic', () => { }); it('handles API errors', async () => { - const promise = http.post.mockReturnValueOnce( + http.post.mockReturnValueOnce( Promise.reject({ body: { statusCode: 400, @@ -473,7 +474,7 @@ describe('DocumentCreationLogic', () => { ); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( '[400 Bad Request] Invalid request payload JSON format' @@ -481,10 +482,10 @@ describe('DocumentCreationLogic', () => { }); it('handles client-side errors', async () => { - const promise = (http.post as jest.Mock).mockReturnValueOnce(new Error()); + (http.post as jest.Mock).mockReturnValueOnce(new Error()); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith( "Cannot read property 'total' of undefined" @@ -493,14 +494,14 @@ describe('DocumentCreationLogic', () => { // NOTE: I can't seem to reproduce this in a production setting. it('handles errors returned from the API', async () => { - const promise = http.post.mockReturnValueOnce( + http.post.mockReturnValueOnce( Promise.resolve({ errors: ['JSON cannot be empty'], }) ); await DocumentCreationLogic.actions.uploadDocuments({ documents: [{}] }); - await promise; + await nextTick(); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ 'JSON cannot be empty', @@ -536,12 +537,12 @@ describe('DocumentCreationLogic', () => { }); it('should correctly merge multiple API calls into a single summary obj', async () => { - const promise = (http.post as jest.Mock) + (http.post as jest.Mock) .mockReturnValueOnce(mockFirstResponse) .mockReturnValueOnce(mockSecondResponse); await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); - await promise; + await nextTick(); expect(http.post).toHaveBeenCalledTimes(2); expect(DocumentCreationLogic.actions.setSummary).toHaveBeenCalledWith({ @@ -562,12 +563,12 @@ describe('DocumentCreationLogic', () => { }); it('should correctly merge response errors', async () => { - const promise = (http.post as jest.Mock) + (http.post as jest.Mock) .mockReturnValueOnce({ ...mockFirstResponse, errors: ['JSON cannot be empty'] }) .mockReturnValueOnce({ ...mockSecondResponse, errors: ['Too large to render'] }); await DocumentCreationLogic.actions.uploadDocuments({ documents: largeDocumentsArray }); - await promise; + await nextTick(); expect(http.post).toHaveBeenCalledTimes(2); expect(DocumentCreationLogic.actions.setErrors).toHaveBeenCalledWith([ diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts index e33cd9b0e9e71..3a8861ee1e20e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/documents/document_detail_logic.test.ts @@ -9,10 +9,11 @@ import { mockHttpValues, mockKibanaValues, mockFlashMessageHelpers, - expectedAsyncError, } from '../../../__mocks__'; import { mockEngineValues } from '../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { DocumentDetailLogic } from './document_detail_logic'; import { InternalSchemaTypes } from '../../../shared/types'; @@ -56,23 +57,21 @@ describe('DocumentDetailLogic', () => { it('will call an API endpoint and then store the result', async () => { const fields = [{ name: 'name', value: 'python', type: 'string' }]; jest.spyOn(DocumentDetailLogic.actions, 'setFields'); - const promise = Promise.resolve({ fields }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ fields })); DocumentDetailLogic.actions.getDocumentDetails('1'); expect(http.get).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); - await promise; + await nextTick(); expect(DocumentDetailLogic.actions.setFields).toHaveBeenCalledWith(fields); }); it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occurred'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occurred')); DocumentDetailLogic.actions.getDocumentDetails('1'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occurred', { isQueued: true }); expect(navigateToUrl).toHaveBeenCalledWith('/engines/engine1/documents'); @@ -81,13 +80,11 @@ describe('DocumentDetailLogic', () => { describe('deleteDocument', () => { let confirmSpy: any; - let promise: Promise<any>; beforeEach(() => { confirmSpy = jest.spyOn(window, 'confirm'); confirmSpy.mockImplementation(jest.fn(() => true)); - promise = Promise.resolve({}); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve({})); }); afterEach(() => { @@ -99,7 +96,7 @@ describe('DocumentDetailLogic', () => { DocumentDetailLogic.actions.deleteDocument('1'); expect(http.delete).toHaveBeenCalledWith(`/api/app_search/engines/engine1/documents/1`); - await promise; + await nextTick(); expect(setQueuedSuccessMessage).toHaveBeenCalledWith( 'Successfully marked document for deletion. It will be deleted momentarily.' ); @@ -113,16 +110,15 @@ describe('DocumentDetailLogic', () => { DocumentDetailLogic.actions.deleteDocument('1'); expect(http.delete).not.toHaveBeenCalled(); - await promise; + await nextTick(); }); it('handles errors', async () => { mount(); - promise = Promise.reject('An error occured'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('An error occured')); DocumentDetailLogic.actions.deleteDocument('1'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts index 48cbaeef70c1a..616dae98e29f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine/engine_logic.test.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LogicMounter, mockHttpValues, expectedAsyncError } from '../../../__mocks__'; +import { LogicMounter, mockHttpValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { EngineLogic } from './'; @@ -172,11 +174,10 @@ describe('EngineLogic', () => { it('fetches and sets engine data', async () => { mount({ engineName: 'some-engine' }); jest.spyOn(EngineLogic.actions, 'setEngineData'); - const promise = Promise.resolve(mockEngineData); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(mockEngineData)); EngineLogic.actions.initializeEngine(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine'); expect(EngineLogic.actions.setEngineData).toHaveBeenCalledWith(mockEngineData); @@ -185,11 +186,10 @@ describe('EngineLogic', () => { it('handles errors', async () => { mount(); jest.spyOn(EngineLogic.actions, 'setEngineNotFound'); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); EngineLogic.actions.initializeEngine(); - await expectedAsyncError(promise); + await nextTick(); expect(EngineLogic.actions.setEngineNotFound).toHaveBeenCalledWith(true); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx index 6c46c849c79bc..1b6acf341c08e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { EuiButton } from '@elastic/eui'; -import { CURRENT_MAJOR_VERSION } from '../../../../../common/version'; +import { docLinks } from '../../../shared/doc_links'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; import { EmptyEngineOverview } from './engine_overview_empty'; @@ -24,10 +24,8 @@ describe('EmptyEngineOverview', () => { expect(wrapper.find('h1').text()).toEqual('Engine setup'); }); - it('renders correctly versioned documentation URLs', () => { - expect(wrapper.find(EuiButton).prop('href')).toEqual( - `https://www.elastic.co/guide/en/app-search/${CURRENT_MAJOR_VERSION}/index.html` - ); + it('renders a documentation link', () => { + expect(wrapper.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`); }); it('renders document creation components', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts index b6620756699d5..9832387a563e3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_logic.test.ts @@ -4,17 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockHttpValues, - mockFlashMessageHelpers, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; jest.mock('../engine', () => ({ EngineLogic: { values: { engineName: 'some-engine' } }, })); +import { nextTick } from '@kbn/test/jest'; + import { EngineOverviewLogic } from './'; describe('EngineOverviewLogic', () => { @@ -85,11 +82,10 @@ describe('EngineOverviewLogic', () => { it('fetches data and calls onPollingSuccess', async () => { mount(); jest.spyOn(EngineOverviewLogic.actions, 'onPollingSuccess'); - const promise = Promise.resolve(mockEngineMetrics); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(mockEngineMetrics)); EngineOverviewLogic.actions.pollForOverviewMetrics(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/overview'); expect(EngineOverviewLogic.actions.onPollingSuccess).toHaveBeenCalledWith( @@ -99,11 +95,10 @@ describe('EngineOverviewLogic', () => { it('handles errors', async () => { mount(); - const promise = Promise.reject('An error occurred'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occurred')); EngineOverviewLogic.actions.pollForOverviewMetrics(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occurred'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts index 5a83717aa0030..2e22c9b76cf6f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engines/engines_logic.test.ts @@ -6,6 +6,8 @@ import { LogicMounter, mockHttpValues } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { EngineDetails } from '../engine/types'; import { EnginesLogic } from './'; @@ -124,13 +126,12 @@ describe('EnginesLogic', () => { describe('loadEngines', () => { it('should call the engines API endpoint and set state based on the results', async () => { - const promise = Promise.resolve(MOCK_ENGINES_API_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ENGINES_API_RESPONSE)); mount({ enginesPage: 10 }); jest.spyOn(EnginesLogic.actions, 'onEnginesLoad'); EnginesLogic.actions.loadEngines(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines', { query: { type: 'indexed', pageIndex: 10 }, @@ -144,13 +145,12 @@ describe('EnginesLogic', () => { describe('loadMetaEngines', () => { it('should call the engines API endpoint and set state based on the results', async () => { - const promise = Promise.resolve(MOCK_ENGINES_API_RESPONSE); - http.get.mockReturnValueOnce(promise); + http.get.mockReturnValueOnce(Promise.resolve(MOCK_ENGINES_API_RESPONSE)); mount({ metaEnginesPage: 99 }); jest.spyOn(EnginesLogic.actions, 'onMetaEnginesLoad'); EnginesLogic.actions.loadMetaEngines(); - await promise; + await nextTick(); expect(http.get).toHaveBeenCalledWith('/api/app_search/engines', { query: { type: 'meta', pageIndex: 99 }, diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts index bfdca6791edc1..18ab05a3676c6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/log_retention/log_retention_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockHttpValues, - mockFlashMessageHelpers, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { LogRetentionOptions } from './types'; import { LogRetentionLogic } from './log_retention_logic'; @@ -202,8 +199,7 @@ describe('LogRetentionLogic', () => { it('will call an API endpoint and update log retention', async () => { jest.spyOn(LogRetentionLogic.actions, 'updateLogRetention'); - const promise = Promise.resolve(TYPICAL_SERVER_LOG_RETENTION); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(TYPICAL_SERVER_LOG_RETENTION)); LogRetentionLogic.actions.saveLogRetention(LogRetentionOptions.Analytics, true); @@ -215,7 +211,7 @@ describe('LogRetentionLogic', () => { }), }); - await promise; + await nextTick(); expect(LogRetentionLogic.actions.updateLogRetention).toHaveBeenCalledWith( TYPICAL_CLIENT_LOG_RETENTION ); @@ -224,11 +220,10 @@ describe('LogRetentionLogic', () => { }); it('handles errors', async () => { - const promise = Promise.reject('An error occured'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('An error occured')); LogRetentionLogic.actions.saveLogRetention(LogRetentionOptions.Analytics, true); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); expect(LogRetentionLogic.actions.clearLogRetentionUpdating).toHaveBeenCalled(); @@ -276,14 +271,13 @@ describe('LogRetentionLogic', () => { .spyOn(LogRetentionLogic.actions, 'updateLogRetention') .mockImplementationOnce(() => {}); - const promise = Promise.resolve(TYPICAL_SERVER_LOG_RETENTION); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(TYPICAL_SERVER_LOG_RETENTION)); LogRetentionLogic.actions.fetchLogRetention(); expect(LogRetentionLogic.values.isLogRetentionUpdating).toBe(true); expect(http.get).toHaveBeenCalledWith('/api/app_search/log_settings'); - await promise; + await nextTick(); expect(LogRetentionLogic.actions.updateLogRetention).toHaveBeenCalledWith( TYPICAL_CLIENT_LOG_RETENTION ); @@ -293,11 +287,10 @@ describe('LogRetentionLogic', () => { it('handles errors', async () => { mount(); jest.spyOn(LogRetentionLogic.actions, 'clearLogRetentionUpdating'); - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); LogRetentionLogic.actions.fetchLogRetention(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); expect(LogRetentionLogic.actions.clearLogRetentionUpdating).toHaveBeenCalled(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 41e9bfa19e0f0..7f12f7d29671a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CURRENT_MAJOR_VERSION } from '../../../common/version'; +import { docLinks } from '../shared/doc_links'; -export const DOCS_PREFIX = `https://www.elastic.co/guide/en/app-search/${CURRENT_MAJOR_VERSION}`; +export const DOCS_PREFIX = docLinks.appSearchBase; export const ROOT_PATH = '/'; export const SETUP_GUIDE_PATH = '/setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts deleted file mode 100644 index 7e774616ff598..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CURRENT_MAJOR_VERSION } from '../../../../common/version'; - -export const ENT_SEARCH_DOCS_PREFIX = `https://www.elastic.co/guide/en/enterprise-search/${CURRENT_MAJOR_VERSION}`; - -export const CLOUD_DOCS_PREFIX = `https://www.elastic.co/guide/en/cloud/current`; // Cloud does not have version-prefixed documentation diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts index 8fa3ccdcb863e..4d4ff5f52ef20 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts @@ -5,4 +5,3 @@ */ export { DEFAULT_META } from './default_meta'; -export * from './documentation_links'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.test.ts new file mode 100644 index 0000000000000..3bee87dbfda3d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { docLinks } from './'; + +describe('DocLinks', () => { + it('setDocLinks', () => { + const links = { + DOC_LINK_VERSION: '', + ELASTIC_WEBSITE_URL: 'https://elastic.co/', + links: { + enterpriseSearch: { + base: 'http://elastic.enterprise.search', + appSearchBase: 'http://elastic.app.search', + workplaceSearchBase: 'http://elastic.workplace.search', + }, + }, + }; + + docLinks.setDocLinks(links as any); + + expect(docLinks.enterpriseSearchBase).toEqual('http://elastic.enterprise.search'); + expect(docLinks.appSearchBase).toEqual('http://elastic.app.search'); + expect(docLinks.workplaceSearchBase).toEqual('http://elastic.workplace.search'); + expect(docLinks.cloudBase).toEqual('https://elastic.co/guide/en/cloud/current'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts new file mode 100644 index 0000000000000..3ecb28d1d4729 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DocLinksStart } from 'kibana/public'; + +class DocLinks { + public enterpriseSearchBase: string; + public appSearchBase: string; + public workplaceSearchBase: string; + public cloudBase: string; + + constructor() { + this.enterpriseSearchBase = ''; + this.appSearchBase = ''; + this.workplaceSearchBase = ''; + this.cloudBase = ''; + } + + public setDocLinks(docLinks: DocLinksStart): void { + this.enterpriseSearchBase = docLinks.links.enterpriseSearch.base; + this.appSearchBase = docLinks.links.enterpriseSearch.appSearchBase; + this.workplaceSearchBase = docLinks.links.enterpriseSearch.workplaceSearchBase; + this.cloudBase = `${docLinks.ELASTIC_WEBSITE_URL}guide/en/cloud/current`; + } +} + +export const docLinks = new DocLinks(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/index.ts similarity index 78% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/index.ts rename to x-pack/plugins/enterprise_search/public/applications/shared/doc_links/index.ts index cb00ec640b5a6..a926efd59a574 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AddDocumentsAccordion } from './add_documents_accordion'; +export { docLinks } from './doc_links'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts index 0a80f8e361025..cfff8cc557836 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/indexing_status/indexing_status_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { IndexingStatusLogic } from './indexing_status_logic'; @@ -57,37 +54,34 @@ describe('IndexingStatusLogic', () => { it('calls API and sets values', async () => { const setIndexingStatusSpy = jest.spyOn(IndexingStatusLogic.actions, 'setIndexingStatus'); - const promise = Promise.resolve(mockStatusResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(mockStatusResponse)); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); expect(http.get).toHaveBeenCalledWith(statusPath); - await promise; + await nextTick(); expect(setIndexingStatusSpy).toHaveBeenCalledWith(mockStatusResponse); }); it('handles error', async () => { - const promise = Promise.reject('An error occured'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('An error occured')); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('An error occured'); }); it('handles indexing complete state', async () => { - const promise = Promise.resolve({ ...mockStatusResponse, percentageComplete: 100 }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve({ ...mockStatusResponse, percentageComplete: 100 })); IndexingStatusLogic.actions.fetchIndexingStatus({ statusPath, onComplete }); jest.advanceTimersByTime(TIMEOUT); - await promise; + await nextTick(); expect(clearInterval).toHaveBeenCalled(); expect(onComplete).toHaveBeenCalledWith(mockStatusResponse.numDocumentsWithErrors); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx index 383fd4b11108a..9af5bfc0c3d40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx @@ -9,7 +9,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiPageContent, EuiSteps, EuiText, EuiLink, EuiCallOut } from '@elastic/eui'; -import { CLOUD_DOCS_PREFIX, ENT_SEARCH_DOCS_PREFIX } from '../../constants'; +import { docLinks } from '../../doc_links'; interface Props { productName: string; @@ -34,10 +34,16 @@ export const CloudSetupInstructions: React.FC<Props> = ({ productName, cloudDepl values={{ editDeploymentLink: cloudDeploymentLink ? ( <EuiLink href={cloudDeploymentLink + '/edit'} target="_blank"> - edit your deployment + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step1.instruction1LinkText', + { defaultMessage: 'edit your deployment' } + )} </EuiLink> ) : ( - 'Visit the Elastic Cloud console' + i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step1.instruction1LinkText', + { defaultMessage: 'edit your deployment' } + ) ), }} /> @@ -73,10 +79,13 @@ export const CloudSetupInstructions: React.FC<Props> = ({ productName, cloudDepl values={{ optionsLink: ( <EuiLink - href={`${ENT_SEARCH_DOCS_PREFIX}/configuration.html`} + href={`${docLinks.enterpriseSearchBase}/configuration.html`} target="_blank" > - configurable options + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step3.instruction1LinkText', + { defaultMessage: 'configurable options' } + )} </EuiLink> ), }} @@ -115,10 +124,13 @@ export const CloudSetupInstructions: React.FC<Props> = ({ productName, cloudDepl productName, configurePolicyLink: ( <EuiLink - href={`${CLOUD_DOCS_PREFIX}/ec-configure-index-management.html`} + href={`${docLinks.cloudBase}/ec-configure-index-management.html`} target="_blank" > - configure an index lifecycle policy + {i18n.translate( + 'xpack.enterpriseSearch.setupGuide.cloud.step5.instruction1LinkText', + { defaultMessage: 'configure an index lifecycle policy' } + )} </EuiLink> ), }} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index f5f534807fabf..2ce7eed236840 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -21,6 +21,7 @@ interface AppValues extends WorkplaceSearchInitialData { interface AppActions { initializeAppData(props: InitialAppData): InitialAppData; setContext(isOrganization: boolean): boolean; + setSourceRestriction(canCreatePersonalSources: boolean): boolean; } const emptyOrg = {} as Organization; @@ -34,6 +35,7 @@ export const AppLogic = kea<MakeLogicType<AppValues, AppActions>>({ isFederatedAuth, }), setContext: (isOrganization) => isOrganization, + setSourceRestriction: (canCreatePersonalSources: boolean) => canCreatePersonalSources, }, reducers: { hasInitialized: [ @@ -64,6 +66,10 @@ export const AppLogic = kea<MakeLogicType<AppValues, AppActions>>({ emptyAccount, { initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.account || emptyAccount, + setSourceRestriction: (state, canCreatePersonalSources) => ({ + ...state, + canCreatePersonalSources, + }), }, ], }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 8a83e9aad5fd9..7357e84f27a41 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -45,9 +45,7 @@ export const WorkplaceSearchNav: React.FC<Props> = ({ <SideNavLink isExternal to={getWorkplaceSearchUrl(`#${ROLE_MAPPINGS_PATH}`)}> {NAV.ROLE_MAPPINGS} </SideNavLink> - <SideNavLink isExternal to={getWorkplaceSearchUrl(`#${SECURITY_PATH}`)}> - {NAV.SECURITY} - </SideNavLink> + <SideNavLink to={SECURITY_PATH}>{NAV.SECURITY}</SideNavLink> <SideNavLink subNav={settingsSubNav} to={ORG_SETTINGS_PATH}> {NAV.SETTINGS} </SideNavLink> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 6eedc9270b83f..17fbbf517f347 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -289,6 +289,87 @@ export const DOCUMENTATION_LINK_TITLE = i18n.translate( } ); +export const PRIVATE_SOURCES_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSources.description', + { + defaultMessage: + 'Private sources are connected by users in your organization to create a personalized search experience.', + } +); + +export const PRIVATE_SOURCES_TOGGLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesToggle.description', + { + defaultMessage: 'Enable private sources for your organization', + } +); + +export const REMOTE_SOURCES_TOGGLE_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesToggle.text', + { + defaultMessage: 'Enable remote private sources', + } +); + +export const REMOTE_SOURCES_TABLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesTable.description', + { + defaultMessage: + 'Remote sources synchronize and store a limited amount of data on disk, with a low impact on storage resources.', + } +); + +export const REMOTE_SOURCES_EMPTY_TABLE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.title', + { + defaultMessage: 'No remote private sources configured yet', + } +); + +export const STANDARD_SOURCES_TOGGLE_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesToggle.text', + { + defaultMessage: 'Enable standard private sources', + } +); + +export const STANDARD_SOURCES_TABLE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesTable.description', + { + defaultMessage: + 'Standard sources synchronize and store all searchable data on disk, with a directly correlated impact on storage resources.', + } +); + +export const STANDARD_SOURCES_EMPTY_TABLE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.title', + { + defaultMessage: 'No standard private sources configured yet', + } +); + +export const SECURITY_UNSAVED_CHANGES_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.unsavedChanges.message', + { + defaultMessage: + 'Your private sources settings have not been saved. Are you sure you want to leave?', + } +); + +export const PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesUpdateConfirmation.text', + { + defaultMessage: 'Updates to private source configuration will take effect immediately.', + } +); + +export const SOURCE_RESTRICTIONS_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.sourceRestrictionsSuccess.message', + { + defaultMessage: 'Successfully updated source restrictions.', + } +); + export const PUBLIC_KEY_LABEL = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.publicKey.label', { @@ -382,6 +463,20 @@ export const SAVE_CHANGES_BUTTON = i18n.translate( } ); +export const SAVE_SETTINGS_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.saveSettings.button', + { + defaultMessage: 'Save settings', + } +); + +export const KEEP_EDITING_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.keepEditing.button', + { + defaultMessage: 'Keep editing', + } +); + export const NAME_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.name.label', { defaultMessage: 'Name', }); @@ -493,6 +588,10 @@ export const UPDATE_BUTTON = i18n.translate( } ); +export const RESET_BUTTON = i18n.translate('xpack.enterpriseSearch.workplaceSearch.reset.button', { + defaultMessage: 'Reset', +}); + export const CONFIGURE_BUTTON = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.configure.button', { @@ -500,6 +599,21 @@ export const CONFIGURE_BUTTON = i18n.translate( } ); +export const SAVE_BUTTON = i18n.translate('xpack.enterpriseSearch.workplaceSearch.save.button', { + defaultMessage: 'Save', +}); + +export const CANCEL_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.cancel.button', + { + defaultMessage: 'Cancel', + } +); + +export const OK_BUTTON = i18n.translate('xpack.enterpriseSearch.workplaceSearch.ok.button', { + defaultMessage: 'Ok', +}); + export const PRIVATE_PLATINUM_LICENSE_CALLOUT = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.privatePlatinumCallout.text', { @@ -507,6 +621,10 @@ export const PRIVATE_PLATINUM_LICENSE_CALLOUT = i18n.translate( } ); +export const SOURCE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.source.text', { + defaultMessage: 'Source', +}); + export const PRIVATE_SOURCE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.privateSource.text', { @@ -514,6 +632,20 @@ export const PRIVATE_SOURCE = i18n.translate( } ); +export const PRIVATE_SOURCES = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.privateSources.text', + { + defaultMessage: 'Private Sources', + } +); + +export const CONFIRM_CHANGES_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.confirmChanges.text', + { + defaultMessage: 'Confirm changes', + } +); + export const CONNECTORS_HEADER_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.connectors.header.title', { @@ -527,3 +659,68 @@ export const CONNECTORS_HEADER_DESCRIPTION = i18n.translate( defaultMessage: 'All of your configurable connectors.', } ); + +export const URL_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.url.label', { + defaultMessage: 'URL', +}); + +export const FIELD_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.field.label', { + defaultMessage: 'Field', +}); + +export const DESCRIPTION_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.description.label', + { + defaultMessage: 'Description', + } +); + +export const UPDATE_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.update.label', { + defaultMessage: 'Update', +}); + +export const ADD_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.add.label', { + defaultMessage: 'Add', +}); + +export const ADD_FIELD_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.addField.label', + { + defaultMessage: 'Add field', + } +); + +export const EDIT_FIELD_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.editField.label', + { + defaultMessage: 'Edit field', + } +); + +export const REMOVE_FIELD_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.removeField.label', + { + defaultMessage: 'Remove field', + } +); + +export const RECENT_ACTIVITY_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.recentActivity.title', + { + defaultMessage: 'Recent activity', + } +); + +export const CONFIRM_MODAL_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.comfirmModal.title', + { + defaultMessage: 'Please confirm', + } +); + +export const REMOVE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.remove.button', + { + defaultMessage: 'Remove', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index d10de7a770171..ec1b8cfcba958 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -22,6 +22,7 @@ import { SOURCES_PATH, PERSONAL_SOURCES_PATH, ORG_SETTINGS_PATH, + SECURITY_PATH, } from './routes'; import { SetupGuide } from './views/setup_guide'; @@ -29,6 +30,7 @@ import { ErrorState } from './views/error_state'; import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; import { GroupsRouter } from './views/groups'; +import { Security } from './views/security'; import { SourcesRouter } from './views/content_sources'; import { SettingsRouter } from './views/settings'; @@ -102,6 +104,11 @@ export const WorkplaceSearchConfigured: React.FC<InitialAppData> = (props) => { <GroupsRouter /> </Layout> </Route> + <Route path={SECURITY_PATH}> + <Layout navigation={<WorkplaceSearchNav />} restrictWidth readOnlyMode={readOnlyMode}> + <Security /> + </Layout> + </Route> <Route path={ORG_SETTINGS_PATH}> <Layout navigation={<WorkplaceSearchNav settingsSubNav={<SettingsSubNav />} />} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 1e4b51e157724..ef1bb03b7921c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -6,8 +6,7 @@ import { generatePath } from 'react-router-dom'; -import { CURRENT_MAJOR_VERSION } from '../../../common/version'; -import { ENT_SEARCH_DOCS_PREFIX } from '../shared/constants'; +import { docLinks } from '../shared/doc_links'; export const SETUP_GUIDE_PATH = '/setup_guide'; @@ -16,7 +15,7 @@ export const NOT_FOUND_PATH = '/404'; export const LEAVE_FEEDBACK_EMAIL = 'support@elastic.co'; export const LEAVE_FEEDBACK_URL = `mailto:${LEAVE_FEEDBACK_EMAIL}?Subject=Elastic%20Workplace%20Search%20Feedback`; -export const DOCS_PREFIX = `https://www.elastic.co/guide/en/workplace-search/${CURRENT_MAJOR_VERSION}`; +export const DOCS_PREFIX = docLinks.workplaceSearchBase; export const DOCUMENT_PERMISSIONS_DOCS_URL = `${DOCS_PREFIX}/workplace-search-sources-document-permissions.html`; export const DOCUMENT_PERMISSIONS_SYNC_DOCS_URL = `${DOCUMENT_PERMISSIONS_DOCS_URL}#sources-permissions-synchronizing`; export const PRIVATE_SOURCES_DOCS_URL = `${DOCUMENT_PERMISSIONS_DOCS_URL}#sources-permissions-org-private`; @@ -42,7 +41,7 @@ export const ZENDESK_DOCS_URL = `${DOCS_PREFIX}/workplace-search-zendesk-connect export const CUSTOM_SOURCE_DOCS_URL = `${DOCS_PREFIX}/workplace-search-custom-api-sources.html`; export const CUSTOM_API_DOCS_URL = `${DOCS_PREFIX}/workplace-search-custom-sources-api.html`; export const CUSTOM_API_DOCUMENT_PERMISSIONS_DOCS_URL = `${CUSTOM_SOURCE_DOCS_URL}#custom-api-source-document-level-access-control`; -export const ENT_SEARCH_LICENSE_MANAGEMENT = `${ENT_SEARCH_DOCS_PREFIX}/license-management.html`; +export const ENT_SEARCH_LICENSE_MANAGEMENT = `${docLinks.enterpriseSearchBase}/license-management.html`; export const PERSONAL_PATH = '/p'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index d08f807691c2b..058645bd30862 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -4,18 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; import { AppLogic } from '../../../../app_logic'; jest.mock('../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); +import { nextTick } from '@kbn/test/jest'; + import { CustomSource } from '../../../../types'; import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; @@ -271,23 +268,21 @@ describe('AddSourceLogic', () => { describe('getSourceConfigData', () => { it('calls API and sets values', async () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); - const promise = Promise.resolve(sourceConfigData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConfigData)); AddSourceLogic.actions.getSourceConfigData('github'); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/settings/connectors/github' ); - await promise; + await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceConfigData('github'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -302,15 +297,14 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setSourceConnectData' ); - const promise = Promise.resolve(sourceConnectData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConnectData)); AddSourceLogic.actions.getSourceConnectData('github', successCallback); expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/sources/github/prepare'); - await promise; + await nextTick(); expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData); expect(successCallback).toHaveBeenCalledWith(sourceConnectData.oauthUrl); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -327,11 +321,10 @@ describe('AddSourceLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceConnectData('github', successCallback); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -343,24 +336,22 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setSourceConnectData' ); - const promise = Promise.resolve(sourceConnectData); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(sourceConnectData)); AddSourceLogic.actions.getSourceReConnectData('github'); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/github/reauth_prepare' ); - await promise; + await nextTick(); expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getSourceReConnectData('github'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -372,22 +363,20 @@ describe('AddSourceLogic', () => { AddSourceLogic.actions, 'setPreContentSourceConfigData' ); - const promise = Promise.resolve(config); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(config)); AddSourceLogic.actions.getPreContentSourceConfigData('123'); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/pre_sources/123'); - await promise; + await nextTick(); expect(setPreContentSourceConfigDataSpy).toHaveBeenCalledWith(config); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.getPreContentSourceConfigData('123'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -414,8 +403,7 @@ describe('AddSourceLogic', () => { const successCallback = jest.fn(); const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); - const promise = Promise.resolve({ sourceConfigData }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ sourceConfigData })); AddSourceLogic.actions.saveSourceConfig(true, successCallback); @@ -428,7 +416,7 @@ describe('AddSourceLogic', () => { { body: JSON.stringify({ params }) } ); - await promise; + await nextTick(); expect(successCallback).toHaveBeenCalled(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -453,11 +441,10 @@ describe('AddSourceLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.saveSourceConfig(true); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -495,8 +482,7 @@ describe('AddSourceLogic', () => { it('calls API and sets values', async () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); const setCustomSourceDataSpy = jest.spyOn(AddSourceLogic.actions, 'setCustomSourceData'); - const promise = Promise.resolve({ sourceConfigData }); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve({ sourceConfigData })); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); @@ -505,18 +491,17 @@ describe('AddSourceLogic', () => { expect(http.post).toHaveBeenCalledWith('/api/workplace_search/org/create_source', { body: JSON.stringify({ ...params }), }); - await promise; + await nextTick(); expect(setCustomSourceDataSpy).toHaveBeenCalledWith({ sourceConfigData }); expect(successCallback).toHaveBeenCalled(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); - await expectedAsyncError(promise); + await nextTick(); expect(errorCallback).toHaveBeenCalled(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index 6fe87290737f5..61cf1ed34fdcc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -35,6 +35,7 @@ import { FeatureIds, Configuration, Features } from '../../../../types'; import { DOCUMENT_PERMISSIONS_DOCS_URL } from '../../../../routes'; import { SourceFeatures } from './source_features'; +import { LEARN_MORE_LINK } from '../../constants'; import { CONNECT_REMOTE, CONNECT_PRIVATE, @@ -206,7 +207,7 @@ export const ConnectInstance: React.FC<ConnectInstanceProps> = ({ values={{ link: ( <EuiLink target="_blank" href={DOCUMENT_PERMISSIONS_DOCS_URL}> - Learn more + {LEARN_MORE_LINK} </EuiLink> ), }} @@ -242,7 +243,6 @@ export const ConnectInstance: React.FC<ConnectInstanceProps> = ({ <EuiFormRow> <EuiButton color="primary" type="submit" fill isLoading={formLoading}> - Connect {name} {i18n.translate('xpack.enterpriseSearch.workplaceSearch.contentSource.connect.button', { defaultMessage: 'Connect {name}', values: { name }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts index 7d891953e618b..8ac3edeb0f308 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts @@ -236,13 +236,6 @@ export const OAUTH_SAVE_CONFIG_BUTTON = i18n.translate( } ); -export const OAUTH_REMOVE_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.remove.button', - { - defaultMessage: 'Remove', - } -); - export const OAUTH_BACK_BUTTON = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.back.button', { @@ -292,20 +285,6 @@ export const SAVE_CUSTOM_API_KEYS_BODY = i18n.translate( } ); -export const SAVE_CUSTOM_ACCESS_TOKEN_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.accessToken.label', - { - defaultMessage: 'Access Token', - } -); - -export const SAVE_CUSTOM_ID_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.id.label', - { - defaultMessage: 'ID', - } -); - export const SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.title', { @@ -327,13 +306,6 @@ export const SAVE_CUSTOM_DOC_PERMISSIONS_TITLE = i18n.translate( } ); -export const SAVE_CUSTOM_FEATURES_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.features.button', - { - defaultMessage: 'Learn about Platinum features', - } -); - export const SOURCE_FEATURES_SEARCHABLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.sourceFeatures.searchable.text', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx index e6e428ecab115..1bab035b8f379 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx @@ -28,14 +28,10 @@ import { BASE_URL_LABEL, CLIENT_ID_LABEL, CLIENT_SECRET_LABEL, + REMOVE_BUTTON, } from '../../../../constants'; -import { - OAUTH_SAVE_CONFIG_BUTTON, - OAUTH_REMOVE_BUTTON, - OAUTH_BACK_BUTTON, - OAUTH_STEP_2, -} from './constants'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON, OAUTH_STEP_2 } from './constants'; import { LicensingLogic } from '../../../../../../applications/shared/licensing'; @@ -99,7 +95,7 @@ export const SaveConfig: React.FC<SaveConfigProps> = ({ const deleteButton = ( <EuiButton color="danger" fill disabled={buttonLoading} onClick={onDeleteConfig}> - {OAUTH_REMOVE_BUTTON} + {REMOVE_BUTTON} </EuiButton> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx index 28aeaec2b47df..8e3bd1c6ab2b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx @@ -36,18 +36,17 @@ import { getSourcesPath, } from '../../../../routes'; +import { ACCESS_TOKEN_LABEL, ID_LABEL, LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants'; + import { SAVE_CUSTOM_BODY1, SAVE_CUSTOM_BODY2, SAVE_CUSTOM_RETURN_BUTTON, SAVE_CUSTOM_API_KEYS_TITLE, SAVE_CUSTOM_API_KEYS_BODY, - SAVE_CUSTOM_ACCESS_TOKEN_LABEL, - SAVE_CUSTOM_ID_LABEL, SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE, SAVE_CUSTOM_STYLING_RESULTS_TITLE, SAVE_CUSTOM_DOC_PERMISSIONS_TITLE, - SAVE_CUSTOM_FEATURES_BUTTON, } from './constants'; interface SaveCustomProps { @@ -109,10 +108,10 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({ <p>{SAVE_CUSTOM_API_KEYS_BODY}</p> </EuiText> <EuiSpacer /> - <CredentialItem label={SAVE_CUSTOM_ID_LABEL} value={id} testSubj="ContentSourceId" /> + <CredentialItem label={ID_LABEL} value={id} testSubj="ContentSourceId" /> <EuiSpacer /> <CredentialItem - label={SAVE_CUSTOM_ACCESS_TOKEN_LABEL} + label={ACCESS_TOKEN_LABEL} value={accessToken} testSubj="AccessToken" /> @@ -200,7 +199,7 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({ <EuiSpacer size="xs" /> <EuiText size="s"> <EuiLink target="_blank" href={ENT_SEARCH_LICENSE_MANAGEMENT}> - {SAVE_CUSTOM_FEATURES_BUTTON} + {LEARN_CUSTOM_FEATURES_BUTTON} </EuiLink> </EuiText> </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/constants.ts index 0e6cbb2560128..3b04456b1f59c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/constants.ts @@ -7,15 +7,142 @@ import { i18n } from '@kbn/i18n'; export const LEAVE_UNASSIGNED_FIELD = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.leaveUnassignedField', + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.leaveUnassigned.field', { defaultMessage: 'Leave unassigned', } ); export const SUCCESS_MESSAGE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.successMessage', + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.success.message', { defaultMessage: 'Display Settings have been successfuly updated.', } ); + +export const UNSAVED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.unsaved.message', + { + defaultMessage: 'Your display settings have not been saved. Are you sure you want to leave?', + } +); + +export const DISPLAY_SETTINGS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.displaySettings.title', + { + defaultMessage: 'Display Settings', + } +); + +export const DISPLAY_SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.displaySettings.description', + { + defaultMessage: + 'Customize the content and appearance of your Custom API Source search results.', + } +); + +export const DISPLAY_SETTINGS_EMPTY_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.displaySettingsEmpty.title', + { + defaultMessage: 'You have no content yet', + } +); + +export const DISPLAY_SETTINGS_EMPTY_BODY = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.displaySettingsEmpty.body', + { + defaultMessage: 'You need some content to display in order to configure the display settings.', + } +); + +export const SEARCH_RESULTS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.searchResults.label', + { + defaultMessage: 'Search Results', + } +); + +export const RESULT_DETAIL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.resultDetail.label', + { + defaultMessage: 'Result Detail', + } +); + +export const SUBTITLE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.subtitle.label', + { + defaultMessage: 'Subtitle', + } +); + +export const TITLE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.title.label', + { + defaultMessage: 'Title', + } +); + +export const EMPTY_FIELDS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.emptyFields.description', + { + defaultMessage: 'Add fields and move them into the order you want them to appear.', + } +); + +export const VISIBLE_FIELDS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.visibleFields.title', + { + defaultMessage: 'Visible fields', + } +); + +export const PREVIEW_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.preview.title', + { + defaultMessage: 'Preview', + } +); + +export const SEARCH_RESULTS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.searchResults.title', + { + defaultMessage: 'Search Results settings', + } +); + +export const FEATURED_RESULTS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.featuredResults.title', + { + defaultMessage: 'Featured Results', + } +); + +export const FEATURED_RESULTS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.featuredResults.description', + { + defaultMessage: 'A matching document will appear as a single bold card.', + } +); + +export const STANDARD_RESULTS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.standardResults.title', + { + defaultMessage: 'Standard Results', + } +); + +export const STANDARD_RESULTS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.standardResults.description', + { + defaultMessage: 'Somewhat matching documents will appear as a set.', + } +); + +export const SEARCH_RESULTS_ROW_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.searchResultsRow.helpText', + { + defaultMessage: 'This area is optional', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx index cf066f3157e39..19ccfab11a729 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -32,15 +32,23 @@ import { AppLogic } from '../../../../app_logic'; import { Loading } from '../../../../../shared/loading'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; +import { SAVE_BUTTON } from '../../../../constants'; +import { + UNSAVED_MESSAGE, + DISPLAY_SETTINGS_TITLE, + DISPLAY_SETTINGS_DESCRIPTION, + DISPLAY_SETTINGS_EMPTY_TITLE, + DISPLAY_SETTINGS_EMPTY_BODY, + SEARCH_RESULTS_LABEL, + RESULT_DETAIL_LABEL, +} from './constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { FieldEditorModal } from './field_editor_modal'; import { ResultDetail } from './result_detail'; import { SearchResults } from './search_results'; -const UNSAVED_MESSAGE = - 'Your display settings have not been saved. Are you sure you want to leave?'; - interface DisplaySettingsProps { tabId: number; } @@ -77,12 +85,12 @@ export const DisplaySettings: React.FC<DisplaySettingsProps> = ({ tabId }) => { const tabs = [ { id: 'search_results', - name: 'Search Results', + name: SEARCH_RESULTS_LABEL, content: <SearchResults />, }, { id: 'result_detail', - name: 'Result Detail', + name: RESULT_DETAIL_LABEL, content: <ResultDetail />, }, ] as EuiTabbedContentTab[]; @@ -105,12 +113,12 @@ export const DisplaySettings: React.FC<DisplaySettingsProps> = ({ tabId }) => { <> <form onSubmit={handleFormSubmit}> <ViewContentHeader - title="Display Settings" - description="Customize the content and appearance of your Custom API Source search results." + title={DISPLAY_SETTINGS_TITLE} + description={DISPLAY_SETTINGS_DESCRIPTION} action={ hasDocuments ? ( <EuiButton type="submit" disabled={!unsavedChanges} fill={true}> - Save + {SAVE_BUTTON} </EuiButton> ) : null } @@ -125,10 +133,8 @@ export const DisplaySettings: React.FC<DisplaySettingsProps> = ({ tabId }) => { <EuiPanel className="euiPanel--inset"> <EuiEmptyPrompt iconType="indexRollupApp" - title={<h2>You have no content yet</h2>} - body={ - <p>You need some content to display in order to configure the display settings.</p> - } + title={<h2>{DISPLAY_SETTINGS_EMPTY_TITLE}</h2>} + body={<p>{DISPLAY_SETTINGS_EMPTY_BODY}</p>} /> </EuiPanel> )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts index aed99bdd950c5..d43afd589468f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings_logic.test.ts @@ -6,11 +6,7 @@ import { LogicMounter } from '../../../../../__mocks__/kea.mock'; -import { - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; const contentSource = { id: 'source123' }; jest.mock('../../source_logic', () => ({ @@ -22,6 +18,8 @@ jest.mock('../../../../app_logic', () => ({ AppLogic: { values: { isOrganization: true } }, })); +import { nextTick } from '@kbn/test/jest'; + import { exampleResult } from '../../../../__mocks__/content_sources.mock'; import { LEAVE_UNASSIGNED_FIELD } from './constants'; @@ -286,14 +284,13 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'onInitializeDisplaySettings' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.initializeDisplaySettings(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/source123/display_settings/config' ); - await promise; + await nextTick(); expect(onInitializeDisplaySettingsSpy).toHaveBeenCalledWith({ ...serverProps, isOrganization: true, @@ -307,14 +304,13 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'onInitializeDisplaySettings' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.initializeDisplaySettings(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/account/sources/source123/display_settings/config' ); - await promise; + await nextTick(); expect(onInitializeDisplaySettingsSpy).toHaveBeenCalledWith({ ...serverProps, isOrganization: false, @@ -322,10 +318,9 @@ describe('DisplaySettingsLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); DisplaySettingsLogic.actions.initializeDisplaySettings(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -337,25 +332,23 @@ describe('DisplaySettingsLogic', () => { DisplaySettingsLogic.actions, 'setServerResponseData' ); - const promise = Promise.resolve(serverProps); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverProps)); DisplaySettingsLogic.actions.onInitializeDisplaySettings(serverProps); DisplaySettingsLogic.actions.setServerData(); expect(http.post).toHaveBeenCalledWith(serverProps.serverRoute, { body: JSON.stringify({ ...searchResultConfig }), }); - await promise; + await nextTick(); expect(setServerResponseDataSpy).toHaveBeenCalledWith({ ...serverProps, }); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); DisplaySettingsLogic.actions.setServerData(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx index 3278140a2dfe6..3ca70979cc247 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx @@ -11,6 +11,8 @@ import { useValues } from 'kea'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { URL_LABEL } from '../../../../constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { CustomSourceIcon } from './custom_source_icon'; @@ -50,7 +52,7 @@ export const ExampleResultDetailCard: React.FC = () => { <div className="eui-textTruncate">{result[urlField]}</div> ) : ( <span className="example-result-content-placeholder" data-test-subj="DefaultUrlLabel"> - URL + {URL_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx index aa7bc4d917886..7f033d8f8d97a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx @@ -10,6 +10,8 @@ import { isColorDark, hexToRgb } from '@elastic/eui'; import classNames from 'classnames'; import { useValues } from 'kea'; +import { DESCRIPTION_LABEL } from '../../../../constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { CustomSourceIcon } from './custom_source_icon'; @@ -65,7 +67,7 @@ export const ExampleSearchResultGroup: React.FC = () => { className="example-result-content-placeholder" data-test-subj="DefaultDescriptionLabel" > - Description + {DESCRIPTION_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx index a80680d219aef..cdd883413481d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx @@ -11,6 +11,8 @@ import { useValues } from 'kea'; import { isColorDark, hexToRgb } from '@elastic/eui'; +import { DESCRIPTION_LABEL } from '../../../../constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { CustomSourceIcon } from './custom_source_icon'; @@ -60,7 +62,7 @@ export const ExampleStandoutResult: React.FC = () => { className="example-result-content-placeholder" data-test-subj="DefaultDescriptionLabel" > - Description + {DESCRIPTION_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx index 587916a741d66..e220e07153867 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx @@ -23,6 +23,8 @@ import { EuiSelect, } from '@elastic/eui'; +import { CANCEL_BUTTON, FIELD_LABEL, UPDATE_LABEL, ADD_LABEL } from '../../../../constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; const emptyField = { fieldName: '', label: '' }; @@ -53,14 +55,16 @@ export const FieldEditorModal: React.FC = () => { } }; - const ACTION_LABEL = isEditing ? 'Update' : 'Add'; + const ACTION_LABEL = isEditing ? UPDATE_LABEL : ADD_LABEL; return ( <EuiOverlayMask> <form onSubmit={handleSubmit}> <EuiModal onClose={toggleFieldEditorModal} maxWidth={475}> <EuiModalHeader> - <EuiModalHeaderTitle>{ACTION_LABEL} Field</EuiModalHeaderTitle> + <EuiModalHeaderTitle> + {ACTION_LABEL} {FIELD_LABEL} + </EuiModalHeaderTitle> </EuiModalHeader> <EuiModalBody> <EuiForm> @@ -89,9 +93,9 @@ export const FieldEditorModal: React.FC = () => { </EuiForm> </EuiModalBody> <EuiModalFooter> - <EuiButtonEmpty onClick={toggleFieldEditorModal}>Cancel</EuiButtonEmpty> + <EuiButtonEmpty onClick={toggleFieldEditorModal}>{CANCEL_BUTTON}</EuiButtonEmpty> <EuiButton data-test-subj="FieldSubmitButton" color="primary" fill={true} type="submit"> - {ACTION_LABEL} Field + {ACTION_LABEL} {FIELD_LABEL} </EuiButton> </EuiModalFooter> </EuiModal> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx index 5ee484250ca62..48e285cdcc778 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx @@ -26,6 +26,9 @@ import { EuiTitle, } from '@elastic/eui'; +import { ADD_FIELD_LABEL, EDIT_FIELD_LABEL, REMOVE_FIELD_LABEL } from '../../../../constants'; +import { VISIBLE_FIELDS_TITLE, EMPTY_FIELDS_DESCRIPTION, PREVIEW_TITLE } from './constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { ExampleResultDetailCard } from './example_result_detail_card'; @@ -55,7 +58,7 @@ export const ResultDetail: React.FC = () => { <EuiFlexGroup alignItems="center" justifyContent="spaceBetween"> <EuiFlexItem> <EuiTitle size="s"> - <h3>Visible Fields</h3> + <h3>{VISIBLE_FIELDS_TITLE}</h3> </EuiTitle> </EuiFlexItem> <EuiFlexItem grow={false}> @@ -65,7 +68,7 @@ export const ResultDetail: React.FC = () => { disabled={availableFieldOptions.length < 1} data-test-subj="AddFieldButton" > - Add Field + {ADD_FIELD_LABEL} </EuiButtonEmpty> </EuiFlexItem> </EuiFlexGroup> @@ -106,13 +109,13 @@ export const ResultDetail: React.FC = () => { <div> <EuiButtonIcon data-test-subj="EditFieldButton" - aria-label="Edit Field" + aria-label={EDIT_FIELD_LABEL} iconType="pencil" onClick={() => openEditDetailField(index)} /> <EuiButtonIcon data-test-subj="RemoveFieldButton" - aria-label="Remove Field" + aria-label={REMOVE_FIELD_LABEL} iconType="cross" onClick={() => removeDetailField(index)} /> @@ -127,9 +130,7 @@ export const ResultDetail: React.FC = () => { </EuiDroppable> </EuiDragDropContext> ) : ( - <p data-test-subj="EmptyFieldsDescription"> - Add fields and move them into the order you want them to appear. - </p> + <p data-test-subj="EmptyFieldsDescription">{EMPTY_FIELDS_DESCRIPTION}</p> )} </> </EuiFormRow> @@ -138,7 +139,7 @@ export const ResultDetail: React.FC = () => { <EuiFlexItem> <EuiPanel> <EuiTitle size="s"> - <h3>Preview</h3> + <h3>{PREVIEW_TITLE}</h3> </EuiTitle> <EuiSpacer /> <ExampleResultDetailCard /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx index c1a65d1c52b65..95096331a49d3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx @@ -21,7 +21,18 @@ import { } from '@elastic/eui'; import { DisplaySettingsLogic } from './display_settings_logic'; -import { LEAVE_UNASSIGNED_FIELD } from './constants'; + +import { DESCRIPTION_LABEL } from '../../../../constants'; +import { + LEAVE_UNASSIGNED_FIELD, + SEARCH_RESULTS_TITLE, + SEARCH_RESULTS_ROW_HELP_TEXT, + PREVIEW_TITLE, + FEATURED_RESULTS_TITLE, + FEATURED_RESULTS_DESCRIPTION, + STANDARD_RESULTS_TITLE, + STANDARD_RESULTS_DESCRIPTION, +} from './constants'; import { ExampleSearchResultGroup } from './example_search_result_group'; import { ExampleStandoutResult } from './example_standout_result'; @@ -51,7 +62,7 @@ export const SearchResults: React.FC = () => { <EuiFlexItem> <EuiSpacer size="m" /> <EuiTitle size="s"> - <h3>Search Result Settings</h3> + <h3>{SEARCH_RESULTS_TITLE}</h3> </EuiTitle> <EuiSpacer size="s" /> <EuiForm> @@ -89,7 +100,7 @@ export const SearchResults: React.FC = () => { </EuiFormRow> <EuiFormRow label="Subtitle" - helpText="This area is optional" + helpText={SEARCH_RESULTS_ROW_HELP_TEXT} onMouseOver={toggleSubtitleFieldHover} onMouseOut={toggleSubtitleFieldHover} onFocus={toggleSubtitleFieldHover} @@ -107,8 +118,8 @@ export const SearchResults: React.FC = () => { /> </EuiFormRow> <EuiFormRow - label="Description" - helpText="This area is optional" + label={DESCRIPTION_LABEL} + helpText={SEARCH_RESULTS_ROW_HELP_TEXT} onMouseOver={toggleDescriptionFieldHover} onMouseOut={toggleDescriptionFieldHover} onFocus={toggleDescriptionFieldHover} @@ -130,27 +141,23 @@ export const SearchResults: React.FC = () => { <EuiFlexItem> <EuiPanel> <EuiTitle size="s"> - <h3>Preview</h3> + <h3>{PREVIEW_TITLE}</h3> </EuiTitle> <EuiSpacer /> <div className="section-header"> <EuiTitle size="xs"> - <h4>Featured Results</h4> + <h4>{FEATURED_RESULTS_TITLE}</h4> </EuiTitle> - <p className="section-header__description"> - A matching document will appear as a single bold card. - </p> + <p className="section-header__description">{FEATURED_RESULTS_DESCRIPTION}</p> </div> <EuiSpacer /> <ExampleStandoutResult /> <EuiSpacer /> <div className="section-header"> <EuiTitle size="xs"> - <h4>Standard Results</h4> + <h4>{STANDARD_RESULTS_TITLE}</h4> </EuiTitle> - <p className="section-header__description"> - Somewhat matching documents will appear as a set. - </p> + <p className="section-header__description">{STANDARD_RESULTS_DESCRIPTION}</p> </div> <EuiSpacer /> <ExampleSearchResultGroup /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx index d2f26cd6726df..77f77ad3d3cb9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx @@ -10,6 +10,8 @@ import classNames from 'classnames'; import { Result } from '../../../../types'; +import { SUBTITLE_LABEL } from './constants'; + interface SubtitleFieldProps { result: Result; subtitleField: string | null; @@ -31,7 +33,7 @@ export const SubtitleField: React.FC<SubtitleFieldProps> = ({ <div className="eui-textTruncate">{result[subtitleField]}</div> ) : ( <span data-test-subj="DefaultSubtitleLabel" className="example-result-content-placeholder"> - Subtitle + {SUBTITLE_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx index fa975c8b11ce0..00b548043aae5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx @@ -10,6 +10,8 @@ import classNames from 'classnames'; import { Result } from '../../../../types'; +import { TITLE_LABEL } from './constants'; + interface TitleFieldProps { result: Result; titleField: string | null; @@ -32,7 +34,7 @@ export const TitleField: React.FC<TitleFieldProps> = ({ result, titleField, titl </div> ) : ( <span className="example-result-content-placeholder" data-test-subj="DefaultTitleLabel"> - Title + {TITLE_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index a0797305de6ca..be20eefa1b481 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -8,6 +8,8 @@ import React from 'react'; import { useValues } from 'kea'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiEmptyPrompt, EuiFlexGroup, @@ -36,6 +38,38 @@ import { getGroupPath, } from '../../../routes'; +import { + RECENT_ACTIVITY_TITLE, + CREDENTIALS_TITLE, + DOCUMENTATION_LINK_TITLE, +} from '../../../constants'; +import { + SOURCES_NO_CONTENT_TITLE, + CONTENT_SUMMARY_TITLE, + CONTENT_TYPE_HEADER, + ITEMS_HEADER, + EVENT_HEADER, + STATUS_HEADER, + TIME_HEADER, + TOTAL_DOCUMENTS_LABEL, + EMPTY_ACTIVITY_TITLE, + GROUP_ACCESS_TITLE, + CONFIGURATION_TITLE, + DOCUMENT_PERMISSIONS_TITLE, + DOCUMENT_PERMISSIONS_TEXT, + DOCUMENT_PERMISSIONS_DISABLED_TEXT, + LEARN_MORE_LINK, + STATUS_HEADING, + STATUS_TEXT, + ADDITIONAL_CONFIG_HEADING, + EXTERNAL_IDENTITIES_LINK, + ACCESS_TOKEN_LABEL, + ID_LABEL, + LEARN_CUSTOM_FEATURES_BUTTON, + DOC_PERMISSIONS_DESCRIPTION, + CUSTOM_CALLOUT_TITLE, +} from '../constants'; + import { AppLogic } from '../../../app_logic'; import { ComponentLoader } from '../../../components/shared/component_loader'; @@ -88,7 +122,7 @@ export const Overview: React.FC = () => { <EuiSpacer size="s" /> <EuiPanel paddingSize="l" className="euiPanel--inset" data-test-subj="EmptyDocumentSummary"> <EuiEmptyPrompt - title={<h2>No content yet</h2>} + title={<h2>{SOURCES_NO_CONTENT_TITLE}</h2>} iconType="documents" iconColor="subdued" /> @@ -100,7 +134,7 @@ export const Overview: React.FC = () => { <div className="content-section"> <div className="section-header"> <EuiTitle size="xs"> - <h4>Content summary</h4> + <h4>{CONTENT_SUMMARY_TITLE}</h4> </EuiTitle> </div> <EuiSpacer size="s" /> @@ -111,14 +145,14 @@ export const Overview: React.FC = () => { ) : ( <EuiTable> <EuiTableHeader> - <EuiTableHeaderCell>Content Type</EuiTableHeaderCell> - <EuiTableHeaderCell>Items</EuiTableHeaderCell> + <EuiTableHeaderCell>{CONTENT_TYPE_HEADER}</EuiTableHeaderCell> + <EuiTableHeaderCell>{ITEMS_HEADER}</EuiTableHeaderCell> </EuiTableHeader> <EuiTableBody> {tableContent} <EuiTableRow> <EuiTableRowCell> - <strong>Total documents</strong> + <strong>{TOTAL_DOCUMENTS_LABEL}</strong> </EuiTableRowCell> <EuiTableRowCell> <strong>{totalDocuments.toLocaleString('en-US')}</strong> @@ -137,7 +171,7 @@ export const Overview: React.FC = () => { <EuiSpacer size="s" /> <EuiPanel paddingSize="l" className="euiPanel--inset" data-test-subj="EmptyActivitySummary"> <EuiEmptyPrompt - title={<h2>There is no recent activity</h2>} + title={<h2>{EMPTY_ACTIVITY_TITLE}</h2>} iconType="clock" iconColor="subdued" /> @@ -148,9 +182,9 @@ export const Overview: React.FC = () => { const activitiesTable = ( <EuiTable> <EuiTableHeader> - <EuiTableHeaderCell>Event</EuiTableHeaderCell> - {!custom && <EuiTableHeaderCell>Status</EuiTableHeaderCell>} - <EuiTableHeaderCell>Time</EuiTableHeaderCell> + <EuiTableHeaderCell>{EVENT_HEADER}</EuiTableHeaderCell> + {!custom && <EuiTableHeaderCell>{STATUS_HEADER}</EuiTableHeaderCell>} + <EuiTableHeaderCell>{TIME_HEADER}</EuiTableHeaderCell> </EuiTableHeader> <EuiTableBody> {activities.map(({ details: activityDetails, event, time, status }, i) => ( @@ -186,7 +220,7 @@ export const Overview: React.FC = () => { <div className="content-section"> <div className="section-header"> <EuiTitle size="xs"> - <h3>Recent activity</h3> + <h3>{RECENT_ACTIVITY_TITLE}</h3> </EuiTitle> </div> <EuiSpacer size="s" /> @@ -198,7 +232,7 @@ export const Overview: React.FC = () => { const groupsSummary = ( <> <EuiText> - <h4>Group Access</h4> + <h4>{GROUP_ACCESS_TITLE}</h4> </EuiText> <EuiSpacer size="s" /> <EuiFlexGroup direction="column" gutterSize="s" data-test-subj="GroupsSummary"> @@ -223,7 +257,7 @@ export const Overview: React.FC = () => { <> <EuiSpacer size="l" /> <EuiText> - <h4>Configuration</h4> + <h4>{CONFIGURATION_TITLE}</h4> </EuiText> <EuiSpacer size="s" /> <EuiPanel> @@ -251,7 +285,7 @@ export const Overview: React.FC = () => { <> <EuiSpacer /> <EuiTitle size="s"> - <h4>Document-level permissions</h4> + <h4>{DOCUMENT_PERMISSIONS_TITLE}</h4> </EuiTitle> <EuiSpacer size="m" /> <EuiPanel> @@ -261,7 +295,7 @@ export const Overview: React.FC = () => { </EuiFlexItem> <EuiFlexItem> <EuiText> - <strong>Using document-level permissions</strong> + <strong>{DOCUMENT_PERMISSIONS_TEXT}</strong> </EuiText> </EuiFlexItem> </EuiFlexGroup> @@ -273,7 +307,7 @@ export const Overview: React.FC = () => { <> <EuiSpacer /> <EuiTitle size="s"> - <h4>Document-level permissions</h4> + <h4>{DOCUMENT_PERMISSIONS_TITLE}</h4> </EuiTitle> <EuiSpacer size="m" /> <EuiPanel className="euiPanel--inset" data-test-subj="DocumentPermissionsDisabled"> @@ -284,13 +318,20 @@ export const Overview: React.FC = () => { </EuiFlexItem> <EuiFlexItem> <EuiText size="m"> - <strong>Disabled for this source</strong> + <strong>{DOCUMENT_PERMISSIONS_DISABLED_TEXT}</strong> </EuiText> <EuiText size="s"> - <EuiLink target="_blank" href={DOCUMENT_PERMISSIONS_DOCS_URL}> - Learn more - </EuiLink>{' '} - about permissions + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.learnMore.text" + defaultMessage="{learnMoreLink} about permissions" + values={{ + learnMoreLink: ( + <EuiLink target="_blank" href={DOCUMENT_PERMISSIONS_DOCS_URL}> + {LEARN_MORE_LINK} + </EuiLink> + ), + }} + /> </EuiText> </EuiFlexItem> </EuiFlexGroup> @@ -303,7 +344,7 @@ export const Overview: React.FC = () => { <EuiPanel> <EuiText size="s"> <h6> - <EuiTextColor color="subdued">Status</EuiTextColor> + <EuiTextColor color="subdued">{STATUS_HEADER}</EuiTextColor> </h6> </EuiText> <EuiSpacer size="s" /> @@ -313,10 +354,10 @@ export const Overview: React.FC = () => { </EuiFlexItem> <EuiFlexItem> <EuiText> - <strong>Everything looks good</strong> + <strong>{STATUS_HEADING}</strong> </EuiText> <EuiText size="s"> - <p>Your endpoints are ready to accept requests.</p> + <p>{STATUS_TEXT}</p> </EuiText> </EuiFlexItem> </EuiFlexGroup> @@ -327,7 +368,7 @@ export const Overview: React.FC = () => { <EuiPanel data-test-subj="PermissionsStatus"> <EuiText size="s"> <h6> - <EuiTextColor color="subdued">Status</EuiTextColor> + <EuiTextColor color="subdued">{STATUS_HEADING}</EuiTextColor> </h6> </EuiText> <EuiSpacer size="s" /> @@ -337,15 +378,21 @@ export const Overview: React.FC = () => { </EuiFlexItem> <EuiFlexItem> <EuiText> - <strong>Requires additional configuration</strong> + <strong>{ADDITIONAL_CONFIG_HEADING}</strong> </EuiText> <EuiText size="s"> <p> - The{' '} - <EuiLink target="_blank" href={EXTERNAL_IDENTITIES_DOCS_URL}> - External Identities API - </EuiLink>{' '} - must be used to configure user access mappings. Read the guide to learn more. + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.externalIdentities.text" + defaultMessage="The {externalIdentitiesLink} must be used to configure user access mappings. Read the guide to learn more." + values={{ + externalIdentitiesLink: ( + <EuiLink target="_blank" href={EXTERNAL_IDENTITIES_DOCS_URL}> + {EXTERNAL_IDENTITIES_LINK} + </EuiLink> + ), + }} + /> </p> </EuiText> </EuiFlexItem> @@ -357,13 +404,13 @@ export const Overview: React.FC = () => { <EuiPanel> <EuiText size="s"> <h6> - <EuiTextColor color="subdued">Credentials</EuiTextColor> + <EuiTextColor color="subdued">{CREDENTIALS_TITLE}</EuiTextColor> </h6> </EuiText> <EuiSpacer size="s" /> - <CredentialItem label="ID" value={id} testSubj="ContentSourceId" /> + <CredentialItem label={ID_LABEL} value={id} testSubj="ContentSourceId" /> <EuiSpacer size="s" /> - <CredentialItem label="Access Token" value={accessToken} testSubj="AccessToken" /> + <CredentialItem label={ACCESS_TOKEN_LABEL} value={accessToken} testSubj="AccessToken" /> </EuiPanel> ); @@ -377,7 +424,7 @@ export const Overview: React.FC = () => { <EuiPanel> <EuiText size="s"> <h6> - <EuiTextColor color="subdued">Documentation</EuiTextColor> + <EuiTextColor color="subdued">{DOCUMENTATION_LINK_TITLE}</EuiTextColor> </h6> </EuiText> <EuiSpacer size="s" /> @@ -393,18 +440,15 @@ export const Overview: React.FC = () => { <LicenseBadge /> <EuiSpacer size="s" /> <EuiTitle size="xs"> - <h4>Document-level permissions</h4> + <h4>{DOCUMENT_PERMISSIONS_TITLE}</h4> </EuiTitle> <EuiText size="s"> - <p> - Document-level permissions manage content access content on individual or group - attributes. Allow or deny access to specific documents. - </p> + <p>{DOC_PERMISSIONS_DESCRIPTION}</p> </EuiText> <EuiSpacer size="s" /> <EuiText size="s"> <EuiLink target="_blank" href={ENT_SEARCH_LICENSE_MANAGEMENT}> - Learn about Platinum features + {LEARN_CUSTOM_FEATURES_BUTTON} </EuiLink> </EuiText> </EuiPanel> @@ -449,13 +493,20 @@ export const Overview: React.FC = () => { <EuiFlexItem> <DocumentationCallout data-test-subj="DocumentationCallout" - title="Getting started with custom sources?" + title={CUSTOM_CALLOUT_TITLE} > <p> - <EuiLink target="_blank" href={CUSTOM_SOURCE_DOCS_URL}> - Learn more - </EuiLink>{' '} - about custom sources. + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.learnMoreCustom.text" + defaultMessage="{learnMoreLink} about custom sources." + values={{ + learnMoreLink: ( + <EuiLink target="_blank" href={CUSTOM_SOURCE_DOCS_URL}> + {LEARN_MORE_LINK} + </EuiLink> + ), + }} + /> </p> </DocumentationCallout> </EuiFlexItem> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts index 2c3aa6114c7da..c9d68201f33ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/schema/schema_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; const contentSource = { id: 'source123' }; jest.mock('../../source_logic', () => ({ @@ -198,14 +195,13 @@ describe('SchemaLogic', () => { describe('initializeSchema', () => { it('calls API and sets values (org)', async () => { const onInitializeSchemaSpy = jest.spyOn(SchemaLogic.actions, 'onInitializeSchema'); - const promise = Promise.resolve(serverResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.initializeSchema(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/org/sources/source123/schemas' ); - await promise; + await nextTick(); expect(onInitializeSchemaSpy).toHaveBeenCalledWith(serverResponse); }); @@ -213,22 +209,20 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onInitializeSchemaSpy = jest.spyOn(SchemaLogic.actions, 'onInitializeSchema'); - const promise = Promise.resolve(serverResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.initializeSchema(); expect(http.get).toHaveBeenCalledWith( '/api/workplace_search/account/sources/source123/schemas' ); - await promise; + await nextTick(); expect(onInitializeSchemaSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SchemaLogic.actions.initializeSchema(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -297,13 +291,12 @@ describe('SchemaLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject({ error: 'this is an error' }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject({ error: 'this is an error' })); SchemaLogic.actions.initializeSchemaFieldErrors( mostRecentIndexJob.activeReindexJobId, contentSource.id ); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith({ error: 'this is an error', @@ -352,8 +345,7 @@ describe('SchemaLogic', () => { it('calls API and sets values (org)', async () => { AppLogic.values.isOrganization = true; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, ADD); expect(http.post).toHaveBeenCalledWith( @@ -362,7 +354,7 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(SCHEMA_FIELD_ADDED_MESSAGE); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); @@ -371,8 +363,7 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, ADD); expect(http.post).toHaveBeenCalledWith( @@ -381,16 +372,15 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { const onSchemaSetFormErrorsSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetFormErrors'); - const promise = Promise.reject({ message: 'this is an error' }); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject({ message: 'this is an error' })); SchemaLogic.actions.setServerField(schema, ADD); - await expectedAsyncError(promise); + await nextTick(); expect(onSchemaSetFormErrorsSpy).toHaveBeenCalledWith('this is an error'); }); @@ -400,8 +390,7 @@ describe('SchemaLogic', () => { it('calls API and sets values (org)', async () => { AppLogic.values.isOrganization = true; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, UPDATE); expect(http.post).toHaveBeenCalledWith( @@ -410,7 +399,7 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(SCHEMA_UPDATED_MESSAGE); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); @@ -419,8 +408,7 @@ describe('SchemaLogic', () => { AppLogic.values.isOrganization = false; const onSchemaSetSuccessSpy = jest.spyOn(SchemaLogic.actions, 'onSchemaSetSuccess'); - const promise = Promise.resolve(serverResponse); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(serverResponse)); SchemaLogic.actions.setServerField(schema, UPDATE); expect(http.post).toHaveBeenCalledWith( @@ -429,15 +417,14 @@ describe('SchemaLogic', () => { body: JSON.stringify({ ...schema }), } ); - await promise; + await nextTick(); expect(onSchemaSetSuccessSpy).toHaveBeenCalledWith(serverResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); SchemaLogic.actions.setServerField(schema, UPDATE); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx index 16aceacbddcd5..3f7d99629ca4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx @@ -10,6 +10,8 @@ import { Location } from 'history'; import { useActions, useValues } from 'kea'; import { Redirect, useLocation } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + import { setErrorMessage } from '../../../../shared/flash_messages'; import { parseQueryParams } from '../../../../../applications/shared/query_params'; @@ -37,7 +39,13 @@ export const SourceAdded: React.FC = () => { const decodedName = decodeURIComponent(name); if (hasError) { - const defaultError = `${decodedName} failed to connect.`; + const defaultError = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceAdded.error', + { + defaultMessage: '{decodedName} failed to connect.', + values: { decodedName }, + } + ); setErrorMessage(errorMessages ? errorMessages.join(' ') : defaultError); } else { setAddedSource(decodedName, indexPermissions, serviceType); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx index 728d21eb1530f..cac74d37f9f66 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx @@ -10,6 +10,9 @@ import { useActions, useValues } from 'kea'; import { startCase } from 'lodash'; import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiButton, EuiButtonEmpty, @@ -41,6 +44,16 @@ import { TablePaginationBar } from '../../../components/shared/table_pagination_ import { ViewContentHeader } from '../../../components/shared/view_content_header'; import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +import { + NO_CONTENT_MESSAGE, + CUSTOM_DOCUMENTATION_LINK, + TITLE_HEADING, + LAST_UPDATED_HEADING, + GO_BUTTON, + RESET_BUTTON, + SOURCE_CONTENT_TITLE, + CONTENT_LOADING_TEXT, +} from '../constants'; import { SourceLogic } from '../source_logic'; @@ -78,8 +91,11 @@ export const SourceContent: React.FC = () => { const showPagination = totalPages > 1; const hasItems = totalItems > 0; const emptyMessage = contentFilterValue - ? `No results for '${contentFilterValue}'` - : "This source doesn't have any content yet"; + ? i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.noContentForValue.message', { + defaultMessage: "No results for '{contentFilterValue}'", + values: { contentFilterValue }, + }) + : NO_CONTENT_MESSAGE; const paginationOptions = { totalPages, @@ -101,10 +117,17 @@ export const SourceContent: React.FC = () => { body={ isCustomSource ? ( <p> - Learn more about adding content in our{' '} - <EuiLink target="_blank" href={CUSTOM_SOURCE_DOCS_URL}> - documentation - </EuiLink> + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.customSourceDocs.text" + defaultMessage="Learn more about adding content in our {documentationLink}" + values={{ + documentationLink: ( + <EuiLink target="_blank" href={CUSTOM_SOURCE_DOCS_URL}> + {CUSTOM_DOCUMENTATION_LINK} + </EuiLink> + ), + }} + /> </p> ) : null } @@ -143,9 +166,9 @@ export const SourceContent: React.FC = () => { <EuiSpacer size="m" /> <EuiTable> <EuiTableHeader> - <EuiTableHeaderCell>Title</EuiTableHeaderCell> + <EuiTableHeaderCell>{TITLE_HEADING}</EuiTableHeaderCell> <EuiTableHeaderCell>{startCase(urlField)}</EuiTableHeaderCell> - <EuiTableHeaderCell>Last Updated</EuiTableHeaderCell> + <EuiTableHeaderCell>{LAST_UPDATED_HEADING}</EuiTableHeaderCell> </EuiTableHeader> <EuiTableBody>{contentItems.map(contentItem)}</EuiTableBody> </EuiTable> @@ -167,12 +190,12 @@ export const SourceContent: React.FC = () => { color="primary" onClick={() => setContentFilterValue(searchTerm)} > - Go + {GO_BUTTON} </EuiButton> </EuiFlexItem> <EuiFlexItem grow={false}> <EuiButtonEmpty disabled={!searchTerm} onClick={resetFederatedSearchTerm}> - Reset + {RESET_BUTTON} </EuiButtonEmpty> </EuiFlexItem> </> @@ -180,12 +203,18 @@ export const SourceContent: React.FC = () => { return ( <> - <ViewContentHeader title="Source content" /> + <ViewContentHeader title={SOURCE_CONTENT_TITLE} /> <EuiFlexGroup gutterSize="s"> <EuiFlexItem grow={false}> <EuiFieldSearch disabled={!hasItems && !contentFilterValue} - placeholder={`${isFederatedSource ? 'Search' : 'Filter'} content...`} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceContent.searchBar.placeholder', + { + defaultMessage: '{prefix} content...', + values: { prefix: isFederatedSource ? 'Search' : 'Filter' }, + } + )} incremental={!isFederatedSource} isClearable={!isFederatedSource} onSearch={setContentFilterValue} @@ -197,7 +226,7 @@ export const SourceContent: React.FC = () => { {isFederatedSource && federatedSearchControls} </EuiFlexGroup> <EuiSpacer size="xl" /> - {sectionLoading && <ComponentLoader text="Loading content..." />} + {sectionLoading && <ComponentLoader text={CONTENT_LOADING_TEXT} />} {!sectionLoading && (hasItems ? contentTable : emptyState)} </> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx index 8e3a116e3ac33..ee877e8f61ad6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx @@ -18,6 +18,8 @@ import { import { SourceIcon } from '../../../components/shared/source_icon'; +import { REMOTE_SOURCE_LABEL, CREATED_LABEL, STATUS_LABEL, READY_TEXT } from '../constants'; + interface SourceInfoCardProps { sourceName: string; sourceType: string; @@ -54,7 +56,7 @@ export const SourceInfoCard: React.FC<SourceInfoCardProps> = ({ <EuiFlexItem grow={null}> <EuiSpacer size="xs" /> <EuiBadge iconType="online" iconSide="left"> - Remote Source + {REMOTE_SOURCE_LABEL} </EuiBadge> </EuiFlexItem> </EuiFlexGroup> @@ -63,7 +65,7 @@ export const SourceInfoCard: React.FC<SourceInfoCardProps> = ({ <EuiFlexItem> <EuiText textAlign="right" size="s"> - <strong>Created: </strong> + <strong>{CREATED_LABEL}</strong> {dateCreated} </EuiText> @@ -71,12 +73,12 @@ export const SourceInfoCard: React.FC<SourceInfoCardProps> = ({ <EuiFlexGroup gutterSize="none" justifyContent="flexEnd" alignItems="center"> <EuiFlexItem grow={null}> <EuiText textAlign="right" size="s"> - <strong>Status: </strong> + <strong>{STATUS_LABEL}</strong> </EuiText> </EuiFlexItem> <EuiFlexItem grow={null}> <EuiText textAlign="right" size="s"> - <EuiHealth color="success">Ready to search</EuiHealth> + <EuiHealth color="success">{READY_TEXT}</EuiHealth> </EuiText> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 8d3219be9b02a..5f47fa2d5927b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -10,6 +10,8 @@ import { useActions, useValues } from 'kea'; import { isEmpty } from 'lodash'; import { Link } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiButton, EuiButtonEmpty, @@ -21,6 +23,24 @@ import { EuiFormRow, } from '@elastic/eui'; +import { + CANCEL_BUTTON, + OK_BUTTON, + CONFIRM_MODAL_TITLE, + SAVE_CHANGES_BUTTON, + REMOVE_BUTTON, +} from '../../../constants'; +import { + SOURCE_SETTINGS_TITLE, + SOURCE_SETTINGS_DESCRIPTION, + SOURCE_NAME_LABEL, + SOURCE_CONFIG_TITLE, + SOURCE_CONFIG_DESCRIPTION, + SOURCE_CONFIG_LINK, + SOURCE_REMOVE_TITLE, + SOURCE_REMOVE_DESCRIPTION, +} from '../constants'; + import { ContentSection } from '../../../components/shared/content_section'; import { SourceConfigFields } from '../../../components/shared/source_config_fields'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; @@ -85,16 +105,22 @@ export const SourceSettings: React.FC = () => { const confirmModal = ( <EuiOverlayMask> <EuiConfirmModal - title="Please confirm" + title={CONFIRM_MODAL_TITLE} onConfirm={handleSourceRemoval} onCancel={hideConfirm} buttonColor="danger" - cancelButtonText="Cancel" - confirmButtonText="Ok" + cancelButtonText={CANCEL_BUTTON} + confirmButtonText={OK_BUTTON} defaultFocusedButton="confirm" > - Your source documents will be deleted from Workplace Search. <br /> - Are you sure you want to remove {name}? + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.settingsModal.text" + defaultMessage="Your source documents will be deleted from Workplace Search.{lineBreak}Are you sure you want to remove {name}?" + values={{ + name, + lineBreak: <br />, + }} + /> </EuiConfirmModal> </EuiOverlayMask> ); @@ -102,10 +128,7 @@ export const SourceSettings: React.FC = () => { return ( <> <ViewContentHeader title="Source settings" /> - <ContentSection - title="Content source name" - description="Customize the name of this content source." - > + <ContentSection title={SOURCE_SETTINGS_TITLE} description={SOURCE_SETTINGS_DESCRIPTION}> <form onSubmit={submitNameChange}> <EuiFlexGroup> <EuiFlexItem grow={false}> @@ -114,7 +137,7 @@ export const SourceSettings: React.FC = () => { value={inputValue} size={64} onChange={handleNameChange} - aria-label="Source Name" + aria-label={SOURCE_NAME_LABEL} disabled={buttonLoading} data-test-subj="SourceNameInput" /> @@ -127,17 +150,14 @@ export const SourceSettings: React.FC = () => { onClick={submitNameChange} data-test-subj="SaveChangesButton" > - Save changes + {SAVE_CHANGES_BUTTON} </EuiButton> </EuiFlexItem> </EuiFlexGroup> </form> </ContentSection> {showConfig && ( - <ContentSection - title="Content source configuration" - description="Edit content source connector settings to change." - > + <ContentSection title={SOURCE_CONFIG_TITLE} description={SOURCE_CONFIG_DESCRIPTION}> <SourceConfigFields clientId={clientId} clientSecret={clientSecret} @@ -147,12 +167,12 @@ export const SourceSettings: React.FC = () => { /> <EuiFormRow> <Link to={editPath}> - <EuiButtonEmpty flush="left">Edit content source connector settings</EuiButtonEmpty> + <EuiButtonEmpty flush="left">{SOURCE_CONFIG_LINK}</EuiButtonEmpty> </Link> </EuiFormRow> </ContentSection> )} - <ContentSection title="Remove this source" description="This action cannot be undone."> + <ContentSection title={SOURCE_REMOVE_TITLE} description={SOURCE_REMOVE_DESCRIPTION}> <EuiButton isLoading={buttonLoading} data-test-subj="DeleteSourceButton" @@ -160,7 +180,7 @@ export const SourceSettings: React.FC = () => { color="danger" onClick={showConfirm} > - Remove + {REMOVE_BUTTON} </EuiButton> {confirmModalVisible && confirmModal} </ContentSection> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts new file mode 100644 index 0000000000000..48b8a06b2549c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const SOURCES_NO_CONTENT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.noContent.title', + { + defaultMessage: 'No content yet', + } +); + +export const CONTENT_SUMMARY_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.contentSummary.title', + { + defaultMessage: 'Content summary', + } +); + +export const CONTENT_TYPE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.contentType.header', + { + defaultMessage: 'Content type', + } +); + +export const ITEMS_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.items.header', + { + defaultMessage: 'Items', + } +); + +export const EVENT_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.event.header', + { + defaultMessage: 'Event', + } +); + +export const STATUS_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.status.header', + { + defaultMessage: 'Status', + } +); + +export const TIME_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.time.header', + { + defaultMessage: 'Time', + } +); + +export const TOTAL_DOCUMENTS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.totalDocuments.label', + { + defaultMessage: 'Total documents', + } +); + +export const EMPTY_ACTIVITY_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.emptyActivity.title', + { + defaultMessage: 'There is no recent activity', + } +); + +export const GROUP_ACCESS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.groupAccess.title', + { + defaultMessage: 'Group access', + } +); + +export const CONFIGURATION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.configuration.title', + { + defaultMessage: 'Configuration', + } +); + +export const DOCUMENT_PERMISSIONS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.documentPermissions.title', + { + defaultMessage: 'Document-level permissions', + } +); + +export const DOCUMENT_PERMISSIONS_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.documentPermissions.text', + { + defaultMessage: 'Using document-level permissions', + } +); + +export const DOCUMENT_PERMISSIONS_DISABLED_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.documentPermissionsDisabled.text', + { + defaultMessage: 'Disabled for this sources', + } +); + +export const LEARN_MORE_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.learnMore.link', + { + defaultMessage: 'Learn more', + } +); + +export const STATUS_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.status.heading', + { + defaultMessage: 'Everything looks good', + } +); + +export const STATUS_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.status.text', + { + defaultMessage: 'Your endpoints are ready to accept requests.', + } +); + +export const ADDITIONAL_CONFIG_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.additionalConfig.heading', + { + defaultMessage: 'Requires additional configuration', + } +); + +export const EXTERNAL_IDENTITIES_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.externalIdentities.link', + { + defaultMessage: 'External Identities API', + } +); + +export const ACCESS_TOKEN_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.accessToken.label', + { + defaultMessage: 'Access Token', + } +); + +export const ID_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.id.label', { + defaultMessage: 'ID', +}); + +export const LEARN_CUSTOM_FEATURES_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.learnCustom.features.button', + { + defaultMessage: 'Learn about Platinum features', + } +); + +export const DOC_PERMISSIONS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.docPermissions.description', + { + defaultMessage: + 'Document-level permissions manage content access content on individual or group attributes. Allow or deny access to specific documents.', + } +); + +export const CUSTOM_CALLOUT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.customCallout.title', + { + defaultMessage: 'Getting started with custom sources?', + } +); + +export const NO_CONTENT_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.noContentEmpty.message', + { + defaultMessage: "This source doesn't have any content yet", + } +); + +export const CUSTOM_DOCUMENTATION_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.customSourceDocs.link', + { + defaultMessage: 'documentation', + } +); + +export const TITLE_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.title.heading', + { + defaultMessage: 'Title', + } +); + +export const LAST_UPDATED_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.lastUpdated.heading', + { + defaultMessage: 'Last updated', + } +); + +export const GO_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.go.button', + { + defaultMessage: 'Go', + } +); + +export const RESET_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.reset.button', + { + defaultMessage: 'Reset', + } +); + +export const SOURCE_CONTENT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceContent.title', + { + defaultMessage: 'Source content', + } +); + +export const CONTENT_LOADING_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.contentLoading.text', + { + defaultMessage: 'Loading content...', + } +); + +export const REMOTE_SOURCE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.remoteSource.label', + { + defaultMessage: 'Remote source', + } +); + +export const CREATED_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.created.label', + { + defaultMessage: 'Created: ', + } +); + +export const STATUS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.status.label', + { + defaultMessage: 'Status: ', + } +); + +export const READY_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.ready.text', + { + defaultMessage: 'Ready to search', + } +); + +export const SOURCE_SETTINGS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.settings.title', + { + defaultMessage: 'Content source name', + } +); + +export const SOURCE_SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.settings.description', + { + defaultMessage: 'Customize the name of this content source.', + } +); + +export const SOURCE_CONFIG_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.config.title', + { + defaultMessage: 'Content source configuration', + } +); + +export const SOURCE_CONFIG_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.config.description', + { + defaultMessage: 'Edit content source connector settings to change.', + } +); + +export const SOURCE_CONFIG_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.config.link', + { + defaultMessage: 'Edit content source connector settings', + } +); + +export const SOURCE_REMOVE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.remove.title', + { + defaultMessage: 'Remove this source', + } +); + +export const SOURCE_REMOVE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.config.description', + { + defaultMessage: 'Edit content source connector settings to change.', + } +); + +export const SOURCE_NAME_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceName.label', + { + defaultMessage: 'Source name', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx index 766aa511ebb2d..2402a862a62e7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx @@ -22,6 +22,8 @@ import { EuiOverlayMask, } from '@elastic/eui'; +import { CANCEL_BUTTON } from '../../../constants'; + import { GroupsLogic } from '../groups_logic'; const ADD_GROUP_HEADER = i18n.translate( @@ -30,12 +32,6 @@ const ADD_GROUP_HEADER = i18n.translate( defaultMessage: 'Add a group', } ); -const ADD_GROUP_CANCEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.cancel.action', - { - defaultMessage: 'Cancel', - } -); const ADD_GROUP_SUBMIT = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.submit.action', { @@ -72,7 +68,7 @@ export const AddGroupModal: React.FC<{}> = () => { </EuiModalBody> <EuiModalFooter> - <EuiButtonEmpty onClick={closeNewGroupModal}>{ADD_GROUP_CANCEL}</EuiButtonEmpty> + <EuiButtonEmpty onClick={closeNewGroupModal}>{CANCEL_BUTTON}</EuiButtonEmpty> <EuiButton disabled={!newGroupName} onClick={saveNewGroup} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx index cbfb22915c4eb..ba7cb95b65a8f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -29,6 +29,7 @@ import { import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { Group } from '../../../types'; +import { CANCEL_BUTTON } from '../../../constants'; import { SOURCES_PATH } from '../../../routes'; import noSharedSourcesIcon from '../../../assets/share_circle.svg'; @@ -36,12 +37,6 @@ import noSharedSourcesIcon from '../../../assets/share_circle.svg'; import { GroupLogic } from '../group_logic'; import { GroupsLogic } from '../groups_logic'; -const CANCEL_BUTTON_TEXT = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerCancel', - { - defaultMessage: 'Cancel', - } -); const UPDATE_BUTTON_TEXT = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerUpdate', { @@ -153,7 +148,7 @@ export const GroupManagerModal: React.FC<GroupManagerModalProps> = ({ <EuiFlexGroup gutterSize="none"> <EuiFlexItem grow={false}> <EuiButtonEmpty data-test-subj="CloseGroupsModal" onClick={handleClose}> - {CANCEL_BUTTON_TEXT} + {CANCEL_BUTTON} </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx index 6f55c03746aa8..a1cf7b2ca0a25 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx @@ -22,6 +22,8 @@ import { EuiHorizontalRule, } from '@elastic/eui'; +import { CANCEL_BUTTON } from '../../../constants'; + import { AppLogic } from '../../../app_logic'; import { TruncatedContent } from '../../../../shared/truncate'; import { ContentSection } from '../../../components/shared/content_section'; @@ -99,12 +101,6 @@ const REMOVE_BUTTON_TEXT = i18n.translate( defaultMessage: 'Remove group', } ); -const CANCEL_REMOVE_BUTTON_TEXT = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.overview.cancelRemoveButtonText', - { - defaultMessage: 'Cancel', - } -); const CONFIRM_TITLE_TEXT = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmTitleText', { @@ -238,7 +234,7 @@ export const GroupOverview: React.FC = () => { onConfirm={deleteGroup} confirmButtonText={CONFIRM_REMOVE_BUTTON_TEXT} title={CONFIRM_TITLE_TEXT} - cancelButtonText={CANCEL_REMOVE_BUTTON_TEXT} + cancelButtonText={CANCEL_BUTTON} defaultFocusedButton="confirm" > {CONFIRM_REMOVE_DESCRIPTION} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts index e90acd929a990..2e7a028e43aec 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/group_logic.test.ts @@ -9,9 +9,10 @@ import { mockKibanaValues, mockFlashMessageHelpers, mockHttpValues, - expectedAsyncError, } from '../../../__mocks__'; +import { nextTick } from '@kbn/test/jest'; + import { groups } from '../../__mocks__/groups.mock'; import { mockGroupValues } from './__mocks__/group_logic.mock'; import { GroupLogic } from './group_logic'; @@ -229,32 +230,29 @@ describe('GroupLogic', () => { describe('initializeGroup', () => { it('calls API and sets values', async () => { const onInitializeGroupSpy = jest.spyOn(GroupLogic.actions, 'onInitializeGroup'); - const promise = Promise.resolve(group); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.initializeGroup(sourceIds[0]); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups/123'); - await promise; + await nextTick(); expect(onInitializeGroupSpy).toHaveBeenCalledWith(group); }); it('handles 404 error', async () => { - const promise = Promise.reject({ response: { status: 404 } }); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject({ response: { status: 404 } })); GroupLogic.actions.initializeGroup(sourceIds[0]); - await expectedAsyncError(promise); + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedErrorMessage).toHaveBeenCalledWith('Unable to find group with ID: "123".'); }); it('handles non-404 error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.initializeGroup(sourceIds[0]); - await expectedAsyncError(promise); + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedErrorMessage).toHaveBeenCalledWith('this is an error'); @@ -266,13 +264,12 @@ describe('GroupLogic', () => { GroupLogic.actions.onInitializeGroup(group); }); it('deletes a group', async () => { - const promise = Promise.resolve(true); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve(true)); GroupLogic.actions.deleteGroup(); expect(http.delete).toHaveBeenCalledWith('/api/workplace_search/groups/123'); - await promise; + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith(GROUPS_PATH); expect(setQueuedSuccessMessage).toHaveBeenCalledWith( 'Group "group" was successfully deleted.' @@ -280,11 +277,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.deleteGroup(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -297,15 +293,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupNameChangedSpy = jest.spyOn(GroupLogic.actions, 'onGroupNameChanged'); - const promise = Promise.resolve(group); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.updateGroupName(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/groups/123', { body: JSON.stringify({ group: { name: 'new name' } }), }); - await promise; + await nextTick(); expect(onGroupNameChangedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully renamed this group to "group".' @@ -313,11 +308,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.updateGroupName(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -330,15 +324,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupSourcesSavedSpy = jest.spyOn(GroupLogic.actions, 'onGroupSourcesSaved'); - const promise = Promise.resolve(group); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupSources(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/123/share', { body: JSON.stringify({ content_source_ids: sourceIds }), }); - await promise; + await nextTick(); expect(onGroupSourcesSavedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated shared content sources.' @@ -346,11 +339,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupSources(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -362,15 +354,14 @@ describe('GroupLogic', () => { }); it('updates name', async () => { const onGroupUsersSavedSpy = jest.spyOn(GroupLogic.actions, 'onGroupUsersSaved'); - const promise = Promise.resolve(group); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupUsers(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/123/assign', { body: JSON.stringify({ user_ids: userIds }), }); - await promise; + await nextTick(); expect(onGroupUsersSavedSpy).toHaveBeenCalledWith(group); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated the users of this group.' @@ -378,11 +369,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupUsers(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -397,8 +387,7 @@ describe('GroupLogic', () => { GroupLogic.actions, 'onGroupPrioritiesChanged' ); - const promise = Promise.resolve(group); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve(group)); GroupLogic.actions.saveGroupSourcePrioritization(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/groups/123/boosts', { @@ -410,7 +399,7 @@ describe('GroupLogic', () => { }), }); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith( 'Successfully updated shared source prioritization.' ); @@ -418,11 +407,10 @@ describe('GroupLogic', () => { }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); GroupLogic.actions.saveGroupSourcePrioritization(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts index 76352a6670650..6c9f912a98ce8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/groups_logic.test.ts @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - LogicMounter, - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, -} from '../../../__mocks__'; +import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { DEFAULT_META } from '../../../shared/constants'; import { JSON_HEADER as headers } from '../../../../../common/constants'; @@ -22,7 +19,6 @@ import { GroupsLogic } from './groups_logic'; // We need to mock out the debounced functionality const TIMEOUT = 400; -const delay = () => new Promise((resolve) => setTimeout(resolve, TIMEOUT)); describe('GroupsLogic', () => { const { mount } = new LogicMounter(GroupsLogic); @@ -218,21 +214,19 @@ describe('GroupsLogic', () => { describe('initializeGroups', () => { it('calls API and sets values', async () => { const onInitializeGroupsSpy = jest.spyOn(GroupsLogic.actions, 'onInitializeGroups'); - const promise = Promise.resolve(groupsResponse); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(groupsResponse)); GroupsLogic.actions.initializeGroups(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups'); - await promise; + await nextTick(); expect(onInitializeGroupsSpy).toHaveBeenCalledWith(groupsResponse); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.initializeGroups(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -256,15 +250,22 @@ describe('GroupsLogic', () => { headers, }; + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + it('calls API and sets values', async () => { const setSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'setSearchResults'); - const promise = Promise.resolve(groups); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups)); GroupsLogic.actions.getSearchResults(); - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/search', payload); - await promise; expect(setSearchResultsSpy).toHaveBeenCalledWith(groups); }); @@ -272,24 +273,22 @@ describe('GroupsLogic', () => { // Set active page to 2 to confirm resetting sends the `payload` value of 1 for the current page. GroupsLogic.actions.setActivePage(2); const setSearchResultsSpy = jest.spyOn(GroupsLogic.actions, 'setSearchResults'); - const promise = Promise.resolve(groups); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups)); GroupsLogic.actions.getSearchResults(true); // Account for `breakpoint` that debounces filter value. - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups/search', payload); - await promise; expect(setSearchResultsSpy).toHaveBeenCalledWith(groups); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.getSearchResults(); - await expectedAsyncError(promise); - await delay(); + jest.advanceTimersByTime(TIMEOUT); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -298,21 +297,19 @@ describe('GroupsLogic', () => { describe('fetchGroupUsers', () => { it('calls API and sets values', async () => { const setGroupUsersSpy = jest.spyOn(GroupsLogic.actions, 'setGroupUsers'); - const promise = Promise.resolve(users); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(users)); GroupsLogic.actions.fetchGroupUsers('123'); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/groups/123/group_users'); - await promise; + await nextTick(); expect(setGroupUsersSpy).toHaveBeenCalledWith(users); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.fetchGroupUsers('123'); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -323,24 +320,22 @@ describe('GroupsLogic', () => { const GROUP_NAME = 'new group'; GroupsLogic.actions.setNewGroupName(GROUP_NAME); const setNewGroupSpy = jest.spyOn(GroupsLogic.actions, 'setNewGroup'); - const promise = Promise.resolve(groups[0]); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.resolve(groups[0])); GroupsLogic.actions.saveNewGroup(); expect(http.post).toHaveBeenCalledWith('/api/workplace_search/groups', { body: JSON.stringify({ group_name: GROUP_NAME }), headers, }); - await promise; + await nextTick(); expect(setNewGroupSpy).toHaveBeenCalledWith(groups[0]); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.post.mockReturnValue(promise); + http.post.mockReturnValue(Promise.reject('this is an error')); GroupsLogic.actions.saveNewGroup(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 6911196afa81d..a81df1aab83bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -16,6 +16,7 @@ import { ContentSection } from '../../components/shared/content_section'; import { TelemetryLogic } from '../../../shared/telemetry'; import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { SOURCE_DETAILS_PATH, getContentSourcePath } from '../../routes'; +import { RECENT_ACTIVITY_TITLE } from '../../constants'; import { AppLogic } from '../../app_logic'; import { OverviewLogic } from './overview_logic'; @@ -38,15 +39,7 @@ export const RecentActivity: React.FC = () => { const { activityFeed } = useValues(OverviewLogic); return ( - <ContentSection - title={ - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.recentActivity.title" - defaultMessage="Recent activity" - /> - } - headerSpacer="m" - > + <ContentSection title={RECENT_ACTIVITY_TITLE} headerSpacer="m"> <EuiPanel> {activityFeed.length > 0 ? ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx new file mode 100644 index 0000000000000..4db5c60d5800d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiSwitch } from '@elastic/eui'; + +import { PrivateSourcesTable } from './private_sources_table'; + +describe('PrivateSourcesTable', () => { + beforeEach(() => { + setMockValues({ hasPlatinumLicense: true, isEnabled: true }); + }); + + const props = { + sourceSection: { isEnabled: true, contentSources: [] }, + updateSource: jest.fn(), + updateEnabled: jest.fn(), + }; + + it('renders', () => { + const wrapper = shallow(<PrivateSourcesTable {...props} sourceType="standard" />); + + expect(wrapper.find(EuiSwitch)).toHaveLength(1); + }); + + it('handles switches clicks', () => { + const wrapper = shallow( + <PrivateSourcesTable + {...props} + sourceSection={{ + isEnabled: false, + contentSources: [{ id: 'gmail', isEnabled: true, name: 'Gmail' }], + }} + sourceType="remote" + /> + ); + + const sectionSwitch = wrapper.find(EuiSwitch).first(); + const sourceSwitch = wrapper.find(EuiSwitch).last(); + + const event = { target: { value: true } }; + sectionSwitch.prop('onChange')(event as any); + sourceSwitch.prop('onChange')(event as any); + + expect(props.updateEnabled).toHaveBeenCalled(); + expect(props.updateSource).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx new file mode 100644 index 0000000000000..c767dfaba86f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/components/private_sources_table.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import classNames from 'classnames'; +import { useValues } from 'kea'; + +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, + EuiTable, + EuiTableBody, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableRow, + EuiTableRowCell, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { LicensingLogic } from '../../../../shared/licensing'; +import { SecurityLogic, PrivateSourceSection } from '../security_logic'; +import { + REMOTE_SOURCES_TOGGLE_TEXT, + REMOTE_SOURCES_TABLE_DESCRIPTION, + REMOTE_SOURCES_EMPTY_TABLE_TITLE, + STANDARD_SOURCES_TOGGLE_TEXT, + STANDARD_SOURCES_TABLE_DESCRIPTION, + STANDARD_SOURCES_EMPTY_TABLE_TITLE, + SOURCE, +} from '../../../constants'; + +interface PrivateSourcesTableProps { + sourceType: 'remote' | 'standard'; + sourceSection: PrivateSourceSection; + updateSource(sourceId: string, isEnabled: boolean): void; + updateEnabled(isEnabled: boolean): void; +} + +const REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION = ( + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.description" + defaultMessage="Once configured, remote private sources are {enabledStrong}, and users can immediately connect the source from their Personal Dashboard." + values={{ + enabledStrong: ( + <strong> + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.enabledStrong', + { defaultMessage: 'enabled by default' } + )} + </strong> + ), + }} + /> +); + +const STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION = ( + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.description" + defaultMessage="Once configured, standard private sources are {notEnabledStrong}, and must be activated before users are allowed to connect the source from their Personal Dashboard." + values={{ + notEnabledStrong: ( + <strong> + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.notEnabledStrong', + { defaultMessage: 'not enabled by default' } + )} + </strong> + ), + }} + /> +); + +export const PrivateSourcesTable: React.FC<PrivateSourcesTableProps> = ({ + sourceType, + sourceSection: { isEnabled: sectionEnabled, contentSources }, + updateSource, + updateEnabled, +}) => { + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { isEnabled } = useValues(SecurityLogic); + + const isRemote = sourceType === 'remote'; + const hasSources = contentSources.length > 0; + const panelDisabled = !isEnabled || !hasPlatinumLicense; + const sectionDisabled = !sectionEnabled; + + const panelClass = classNames('euiPanel--outline euiPanel--noShadow', { + 'euiPanel--disabled': panelDisabled, + }); + + const tableClass = classNames({ 'euiTable--disabled': sectionDisabled }); + + const emptyState = ( + <> + <EuiSpacer /> + <EuiPanel className="euiPanel--inset euiPanel--noShadow euiPanel--outline"> + <EuiText textAlign="center" color="subdued" size="s"> + <strong> + {isRemote ? REMOTE_SOURCES_EMPTY_TABLE_TITLE : STANDARD_SOURCES_EMPTY_TABLE_TITLE} + </strong> + </EuiText> + <EuiText textAlign="center" color="subdued" size="s"> + {isRemote + ? REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION + : STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION} + </EuiText> + </EuiPanel> + </> + ); + + const sectionHeading = ( + <EuiFlexGroup alignItems="flexStart" justifyContent="flexStart" gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiSpacer size="xs" /> + <EuiSwitch + checked={sectionEnabled} + onChange={(e) => updateEnabled(e.target.checked)} + disabled={!isEnabled || !hasPlatinumLicense} + showLabel={false} + label={`${sourceType} Sources Toggle`} + data-test-subj={`${sourceType}EnabledToggle`} + compressed + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiText size="s"> + <h4>{isRemote ? REMOTE_SOURCES_TOGGLE_TEXT : STANDARD_SOURCES_TOGGLE_TEXT}</h4> + </EuiText> + <EuiText color="subdued" size="s"> + {isRemote ? REMOTE_SOURCES_TABLE_DESCRIPTION : STANDARD_SOURCES_TABLE_DESCRIPTION} + </EuiText> + {!hasSources && emptyState} + </EuiFlexItem> + </EuiFlexGroup> + ); + + const sourcesTable = ( + <> + <EuiSpacer /> + <EuiTable className={tableClass}> + <EuiTableHeader> + <EuiTableHeaderCell>{SOURCE}</EuiTableHeaderCell> + <EuiTableHeaderCell /> + </EuiTableHeader> + <EuiTableBody> + {contentSources.map((source, i) => ( + <EuiTableRow key={i}> + <EuiTableRowCell>{source.name}</EuiTableRowCell> + <EuiTableRowCell> + <EuiSwitch + checked={!!source.isEnabled} + disabled={sectionDisabled} + onChange={(e) => updateSource(source.id, e.target.checked)} + showLabel={false} + label={`${source.name} Toggle`} + data-test-subj={`${sourceType}SourceToggle`} + compressed + /> + </EuiTableRowCell> + </EuiTableRow> + ))} + </EuiTableBody> + </EuiTable> + </> + ); + + return ( + <EuiPanel className={panelClass}> + {sectionHeading} + {hasSources && sourcesTable} + </EuiPanel> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts new file mode 100644 index 0000000000000..a2db1bbc15a15 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Security } from './security'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx new file mode 100644 index 0000000000000..bca0d5edc32d6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { setMockValues, setMockActions } from '../../../__mocks__'; +import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiSwitch, EuiConfirmModal } from '@elastic/eui'; +import { Loading } from '../../../shared/loading'; + +import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { Security } from './security'; + +describe('Security', () => { + const initializeSourceRestrictions = jest.fn(); + const updatePrivateSourcesEnabled = jest.fn(); + const updateRemoteEnabled = jest.fn(); + const updateRemoteSource = jest.fn(); + const updateStandardEnabled = jest.fn(); + const updateStandardSource = jest.fn(); + const saveSourceRestrictions = jest.fn(); + const resetState = jest.fn(); + + const mockValues = { + isEnabled: true, + remote: { isEnabled: true, contentSources: [] }, + standard: { isEnabled: true, contentSources: [] }, + dataLoading: false, + unsavedChanges: false, + hasPlatinumLicense: true, + }; + + beforeEach(() => { + setMockValues(mockValues); + setMockActions({ + initializeSourceRestrictions, + updatePrivateSourcesEnabled, + updateRemoteEnabled, + updateRemoteSource, + updateStandardEnabled, + updateStandardSource, + saveSourceRestrictions, + resetState, + }); + }); + + it('renders on Basic license', () => { + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + const wrapper = shallow(<Security />); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(true); + }); + + it('renders on Platinum license', () => { + const wrapper = shallow(<Security />); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(false); + }); + + it('returns Loading when loading', () => { + setMockValues({ ...mockValues, dataLoading: true }); + const wrapper = shallow(<Security />); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('handles window.onbeforeunload change', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + shallow(<Security />); + + expect(window.onbeforeunload!({} as any)).toEqual( + 'Your private sources settings have not been saved. Are you sure you want to leave?' + ); + }); + + it('handles window.onbeforeunload unmount', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + shallow(<Security />); + + unmountHandler(); + + expect(window.onbeforeunload).toEqual(null); + }); + + it('handles switch click', () => { + const wrapper = shallow(<Security />); + + const privateSourcesSwitch = wrapper.find(EuiSwitch); + const event = { target: { checked: true } }; + privateSourcesSwitch.prop('onChange')(event as any); + + expect(updatePrivateSourcesEnabled).toHaveBeenCalled(); + }); + + it('handles confirmModal submission', () => { + setMockValues({ ...mockValues, unsavedChanges: true }); + const wrapper = shallow(<Security />); + + const header = wrapper.find(ViewContentHeader).dive(); + header.find('[data-test-subj="SaveSettingsButton"]').prop('onClick')!({} as any); + const modal = wrapper.find(EuiConfirmModal); + modal.prop('onConfirm')!({} as any); + + expect(saveSourceRestrictions).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx new file mode 100644 index 0000000000000..41df1a1acc515 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security.tsx @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; + +import classNames from 'classnames'; +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiText, + EuiSpacer, + EuiPanel, + EuiConfirmModal, + EuiOverlayMask, +} from '@elastic/eui'; + +import { LicensingLogic } from '../../../shared/licensing'; +import { FlashMessages } from '../../../shared/flash_messages'; +import { LicenseCallout } from '../../components/shared/license_callout'; +import { Loading } from '../../../shared/loading'; +import { ViewContentHeader } from '../../components/shared/view_content_header'; +import { SecurityLogic } from './security_logic'; + +import { PrivateSourcesTable } from './components/private_sources_table'; + +import { + SECURITY_UNSAVED_CHANGES_MESSAGE, + RESET_BUTTON, + SAVE_SETTINGS_BUTTON, + SAVE_CHANGES_BUTTON, + KEEP_EDITING_BUTTON, + PRIVATE_SOURCES, + PRIVATE_SOURCES_DESCRIPTION, + PRIVATE_SOURCES_TOGGLE_DESCRIPTION, + PRIVATE_PLATINUM_LICENSE_CALLOUT, + CONFIRM_CHANGES_TEXT, + PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT, +} from '../../constants'; + +export const Security: React.FC = () => { + const [confirmModalVisible, setConfirmModalVisibility] = useState(false); + + const hideConfirmModal = () => setConfirmModalVisibility(false); + const showConfirmModal = () => setConfirmModalVisibility(true); + + const { hasPlatinumLicense } = useValues(LicensingLogic); + + const { + initializeSourceRestrictions, + updatePrivateSourcesEnabled, + updateRemoteEnabled, + updateRemoteSource, + updateStandardEnabled, + updateStandardSource, + saveSourceRestrictions, + resetState, + } = useActions(SecurityLogic); + + const { isEnabled, remote, standard, dataLoading, unsavedChanges } = useValues(SecurityLogic); + + useEffect(() => { + initializeSourceRestrictions(); + }, []); + + useEffect(() => { + window.onbeforeunload = unsavedChanges ? () => SECURITY_UNSAVED_CHANGES_MESSAGE : null; + return () => { + window.onbeforeunload = null; + }; + }, [unsavedChanges]); + + if (dataLoading) return <Loading />; + + const panelClass = classNames('euiPanel--noShadow', { + 'euiPanel--disabled': !hasPlatinumLicense, + }); + + const savePrivateSources = () => { + saveSourceRestrictions(); + hideConfirmModal(); + }; + + const headerActions = ( + <EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="m"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty disabled={!unsavedChanges} onClick={resetState}> + {RESET_BUTTON} + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem> + <EuiButton + disabled={!hasPlatinumLicense || !unsavedChanges} + onClick={showConfirmModal} + fill + data-test-subj="SaveSettingsButton" + > + {SAVE_SETTINGS_BUTTON} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + ); + + const header = ( + <> + <ViewContentHeader + title={PRIVATE_SOURCES} + alignItems="flexStart" + description={PRIVATE_SOURCES_DESCRIPTION} + action={headerActions} + /> + <EuiSpacer /> + </> + ); + + const allSourcesToggle = ( + <EuiPanel paddingSize="none" className={panelClass}> + <EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="m"> + <EuiFlexItem grow={false}> + <EuiSwitch + checked={isEnabled} + onChange={(e) => updatePrivateSourcesEnabled(e.target.checked)} + disabled={!hasPlatinumLicense} + showLabel={false} + label="Private Sources Toggle" + data-test-subj="PrivateSourcesToggle" + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiText size="s"> + <h4>{PRIVATE_SOURCES_TOGGLE_DESCRIPTION}</h4> + </EuiText> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPanel> + ); + + const platinumLicenseCallout = ( + <> + <EuiSpacer size="s" /> + <LicenseCallout message={PRIVATE_PLATINUM_LICENSE_CALLOUT} /> + </> + ); + + const sourceTables = ( + <> + <EuiSpacer size="xl" /> + <PrivateSourcesTable + sourceType="remote" + sourceSection={remote} + updateEnabled={updateRemoteEnabled} + updateSource={updateRemoteSource} + /> + <EuiSpacer size="xxl" /> + <PrivateSourcesTable + sourceType="standard" + sourceSection={standard} + updateEnabled={updateStandardEnabled} + updateSource={updateStandardSource} + /> + </> + ); + + const confirmModal = ( + <EuiOverlayMask> + <EuiConfirmModal + title={CONFIRM_CHANGES_TEXT} + onConfirm={savePrivateSources} + onCancel={hideConfirmModal} + buttonColor="primary" + cancelButtonText={KEEP_EDITING_BUTTON} + confirmButtonText={SAVE_CHANGES_BUTTON} + > + {PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT} + </EuiConfirmModal> + </EuiOverlayMask> + ); + + return ( + <> + <FlashMessages /> + {header} + {allSourcesToggle} + {!hasPlatinumLicense && platinumLicenseCallout} + {sourceTables} + {confirmModalVisible && confirmModal} + </> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts new file mode 100644 index 0000000000000..abb1308081f0c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LogicMounter } from '../../../__mocks__/kea.mock'; +import { mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import { SecurityLogic } from './security_logic'; +import { nextTick } from '@kbn/test/jest'; + +describe('SecurityLogic', () => { + const { http } = mockHttpValues; + const { flashAPIErrors } = mockFlashMessageHelpers; + const { mount } = new LogicMounter(SecurityLogic); + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + const defaultValues = { + dataLoading: true, + cachedServerState: {}, + isEnabled: false, + remote: {}, + standard: {}, + unsavedChanges: true, + }; + + const serverProps = { + isEnabled: true, + remote: { + isEnabled: true, + contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }], + }, + standard: { + isEnabled: true, + contentSources: [{ id: 'one_drive', name: 'OneDrive', isEnabled: true }], + }, + }; + + it('has expected default values', () => { + expect(SecurityLogic.values).toEqual(defaultValues); + }); + + describe('actions', () => { + it('setServerProps', () => { + SecurityLogic.actions.setServerProps(serverProps); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + + it('setSourceRestrictionsUpdated', () => { + SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + + it('updatePrivateSourcesEnabled', () => { + SecurityLogic.actions.updatePrivateSourcesEnabled(false); + + expect(SecurityLogic.values.isEnabled).toEqual(false); + }); + + it('updateRemoteEnabled', () => { + SecurityLogic.actions.updateRemoteEnabled(false); + + expect(SecurityLogic.values.remote.isEnabled).toEqual(false); + }); + + it('updateStandardEnabled', () => { + SecurityLogic.actions.updateStandardEnabled(false); + + expect(SecurityLogic.values.standard.isEnabled).toEqual(false); + }); + + it('updateRemoteSource', () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updateRemoteSource('gmail', false); + + expect(SecurityLogic.values.remote.contentSources[0].isEnabled).toEqual(false); + }); + + it('updateStandardSource', () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updateStandardSource('one_drive', false); + + expect(SecurityLogic.values.standard.contentSources[0].isEnabled).toEqual(false); + }); + }); + + describe('selectors', () => { + describe('unsavedChanges', () => { + it('returns true while loading', () => { + expect(SecurityLogic.values.unsavedChanges).toEqual(true); + }); + + it('returns false after loading', () => { + SecurityLogic.actions.setServerProps(serverProps); + + expect(SecurityLogic.values.unsavedChanges).toEqual(false); + }); + }); + }); + + describe('listeners', () => { + describe('initializeSourceRestrictions', () => { + it('calls API and sets values', async () => { + const setServerPropsSpy = jest.spyOn(SecurityLogic.actions, 'setServerProps'); + http.get.mockReturnValue(Promise.resolve(serverProps)); + SecurityLogic.actions.initializeSourceRestrictions(); + + expect(http.get).toHaveBeenCalledWith( + '/api/workplace_search/org/security/source_restrictions' + ); + await nextTick(); + expect(setServerPropsSpy).toHaveBeenCalledWith(serverProps); + }); + + it('handles error', async () => { + http.get.mockReturnValue(Promise.reject('this is an error')); + + SecurityLogic.actions.initializeSourceRestrictions(); + try { + await nextTick(); + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('saveSourceRestrictions', () => { + it('calls API and sets values', async () => { + http.patch.mockReturnValue(Promise.resolve(serverProps)); + SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps); + SecurityLogic.actions.saveSourceRestrictions(); + + expect(http.patch).toHaveBeenCalledWith( + '/api/workplace_search/org/security/source_restrictions', + { + body: JSON.stringify(serverProps), + } + ); + }); + + it('handles error', async () => { + http.patch.mockReturnValue(Promise.reject('this is an error')); + + SecurityLogic.actions.saveSourceRestrictions(); + try { + await nextTick(); + } catch { + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + } + }); + }); + + describe('resetState', () => { + it('calls API and sets values', async () => { + SecurityLogic.actions.setServerProps(serverProps); + SecurityLogic.actions.updatePrivateSourcesEnabled(false); + SecurityLogic.actions.resetState(); + + expect(SecurityLogic.values.isEnabled).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts new file mode 100644 index 0000000000000..df843b330d411 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/security/security_logic.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cloneDeep } from 'lodash'; +import { isEqual } from 'lodash'; + +import { kea, MakeLogicType } from 'kea'; + +import { + clearFlashMessages, + setSuccessMessage, + flashAPIErrors, +} from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { AppLogic } from '../../app_logic'; + +import { SOURCE_RESTRICTIONS_SUCCESS_MESSAGE } from '../../constants'; + +export interface PrivateSource { + id: string; + name: string; + isEnabled: boolean; +} + +export interface PrivateSourceSection { + isEnabled: boolean; + contentSources: PrivateSource[]; +} + +export interface SecurityServerProps { + isEnabled: boolean; + remote: PrivateSourceSection; + standard: PrivateSourceSection; +} + +interface SecurityValues extends SecurityServerProps { + dataLoading: boolean; + unsavedChanges: boolean; + cachedServerState: SecurityServerProps; +} + +interface SecurityActions { + setServerProps(serverProps: SecurityServerProps): SecurityServerProps; + setSourceRestrictionsUpdated(serverProps: SecurityServerProps): SecurityServerProps; + initializeSourceRestrictions(): void; + saveSourceRestrictions(): void; + updatePrivateSourcesEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateRemoteEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateRemoteSource( + sourceId: string, + isEnabled: boolean + ): { sourceId: string; isEnabled: boolean }; + updateStandardEnabled(isEnabled: boolean): { isEnabled: boolean }; + updateStandardSource( + sourceId: string, + isEnabled: boolean + ): { sourceId: string; isEnabled: boolean }; + resetState(): void; +} + +const route = '/api/workplace_search/org/security/source_restrictions'; + +export const SecurityLogic = kea<MakeLogicType<SecurityValues, SecurityActions>>({ + path: ['enterprise_search', 'workplace_search', 'security_logic'], + actions: { + setServerProps: (serverProps: SecurityServerProps) => serverProps, + setSourceRestrictionsUpdated: (serverProps: SecurityServerProps) => serverProps, + initializeSourceRestrictions: () => true, + saveSourceRestrictions: () => null, + updatePrivateSourcesEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateRemoteEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateRemoteSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }), + updateStandardEnabled: (isEnabled: boolean) => ({ isEnabled }), + updateStandardSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }), + resetState: () => null, + }, + reducers: { + dataLoading: [ + true, + { + setServerProps: () => false, + }, + ], + cachedServerState: [ + {} as SecurityServerProps, + { + setServerProps: (_, serverProps) => cloneDeep(serverProps), + setSourceRestrictionsUpdated: (_, serverProps) => cloneDeep(serverProps), + }, + ], + isEnabled: [ + false, + { + setServerProps: (_, { isEnabled }) => isEnabled, + setSourceRestrictionsUpdated: (_, { isEnabled }) => isEnabled, + updatePrivateSourcesEnabled: (_, { isEnabled }) => isEnabled, + }, + ], + remote: [ + {} as PrivateSourceSection, + { + setServerProps: (_, { remote }) => remote, + setSourceRestrictionsUpdated: (_, { remote }) => remote, + updateRemoteEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }), + updateRemoteSource: (state, { sourceId, isEnabled }) => + updateSourceEnabled(state, sourceId, isEnabled), + }, + ], + standard: [ + {} as PrivateSourceSection, + { + setServerProps: (_, { standard }) => standard, + setSourceRestrictionsUpdated: (_, { standard }) => standard, + updateStandardEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }), + updateStandardSource: (state, { sourceId, isEnabled }) => + updateSourceEnabled(state, sourceId, isEnabled), + }, + ], + }, + selectors: ({ selectors }) => ({ + unsavedChanges: [ + () => [ + selectors.cachedServerState, + selectors.isEnabled, + selectors.remote, + selectors.standard, + ], + (cached, isEnabled, remote, standard) => + cached.isEnabled !== isEnabled || + !isEqual(cached.remote, remote) || + !isEqual(cached.standard, standard), + ], + }), + listeners: ({ actions, values }) => ({ + initializeSourceRestrictions: async () => { + const { http } = HttpLogic.values; + + try { + const response = await http.get(route); + actions.setServerProps(response); + } catch (e) { + flashAPIErrors(e); + } + }, + saveSourceRestrictions: async () => { + const { isEnabled, remote, standard } = values; + const serverData = { isEnabled, remote, standard }; + const body = JSON.stringify(serverData); + const { http } = HttpLogic.values; + + try { + const response = await http.patch(route, { body }); + actions.setSourceRestrictionsUpdated(response); + setSuccessMessage(SOURCE_RESTRICTIONS_SUCCESS_MESSAGE); + AppLogic.actions.setSourceRestriction(isEnabled); + } catch (e) { + flashAPIErrors(e); + } + }, + resetState: () => { + actions.setServerProps(cloneDeep(values.cachedServerState)); + clearFlashMessages(); + }, + }), +}); + +const updateSourceEnabled = ( + section: PrivateSourceSection, + id: string, + isEnabled: boolean +): PrivateSourceSection => { + const updatedSection = { ...section }; + const sources = updatedSection.contentSources; + const sourceIndex = sources.findIndex((source) => source.id === id); + updatedSection.contentSources[sourceIndex] = { ...sources[sourceIndex], isEnabled }; + + return updatedSection; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts index aaeae08d552d4..e21b62b500067 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts @@ -6,12 +6,9 @@ import { LogicMounter } from '../../../__mocks__/kea.mock'; -import { - mockFlashMessageHelpers, - mockHttpValues, - expectedAsyncError, - mockKibanaValues, -} from '../../../__mocks__'; +import { mockFlashMessageHelpers, mockHttpValues, mockKibanaValues } from '../../../__mocks__'; + +import { nextTick } from '@kbn/test/jest'; import { configuredSources, oauthApplication } from '../../__mocks__/content_sources.mock'; @@ -89,20 +86,18 @@ describe('SettingsLogic', () => { describe('initializeSettings', () => { it('calls API and sets values', async () => { const setServerPropsSpy = jest.spyOn(SettingsLogic.actions, 'setServerProps'); - const promise = Promise.resolve(configuredSources); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(configuredSources)); SettingsLogic.actions.initializeSettings(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings'); - await promise; + await nextTick(); expect(setServerPropsSpy).toHaveBeenCalledWith(configuredSources); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.initializeSettings(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -114,20 +109,18 @@ describe('SettingsLogic', () => { SettingsLogic.actions, 'onInitializeConnectors' ); - const promise = Promise.resolve(serverProps); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.resolve(serverProps)); SettingsLogic.actions.initializeConnectors(); expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings/connectors'); - await promise; + await nextTick(); expect(onInitializeConnectorsSpy).toHaveBeenCalledWith(serverProps); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.get.mockReturnValue(promise); + http.get.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.initializeConnectors(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -138,25 +131,23 @@ describe('SettingsLogic', () => { const NAME = 'updated name'; SettingsLogic.actions.onOrgNameInputChange(NAME); const setUpdatedNameSpy = jest.spyOn(SettingsLogic.actions, 'setUpdatedName'); - const promise = Promise.resolve({ organizationName: NAME }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ organizationName: NAME })); SettingsLogic.actions.updateOrgName(); expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/customize', { body: JSON.stringify({ name: NAME }), }); - await promise; + await nextTick(); expect(setSuccessMessage).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); expect(setUpdatedNameSpy).toHaveBeenCalledWith({ organizationName: NAME }); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.updateOrgName(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); }); @@ -168,8 +159,7 @@ describe('SettingsLogic', () => { SettingsLogic.actions, 'setUpdatedOauthApplication' ); - const promise = Promise.resolve({ oauthApplication }); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.resolve({ oauthApplication })); SettingsLogic.actions.setOauthApplication(oauthApplication); SettingsLogic.actions.updateOauthApplication(); @@ -183,16 +173,15 @@ describe('SettingsLogic', () => { }), } ); - await promise; + await nextTick(); expect(setUpdatedOauthApplicationSpy).toHaveBeenCalledWith({ oauthApplication }); expect(setSuccessMessage).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.put.mockReturnValue(promise); + http.put.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.updateOauthApplication(); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); @@ -203,20 +192,18 @@ describe('SettingsLogic', () => { const NAME = 'baz'; it('calls API and sets values', async () => { - const promise = Promise.resolve({}); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.resolve({})); SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); - await promise; + await nextTick(); expect(navigateToUrl).toHaveBeenCalledWith('/settings/connectors'); expect(setQueuedSuccessMessage).toHaveBeenCalled(); }); it('handles error', async () => { - const promise = Promise.reject('this is an error'); - http.delete.mockReturnValue(promise); + http.delete.mockReturnValue(Promise.reject('this is an error')); SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); - await expectedAsyncError(promise); + await nextTick(); expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); }); diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 632bb425f203e..5f467c872447d 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -6,6 +6,7 @@ import { AppMountParameters, + CoreStart, CoreSetup, HttpSetup, Plugin, @@ -27,6 +28,8 @@ import { } from '../common/constants'; import { InitialAppData } from '../common/types'; +import { docLinks } from './applications/shared/doc_links'; + export interface ClientConfigType { host?: string; } @@ -153,7 +156,11 @@ export class EnterpriseSearchPlugin implements Plugin { } } - public start() {} + public start(core: CoreStart) { + // This must be called here in start() and not in `applications/index.tsx` to prevent loading + // race conditions with our apps' `routes.ts` being initialized before `renderApp()` + docLinks.setDocLinks(core.docLinks); + } public stop() {} diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts index 99445108b315a..f2792be8e6535 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/index.ts @@ -10,10 +10,12 @@ import { registerOverviewRoute } from './overview'; import { registerGroupsRoutes } from './groups'; import { registerSourcesRoutes } from './sources'; import { registerSettingsRoutes } from './settings'; +import { registerSecurityRoutes } from './security'; export const registerWorkplaceSearchRoutes = (dependencies: RouteDependencies) => { registerOverviewRoute(dependencies); registerGroupsRoutes(dependencies); registerSourcesRoutes(dependencies); registerSettingsRoutes(dependencies); + registerSecurityRoutes(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts new file mode 100644 index 0000000000000..12f84278e9ead --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSecurityRoute, registerSecuritySourceRestrictionsRoute } from './security'; + +describe('security routes', () => { + describe('GET /api/workplace_search/org/security', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/security', + }); + + registerSecurityRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security', + }); + }); + }); + + describe('GET /api/workplace_search/org/security/source_restrictions', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/security/source_restrictions', + payload: 'body', + }); + + registerSecuritySourceRestrictionsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security/source_restrictions', + }); + }); + }); + + describe('PATCH /api/workplace_search/org/security/source_restrictions', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRouter = new MockRouter({ + method: 'patch', + path: '/api/workplace_search/org/security/source_restrictions', + payload: 'body', + }); + + registerSecuritySourceRestrictionsRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/security/source_restrictions', + }); + }); + + describe('validates', () => { + it('correctly', () => { + const request = { + body: { + isEnabled: true, + remote: { + isEnabled: true, + contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }], + }, + standard: { + isEnabled: false, + contentSources: [{ id: 'dropbox', name: 'Dropbox', isEnabled: false }], + }, + }, + }; + mockRouter.shouldValidate(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts new file mode 100644 index 0000000000000..0aa218dfc2883 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/security.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerSecurityRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/security', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security', + }) + ); +} + +export function registerSecuritySourceRestrictionsRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/security/source_restrictions', + validate: false, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security/source_restrictions', + }) + ); + + router.patch( + { + path: '/api/workplace_search/org/security/source_restrictions', + validate: { + body: schema.object({ + isEnabled: schema.boolean(), + remote: schema.object({ + isEnabled: schema.boolean(), + contentSources: schema.arrayOf( + schema.object({ + isEnabled: schema.boolean(), + id: schema.string(), + name: schema.string(), + }) + ), + }), + standard: schema.object({ + isEnabled: schema.boolean(), + contentSources: schema.arrayOf( + schema.object({ + isEnabled: schema.boolean(), + id: schema.string(), + name: schema.string(), + }) + ), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/security/source_restrictions', + }) + ); +} + +export const registerSecurityRoutes = (dependencies: RouteDependencies) => { + registerSecurityRoute(dependencies); + registerSecuritySourceRestrictionsRoute(dependencies); +}; diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json new file mode 100644 index 0000000000000..6b4c50770b49f --- /dev/null +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index 545b3b1517145..32f08e685c75d 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyClusterClient } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; import { ClusterClientAdapter, @@ -15,20 +15,21 @@ import { contextMock } from './context.mock'; import { findOptionsSchema } from '../event_log_client'; import { delay } from '../lib/delay'; import { times } from 'lodash'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { RequestEvent } from '@elastic/elasticsearch'; -type EsClusterClient = Pick<jest.Mocked<LegacyClusterClient>, 'callAsInternalUser' | 'asScoped'>; type MockedLogger = ReturnType<typeof loggingSystemMock['createLogger']>; let logger: MockedLogger; -let clusterClient: EsClusterClient; +let clusterClient: DeeplyMockedKeys<ElasticsearchClient>; let clusterClientAdapter: IClusterClientAdapter; beforeEach(() => { logger = loggingSystemMock.createLogger(); - clusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; clusterClientAdapter = new ClusterClientAdapter({ logger, - clusterClientPromise: Promise.resolve(clusterClient), + elasticsearchClientPromise: Promise.resolve(clusterClient), context: contextMock.create(), }); }); @@ -38,16 +39,16 @@ describe('indexDocument', () => { clusterClientAdapter.indexDocument({ body: { message: 'foo' }, index: 'event-log' }); await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length !== 0; + return clusterClient.bulk.mock.calls.length !== 0; }); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('bulk', { + expect(clusterClient.bulk).toHaveBeenCalledWith({ body: [{ create: { _index: 'event-log' } }, { message: 'foo' }], }); }); test('should log an error when cluster client throws an error', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('expected failure')); + clusterClient.bulk.mockRejectedValue(new Error('expected failure')); clusterClientAdapter.indexDocument({ body: { message: 'foo' }, index: 'event-log' }); await retryUntil('cluster client bulk called', () => { return logger.error.mock.calls.length !== 0; @@ -69,7 +70,7 @@ describe('shutdown()', () => { const resultPromise = clusterClientAdapter.shutdown(); await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length !== 0; + return clusterClient.bulk.mock.calls.length !== 0; }); const result = await resultPromise; @@ -85,7 +86,7 @@ describe('buffering documents', () => { } await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length !== 0; + return clusterClient.bulk.mock.calls.length !== 0; }); const expectedBody = []; @@ -93,7 +94,7 @@ describe('buffering documents', () => { expectedBody.push({ create: { _index: 'event-log' } }, { message: `foo ${i}` }); } - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('bulk', { + expect(clusterClient.bulk).toHaveBeenCalledWith({ body: expectedBody, }); }); @@ -105,7 +106,7 @@ describe('buffering documents', () => { } await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length >= 2; + return clusterClient.bulk.mock.calls.length >= 2; }); const expectedBody = []; @@ -113,18 +114,18 @@ describe('buffering documents', () => { expectedBody.push({ create: { _index: 'event-log' } }, { message: `foo ${i}` }); } - expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(1, 'bulk', { + expect(clusterClient.bulk).toHaveBeenNthCalledWith(1, { body: expectedBody, }); - expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(2, 'bulk', { + expect(clusterClient.bulk).toHaveBeenNthCalledWith(2, { body: [{ create: { _index: 'event-log' } }, { message: `foo 100` }], }); }); test('should handle lots of docs correctly with a delay in the bulk index', async () => { // @ts-ignore - clusterClient.callAsInternalUser.mockImplementation = async () => await delay(100); + clusterClient.bulk.mockImplementation = async () => await delay(100); const docs = times(EVENT_BUFFER_LENGTH * 10, (i) => ({ body: { message: `foo ${i}` }, @@ -137,7 +138,7 @@ describe('buffering documents', () => { } await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length >= 10; + return clusterClient.bulk.mock.calls.length >= 10; }); for (let i = 0; i < 10; i++) { @@ -149,7 +150,7 @@ describe('buffering documents', () => { ); } - expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(i + 1, 'bulk', { + expect(clusterClient.bulk).toHaveBeenNthCalledWith(i + 1, { body: expectedBody, }); } @@ -164,19 +165,19 @@ describe('doesIlmPolicyExist', () => { test('should call cluster with proper arguments', async () => { await clusterClientAdapter.doesIlmPolicyExist('foo'); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('transport.request', { + expect(clusterClient.transport.request).toHaveBeenCalledWith({ method: 'GET', path: '/_ilm/policy/foo', }); }); test('should return false when 404 error is returned by Elasticsearch', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(notFoundError); + clusterClient.transport.request.mockRejectedValue(notFoundError); await expect(clusterClientAdapter.doesIlmPolicyExist('foo')).resolves.toEqual(false); }); test('should throw error when error is not 404', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.transport.request.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.doesIlmPolicyExist('foo') ).rejects.toThrowErrorMatchingInlineSnapshot(`"error checking existance of ilm policy: Fail"`); @@ -189,9 +190,9 @@ describe('doesIlmPolicyExist', () => { describe('createIlmPolicy', () => { test('should call cluster client with given policy', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ success: true }); + clusterClient.transport.request.mockResolvedValue(asApiResponse({ success: true })); await clusterClientAdapter.createIlmPolicy('foo', { args: true }); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('transport.request', { + expect(clusterClient.transport.request).toHaveBeenCalledWith({ method: 'PUT', path: '/_ilm/policy/foo', body: { args: true }, @@ -199,7 +200,7 @@ describe('createIlmPolicy', () => { }); test('should throw error when call cluster client throws', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.transport.request.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.createIlmPolicy('foo', { args: true }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"error creating ilm policy: Fail"`); @@ -209,23 +210,23 @@ describe('createIlmPolicy', () => { describe('doesIndexTemplateExist', () => { test('should call cluster with proper arguments', async () => { await clusterClientAdapter.doesIndexTemplateExist('foo'); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsTemplate', { + expect(clusterClient.indices.existsTemplate).toHaveBeenCalledWith({ name: 'foo', }); }); test('should return true when call cluster returns true', async () => { - clusterClient.callAsInternalUser.mockResolvedValue(true); + clusterClient.indices.existsTemplate.mockResolvedValue(asApiResponse(true)); await expect(clusterClientAdapter.doesIndexTemplateExist('foo')).resolves.toEqual(true); }); test('should return false when call cluster returns false', async () => { - clusterClient.callAsInternalUser.mockResolvedValue(false); + clusterClient.indices.existsTemplate.mockResolvedValue(asApiResponse(false)); await expect(clusterClientAdapter.doesIndexTemplateExist('foo')).resolves.toEqual(false); }); test('should throw error when call cluster throws an error', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.indices.existsTemplate.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.doesIndexTemplateExist('foo') ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -237,7 +238,7 @@ describe('doesIndexTemplateExist', () => { describe('createIndexTemplate', () => { test('should call cluster with given template', async () => { await clusterClientAdapter.createIndexTemplate('foo', { args: true }); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + expect(clusterClient.indices.putTemplate).toHaveBeenCalledWith({ name: 'foo', create: true, body: { args: true }, @@ -245,16 +246,16 @@ describe('createIndexTemplate', () => { }); test(`should throw error if index template still doesn't exist after error is thrown`, async () => { - clusterClient.callAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - clusterClient.callAsInternalUser.mockResolvedValueOnce(false); + clusterClient.indices.putTemplate.mockRejectedValueOnce(new Error('Fail')); + clusterClient.indices.existsTemplate.mockResolvedValueOnce(asApiResponse(false)); await expect( clusterClientAdapter.createIndexTemplate('foo', { args: true }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"error creating index template: Fail"`); }); test('should not throw error if index template exists after error is thrown', async () => { - clusterClient.callAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - clusterClient.callAsInternalUser.mockResolvedValueOnce(true); + clusterClient.indices.putTemplate.mockRejectedValueOnce(new Error('Fail')); + clusterClient.indices.existsTemplate.mockResolvedValueOnce(asApiResponse(true)); await clusterClientAdapter.createIndexTemplate('foo', { args: true }); }); }); @@ -262,23 +263,23 @@ describe('createIndexTemplate', () => { describe('doesAliasExist', () => { test('should call cluster with proper arguments', async () => { await clusterClientAdapter.doesAliasExist('foo'); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsAlias', { + expect(clusterClient.indices.existsAlias).toHaveBeenCalledWith({ name: 'foo', }); }); test('should return true when call cluster returns true', async () => { - clusterClient.callAsInternalUser.mockResolvedValueOnce(true); + clusterClient.indices.existsAlias.mockResolvedValueOnce(asApiResponse(true)); await expect(clusterClientAdapter.doesAliasExist('foo')).resolves.toEqual(true); }); test('should return false when call cluster returns false', async () => { - clusterClient.callAsInternalUser.mockResolvedValueOnce(false); + clusterClient.indices.existsAlias.mockResolvedValueOnce(asApiResponse(false)); await expect(clusterClientAdapter.doesAliasExist('foo')).resolves.toEqual(false); }); test('should throw error when call cluster throws an error', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.indices.existsAlias.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.doesAliasExist('foo') ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -290,14 +291,14 @@ describe('doesAliasExist', () => { describe('createIndex', () => { test('should call cluster with proper arguments', async () => { await clusterClientAdapter.createIndex('foo'); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ index: 'foo', body: {}, }); }); test('should throw error when not getting an error of type resource_already_exists_exception', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.indices.create.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.createIndex('foo') ).rejects.toThrowErrorMatchingInlineSnapshot(`"error creating initial index: Fail"`); @@ -312,7 +313,7 @@ describe('createIndex', () => { type: 'resource_already_exists_exception', }, }; - clusterClient.callAsInternalUser.mockRejectedValue(err); + clusterClient.indices.create.mockRejectedValue(err); await clusterClientAdapter.createIndex('foo'); }); }); @@ -321,12 +322,14 @@ describe('queryEventsBySavedObject', () => { const DEFAULT_OPTIONS = findOptionsSchema.validate({}); test('should call cluster with proper arguments with non-default namespace', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); await clusterClientAdapter.queryEventsBySavedObjects( 'index-name', 'namespace', @@ -335,14 +338,14 @@ describe('queryEventsBySavedObject', () => { DEFAULT_OPTIONS ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchInlineSnapshot(` Object { "body": Object { "from": 0, "query": Object { "bool": Object { + "filter": Array [], "must": Array [ Object { "nested": Object { @@ -400,12 +403,14 @@ describe('queryEventsBySavedObject', () => { }); test('should call cluster with proper arguments with default namespace', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); await clusterClientAdapter.queryEventsBySavedObjects( 'index-name', undefined, @@ -414,14 +419,14 @@ describe('queryEventsBySavedObject', () => { DEFAULT_OPTIONS ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchInlineSnapshot(` Object { "body": Object { "from": 0, "query": Object { "bool": Object { + "filter": Array [], "must": Array [ Object { "nested": Object { @@ -481,12 +486,14 @@ describe('queryEventsBySavedObject', () => { }); test('should call cluster with sort', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); await clusterClientAdapter.queryEventsBySavedObjects( 'index-name', 'namespace', @@ -495,8 +502,7 @@ describe('queryEventsBySavedObject', () => { { ...DEFAULT_OPTIONS, sort_field: 'event.end', sort_order: 'desc' } ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchObject({ index: 'index-name', body: { @@ -506,12 +512,14 @@ describe('queryEventsBySavedObject', () => { }); test('supports open ended date', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); const start = '2020-07-08T00:52:28.350Z'; @@ -523,14 +531,14 @@ describe('queryEventsBySavedObject', () => { { ...DEFAULT_OPTIONS, start } ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchInlineSnapshot(` Object { "body": Object { "from": 0, "query": Object { "bool": Object { + "filter": Array [], "must": Array [ Object { "nested": Object { @@ -595,12 +603,14 @@ describe('queryEventsBySavedObject', () => { }); test('supports optional date range', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); const start = '2020-07-08T00:52:28.350Z'; const end = '2020-07-08T00:00:00.000Z'; @@ -613,14 +623,14 @@ describe('queryEventsBySavedObject', () => { { ...DEFAULT_OPTIONS, start, end } ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchInlineSnapshot(` Object { "body": Object { "from": 0, "query": Object { "bool": Object { + "filter": Array [], "must": Array [ Object { "nested": Object { @@ -697,6 +707,12 @@ type RetryableFunction = () => boolean; const RETRY_UNTIL_DEFAULT_COUNT = 20; const RETRY_UNTIL_DEFAULT_WAIT = 1000; // milliseconds +function asApiResponse<T>(body: T): RequestEvent<T> { + return { + body, + } as RequestEvent<T>; +} + async function retryUntil( label: string, fn: RetryableFunction, diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index 5d4c33f319fcc..4488dc74556ca 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -5,20 +5,18 @@ */ import { Subject } from 'rxjs'; -import { bufferTime, filter, switchMap } from 'rxjs/operators'; +import { bufferTime, filter as rxFilter, switchMap } from 'rxjs/operators'; import { reject, isUndefined } from 'lodash'; -import { Client } from 'elasticsearch'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { Logger, LegacyClusterClient } from 'src/core/server'; -import { ESSearchResponse } from '../../../../typings/elasticsearch'; +import { Logger, ElasticsearchClient } from 'src/core/server'; import { EsContext } from '.'; import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; import { FindOptionsType } from '../event_log_client'; +import { esKuery } from '../../../../../src/plugins/data/server'; export const EVENT_BUFFER_TIME = 1000; // milliseconds export const EVENT_BUFFER_LENGTH = 100; -export type EsClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; export type IClusterClientAdapter = PublicMethodsOf<ClusterClientAdapter>; export interface Doc { @@ -28,7 +26,7 @@ export interface Doc { export interface ConstructorOpts { logger: Logger; - clusterClientPromise: Promise<EsClusterClient>; + elasticsearchClientPromise: Promise<ElasticsearchClient>; context: EsContext; } @@ -41,14 +39,14 @@ export interface QueryEventsBySavedObjectResult { export class ClusterClientAdapter { private readonly logger: Logger; - private readonly clusterClientPromise: Promise<EsClusterClient>; + private readonly elasticsearchClientPromise: Promise<ElasticsearchClient>; private readonly docBuffer$: Subject<Doc>; private readonly context: EsContext; private readonly docsBufferedFlushed: Promise<void>; constructor(opts: ConstructorOpts) { this.logger = opts.logger; - this.clusterClientPromise = opts.clusterClientPromise; + this.elasticsearchClientPromise = opts.elasticsearchClientPromise; this.context = opts.context; this.docBuffer$ = new Subject<Doc>(); @@ -58,7 +56,7 @@ export class ClusterClientAdapter { this.docsBufferedFlushed = this.docBuffer$ .pipe( bufferTime(EVENT_BUFFER_TIME, null, EVENT_BUFFER_LENGTH), - filter((docs) => docs.length > 0), + rxFilter((docs) => docs.length > 0), switchMap(async (docs) => await this.indexDocuments(docs)) ) .toPromise(); @@ -97,7 +95,8 @@ export class ClusterClientAdapter { } try { - await this.callEs<ReturnType<Client['bulk']>>('bulk', { body: bulkBody }); + const esClient = await this.elasticsearchClientPromise; + await esClient.bulk({ body: bulkBody }); } catch (err) { this.logger.error( `error writing bulk events: "${err.message}"; docs: ${JSON.stringify(bulkBody)}` @@ -111,7 +110,8 @@ export class ClusterClientAdapter { path: `/_ilm/policy/${policyName}`, }; try { - await this.callEs('transport.request', request); + const esClient = await this.elasticsearchClientPromise; + await esClient.transport.request(request); } catch (err) { if (err.statusCode === 404) return false; throw new Error(`error checking existance of ilm policy: ${err.message}`); @@ -119,14 +119,15 @@ export class ClusterClientAdapter { return true; } - public async createIlmPolicy(policyName: string, policy: unknown): Promise<void> { + public async createIlmPolicy(policyName: string, policy: Record<string, unknown>): Promise<void> { const request = { method: 'PUT', path: `/_ilm/policy/${policyName}`, body: policy, }; try { - await this.callEs('transport.request', request); + const esClient = await this.elasticsearchClientPromise; + await esClient.transport.request(request); } catch (err) { throw new Error(`error creating ilm policy: ${err.message}`); } @@ -135,27 +136,18 @@ export class ClusterClientAdapter { public async doesIndexTemplateExist(name: string): Promise<boolean> { let result; try { - result = await this.callEs<ReturnType<Client['indices']['existsTemplate']>>( - 'indices.existsTemplate', - { name } - ); + const esClient = await this.elasticsearchClientPromise; + result = (await esClient.indices.existsTemplate({ name })).body; } catch (err) { throw new Error(`error checking existance of index template: ${err.message}`); } return result as boolean; } - public async createIndexTemplate(name: string, template: unknown): Promise<void> { - const addTemplateParams = { - name, - create: true, - body: template, - }; + public async createIndexTemplate(name: string, template: Record<string, unknown>): Promise<void> { try { - await this.callEs<ReturnType<Client['indices']['putTemplate']>>( - 'indices.putTemplate', - addTemplateParams - ); + const esClient = await this.elasticsearchClientPromise; + await esClient.indices.putTemplate({ name, body: template, create: true }); } catch (err) { // The error message doesn't have a type attribute we can look to guarantee it's due // to the template already existing (only long message) so we'll check ourselves to see @@ -171,19 +163,21 @@ export class ClusterClientAdapter { public async doesAliasExist(name: string): Promise<boolean> { let result; try { - result = await this.callEs<ReturnType<Client['indices']['existsAlias']>>( - 'indices.existsAlias', - { name } - ); + const esClient = await this.elasticsearchClientPromise; + result = (await esClient.indices.existsAlias({ name })).body; } catch (err) { throw new Error(`error checking existance of initial index: ${err.message}`); } return result as boolean; } - public async createIndex(name: string, body: unknown = {}): Promise<void> { + public async createIndex( + name: string, + body: string | Record<string, unknown> = {} + ): Promise<void> { try { - await this.callEs<ReturnType<Client['indices']['create']>>('indices.create', { + const esClient = await this.elasticsearchClientPromise; + await esClient.indices.create({ index: name, body, }); @@ -200,7 +194,7 @@ export class ClusterClientAdapter { type: string, ids: string[], // eslint-disable-next-line @typescript-eslint/naming-convention - { page, per_page: perPage, start, end, sort_field, sort_order }: FindOptionsType + { page, per_page: perPage, start, end, sort_field, sort_order, filter }: FindOptionsType ): Promise<QueryEventsBySavedObjectResult> { const defaultNamespaceQuery = { bool: { @@ -220,12 +214,26 @@ export class ClusterClientAdapter { }; const namespaceQuery = namespace === undefined ? defaultNamespaceQuery : namedNamespaceQuery; + const esClient = await this.elasticsearchClientPromise; + let dslFilterQuery; + try { + dslFilterQuery = filter + ? esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(filter)) + : []; + } catch (err) { + this.debug(`Invalid kuery syntax for the filter (${filter}) error:`, { + message: err.message, + statusCode: err.statusCode, + }); + throw err; + } const body = { size: perPage, from: (page - 1) * perPage, sort: { [sort_field]: { order: sort_order } }, query: { bool: { + filter: dslFilterQuery, must: reject( [ { @@ -283,8 +291,10 @@ export class ClusterClientAdapter { try { const { - hits: { hits, total }, - }: ESSearchResponse<unknown, {}> = await this.callEs('search', { + body: { + hits: { hits, total }, + }, + } = await esClient.search({ index, track_total_hits: true, body, @@ -293,7 +303,7 @@ export class ClusterClientAdapter { page, per_page: perPage, total: total.value, - data: hits.map((hit) => hit._source) as IValidatedEvent[], + data: hits.map((hit: { _source: unknown }) => hit._source) as IValidatedEvent[], }; } catch (err) { throw new Error( @@ -302,24 +312,6 @@ export class ClusterClientAdapter { } } - // We have a common problem typing ES-DSL Queries - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async callEs<ESQueryResult = unknown>(operation: string, body?: any) { - try { - this.debug(`callEs(${operation}) calls:`, body); - const clusterClient = await this.clusterClientPromise; - const result = await clusterClient.callAsInternalUser(operation, body); - this.debug(`callEs(${operation}) result:`, result); - return result as ESQueryResult; - } catch (err) { - this.debug(`callEs(${operation}) error:`, { - message: err.message, - statusCode: err.statusCode, - }); - throw err; - } - } - private debug(message: string, object?: unknown) { const objectString = object == null ? '' : JSON.stringify(object); this.logger.debug(`esContext: ${message} ${objectString}`); diff --git a/x-pack/plugins/event_log/server/es/context.test.ts b/x-pack/plugins/event_log/server/es/context.test.ts index 5f26399618e38..fc137b4e45b13 100644 --- a/x-pack/plugins/event_log/server/es/context.test.ts +++ b/x-pack/plugins/event_log/server/es/context.test.ts @@ -5,27 +5,28 @@ */ import { createEsContext } from './context'; -import { LegacyClusterClient, Logger } from '../../../../../src/core/server'; +import { ElasticsearchClient, Logger } from '../../../../../src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { RequestEvent } from '@elastic/elasticsearch'; jest.mock('../lib/../../../../package.json', () => ({ version: '1.2.3' })); jest.mock('./init'); -type EsClusterClient = Pick<jest.Mocked<LegacyClusterClient>, 'callAsInternalUser' | 'asScoped'>; let logger: Logger; -let clusterClient: EsClusterClient; +let elasticsearchClient: DeeplyMockedKeys<ElasticsearchClient>; beforeEach(() => { logger = loggingSystemMock.createLogger(); - clusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; }); describe('createEsContext', () => { test('should return is ready state as falsy if not initialized', () => { const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test0', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); expect(context.initialized).toBeFalsy(); @@ -37,9 +38,9 @@ describe('createEsContext', () => { test('should return esNames', () => { const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test-index', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); const esNames = context.esNames; @@ -57,12 +58,12 @@ describe('createEsContext', () => { test('should return exist false for esAdapter ilm policy, index template and alias before initialize', async () => { const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test1', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); - clusterClient.callAsInternalUser.mockResolvedValue(false); - + elasticsearchClient.indices.existsTemplate.mockResolvedValue(asApiResponse(false)); + elasticsearchClient.indices.existsAlias.mockResolvedValue(asApiResponse(false)); const doesAliasExist = await context.esAdapter.doesAliasExist(context.esNames.alias); expect(doesAliasExist).toBeFalsy(); @@ -75,11 +76,11 @@ describe('createEsContext', () => { test('should return exist true for esAdapter ilm policy, index template and alias after initialize', async () => { const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test2', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); - clusterClient.callAsInternalUser.mockResolvedValue(true); + elasticsearchClient.indices.existsTemplate.mockResolvedValue(asApiResponse(true)); context.initialize(); const doesIlmPolicyExist = await context.esAdapter.doesIlmPolicyExist( @@ -100,12 +101,18 @@ describe('createEsContext', () => { jest.requireMock('./init').initializeEs.mockResolvedValue(false); const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test2', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); context.initialize(); const success = await context.waitTillReady(); expect(success).toBe(false); }); }); + +function asApiResponse<T>(body: T): RequestEvent<T> { + return { + body, + } as RequestEvent<T>; +} diff --git a/x-pack/plugins/event_log/server/es/context.ts b/x-pack/plugins/event_log/server/es/context.ts index c1777d6979c5c..26f249d3b2c06 100644 --- a/x-pack/plugins/event_log/server/es/context.ts +++ b/x-pack/plugins/event_log/server/es/context.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, LegacyClusterClient } from 'src/core/server'; +import { Logger, ElasticsearchClient } from 'src/core/server'; import { EsNames, getEsNames } from './names'; import { initializeEs } from './init'; import { ClusterClientAdapter, IClusterClientAdapter } from './cluster_client_adapter'; import { createReadySignal, ReadySignal } from '../lib/ready_signal'; -export type EsClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; - export interface EsContext { logger: Logger; esNames: EsNames; @@ -34,9 +32,9 @@ export function createEsContext(params: EsContextCtorParams): EsContext { export interface EsContextCtorParams { logger: Logger; - clusterClientPromise: Promise<EsClusterClient>; indexNameRoot: string; kibanaVersion: string; + elasticsearchClientPromise: Promise<ElasticsearchClient>; } class EsContextImpl implements EsContext { @@ -53,7 +51,7 @@ class EsContextImpl implements EsContext { this.initialized = false; this.esAdapter = new ClusterClientAdapter({ logger: params.logger, - clusterClientPromise: params.clusterClientPromise, + elasticsearchClientPromise: params.elasticsearchClientPromise, context: this, }); } diff --git a/x-pack/plugins/event_log/server/es/index.ts b/x-pack/plugins/event_log/server/es/index.ts index ad1409e33589f..adc7ed011aa14 100644 --- a/x-pack/plugins/event_log/server/es/index.ts +++ b/x-pack/plugins/event_log/server/es/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EsClusterClient, EsContext, createEsContext } from './context'; +export { EsContext, createEsContext } from './context'; diff --git a/x-pack/plugins/event_log/server/event_log_client.ts b/x-pack/plugins/event_log/server/event_log_client.ts index 63453c6327da2..091f997fe62ea 100644 --- a/x-pack/plugins/event_log/server/event_log_client.ts +++ b/x-pack/plugins/event_log/server/event_log_client.ts @@ -6,14 +6,14 @@ import { Observable } from 'rxjs'; import { schema, TypeOf } from '@kbn/config-schema'; -import { LegacyClusterClient, KibanaRequest } from 'src/core/server'; +import { IClusterClient, KibanaRequest } from 'src/core/server'; import { SpacesServiceStart } from '../../spaces/server'; import { EsContext } from './es'; import { IEventLogClient } from './types'; import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; import { SavedObjectBulkGetterResult } from './saved_object_provider_registry'; -export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; +export type PluginClusterClient = Pick<IClusterClient, 'asInternalUser'>; export type AdminClusterClient$ = Observable<PluginClusterClient>; const optionalDateFieldSchema = schema.maybe( @@ -48,12 +48,13 @@ export const findOptionsSchema = schema.object({ sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')], { defaultValue: 'asc', }), + filter: schema.maybe(schema.string()), }); // page & perPage are required, other fields are optional // using schema.maybe allows us to set undefined, but not to make the field optional export type FindOptionsType = Pick< TypeOf<typeof findOptionsSchema>, - 'page' | 'per_page' | 'sort_field' | 'sort_order' + 'page' | 'per_page' | 'sort_field' | 'sort_order' | 'filter' > & Partial<TypeOf<typeof findOptionsSchema>>; diff --git a/x-pack/plugins/event_log/server/event_log_service.ts b/x-pack/plugins/event_log/server/event_log_service.ts index 9249288d33939..0bc675fee928d 100644 --- a/x-pack/plugins/event_log/server/event_log_service.ts +++ b/x-pack/plugins/event_log/server/event_log_service.ts @@ -5,14 +5,14 @@ */ import { Observable } from 'rxjs'; -import { LegacyClusterClient } from 'src/core/server'; +import { IClusterClient } from 'src/core/server'; import { Plugin } from './plugin'; import { EsContext } from './es'; import { IEvent, IEventLogger, IEventLogService, IEventLogConfig } from './types'; import { EventLogger } from './event_logger'; import { SavedObjectProvider, SavedObjectProviderRegistry } from './saved_object_provider_registry'; -export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; +export type PluginClusterClient = Pick<IClusterClient, 'asInternalUser'>; export type AdminClusterClient$ = Observable<PluginClusterClient>; type SystemLogger = Plugin['systemLogger']; diff --git a/x-pack/plugins/event_log/server/event_log_start_service.ts b/x-pack/plugins/event_log/server/event_log_start_service.ts index 51dd7d6e95d15..82b8f06c251a3 100644 --- a/x-pack/plugins/event_log/server/event_log_start_service.ts +++ b/x-pack/plugins/event_log/server/event_log_start_service.ts @@ -5,14 +5,14 @@ */ import { Observable } from 'rxjs'; -import { LegacyClusterClient, KibanaRequest } from 'src/core/server'; +import { IClusterClient, KibanaRequest } from 'src/core/server'; import { SpacesServiceStart } from '../../spaces/server'; import { EsContext } from './es'; import { IEventLogClientService } from './types'; import { EventLogClient } from './event_log_client'; import { SavedObjectProviderRegistry } from './saved_object_provider_registry'; -export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; +export type PluginClusterClient = Pick<IClusterClient, 'asInternalUser'>; export type AdminClusterClient$ = Observable<PluginClusterClient>; interface EventLogServiceCtorParams { diff --git a/x-pack/plugins/event_log/server/plugin.ts b/x-pack/plugins/event_log/server/plugin.ts index 3bf726de71856..e2e31864eb31f 100644 --- a/x-pack/plugins/event_log/server/plugin.ts +++ b/x-pack/plugins/event_log/server/plugin.ts @@ -12,7 +12,7 @@ import { Logger, Plugin as CorePlugin, PluginInitializerContext, - LegacyClusterClient, + IClusterClient, SharedGlobalConfig, IContextProvider, } from 'src/core/server'; @@ -33,7 +33,7 @@ import { EventLogClientService } from './event_log_start_service'; import { SavedObjectProviderRegistry } from './saved_object_provider_registry'; import { findByIdsRoute } from './routes/find_by_ids'; -export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; +export type PluginClusterClient = Pick<IClusterClient, 'asInternalUser'>; const PROVIDER = 'eventLog'; @@ -77,9 +77,9 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi logger: this.systemLogger, // TODO: get index prefix from config.get(kibana.index) indexNameRoot: kibanaIndex, - clusterClientPromise: core + elasticsearchClientPromise: core .getStartServices() - .then(([{ elasticsearch }]) => elasticsearch.legacy.client), + .then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser), kibanaVersion: this.kibanaVersion, }); diff --git a/x-pack/plugins/fleet/common/constants/agent.ts b/x-pack/plugins/fleet/common/constants/agent.ts index 8bfb32b5ed2b0..2e9161ca1c534 100644 --- a/x-pack/plugins/fleet/common/constants/agent.ts +++ b/x-pack/plugins/fleet/common/constants/agent.ts @@ -24,3 +24,4 @@ export const AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS = 1000; export const AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL = 5; export const AGENTS_INDEX = '.fleet-agents'; +export const AGENT_ACTIONS_INDEX = '.fleet-actions'; diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index 5ba4de914c724..ece669293fdff 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -8,6 +8,8 @@ export const ASSETS_SAVED_OBJECT_TYPE = 'epm-packages-assets'; export const INDEX_PATTERN_SAVED_OBJECT_TYPE = 'index-pattern'; export const MAX_TIME_COMPLETE_INSTALL = 60000; +export const FLEET_SERVER_PACKAGE = 'fleet_server'; + export const requiredPackages = { System: 'system', Endpoint: 'endpoint', diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index bdc5714f7e2fe..409375f81d6fe 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -19,3 +19,12 @@ export * from './settings'; // for the actual setting to differ from the default. Can we retrieve the real // setting in the future? export const SO_SEARCH_LIMIT = 10000; + +export const FLEET_SERVER_INDICES = [ + '.fleet-actions', + '.fleet-agents', + '.fleet-enrollment-api-keys', + '.fleet-policies', + '.fleet-policies-leader', + '.fleet-servers', +]; diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index e09fbfc80b196..d0df9b73dd88a 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -326,7 +326,6 @@ export interface IndexTemplate { template: { settings: any; mappings: any; - aliases: object; }; data_stream: { hidden?: boolean }; composed_of: string[]; diff --git a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts index 8925f3386dfb8..fcead1bc89749 100644 --- a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts +++ b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts @@ -6,6 +6,7 @@ import { ElasticsearchClient, SavedObjectsClient } from 'kibana/server'; import * as AgentService from '../services/agents'; +import { isFleetServerSetup } from '../services/fleet_server_migration'; export interface AgentUsage { total: number; online: number; @@ -18,7 +19,7 @@ export const getAgentUsage = async ( esClient?: ElasticsearchClient ): Promise<AgentUsage> => { // TODO: unsure if this case is possible at all. - if (!soClient || !esClient) { + if (!soClient || !esClient || !(await isFleetServerSetup())) { return { total: 0, online: 0, @@ -26,6 +27,7 @@ export const getAgentUsage = async ( offline: 0, }; } + const { total, online, error, offline } = await AgentService.getAgentStatusForAgentPolicy( soClient, esClient diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 253b614dc228a..a0eb1547a3d63 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -81,7 +81,7 @@ import { agentCheckinState } from './services/agents/checkin/state'; import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation } from './services/epm/packages'; import { makeRouterEnforcingSuperuser } from './routes/security'; -import { runFleetServerMigration } from './services/fleet_server_migration'; +import { isFleetServerSetup } from './services/fleet_server_migration'; export interface FleetSetupDeps { licensing: LicensingPluginSetup; @@ -299,7 +299,14 @@ export class FleetPlugin if (fleetServerEnabled) { // We need licence to be initialized before using the SO service. await this.licensing$.pipe(first()).toPromise(); - await runFleetServerMigration(); + + const fleetSetup = await isFleetServerSetup(); + + if (!fleetSetup) { + this.logger?.warn( + 'Extra setup is needed to be able to use central management for agent, please visit the Fleet app in Kibana.' + ); + } } return { diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index dcc686e565b8e..7b4149819dc78 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -6,7 +6,10 @@ import { SavedObjectsServiceSetup, SavedObjectsType } from 'kibana/server'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; -import { migratePackagePolicyToV7110 } from '../../../security_solution/common'; +import { + migratePackagePolicyToV7110, + migratePackagePolicyToV7120, +} from '../../../security_solution/common'; import { OUTPUT_SAVED_OBJECT_TYPE, AGENT_POLICY_SAVED_OBJECT_TYPE, @@ -273,6 +276,7 @@ const getSavedObjectTypes = ( migrations: { '7.10.0': migratePackagePolicyToV7100, '7.11.0': migratePackagePolicyToV7110, + '7.12.0': migratePackagePolicyToV7120, }, }, [PACKAGES_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index 0333fb024a717..2d2478843c454 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -95,8 +95,7 @@ exports[`tests loading base.yml: base.yml 1`] = ` "managed_by": "ingest-manager", "managed": true } - }, - "aliases": {} + } }, "data_stream": {}, "composed_of": [], @@ -205,8 +204,7 @@ exports[`tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` "managed_by": "ingest-manager", "managed": true } - }, - "aliases": {} + } }, "data_stream": {}, "composed_of": [], @@ -1699,8 +1697,7 @@ exports[`tests loading system.yml: system.yml 1`] = ` "managed_by": "ingest-manager", "managed": true } - }, - "aliases": {} + } }, "data_stream": {}, "composed_of": [], diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index fd75139d4cd45..95f9997645176 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -11,7 +11,6 @@ import { TemplateRef, IndexTemplate, IndexTemplateMappings, - DataType, } from '../../../../types'; import { getRegistryDataStreamAssetBaseName } from '../index'; @@ -26,8 +25,8 @@ interface MultiFields { export interface IndexTemplateMapping { [key: string]: any; } -export interface CurrentIndex { - indexName: string; +export interface CurrentDataStream { + dataStreamName: string; indexTemplate: IndexTemplate; } const DEFAULT_SCALING_FACTOR = 1000; @@ -335,8 +334,6 @@ function getBaseTemplate( properties: mappings.properties, _meta, }, - // To be filled with the aliases that we need - aliases: {}, }, data_stream: { hidden }, composed_of: composedOfTemplates, @@ -350,33 +347,31 @@ export const updateCurrentWriteIndices = async ( ): Promise<void> => { if (!templates.length) return; - const allIndices = await queryIndicesFromTemplates(callCluster, templates); + const allIndices = await queryDataStreamsFromTemplates(callCluster, templates); if (!allIndices.length) return; - return updateAllIndices(allIndices, callCluster); + return updateAllDataStreams(allIndices, callCluster); }; -function isCurrentIndex(item: CurrentIndex[] | undefined): item is CurrentIndex[] { +function isCurrentDataStream(item: CurrentDataStream[] | undefined): item is CurrentDataStream[] { return item !== undefined; } -const queryIndicesFromTemplates = async ( +const queryDataStreamsFromTemplates = async ( callCluster: CallESAsCurrentUser, templates: TemplateRef[] -): Promise<CurrentIndex[]> => { - const indexPromises = templates.map((template) => { - return getIndices(callCluster, template); +): Promise<CurrentDataStream[]> => { + const dataStreamPromises = templates.map((template) => { + return getDataStreams(callCluster, template); }); - const indexObjects = await Promise.all(indexPromises); - return indexObjects.filter(isCurrentIndex).flat(); + const dataStreamObjects = await Promise.all(dataStreamPromises); + return dataStreamObjects.filter(isCurrentDataStream).flat(); }; -const getIndices = async ( +const getDataStreams = async ( callCluster: CallESAsCurrentUser, template: TemplateRef -): Promise<CurrentIndex[] | undefined> => { +): Promise<CurrentDataStream[] | undefined> => { const { templateName, indexTemplate } = template; - // Until ES provides a way to update mappings of a data stream - // get the last index of the data stream, which is the current write index const res = await callCluster('transport.request', { method: 'GET', path: `/_data_stream/${templateName}-*`, @@ -384,26 +379,28 @@ const getIndices = async ( const dataStreams = res.data_streams; if (!dataStreams.length) return; return dataStreams.map((dataStream: any) => ({ - indexName: dataStream.indices[dataStream.indices.length - 1].index_name, + dataStreamName: dataStream.name, indexTemplate, })); }; -const updateAllIndices = async ( - indexNameWithTemplates: CurrentIndex[], +const updateAllDataStreams = async ( + indexNameWithTemplates: CurrentDataStream[], callCluster: CallESAsCurrentUser ): Promise<void> => { - const updateIndexPromises = indexNameWithTemplates.map(({ indexName, indexTemplate }) => { - return updateExistingIndex({ indexName, callCluster, indexTemplate }); - }); - await Promise.all(updateIndexPromises); + const updatedataStreamPromises = indexNameWithTemplates.map( + ({ dataStreamName, indexTemplate }) => { + return updateExistingDataStream({ dataStreamName, callCluster, indexTemplate }); + } + ); + await Promise.all(updatedataStreamPromises); }; -const updateExistingIndex = async ({ - indexName, +const updateExistingDataStream = async ({ + dataStreamName, callCluster, indexTemplate, }: { - indexName: string; + dataStreamName: string; callCluster: CallESAsCurrentUser; indexTemplate: IndexTemplate; }) => { @@ -418,53 +415,13 @@ const updateExistingIndex = async ({ // try to update the mappings first try { await callCluster('indices.putMapping', { - index: indexName, + index: dataStreamName, body: mappings, + write_index_only: true, }); // if update fails, rollover data stream } catch (err) { try { - // get the data_stream values to compose datastream name - const searchDataStreamFieldsResponse = await callCluster('search', { - index: indexTemplate.index_patterns[0], - body: { - size: 1, - _source: ['data_stream.namespace', 'data_stream.type', 'data_stream.dataset'], - query: { - bool: { - filter: [ - { - exists: { - field: 'data_stream.type', - }, - }, - { - exists: { - field: 'data_stream.dataset', - }, - }, - { - exists: { - field: 'data_stream.namespace', - }, - }, - ], - }, - }, - }, - }); - if (searchDataStreamFieldsResponse.hits.total.value === 0) - throw new Error('data_stream fields are missing from datastream indices'); - const { - dataset, - namespace, - type, - }: { - dataset: string; - namespace: string; - type: DataType; - } = searchDataStreamFieldsResponse.hits.hits[0]._source.data_stream; - const dataStreamName = `${type}-${dataset}-${namespace}`; const path = `/${dataStreamName}/_rollover`; await callCluster('transport.request', { method: 'POST', @@ -480,10 +437,10 @@ const updateExistingIndex = async ({ if (!settings.index.default_pipeline) return; try { await callCluster('indices.putSettings', { - index: indexName, + index: dataStreamName, body: { index: { default_pipeline: settings.index.default_pipeline } }, }); } catch (err) { - throw new Error(`could not update index template settings for ${indexName}`); + throw new Error(`could not update index template settings for ${dataStreamName}`); } }; diff --git a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts index efc25cc2efb5d..4f17a2b88670a 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts @@ -11,12 +11,10 @@ import { appContextService, licenseService } from '../../'; const PRODUCTION_REGISTRY_URL_CDN = 'https://epr.elastic.co'; // const STAGING_REGISTRY_URL_CDN = 'https://epr-staging.elastic.co'; -// const EXPERIMENTAL_REGISTRY_URL_CDN = 'https://epr-experimental.elastic.co/'; const SNAPSHOT_REGISTRY_URL_CDN = 'https://epr-snapshot.elastic.co'; // const PRODUCTION_REGISTRY_URL_NO_CDN = 'https://epr.ea-web.elastic.dev'; // const STAGING_REGISTRY_URL_NO_CDN = 'https://epr-staging.ea-web.elastic.dev'; -// const EXPERIMENTAL_REGISTRY_URL_NO_CDN = 'https://epr-experimental.ea-web.elastic.dev/'; // const SNAPSHOT_REGISTRY_URL_NO_CDN = 'https://epr-snapshot.ea-web.elastic.dev'; const getDefaultRegistryUrl = (): string => { diff --git a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts b/x-pack/plugins/fleet/server/services/fleet_server_migration.ts index 1a50b5c9df767..44065a9346c5d 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server_migration.ts @@ -9,15 +9,39 @@ import { ENROLLMENT_API_KEYS_INDEX, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, FleetServerEnrollmentAPIKey, + FLEET_SERVER_PACKAGE, + FLEET_SERVER_INDICES, } from '../../common'; import { listEnrollmentApiKeys, getEnrollmentAPIKey } from './api_keys/enrollment_api_key_so'; import { appContextService } from './app_context'; +import { getInstallation } from './epm/packages'; + +export async function isFleetServerSetup() { + const pkgInstall = await getInstallation({ + savedObjectsClient: getInternalUserSOClient(), + pkgName: FLEET_SERVER_PACKAGE, + }); + + if (!pkgInstall) { + return false; + } + + const esClient = appContextService.getInternalUserESClient(); + + const exists = await Promise.all( + FLEET_SERVER_INDICES.map(async (index) => { + const res = await esClient.indices.exists({ + index, + }); + return res.statusCode !== 404; + }) + ); + + return exists.every((exist) => exist === true); +} export async function runFleetServerMigration() { - const logger = appContextService.getLogger(); - logger.info('Starting fleet server migration'); await migrateEnrollmentApiKeys(); - logger.info('Fleet server migration finished'); } function getInternalUserSOClient() { diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 0dcdfeb7b3801..ff96e2724c892 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -11,6 +11,7 @@ import { agentPolicyService } from './agent_policy'; import { outputService } from './output'; import { ensureInstalledDefaultPackages, + ensureInstalledPackage, ensurePackagesCompletedInstall, } from './epm/packages/install'; import { @@ -20,6 +21,8 @@ import { Installation, Output, DEFAULT_AGENT_POLICIES_PACKAGES, + FLEET_SERVER_PACKAGE, + FLEET_SERVER_INDICES, } from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; import { getPackageInfo } from './epm/packages'; @@ -29,6 +32,8 @@ import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { createDefaultSettings } from './settings'; import { ensureAgentActionPolicyChangeExists } from './agents'; +import { appContextService } from './app_context'; +import { runFleetServerMigration } from './fleet_server_migration'; const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; @@ -77,6 +82,15 @@ async function createSetupSideEffects( // By moving this outside of the Promise.all, the upgrade will occur first, and then we'll attempt to reinstall any // packages that are stuck in the installing state. await ensurePackagesCompletedInstall(soClient, callCluster); + if (appContextService.getConfig()?.agents.fleetServerEnabled) { + await ensureInstalledPackage({ + savedObjectsClient: soClient, + pkgName: FLEET_SERVER_PACKAGE, + callCluster, + }); + await ensureFleetServerIndicesCreated(esClient); + await runFleetServerMigration(); + } // If we just created the default policy, ensure default packages are added to it if (defaultAgentPolicyCreated) { @@ -144,6 +158,21 @@ async function updateFleetRoleIfExists(callCluster: CallESAsCurrentUser) { return putFleetRole(callCluster); } +async function ensureFleetServerIndicesCreated(esClient: ElasticsearchClient) { + await Promise.all( + FLEET_SERVER_INDICES.map(async (index) => { + const res = await esClient.indices.exists({ + index, + }); + if (res.statusCode === 404) { + await esClient.indices.create({ + index, + }); + } + }) + ); +} + async function putFleetRole(callCluster: CallESAsCurrentUser) { return callCluster('transport.request', { method: 'PUT', diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 64b654b030236..d9256ec916ec8 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -251,6 +251,7 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte setWaitForSnapshotPolicy, savePolicy, timeline: { + hasRolloverIndicator: () => exists('timelineHotPhaseRolloverToolTip'), hasHotPhase: () => exists('ilmTimelineHotPhase'), hasWarmPhase: () => exists('ilmTimelineWarmPhase'), hasColdPhase: () => exists('ilmTimelineColdPhase'), diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index bb96e8b4df239..05793a4bed581 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -843,5 +843,13 @@ describe('<EditPolicy />', () => { expect(actions.timeline.hasColdPhase()).toBe(true); expect(actions.timeline.hasDeletePhase()).toBe(true); }); + + test('show and hide rollover indicator on timeline', async () => { + const { actions } = testBed; + expect(actions.timeline.hasRolloverIndicator()).toBe(true); + await actions.hot.toggleDefaultRollover(false); + await actions.hot.toggleRollover(false); + expect(actions.timeline.hasRolloverIndicator()).toBe(false); + }); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index fb7c9a80acba0..02de47f8c56ef 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -16,6 +16,7 @@ import { EuiTextColor, EuiSwitch, EuiIconTip, + EuiIcon, } from '@elastic/eui'; import { useFormData, UseField, SelectField, NumericField } from '../../../../../../shared_imports'; @@ -80,6 +81,10 @@ export const HotPhase: FunctionComponent = () => { </p> </EuiTextColor> <EuiSpacer /> + <EuiIcon type="iInCircle" /> +   + {i18nTexts.editPolicy.rolloverOffsetsHotPhaseTiming} + <EuiSpacer /> <UseField<boolean> path={isUsingDefaultRolloverPath}> {(field) => ( <> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/index.ts new file mode 100644 index 0000000000000..1c9d5e1abc316 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TimelinePhaseText } from './timeline_phase_text'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/timeline_phase_text.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/timeline_phase_text.tsx new file mode 100644 index 0000000000000..a44e0f2407c52 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/components/timeline_phase_text.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent, ReactNode } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +export const TimelinePhaseText: FunctionComponent<{ + phaseName: ReactNode | string; + durationInPhase?: ReactNode | string; +}> = ({ phaseName, durationInPhase }) => ( + <EuiFlexGroup justifyContent="spaceBetween" gutterSize="none"> + <EuiFlexItem> + <EuiText size="s"> + <strong>{phaseName}</strong> + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + {typeof durationInPhase === 'string' ? ( + <EuiText size="s">{durationInPhase}</EuiText> + ) : ( + durationInPhase + )} + </EuiFlexItem> + </EuiFlexGroup> +); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/index.ts index 4664429db37d7..7bcaa6584edf0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/index.ts @@ -3,4 +3,4 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -export { Timeline } from './timeline'; +export { Timeline } from './timeline.container'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx new file mode 100644 index 0000000000000..75f53fcb25091 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.container.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; + +import { useFormData } from '../../../../../shared_imports'; + +import { formDataToAbsoluteTimings } from '../../lib'; + +import { useConfigurationIssues } from '../../form'; + +import { FormInternal } from '../../types'; + +import { Timeline as ViewComponent } from './timeline'; + +export const Timeline: FunctionComponent = () => { + const [formData] = useFormData<FormInternal>(); + const timings = formDataToAbsoluteTimings(formData); + const { isUsingRollover } = useConfigurationIssues(); + return ( + <ViewComponent + hotPhaseMinAge={timings.hot.min_age} + warmPhaseMinAge={timings.warm?.min_age} + coldPhaseMinAge={timings.cold?.min_age} + deletePhaseMinAge={timings.delete?.min_age} + isUsingRollover={isUsingRollover} + hasDeletePhase={Boolean(formData._meta?.delete?.enabled)} + /> + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.scss index 452221a29a991..7d65d2cd6b212 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.scss +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.scss @@ -84,4 +84,8 @@ $ilmDeletePhaseColor: shadeOrTint($euiColorVis5, 40%, 40%); background-color: $euiColorVis1; } } + + &__rolloverIcon { + display: inline-block; + } } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx index 40bab9c676de2..2e2db88e1384d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, useMemo } from 'react'; +import React, { FunctionComponent, memo } from 'react'; import { - EuiText, EuiIcon, EuiIconProps, EuiFlexGroup, @@ -16,18 +15,19 @@ import { } from '@elastic/eui'; import { PhasesExceptDelete } from '../../../../../../common/types'; -import { useFormData } from '../../../../../shared_imports'; - -import { FormInternal } from '../../types'; import { - calculateRelativeTimingMs, + calculateRelativeFromAbsoluteMilliseconds, normalizeTimingsToHumanReadable, PhaseAgeInMilliseconds, + AbsoluteTimings, } from '../../lib'; import './timeline.scss'; import { InfinityIconSvg } from './infinity_icon.svg'; +import { TimelinePhaseText } from './components'; + +const exists = (v: unknown) => v != null; const InfinityIcon: FunctionComponent<Omit<EuiIconProps, 'type'>> = (props) => ( <EuiIcon type={InfinityIconSvg} {...props} /> @@ -56,6 +56,13 @@ const i18nTexts = { hotPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.hotPhaseSectionTitle', { defaultMessage: 'Hot phase', }), + rolloverTooltip: i18n.translate( + 'xpack.indexLifecycleMgmt.timeline.hotPhaseRolloverToolTipContent', + { + defaultMessage: + 'How long it takes to reach the rollover criteria in the hot phase can vary. Data moves to the next phase when the time since rollover reaches the minimum age.', + } + ), warmPhase: i18n.translate('xpack.indexLifecycleMgmt.timeline.warmPhaseSectionTitle', { defaultMessage: 'Warm phase', }), @@ -88,121 +95,136 @@ const calculateWidths = (inputs: PhaseAgeInMilliseconds) => { }; }; -const TimelinePhaseText: FunctionComponent<{ - phaseName: string; - durationInPhase?: React.ReactNode | string; -}> = ({ phaseName, durationInPhase }) => ( - <EuiFlexGroup justifyContent="spaceBetween" gutterSize="none"> - <EuiFlexItem> - <EuiText size="s"> - <strong>{phaseName}</strong> - </EuiText> - </EuiFlexItem> - <EuiFlexItem grow={false}> - {typeof durationInPhase === 'string' ? ( - <EuiText size="s">{durationInPhase}</EuiText> - ) : ( - durationInPhase - )} - </EuiFlexItem> - </EuiFlexGroup> -); - -export const Timeline: FunctionComponent = () => { - const [formData] = useFormData<FormInternal>(); - - const phaseTimingInMs = useMemo(() => { - return calculateRelativeTimingMs(formData); - }, [formData]); +interface Props { + hasDeletePhase: boolean; + /** + * For now we assume the hot phase does not have a min age + */ + hotPhaseMinAge: undefined; + isUsingRollover: boolean; + warmPhaseMinAge?: string; + coldPhaseMinAge?: string; + deletePhaseMinAge?: string; +} - const humanReadableTimings = useMemo(() => normalizeTimingsToHumanReadable(phaseTimingInMs), [ - phaseTimingInMs, - ]); - - const widths = calculateWidths(phaseTimingInMs); - - const getDurationInPhaseContent = (phase: PhasesExceptDelete): string | React.ReactNode => - phaseTimingInMs.phases[phase] === Infinity ? ( - <InfinityIcon aria-label={humanReadableTimings[phase]} /> - ) : ( - humanReadableTimings[phase] - ); - - return ( - <EuiFlexGroup gutterSize="s" direction="column" responsive={false}> - <EuiFlexItem> - <EuiTitle size="s"> - <h2> - {i18n.translate('xpack.indexLifecycleMgmt.timeline.title', { - defaultMessage: 'Policy Timeline', - })} - </h2> - </EuiTitle> - </EuiFlexItem> - <EuiFlexItem> - <div - className="ilmTimeline" - ref={(el) => { - if (el) { - el.style.setProperty('--ilm-timeline-hot-phase-width', widths.hot); - el.style.setProperty('--ilm-timeline-warm-phase-width', widths.warm ?? null); - el.style.setProperty('--ilm-timeline-cold-phase-width', widths.cold ?? null); - } - }} - > - <EuiFlexGroup gutterSize="none" alignItems="flexStart" responsive={false}> - <EuiFlexItem> - <div className="ilmTimeline__phasesContainer"> - {/* These are the actual color bars for the timeline */} - <div - data-test-subj="ilmTimelineHotPhase" - className="ilmTimeline__phasesContainer__phase ilmTimeline__hotPhase" - > - <div className="ilmTimeline__colorBar ilmTimeline__hotPhase__colorBar" /> - <TimelinePhaseText - phaseName={i18nTexts.hotPhase} - durationInPhase={getDurationInPhaseContent('hot')} - /> - </div> - {formData._meta?.warm.enabled && ( +/** + * Display a timeline given ILM policy phase information. This component is re-usable and memo-ized + * and should not rely directly on any application-specific context. + */ +export const Timeline: FunctionComponent<Props> = memo( + ({ hasDeletePhase, isUsingRollover, ...phasesMinAge }) => { + const absoluteTimings: AbsoluteTimings = { + hot: { min_age: phasesMinAge.hotPhaseMinAge }, + warm: phasesMinAge.warmPhaseMinAge ? { min_age: phasesMinAge.warmPhaseMinAge } : undefined, + cold: phasesMinAge.coldPhaseMinAge ? { min_age: phasesMinAge.coldPhaseMinAge } : undefined, + delete: phasesMinAge.deletePhaseMinAge + ? { min_age: phasesMinAge.deletePhaseMinAge } + : undefined, + }; + + const phaseAgeInMilliseconds = calculateRelativeFromAbsoluteMilliseconds(absoluteTimings); + const humanReadableTimings = normalizeTimingsToHumanReadable(phaseAgeInMilliseconds); + + const widths = calculateWidths(phaseAgeInMilliseconds); + + const getDurationInPhaseContent = (phase: PhasesExceptDelete): string | React.ReactNode => + phaseAgeInMilliseconds.phases[phase] === Infinity ? ( + <InfinityIcon aria-label={humanReadableTimings[phase]} /> + ) : ( + humanReadableTimings[phase] + ); + + return ( + <EuiFlexGroup gutterSize="s" direction="column" responsive={false}> + <EuiFlexItem> + <EuiTitle size="s"> + <h2> + {i18n.translate('xpack.indexLifecycleMgmt.timeline.title', { + defaultMessage: 'Policy Timeline', + })} + </h2> + </EuiTitle> + </EuiFlexItem> + <EuiFlexItem> + <div + className="ilmTimeline" + ref={(el) => { + if (el) { + el.style.setProperty('--ilm-timeline-hot-phase-width', widths.hot); + el.style.setProperty('--ilm-timeline-warm-phase-width', widths.warm ?? null); + el.style.setProperty('--ilm-timeline-cold-phase-width', widths.cold ?? null); + } + }} + > + <EuiFlexGroup gutterSize="none" alignItems="flexStart" responsive={false}> + <EuiFlexItem> + <div className="ilmTimeline__phasesContainer"> + {/* These are the actual color bars for the timeline */} <div - data-test-subj="ilmTimelineWarmPhase" - className="ilmTimeline__phasesContainer__phase ilmTimeline__warmPhase" + data-test-subj="ilmTimelineHotPhase" + className="ilmTimeline__phasesContainer__phase ilmTimeline__hotPhase" > - <div className="ilmTimeline__colorBar ilmTimeline__warmPhase__colorBar" /> + <div className="ilmTimeline__colorBar ilmTimeline__hotPhase__colorBar" /> <TimelinePhaseText - phaseName={i18nTexts.warmPhase} - durationInPhase={getDurationInPhaseContent('warm')} + phaseName={ + isUsingRollover ? ( + <> + {i18nTexts.hotPhase} +   + <div + className="ilmTimeline__rolloverIcon" + data-test-subj="timelineHotPhaseRolloverToolTip" + > + <EuiIconTip type="iInCircle" content={i18nTexts.rolloverTooltip} /> + </div> + </> + ) : ( + i18nTexts.hotPhase + ) + } + durationInPhase={getDurationInPhaseContent('hot')} /> </div> - )} - {formData._meta?.cold.enabled && ( + {exists(phaseAgeInMilliseconds.phases.warm) && ( + <div + data-test-subj="ilmTimelineWarmPhase" + className="ilmTimeline__phasesContainer__phase ilmTimeline__warmPhase" + > + <div className="ilmTimeline__colorBar ilmTimeline__warmPhase__colorBar" /> + <TimelinePhaseText + phaseName={i18nTexts.warmPhase} + durationInPhase={getDurationInPhaseContent('warm')} + /> + </div> + )} + {exists(phaseAgeInMilliseconds.phases.cold) && ( + <div + data-test-subj="ilmTimelineColdPhase" + className="ilmTimeline__phasesContainer__phase ilmTimeline__coldPhase" + > + <div className="ilmTimeline__colorBar ilmTimeline__coldPhase__colorBar" /> + <TimelinePhaseText + phaseName={i18nTexts.coldPhase} + durationInPhase={getDurationInPhaseContent('cold')} + /> + </div> + )} + </div> + </EuiFlexItem> + {hasDeletePhase && ( + <EuiFlexItem grow={false}> <div - data-test-subj="ilmTimelineColdPhase" - className="ilmTimeline__phasesContainer__phase ilmTimeline__coldPhase" + data-test-subj="ilmTimelineDeletePhase" + className="ilmTimeline__deleteIconContainer" > - <div className="ilmTimeline__colorBar ilmTimeline__coldPhase__colorBar" /> - <TimelinePhaseText - phaseName={i18nTexts.coldPhase} - durationInPhase={getDurationInPhaseContent('cold')} - /> + <EuiIconTip type="trash" content={i18nTexts.deleteIcon.toolTipContent} /> </div> - )} - </div> - </EuiFlexItem> - {formData._meta?.delete.enabled && ( - <EuiFlexItem grow={false}> - <div - data-test-subj="ilmTimelineDeletePhase" - className="ilmTimeline__deleteIconContainer" - > - <EuiIconTip type="trash" content={i18nTexts.deleteIcon.toolTipContent} /> - </div> - </EuiFlexItem> - )} - </EuiFlexGroup> - </div> - </EuiFlexItem> - </EuiFlexGroup> - ); -}; + </EuiFlexItem> + )} + </EuiFlexGroup> + </div> + </EuiFlexItem> + </EuiFlexGroup> + ); + } +); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 71085a6d7a2b8..cf8c92b8333d0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -11,6 +11,13 @@ export const i18nTexts = { shrinkLabel: i18n.translate('xpack.indexLifecycleMgmt.shrink.indexFieldLabel', { defaultMessage: 'Shrink index', }), + rolloverOffsetsHotPhaseTiming: i18n.translate( + 'xpack.indexLifecycleMgmt.rollover.rolloverOffsetsPhaseTimingDescription', + { + defaultMessage: + 'How long it takes to reach the rollover criteria in the hot phase can vary. Data moves to the next phase when the time since rollover reaches the minimum age.', + } + ), searchableSnapshotInHotPhase: { searchableSnapshotDisallowed: { calloutTitle: i18n.translate( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts index 28910871fa33b..405de2b55a2f7 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts @@ -4,13 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { flow } from 'fp-ts/function'; import { deserializer } from '../form'; import { + formDataToAbsoluteTimings, + calculateRelativeFromAbsoluteMilliseconds, absoluteTimingToRelativeTiming, - calculateRelativeTimingMs, } from './absolute_timing_to_relative_timing'; +export const calculateRelativeTimingMs = flow( + formDataToAbsoluteTimings, + calculateRelativeFromAbsoluteMilliseconds +); + describe('Conversion of absolute policy timing to relative timing', () => { describe('calculateRelativeTimingMs', () => { describe('policy that never deletes data (keep forever)', () => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts index 2f37608b2d7ae..a44863b2f1ce2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts @@ -14,16 +14,21 @@ * * This code converts the absolute timings to _relative_ timings of the form: 30 days in hot phase, * 40 days in warm phase then forever in cold phase. + * + * All functions exported from this file can be viewed as utilities for working with form data and + * other defined interfaces to calculate the relative amount of time data will spend in a phase. */ import moment from 'moment'; -import { flow } from 'fp-ts/lib/function'; import { i18n } from '@kbn/i18n'; +import { flow } from 'fp-ts/function'; import { splitSizeAndUnits } from '../../../lib/policies'; import { FormInternal } from '../types'; +/* -===- Private functions and types -===- */ + type MinAgePhase = 'warm' | 'cold' | 'delete'; type Phase = 'hot' | MinAgePhase; @@ -43,7 +48,34 @@ const i18nTexts = { }), }; -interface AbsoluteTimings { +const phaseOrder: Phase[] = ['hot', 'warm', 'cold', 'delete']; + +const getMinAge = (phase: MinAgePhase, formData: FormInternal) => ({ + min_age: formData.phases?.[phase]?.min_age + ? formData.phases[phase]!.min_age! + formData._meta[phase].minAgeUnit + : '0ms', +}); + +/** + * See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math + * for all date math values. ILM policies also support "micros" and "nanos". + */ +const getPhaseMinAgeInMilliseconds = (phase: { min_age: string }): number => { + let milliseconds: number; + const { units, size } = splitSizeAndUnits(phase.min_age); + if (units === 'micros') { + milliseconds = parseInt(size, 10) / 1e3; + } else if (units === 'nanos') { + milliseconds = parseInt(size, 10) / 1e6; + } else { + milliseconds = moment.duration(size, units as any).asMilliseconds(); + } + return milliseconds; +}; + +/* -===- Public functions and types -===- */ + +export interface AbsoluteTimings { hot: { min_age: undefined; }; @@ -67,16 +99,7 @@ export interface PhaseAgeInMilliseconds { }; } -const phaseOrder: Phase[] = ['hot', 'warm', 'cold', 'delete']; - -const getMinAge = (phase: MinAgePhase, formData: FormInternal) => ({ - min_age: - formData.phases && formData.phases[phase]?.min_age - ? formData.phases[phase]!.min_age! + formData._meta[phase].minAgeUnit - : '0ms', -}); - -const formDataToAbsoluteTimings = (formData: FormInternal): AbsoluteTimings => { +export const formDataToAbsoluteTimings = (formData: FormInternal): AbsoluteTimings => { const { _meta } = formData; if (!_meta) { return { hot: { min_age: undefined } }; @@ -89,28 +112,13 @@ const formDataToAbsoluteTimings = (formData: FormInternal): AbsoluteTimings => { }; }; -/** - * See https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math - * for all date math values. ILM policies also support "micros" and "nanos". - */ -const getPhaseMinAgeInMilliseconds = (phase: { min_age: string }): number => { - let milliseconds: number; - const { units, size } = splitSizeAndUnits(phase.min_age); - if (units === 'micros') { - milliseconds = parseInt(size, 10) / 1e3; - } else if (units === 'nanos') { - milliseconds = parseInt(size, 10) / 1e6; - } else { - milliseconds = moment.duration(size, units as any).asMilliseconds(); - } - return milliseconds; -}; - /** * Given a set of phase minimum age absolute timings, like hot phase 0ms and warm phase 3d, work out * the number of milliseconds data will reside in phase. */ -const calculateMilliseconds = (inputs: AbsoluteTimings): PhaseAgeInMilliseconds => { +export const calculateRelativeFromAbsoluteMilliseconds = ( + inputs: AbsoluteTimings +): PhaseAgeInMilliseconds => { return phaseOrder.reduce<PhaseAgeInMilliseconds>( (acc, phaseName, idx) => { // Delete does not have an age associated with it @@ -152,6 +160,8 @@ const calculateMilliseconds = (inputs: AbsoluteTimings): PhaseAgeInMilliseconds ); }; +export type RelativePhaseTimingInMs = ReturnType<typeof calculateRelativeFromAbsoluteMilliseconds>; + const millisecondsToDays = (milliseconds?: number): string | undefined => { if (milliseconds == null) { return; @@ -177,10 +187,12 @@ export const normalizeTimingsToHumanReadable = ({ }; }; -export const calculateRelativeTimingMs = flow(formDataToAbsoluteTimings, calculateMilliseconds); - +/** + * Given {@link FormInternal}, extract the min_age values for each phase and calculate + * human readable strings for communicating how long data will remain in a phase. + */ export const absoluteTimingToRelativeTiming = flow( formDataToAbsoluteTimings, - calculateMilliseconds, + calculateRelativeFromAbsoluteMilliseconds, normalizeTimingsToHumanReadable ); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts index 9593fcc810a6f..a9372c99a72fc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/index.ts @@ -6,7 +6,10 @@ export { absoluteTimingToRelativeTiming, - calculateRelativeTimingMs, + calculateRelativeFromAbsoluteMilliseconds, normalizeTimingsToHumanReadable, + formDataToAbsoluteTimings, + AbsoluteTimings, PhaseAgeInMilliseconds, + RelativePhaseTimingInMs, } from './absolute_timing_to_relative_timing'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx index 09c81efe163b5..21f028d1fec60 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx @@ -301,7 +301,7 @@ export const TableContent: React.FunctionComponent<Props> = ({ style={{ width: 150 }} > <EuiPopover - id="contextMenuPolicy" + id={`contextMenuPolicy-${name}`} button={button} isOpen={isPolicyPopoverOpen(policy.name)} closePopover={closePolicyPopover} diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 87f5408f6ca42..3221054839865 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -71,7 +71,7 @@ describe('Data Streams tab', () => { test('when Fleet is enabled, links to Fleet', async () => { testBed = await setup({ - plugins: { fleet: { hi: 'ok' } }, + plugins: { isFleetEnabled: true }, }); await act(async () => { diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx index 91bcfe5ed55c0..4e5164562207d 100644 --- a/x-pack/plugins/index_management/public/application/app_context.tsx +++ b/x-pack/plugins/index_management/public/application/app_context.tsx @@ -8,9 +8,8 @@ import React, { createContext, useContext } from 'react'; import { ScopedHistory } from 'kibana/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import { CoreSetup, CoreStart } from '../../../../../src/core/public'; -import { FleetSetup } from '../../../fleet/public'; +import { CoreSetup, CoreStart } from '../../../../../src/core/public'; import { UiMetricService, NotificationService, HttpService } from './services'; import { ExtensionsService } from '../services'; import { SharePluginStart } from '../../../../../src/plugins/share/public'; @@ -24,7 +23,7 @@ export interface AppDependencies { }; plugins: { usageCollection: UsageCollectionSetup; - fleet?: FleetSetup; + isFleetEnabled: boolean; }; services: { uiMetricService: UiMetricService; diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index b94718c14d3aa..f4136a977df1a 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -9,7 +9,6 @@ import { CoreSetup } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public/'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; -import { FleetSetup } from '../../../fleet/public'; import { UIM_APP_NAME } from '../../common/constants'; import { PLUGIN } from '../../common/constants/plugin'; import { ExtensionsService } from '../services'; @@ -50,7 +49,7 @@ export async function mountManagementSection( usageCollection: UsageCollectionSetup, params: ManagementAppMountParams, extensionsService: ExtensionsService, - fleet?: FleetSetup + isFleetEnabled: boolean ) { const { element, setBreadcrumbs, history } = params; const [core, startDependencies] = await coreSetup.getStartServices(); @@ -80,7 +79,7 @@ export async function mountManagementSection( }, plugins: { usageCollection, - fleet, + isFleetEnabled, }, services: { httpService, notificationService, uiMetricService, extensionsService }, history, diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx index 64d874c76afb3..07eccd23d9f44 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx @@ -52,7 +52,7 @@ export const DataStreamList: React.FunctionComponent<RouteComponentProps<MatchPa const { core: { getUrlForApp }, - plugins: { fleet }, + plugins: { isFleetEnabled }, } = useAppContext(); const [isIncludeStatsChecked, setIsIncludeStatsChecked] = useState(false); @@ -203,7 +203,7 @@ export const DataStreamList: React.FunctionComponent<RouteComponentProps<MatchPa defaultMessage="Data streams store time-series data across multiple indices." /> {' ' /* We need this space to separate these two sentences. */} - {fleet ? ( + {isFleetEnabled ? ( <FormattedMessage id="xpack.idxMgmt.dataStreamList.emptyPrompt.noDataStreamsCtaIngestManagerMessage" defaultMessage="Get started with data streams in {link}." diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index 35413bd4f9ca1..bc430d4f91a2e 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -41,7 +41,7 @@ export class IndexMgmtUIPlugin { usageCollection, params, this.extensionsService, - fleet + Boolean(fleet) ); }, }); diff --git a/x-pack/plugins/index_management/public/types.ts b/x-pack/plugins/index_management/public/types.ts index 90ecbd76fe008..e14b3c5b23296 100644 --- a/x-pack/plugins/index_management/public/types.ts +++ b/x-pack/plugins/index_management/public/types.ts @@ -5,7 +5,6 @@ */ import { ExtensionsSetup } from './services'; -import { FleetSetup } from '../../fleet/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginStart } from '../../../../src/plugins/share/public'; @@ -15,7 +14,7 @@ export interface IndexManagementPluginSetup { } export interface SetupDependencies { - fleet?: FleetSetup; + fleet?: unknown; usageCollection: UsageCollectionSetup; management: ManagementSetup; } diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts index 62b76a0ae475e..614684d29ae76 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts @@ -7,48 +7,17 @@ import * as rt from 'io-ts'; import { timeRangeRT, routeTimingMetadataRT } from '../../shared'; +import { + logEntryAnomalyRT, + logEntryAnomalyDatasetsRT, + anomaliesSortRT, + paginationRT, + paginationCursorRT, +} from '../../../log_analysis'; export const LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH = '/api/infra/log_analysis/results/log_entry_anomalies'; -// [Sort field value, tiebreaker value] -const paginationCursorRT = rt.tuple([ - rt.union([rt.string, rt.number]), - rt.union([rt.string, rt.number]), -]); - -export type PaginationCursor = rt.TypeOf<typeof paginationCursorRT>; - -export const anomalyTypeRT = rt.keyof({ - logRate: null, - logCategory: null, -}); - -export type AnomalyType = rt.TypeOf<typeof anomalyTypeRT>; - -const logEntryAnomalyCommonFieldsRT = rt.type({ - id: rt.string, - anomalyScore: rt.number, - dataset: rt.string, - typical: rt.number, - actual: rt.number, - type: anomalyTypeRT, - duration: rt.number, - startTime: rt.number, - jobId: rt.string, -}); -const logEntrylogRateAnomalyRT = logEntryAnomalyCommonFieldsRT; -const logEntrylogCategoryAnomalyRT = rt.partial({ - categoryId: rt.string, -}); -const logEntryAnomalyRT = rt.intersection([ - logEntryAnomalyCommonFieldsRT, - logEntrylogRateAnomalyRT, - logEntrylogCategoryAnomalyRT, -]); - -export type LogEntryAnomaly = rt.TypeOf<typeof logEntryAnomalyRT>; - export const getLogEntryAnomaliesSuccessReponsePayloadRT = rt.intersection([ rt.type({ data: rt.intersection([ @@ -78,43 +47,6 @@ export type GetLogEntryAnomaliesSuccessResponsePayload = rt.TypeOf< typeof getLogEntryAnomaliesSuccessReponsePayloadRT >; -const sortOptionsRT = rt.keyof({ - anomalyScore: null, - dataset: null, - startTime: null, -}); - -const sortDirectionsRT = rt.keyof({ - asc: null, - desc: null, -}); - -const paginationPreviousPageCursorRT = rt.type({ - searchBefore: paginationCursorRT, -}); - -const paginationNextPageCursorRT = rt.type({ - searchAfter: paginationCursorRT, -}); - -const paginationRT = rt.intersection([ - rt.type({ - pageSize: rt.number, - }), - rt.partial({ - cursor: rt.union([paginationPreviousPageCursorRT, paginationNextPageCursorRT]), - }), -]); - -export type Pagination = rt.TypeOf<typeof paginationRT>; - -const sortRT = rt.type({ - field: sortOptionsRT, - direction: sortDirectionsRT, -}); - -export type Sort = rt.TypeOf<typeof sortRT>; - export const getLogEntryAnomaliesRequestPayloadRT = rt.type({ data: rt.intersection([ rt.type({ @@ -127,9 +59,9 @@ export const getLogEntryAnomaliesRequestPayloadRT = rt.type({ // Pagination properties pagination: paginationRT, // Sort properties - sort: sortRT, + sort: anomaliesSortRT, // Dataset filters - datasets: rt.array(rt.string), + datasets: logEntryAnomalyDatasetsRT, }), ]), }); diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts index 0554192398fc5..019ae01c1437c 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts @@ -13,6 +13,8 @@ import { routeTimingMetadataRT, } from '../../shared'; +import { logEntryCategoryRT, categoriesSortRT } from '../../../log_analysis'; + export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH = '/api/infra/log_analysis/results/log_entry_categories'; @@ -30,23 +32,6 @@ export type LogEntryCategoriesHistogramParameters = rt.TypeOf< typeof logEntryCategoriesHistogramParametersRT >; -const sortOptionsRT = rt.keyof({ - maximumAnomalyScore: null, - logEntryCount: null, -}); - -const sortDirectionsRT = rt.keyof({ - asc: null, - desc: null, -}); - -const categorySortRT = rt.type({ - field: sortOptionsRT, - direction: sortDirectionsRT, -}); - -export type CategorySort = rt.TypeOf<typeof categorySortRT>; - export const getLogEntryCategoriesRequestPayloadRT = rt.type({ data: rt.intersection([ rt.type({ @@ -59,7 +44,7 @@ export const getLogEntryCategoriesRequestPayloadRT = rt.type({ // a list of histograms to create histograms: rt.array(logEntryCategoriesHistogramParametersRT), // the criteria to the categories by - sort: categorySortRT, + sort: categoriesSortRT, }), rt.partial({ // the datasets to filter for (optional, unfiltered if not present) @@ -76,39 +61,6 @@ export type GetLogEntryCategoriesRequestPayload = rt.TypeOf< * response */ -export const logEntryCategoryHistogramBucketRT = rt.type({ - startTime: rt.number, - bucketDuration: rt.number, - logEntryCount: rt.number, -}); - -export type LogEntryCategoryHistogramBucket = rt.TypeOf<typeof logEntryCategoryHistogramBucketRT>; - -export const logEntryCategoryHistogramRT = rt.type({ - histogramId: rt.string, - buckets: rt.array(logEntryCategoryHistogramBucketRT), -}); - -export type LogEntryCategoryHistogram = rt.TypeOf<typeof logEntryCategoryHistogramRT>; - -export const logEntryCategoryDatasetRT = rt.type({ - name: rt.string, - maximumAnomalyScore: rt.number, -}); - -export type LogEntryCategoryDataset = rt.TypeOf<typeof logEntryCategoryDatasetRT>; - -export const logEntryCategoryRT = rt.type({ - categoryId: rt.number, - datasets: rt.array(logEntryCategoryDatasetRT), - histograms: rt.array(logEntryCategoryHistogramRT), - logEntryCount: rt.number, - maximumAnomalyScore: rt.number, - regularExpression: rt.string, -}); - -export type LogEntryCategory = rt.TypeOf<typeof logEntryCategoryRT>; - export const getLogEntryCategoriesSuccessReponsePayloadRT = rt.intersection([ rt.type({ data: rt.type({ diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts index e9e3c6e0ca3f9..3166d40d70392 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts @@ -12,7 +12,7 @@ import { timeRangeRT, routeTimingMetadataRT, } from '../../shared'; -import { logEntryContextRT } from '../../log_entries'; +import { logEntryContextRT } from '../../../log_entry'; export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH = '/api/infra/log_analysis/results/log_entry_category_examples'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts index 1eed29cd37560..c061545ec09ed 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts @@ -5,7 +5,7 @@ */ import * as rt from 'io-ts'; - +import { logEntryExampleRT } from '../../../log_analysis'; import { badRequestErrorRT, forbiddenErrorRT, @@ -46,16 +46,6 @@ export type GetLogEntryExamplesRequestPayload = rt.TypeOf< * response */ -const logEntryExampleRT = rt.type({ - id: rt.string, - dataset: rt.string, - message: rt.string, - timestamp: rt.number, - tiebreaker: rt.number, -}); - -export type LogEntryExample = rt.TypeOf<typeof logEntryExampleRT>; - export const getLogEntryExamplesSuccessReponsePayloadRT = rt.intersection([ rt.type({ data: rt.type({ diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts index 31bc62f48791a..b4d9a5744d5ac 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts @@ -5,8 +5,7 @@ */ import * as rt from 'io-ts'; -import { logEntryCursorRT } from '../../log_entry'; -import { jsonArrayRT } from '../../typed_json'; +import { logEntryCursorRT, logEntryRT } from '../../log_entry'; import { logSourceColumnConfigurationRT } from '../log_sources'; export const LOG_ENTRIES_PATH = '/api/log_entries/entries'; @@ -52,54 +51,6 @@ export type LogEntriesAfterRequest = rt.TypeOf<typeof logEntriesAfterRequestRT>; export type LogEntriesCenteredRequest = rt.TypeOf<typeof logEntriesCenteredRequestRT>; export type LogEntriesRequest = rt.TypeOf<typeof logEntriesRequestRT>; -export const logMessageConstantPartRT = rt.type({ - constant: rt.string, -}); -export const logMessageFieldPartRT = rt.type({ - field: rt.string, - value: jsonArrayRT, - highlights: rt.array(rt.string), -}); - -export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); - -export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); -export const logFieldColumnRT = rt.type({ - columnId: rt.string, - field: rt.string, - value: jsonArrayRT, - highlights: rt.array(rt.string), -}); -export const logMessageColumnRT = rt.type({ - columnId: rt.string, - message: rt.array(logMessagePartRT), -}); - -export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); - -export const logEntryContextRT = rt.union([ - rt.type({}), - rt.type({ 'container.id': rt.string }), - rt.type({ 'host.name': rt.string, 'log.file.path': rt.string }), -]); - -export const logEntryRT = rt.type({ - id: rt.string, - cursor: logEntryCursorRT, - columns: rt.array(logColumnRT), - context: logEntryContextRT, -}); - -export type LogMessageConstantPart = rt.TypeOf<typeof logMessageConstantPartRT>; -export type LogMessageFieldPart = rt.TypeOf<typeof logMessageFieldPartRT>; -export type LogMessagePart = rt.TypeOf<typeof logMessagePartRT>; -export type LogTimestampColumn = rt.TypeOf<typeof logTimestampColumnRT>; -export type LogFieldColumn = rt.TypeOf<typeof logFieldColumnRT>; -export type LogMessageColumn = rt.TypeOf<typeof logMessageColumnRT>; -export type LogColumn = rt.TypeOf<typeof logColumnRT>; -export type LogEntryContext = rt.TypeOf<typeof logEntryContextRT>; -export type LogEntry = rt.TypeOf<typeof logEntryRT>; - export const logEntriesResponseRT = rt.type({ data: rt.intersection([ rt.type({ diff --git a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts index 648da43134a27..96bf8beb29021 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts @@ -5,13 +5,12 @@ */ import * as rt from 'io-ts'; -import { logEntryCursorRT } from '../../log_entry'; +import { logEntryCursorRT, logEntryRT } from '../../log_entry'; import { logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, logEntriesCenteredRequestRT, - logEntryRT, } from './entries'; export const LOG_ENTRIES_HIGHLIGHTS_PATH = '/api/log_entries/highlights'; diff --git a/x-pack/plugins/infra/common/http_api/shared/time_range.ts b/x-pack/plugins/infra/common/http_api/shared/time_range.ts index efda07423748b..07317092cdedb 100644 --- a/x-pack/plugins/infra/common/http_api/shared/time_range.ts +++ b/x-pack/plugins/infra/common/http_api/shared/time_range.ts @@ -4,11 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as rt from 'io-ts'; - -export const timeRangeRT = rt.type({ - startTime: rt.number, - endTime: rt.number, -}); - -export type TimeRange = rt.TypeOf<typeof timeRangeRT>; +export * from '../../time/time_range'; diff --git a/x-pack/plugins/infra/common/log_analysis/index.ts b/x-pack/plugins/infra/common/log_analysis/index.ts index 0b4fa374a5da9..f055f642c8d1b 100644 --- a/x-pack/plugins/infra/common/log_analysis/index.ts +++ b/x-pack/plugins/infra/common/log_analysis/index.ts @@ -10,3 +10,5 @@ export * from './log_analysis_results'; export * from './log_entry_rate_analysis'; export * from './log_entry_categories_analysis'; export * from './job_parameters'; +export * from './log_entry_anomalies'; +export * from './log_entry_examples'; diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts index f4497dbba5056..897a5a4bb84df 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import * as rt from 'io-ts'; + export const ML_SEVERITY_SCORES = { warning: 3, minor: 25, @@ -55,3 +57,44 @@ export const compareDatasetsByMaximumAnomalyScore = < firstDataset: Dataset, secondDataset: Dataset ) => firstDataset.maximumAnomalyScore - secondDataset.maximumAnomalyScore; + +// Generic Sort + +const sortDirectionsRT = rt.keyof({ + asc: null, + desc: null, +}); + +export const sortRT = <Fields extends rt.Mixed>(fields: Fields) => + rt.type({ + field: fields, + direction: sortDirectionsRT, + }); + +// Pagination +// [Sort field value, tiebreaker value] +export const paginationCursorRT = rt.tuple([ + rt.union([rt.string, rt.number]), + rt.union([rt.string, rt.number]), +]); + +export type PaginationCursor = rt.TypeOf<typeof paginationCursorRT>; + +const paginationPreviousPageCursorRT = rt.type({ + searchBefore: paginationCursorRT, +}); + +const paginationNextPageCursorRT = rt.type({ + searchAfter: paginationCursorRT, +}); + +export const paginationRT = rt.intersection([ + rt.type({ + pageSize: rt.number, + }), + rt.partial({ + cursor: rt.union([paginationPreviousPageCursorRT, paginationNextPageCursorRT]), + }), +]); + +export type Pagination = rt.TypeOf<typeof paginationRT>; diff --git a/x-pack/plugins/infra/common/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/common/log_analysis/log_entry_anomalies.ts new file mode 100644 index 0000000000000..c426646e8e847 --- /dev/null +++ b/x-pack/plugins/infra/common/log_analysis/log_entry_anomalies.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { sortRT } from './log_analysis_results'; + +export const anomalyTypeRT = rt.keyof({ + logRate: null, + logCategory: null, +}); + +export type AnomalyType = rt.TypeOf<typeof anomalyTypeRT>; + +export const logEntryAnomalyCommonFieldsRT = rt.type({ + id: rt.string, + anomalyScore: rt.number, + dataset: rt.string, + typical: rt.number, + actual: rt.number, + type: anomalyTypeRT, + duration: rt.number, + startTime: rt.number, + jobId: rt.string, +}); +export const logEntrylogRateAnomalyRT = logEntryAnomalyCommonFieldsRT; +export type RateAnomaly = rt.TypeOf<typeof logEntrylogRateAnomalyRT>; + +export const logEntrylogCategoryAnomalyRT = rt.intersection([ + logEntryAnomalyCommonFieldsRT, + rt.type({ + categoryId: rt.string, + categoryRegex: rt.string, + categoryTerms: rt.string, + }), +]); +export type CategoryAnomaly = rt.TypeOf<typeof logEntrylogCategoryAnomalyRT>; + +export const logEntryAnomalyRT = rt.union([logEntrylogRateAnomalyRT, logEntrylogCategoryAnomalyRT]); + +export type LogEntryAnomaly = rt.TypeOf<typeof logEntryAnomalyRT>; + +export const logEntryAnomalyDatasetsRT = rt.array(rt.string); +export type LogEntryAnomalyDatasets = rt.TypeOf<typeof logEntryAnomalyDatasetsRT>; + +export const isCategoryAnomaly = (anomaly: LogEntryAnomaly): anomaly is CategoryAnomaly => { + return anomaly.type === 'logCategory'; +}; + +const sortOptionsRT = rt.keyof({ + anomalyScore: null, + dataset: null, + startTime: null, +}); + +export const anomaliesSortRT = sortRT(sortOptionsRT); +export type AnomaliesSort = rt.TypeOf<typeof anomaliesSortRT>; diff --git a/x-pack/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts index 0957126ee52e3..4292eaeb5f98c 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts @@ -5,6 +5,7 @@ */ import * as rt from 'io-ts'; +import { sortRT } from './log_analysis_results'; export const logEntryCategoriesJobTypeRT = rt.keyof({ 'log-entry-categories-count': null, @@ -15,3 +16,44 @@ export type LogEntryCategoriesJobType = rt.TypeOf<typeof logEntryCategoriesJobTy export const logEntryCategoriesJobTypes: LogEntryCategoriesJobType[] = [ 'log-entry-categories-count', ]; + +export const logEntryCategoryDatasetRT = rt.type({ + name: rt.string, + maximumAnomalyScore: rt.number, +}); + +export type LogEntryCategoryDataset = rt.TypeOf<typeof logEntryCategoryDatasetRT>; + +export const logEntryCategoryHistogramBucketRT = rt.type({ + startTime: rt.number, + bucketDuration: rt.number, + logEntryCount: rt.number, +}); + +export type LogEntryCategoryHistogramBucket = rt.TypeOf<typeof logEntryCategoryHistogramBucketRT>; + +export const logEntryCategoryHistogramRT = rt.type({ + histogramId: rt.string, + buckets: rt.array(logEntryCategoryHistogramBucketRT), +}); + +export type LogEntryCategoryHistogram = rt.TypeOf<typeof logEntryCategoryHistogramRT>; + +export const logEntryCategoryRT = rt.type({ + categoryId: rt.number, + datasets: rt.array(logEntryCategoryDatasetRT), + histograms: rt.array(logEntryCategoryHistogramRT), + logEntryCount: rt.number, + maximumAnomalyScore: rt.number, + regularExpression: rt.string, +}); + +export type LogEntryCategory = rt.TypeOf<typeof logEntryCategoryRT>; + +const sortOptionsRT = rt.keyof({ + maximumAnomalyScore: null, + logEntryCount: null, +}); + +export const categoriesSortRT = sortRT(sortOptionsRT); +export type CategoriesSort = rt.TypeOf<typeof categoriesSortRT>; diff --git a/x-pack/plugins/infra/common/log_analysis/log_entry_examples.ts b/x-pack/plugins/infra/common/log_analysis/log_entry_examples.ts new file mode 100644 index 0000000000000..78d230e35dc74 --- /dev/null +++ b/x-pack/plugins/infra/common/log_analysis/log_entry_examples.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const logEntryExampleRT = rt.type({ + id: rt.string, + dataset: rt.string, + message: rt.string, + timestamp: rt.number, + tiebreaker: rt.number, +}); + +export type LogEntryExample = rt.TypeOf<typeof logEntryExampleRT>; diff --git a/x-pack/plugins/infra/common/log_entry/log_entry.ts b/x-pack/plugins/infra/common/log_entry/log_entry.ts index e02acebe27711..eec1fb59f3091 100644 --- a/x-pack/plugins/infra/common/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/common/log_entry/log_entry.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import * as rt from 'io-ts'; import { TimeKey } from '../time'; -import { InfraLogEntry } from '../graphql/types'; - -export type LogEntry = InfraLogEntry; +import { logEntryCursorRT } from './log_entry_cursor'; +import { jsonArrayRT } from '../typed_json'; export interface LogEntryOrigin { id: string; @@ -42,3 +42,51 @@ export function isLessOrEqual(time1: LogEntryTime, time2: LogEntryTime) { export function isBetween(min: LogEntryTime, max: LogEntryTime, operand: LogEntryTime) { return isLessOrEqual(min, operand) && isLessOrEqual(operand, max); } + +export const logMessageConstantPartRT = rt.type({ + constant: rt.string, +}); +export const logMessageFieldPartRT = rt.type({ + field: rt.string, + value: jsonArrayRT, + highlights: rt.array(rt.string), +}); + +export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); + +export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); +export const logFieldColumnRT = rt.type({ + columnId: rt.string, + field: rt.string, + value: jsonArrayRT, + highlights: rt.array(rt.string), +}); +export const logMessageColumnRT = rt.type({ + columnId: rt.string, + message: rt.array(logMessagePartRT), +}); + +export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); + +export const logEntryContextRT = rt.union([ + rt.type({}), + rt.type({ 'container.id': rt.string }), + rt.type({ 'host.name': rt.string, 'log.file.path': rt.string }), +]); + +export const logEntryRT = rt.type({ + id: rt.string, + cursor: logEntryCursorRT, + columns: rt.array(logColumnRT), + context: logEntryContextRT, +}); + +export type LogMessageConstantPart = rt.TypeOf<typeof logMessageConstantPartRT>; +export type LogMessageFieldPart = rt.TypeOf<typeof logMessageFieldPartRT>; +export type LogMessagePart = rt.TypeOf<typeof logMessagePartRT>; +export type LogEntryContext = rt.TypeOf<typeof logEntryContextRT>; +export type LogEntry = rt.TypeOf<typeof logEntryRT>; +export type LogTimestampColumn = rt.TypeOf<typeof logTimestampColumnRT>; +export type LogFieldColumn = rt.TypeOf<typeof logFieldColumnRT>; +export type LogMessageColumn = rt.TypeOf<typeof logMessageColumnRT>; +export type LogColumn = rt.TypeOf<typeof logColumnRT>; diff --git a/x-pack/plugins/infra/common/time/index.ts b/x-pack/plugins/infra/common/time/index.ts index f49d46fa4920f..63bba2fa807ac 100644 --- a/x-pack/plugins/infra/common/time/index.ts +++ b/x-pack/plugins/infra/common/time/index.ts @@ -7,3 +7,4 @@ export * from './time_unit'; export * from './time_scale'; export * from './time_key'; +export * from './time_range'; diff --git a/x-pack/plugins/infra/common/time/time_range.ts b/x-pack/plugins/infra/common/time/time_range.ts new file mode 100644 index 0000000000000..efda07423748b --- /dev/null +++ b/x-pack/plugins/infra/common/time/time_range.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const timeRangeRT = rt.type({ + startTime: rt.number, + endTime: rt.number, +}); + +export type TimeRange = rt.TypeOf<typeof timeRangeRT>; diff --git a/x-pack/plugins/infra/kibana.json b/x-pack/plugins/infra/kibana.json index e84767f4931ca..d1fa83793d1dd 100644 --- a/x-pack/plugins/infra/kibana.json +++ b/x-pack/plugins/infra/kibana.json @@ -6,7 +6,7 @@ "features", "usageCollection", "spaces", - + "embeddable", "data", "dataEnhanced", "visTypeTimeseries", diff --git a/x-pack/plugins/infra/public/components/log_stream/index.ts b/x-pack/plugins/infra/public/components/log_stream/index.ts new file mode 100644 index 0000000000000..6abb292f919d9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './log_stream'; diff --git a/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx b/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx index 65433aab15716..13eb6431f97a3 100644 --- a/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/lazy_log_stream_wrapper.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; -import type { LogStreamProps } from './'; +import type { LogStreamProps } from './log_stream'; -const LazyLogStream = React.lazy(() => import('./')); +const LazyLogStream = React.lazy(() => import('./log_stream')); export const LazyLogStreamWrapper: React.FC<LogStreamProps> = (props) => ( <React.Suspense fallback={<div />}> diff --git a/x-pack/plugins/infra/public/components/log_stream/index.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx similarity index 98% rename from x-pack/plugins/infra/public/components/log_stream/index.tsx rename to x-pack/plugins/infra/public/components/log_stream/log_stream.tsx index b485a21221af2..b7410fda6f6fd 100644 --- a/x-pack/plugins/infra/public/components/log_stream/index.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream.tsx @@ -17,6 +17,7 @@ import { useLogStream } from '../../containers/logs/log_stream'; import { ScrollableLogTextStreamView } from '../logging/log_text_stream'; import { LogColumnRenderConfiguration } from '../../utils/log_column_render_configuration'; import { JsonValue } from '../../../../../../src/plugins/kibana_utils/common'; +import { Query } from '../../../../../../src/plugins/data/common'; const PAGE_THRESHOLD = 2; @@ -55,7 +56,7 @@ export interface LogStreamProps { sourceId?: string; startTimestamp: number; endTimestamp: number; - query?: string; + query?: string | Query; center?: LogEntryCursor; highlight?: string; height?: string | number; diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx new file mode 100644 index 0000000000000..0d6dfc50960f9 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { CoreStart } from 'kibana/public'; + +import { I18nProvider } from '@kbn/i18n/react'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common'; +import { Query, TimeRange } from '../../../../../../src/plugins/data/public'; +import { + Embeddable, + EmbeddableInput, + IContainer, +} from '../../../../../../src/plugins/embeddable/public'; +import { datemathToEpochMillis } from '../../utils/datemath'; +import { LazyLogStreamWrapper } from './lazy_log_stream_wrapper'; + +export const LOG_STREAM_EMBEDDABLE = 'LOG_STREAM_EMBEDDABLE'; + +export interface LogStreamEmbeddableInput extends EmbeddableInput { + timeRange: TimeRange; + query: Query; +} + +export class LogStreamEmbeddable extends Embeddable<LogStreamEmbeddableInput> { + public readonly type = LOG_STREAM_EMBEDDABLE; + private node?: HTMLElement; + + constructor( + private services: CoreStart, + initialInput: LogStreamEmbeddableInput, + parent?: IContainer + ) { + super(initialInput, {}, parent); + } + + public render(node: HTMLElement) { + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + this.node = node; + + this.renderComponent(); + } + + public reload() { + this.renderComponent(); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + private renderComponent() { + if (!this.node) { + return; + } + + const startTimestamp = datemathToEpochMillis(this.input.timeRange.from); + const endTimestamp = datemathToEpochMillis(this.input.timeRange.to); + + if (!startTimestamp || !endTimestamp) { + return; + } + + ReactDOM.render( + <I18nProvider> + <EuiThemeProvider> + <KibanaContextProvider services={this.services}> + <div style={{ width: '100%' }}> + <LazyLogStreamWrapper + startTimestamp={startTimestamp} + endTimestamp={endTimestamp} + height="100%" + query={this.input.query} + /> + </div> + </KibanaContextProvider> + </EuiThemeProvider> + </I18nProvider>, + this.node + ); + } +} diff --git a/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts new file mode 100644 index 0000000000000..f4d1b83a07593 --- /dev/null +++ b/x-pack/plugins/infra/public/components/log_stream/log_stream_embeddable_factory.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart } from 'kibana/public'; +import { + EmbeddableFactoryDefinition, + IContainer, +} from '../../../../../../src/plugins/embeddable/public'; +import { + LogStreamEmbeddable, + LOG_STREAM_EMBEDDABLE, + LogStreamEmbeddableInput, +} from './log_stream_embeddable'; + +export class LogStreamEmbeddableFactoryDefinition + implements EmbeddableFactoryDefinition<LogStreamEmbeddableInput> { + public readonly type = LOG_STREAM_EMBEDDABLE; + + constructor(private getCoreServices: () => Promise<CoreStart>) {} + + public async isEditable() { + const { application } = await this.getCoreServices(); + return application.capabilities.logs.save as boolean; + } + + public async create(initialInput: LogStreamEmbeddableInput, parent?: IContainer) { + const services = await this.getCoreServices(); + return new LogStreamEmbeddable(services, initialInput, parent); + } + + public getDisplayName() { + return 'Log stream'; + } +} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/category_expression.tsx similarity index 95% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_results/category_expression.tsx index d5480977e7f9e..9684777ac9216 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/category_expression.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { memo } from 'react'; -import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; export const RegularExpressionRepresentation: React.FunctionComponent<{ maximumSegmentCount?: number; diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx index aa3b4532e878e..9fef939733432 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_actions_menu.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; +import { EuiButton, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useMemo } from 'react'; import { useVisibilityState } from '../../../utils/use_visibility_state'; @@ -67,7 +67,7 @@ export const LogEntryActionsMenu: React.FunctionComponent<{ <EuiPopover anchorPosition="downRight" button={ - <EuiButtonEmpty + <EuiButton data-test-subj="logEntryActionsMenuButton" disabled={!hasMenuItems} iconSide="right" @@ -76,9 +76,9 @@ export const LogEntryActionsMenu: React.FunctionComponent<{ > <FormattedMessage id="xpack.infra.logEntryActionsMenu.buttonLabel" - defaultMessage="Actions" + defaultMessage="Investigate" /> - </EuiButtonEmpty> + </EuiButton> } closePopover={hide} id="logEntryActionsMenu" diff --git a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx index 5684d4068f3be..7d8ca95f9b93b 100644 --- a/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_entry_flyout/log_entry_flyout.tsx @@ -88,7 +88,7 @@ export const LogEntryFlyout = ({ </> ) : null} </EuiFlexItem> - <EuiFlexItem grow={false}> + <EuiFlexItem style={{ padding: 8 }} grow={false}> {logEntry ? <LogEntryActionsMenu logEntry={logEntry} /> : null} </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts index 19e8108ee50e8..b0ff36574bede 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts @@ -7,7 +7,7 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntry } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; export type StreamItem = LogEntryStreamItem; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx index 8de9e565b00be..2b30d43f8c38d 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx @@ -7,7 +7,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; -import { LogFieldColumn } from '../../../../common/http_api'; +import { LogFieldColumn } from '../../../../common/log_entry'; import { LogEntryFieldColumn } from './log_entry_field_column'; describe('LogEntryFieldColumn', () => { diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx index 4a9b0d0906a76..0d295b4df5566 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { JsonValue } from '../../../../../../../src/plugins/kibana_utils/common'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { LogColumn } from '../../../../common/http_api'; +import { LogColumn } from '../../../../common/log_entry'; import { isFieldColumn, isHighlightFieldColumn } from '../../../utils/log_entry'; import { FieldValue } from './field_value'; import { LogEntryColumnContent } from './log_entry_column'; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.test.tsx index 5d36e5cd47c59..00281c2df3133 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.test.tsx @@ -7,7 +7,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; -import { LogMessageColumn } from '../../../../common/http_api'; +import { LogMessageColumn } from '../../../../common/log_entry'; import { LogEntryMessageColumn } from './log_entry_message_column'; describe('LogEntryMessageColumn', () => { diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx index bfc160ada2e6a..92214dee9de22 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx @@ -6,7 +6,7 @@ import React, { memo, useMemo } from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { LogColumn, LogMessagePart } from '../../../../common/http_api'; +import { LogColumn, LogMessagePart } from '../../../../common/log_entry'; import { isConstantSegment, isFieldSegment, diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 93c657fbdda97..1a472df2b5c90 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -17,7 +17,7 @@ import { LogEntryFieldColumn } from './log_entry_field_column'; import { LogEntryMessageColumn } from './log_entry_message_column'; import { LogEntryTimestampColumn } from './log_entry_timestamp_column'; import { monospaceTextStyle, hoveredContentStyle, highlightedContentStyle } from './text_styles'; -import { LogEntry, LogColumn } from '../../../../common/http_api'; +import { LogEntry, LogColumn } from '../../../../common/log_entry'; import { LogEntryContextMenu } from './log_entry_context_menu'; import { LogColumnRenderConfiguration, diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index d399e47a73562..8fb63533cf61b 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -25,7 +25,7 @@ import { MeasurableItemView } from './measurable_item_view'; import { VerticalScrollPanel } from './vertical_scroll_panel'; import { useColumnWidths, LogEntryColumnWidths } from './log_entry_column'; import { LogDateRow } from './log_date_row'; -import { LogEntry } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; import { LogColumnRenderConfiguration } from '../../../utils/log_column_render_configuration'; interface ScrollableLogTextStreamViewProps { diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index bf4c5fbe0b13b..f1b820857e340 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -10,10 +10,10 @@ import { pick, throttle } from 'lodash'; import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; import { LogEntriesResponse, - LogEntry, LogEntriesRequest, LogEntriesBaseRequest, } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; import { fetchLogEntries } from './api/fetch_log_entries'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx index b4edebe8f8207..fb72874df5409 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx @@ -9,7 +9,8 @@ import { useEffect, useMemo, useState } from 'react'; import { TimeKey } from '../../../../common/time'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights'; -import { LogEntry, LogEntriesHighlightsResponse } from '../../../../common/http_api'; +import { LogEntriesHighlightsResponse } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; export const useLogEntryHighlights = ( diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index ff30e993aa3a9..1d9a7a1b1d777 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -7,11 +7,10 @@ import { useMemo, useEffect } from 'react'; import useSetState from 'react-use/lib/useSetState'; import usePrevious from 'react-use/lib/usePrevious'; -import { esKuery } from '../../../../../../../src/plugins/data/public'; +import { esKuery, esQuery, Query } from '../../../../../../../src/plugins/data/public'; import { fetchLogEntries } from '../log_entries/api/fetch_log_entries'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { LogEntry } from '../../../../common/http_api'; -import { LogEntryCursor } from '../../../../common/log_entry'; +import { LogEntryCursor, LogEntry } from '../../../../common/log_entry'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { LogSourceConfigurationProperties } from '../log_source'; @@ -19,7 +18,7 @@ interface LogStreamProps { sourceId: string; startTimestamp: number; endTimestamp: number; - query?: string; + query?: string | Query; center?: LogEntryCursor; columns?: LogSourceConfigurationProperties['logColumns']; } @@ -85,9 +84,21 @@ export function useLogStream({ }, [prevEndTimestamp, endTimestamp, setState]); const parsedQuery = useMemo(() => { - return query - ? JSON.stringify(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query))) - : null; + if (!query) { + return null; + } + + let q; + + if (typeof query === 'string') { + q = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query)); + } else if (query.language === 'kuery') { + q = esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(query.query as string)); + } else if (query.language === 'lucene') { + q = esQuery.luceneStringToDsl(query.query as string); + } + + return JSON.stringify(q); }, [query]); // Callbacks diff --git a/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts b/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts index 61e1ea353880a..2888e5a2b3ac5 100644 --- a/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts +++ b/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts @@ -5,7 +5,7 @@ */ import { useState } from 'react'; import createContainer from 'constate'; -import { LogEntry } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; interface ViewLogInContextProps { sourceId: string; diff --git a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts index 2b8986820d5a4..89b5d993fa01e 100644 --- a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts +++ b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts @@ -11,7 +11,7 @@ import { RendererFunction } from '../../utils/typed_react'; import { LogHighlightsState } from './log_highlights/log_highlights'; import { LogEntriesState, LogEntriesStateParams, LogEntriesCallbacks } from './log_entries'; import { UniqueTimeKey } from '../../../common/time'; -import { LogEntry } from '../../../common/http_api'; +import { LogEntry } from '../../../common/log_entry'; export const WithStreamItems: React.FunctionComponent<{ children: RendererFunction< diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index ecddd8a9aa5be..4445b735bedc9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -12,7 +12,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; -import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../common/time/time_range'; import { CategoryJobNoticesSection } from '../../../components/logging/log_analysis_job_status'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx index 3e1398c804686..8fe87c14c1a7c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx @@ -8,7 +8,7 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results'; import { useLinkProps } from '../../../../../hooks/use_link_props'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx index 47bb31ab4ae3e..20f0ee00bd505 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; +import { LogEntryCategoryDataset } from '../../../../../../common/log_analysis'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx index de07f3eb02029..8b4f075b782a9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx @@ -7,7 +7,7 @@ import React, { useEffect } from 'react'; import { useLogEntryCategoryExamples } from '../../use_log_entry_category_examples'; import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { CategoryExampleMessage } from './category_example_message'; const exampleCount = 5; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx index 84d7e198636e9..e24fdd06bc6d9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -9,8 +9,8 @@ import { i18n } from '@kbn/i18n'; import { encode } from 'rison-node'; import moment from 'moment'; -import { LogEntry, LogEntryContext } from '../../../../../../common/http_api'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LogEntry, LogEntryContext } from '../../../../../../common/log_entry'; +import { TimeRange } from '../../../../../../common/time'; import { getFriendlyNameForPartitionId, partitionField, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx index 2321dafaead1c..6bbc640b5b007 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx @@ -6,8 +6,8 @@ import React from 'react'; -import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LogEntryCategoryDataset } from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; import { AnalyzeCategoryDatasetInMlAction } from './analyze_dataset_in_ml_action'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx index 779ac3e8c3a07..78690285180d7 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; +import { LogEntryCategoryDataset } from '../../../../../../common/log_analysis'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; export const DatasetsList: React.FunctionComponent<{ diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx index 42d6509802ed4..d94dbb9d33556 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx @@ -6,8 +6,8 @@ import React, { useMemo } from 'react'; -import { LogEntryCategoryHistogram } from '../../../../../../common/http_api/log_analysis'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LogEntryCategoryHistogram } from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time'; import { SingleMetricComparison } from './single_metric_comparison'; import { SingleMetricSparkline } from './single_metric_sparkline'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx index 5fb8e3380f23f..c8453bdcdefbd 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx @@ -13,7 +13,7 @@ import { } from '@elastic/eui/dist/eui_charts_theme'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { TimeRange } from '../../../../../../common/time'; interface TimeSeriesPoint { timestamp: number; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx index c7a6c89012a3a..f810a675a18d1 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -8,8 +8,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiTitle } fro import { i18n } from '@kbn/i18n'; import React from 'react'; -import { LogEntryCategory } from '../../../../../../common/http_api/log_analysis'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LogEntryCategory } from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time'; import { BetaBadge } from '../../../../../components/beta_badge'; import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; import { RecreateJobButton } from '../../../../../components/logging/log_analysis_setup/create_job_button'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx index 954b6a9ab3ed3..834c99502a590 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx @@ -15,12 +15,12 @@ import { LogEntryCategory, LogEntryCategoryDataset, LogEntryCategoryHistogram, -} from '../../../../../../common/http_api/log_analysis'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +} from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time'; import { RowExpansionButton } from '../../../../../components/basic_table'; import { AnomalySeverityIndicatorList } from './anomaly_severity_indicator_list'; import { CategoryDetailsRow } from './category_details_row'; -import { RegularExpressionRepresentation } from './category_expression'; +import { RegularExpressionRepresentation } from '../../../../../components/logging/log_analysis_results/category_expression'; import { DatasetActionsList } from './datasets_action_list'; import { DatasetsList } from './datasets_list'; import { LogEntryCountSparkline } from './log_entry_count_sparkline'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts index a0eaecf04fa4b..b25b6cbe6f631 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts @@ -10,8 +10,8 @@ import { getLogEntryCategoriesRequestPayloadRT, getLogEntryCategoriesSuccessReponsePayloadRT, LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, - CategorySort, } from '../../../../../common/http_api/log_analysis'; +import { CategoriesSort } from '../../../../../common/log_analysis'; import { decodeOrThrow } from '../../../../../common/runtime_types'; interface RequestArgs { @@ -20,7 +20,7 @@ interface RequestArgs { endTime: number; categoryCount: number; datasets?: string[]; - sort: CategorySort; + sort: CategoriesSort; } export const callGetTopLogEntryCategoriesAPI = async ( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts index a64b73dea25e6..e3fba92610955 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts @@ -9,8 +9,8 @@ import { useMemo, useState } from 'react'; import { GetLogEntryCategoriesSuccessResponsePayload, GetLogEntryCategoryDatasetsSuccessResponsePayload, - CategorySort, } from '../../../../common/http_api/log_analysis'; +import { CategoriesSort } from '../../../../common/log_analysis'; import { useTrackedPromise, CanceledPromiseError } from '../../../utils/use_tracked_promise'; import { callGetTopLogEntryCategoriesAPI } from './service_calls/get_top_log_entry_categories'; import { callGetLogEntryCategoryDatasetsAPI } from './service_calls/get_log_entry_category_datasets'; @@ -19,8 +19,8 @@ import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; type TopLogEntryCategories = GetLogEntryCategoriesSuccessResponsePayload['data']['categories']; type LogEntryCategoryDatasets = GetLogEntryCategoryDatasetsSuccessResponsePayload['data']['datasets']; -export type SortOptions = CategorySort; -export type ChangeSortOptions = (sortOptions: CategorySort) => void; +export type SortOptions = CategoriesSort; +export type ChangeSortOptions = (sortOptions: CategoriesSort) => void; export const useLogEntryCategoriesResults = ({ categoriesCount, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index 09d3746c6ace6..f5007a1d48c4a 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -13,7 +13,7 @@ import { encode, RisonValue } from 'rison-node'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; -import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../common/time/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; import { TimeKey } from '../../../../common/time'; import { diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index ae5c3b5b93b47..503d383201592 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx @@ -23,7 +23,7 @@ import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { MLSeverityScoreCategories, ML_SEVERITY_COLORS, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx index 37032a95e9640..39fb1a5e6ae19 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx @@ -10,8 +10,8 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import useMount from 'react-use/lib/useMount'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { LogEntryAnomaly } from '../../../../../../common/http_api'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { LogEntryAnomaly, isCategoryAnomaly } from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; import { useLogSourceContext } from '../../../../../containers/logs/log_source'; import { useLogEntryExamples } from '../../use_log_entry_examples'; @@ -40,7 +40,7 @@ export const AnomaliesTableExpandedRow: React.FunctionComponent<{ exampleCount: EXAMPLE_COUNT, sourceId, startTime: anomaly.startTime, - categoryId: anomaly.categoryId, + categoryId: isCategoryAnomaly(anomaly) ? anomaly.categoryId : undefined, }); useMount(() => { diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx index c89f0329e9f2e..780e8c7ec5ec9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { getAnnotationsForAll, getLogEntryRateCombinedSeries } from '../helpers/data_formatters'; import { AnomaliesChart } from './chart'; import { AnomaliesTable } from './table'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index ab3476cd78eb3..7446b3c348606 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -25,10 +25,10 @@ import { LogColumnHeader, } from '../../../../../components/logging/log_text_stream/column_headers'; import { useLinkProps } from '../../../../../hooks/use_link_props'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button'; -import { LogEntryExample } from '../../../../../../common/http_api/log_analysis/results'; +import { LogEntryExample, isCategoryAnomaly } from '../../../../../../common/log_analysis'; import { LogColumnConfiguration, isTimestampLogColumnConfiguration, @@ -36,7 +36,7 @@ import { isMessageLogColumnConfiguration, } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; -import { LogEntryAnomaly } from '../../../../../../common/http_api'; +import { LogEntryAnomaly } from '../../../../../../common/log_analysis'; import { useLogEntryFlyoutContext } from '../../../../../containers/logs/log_flyout'; export const exampleMessageScale = 'medium' as const; @@ -116,7 +116,7 @@ export const LogEntryExampleMessage: React.FunctionComponent<Props> = ({ const viewAnomalyInMachineLearningLinkProps = useLinkProps( getEntitySpecificSingleMetricViewerLink(anomaly.jobId, timeRange, { [partitionField]: dataset, - ...(anomaly.categoryId ? { mlcategory: anomaly.categoryId } : {}), + ...(isCategoryAnomaly(anomaly) ? { mlcategory: anomaly.categoryId } : {}), }) ); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 855113d66f510..4b8c2b02bb8af 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -18,16 +18,18 @@ import moment from 'moment'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; import useSet from 'react-use/lib/useSet'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { + AnomalyType, formatAnomalyScore, getFriendlyNameForPartitionId, formatOneDecimalPlace, + isCategoryAnomaly, } from '../../../../../../common/log_analysis'; -import { AnomalyType } from '../../../../../../common/http_api/log_analysis'; import { RowExpansionButton } from '../../../../../components/basic_table'; import { AnomaliesTableExpandedRow } from './expanded_row'; import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; +import { RegularExpressionRepresentation } from '../../../../../components/logging/log_analysis_results/category_expression'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; import { Page, @@ -50,6 +52,7 @@ interface TableItem { typical: number; actual: number; type: AnomalyType; + categoryRegex?: string; } const anomalyScoreColumnName = i18n.translate( @@ -124,6 +127,7 @@ export const AnomaliesTable: React.FunctionComponent<{ type: anomaly.type, typical: anomaly.typical, actual: anomaly.actual, + categoryRegex: isCategoryAnomaly(anomaly) ? anomaly.categoryRegex : undefined, }; }); }, [results]); @@ -166,9 +170,7 @@ export const AnomaliesTable: React.FunctionComponent<{ { name: anomalyMessageColumnName, truncateText: true, - render: (item: TableItem) => ( - <AnomalyMessage actual={item.actual} typical={item.typical} type={item.type} /> - ), + render: (item: TableItem) => <AnomalyMessage anomaly={item} />, }, { field: 'startTime', @@ -226,15 +228,9 @@ export const AnomaliesTable: React.FunctionComponent<{ ); }; -const AnomalyMessage = ({ - actual, - typical, - type, -}: { - actual: number; - typical: number; - type: AnomalyType; -}) => { +const AnomalyMessage = ({ anomaly }: { anomaly: TableItem }) => { + const { type, actual, typical } = anomaly; + const moreThanExpectedAnomalyMessage = i18n.translate( 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage', { @@ -262,9 +258,20 @@ const AnomalyMessage = ({ const ratioMessage = useRatio ? `${formatOneDecimalPlace(ratio)}x` : ''; return ( - <span> - <EuiIcon type={icon} /> {`${ratioMessage} ${message}`} - </span> + <EuiFlexGroup gutterSize="s" responsive={false} alignItems="center"> + <EuiFlexItem grow={false} component="span"> + <EuiIcon type={icon} /> + </EuiFlexItem> + <EuiFlexItem component="span"> + {`${ratioMessage} ${message}`} + {anomaly.categoryRegex && ( + <> + {': '} + <RegularExpressionRepresentation regularExpression={anomaly.categoryRegex} /> + </> + )} + </EuiFlexItem> + </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts index 7f90604bfefdd..f915b0d78c43d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts @@ -11,13 +11,13 @@ import { LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, } from '../../../../../common/http_api/log_analysis'; import { decodeOrThrow } from '../../../../../common/runtime_types'; -import { Sort, Pagination } from '../../../../../common/http_api/log_analysis'; +import { AnomaliesSort, Pagination } from '../../../../../common/log_analysis'; interface RequestArgs { sourceId: string; startTime: number; endTime: number; - sort: Sort; + sort: AnomaliesSort; pagination: Pagination; datasets?: string[]; } diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts index 396c1ad3e1857..fbfe76f1473f5 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts @@ -9,21 +9,21 @@ import useMount from 'react-use/lib/useMount'; import { useTrackedPromise, CanceledPromiseError } from '../../../utils/use_tracked_promise'; import { callGetLogEntryAnomaliesAPI } from './service_calls/get_log_entry_anomalies'; import { callGetLogEntryAnomaliesDatasetsAPI } from './service_calls/get_log_entry_anomalies_datasets'; +import { GetLogEntryAnomaliesDatasetsSuccessResponsePayload } from '../../../../common/http_api/log_analysis'; import { - Sort, + AnomaliesSort, Pagination, PaginationCursor, - GetLogEntryAnomaliesDatasetsSuccessResponsePayload, LogEntryAnomaly, -} from '../../../../common/http_api/log_analysis'; +} from '../../../../common/log_analysis'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; -export type SortOptions = Sort; +export type SortOptions = AnomaliesSort; export type PaginationOptions = Pick<Pagination, 'pageSize'>; export type Page = number; export type FetchNextPage = () => void; export type FetchPreviousPage = () => void; -export type ChangeSortOptions = (sortOptions: Sort) => void; +export type ChangeSortOptions = (sortOptions: AnomaliesSort) => void; export type ChangePaginationOptions = (paginationOptions: PaginationOptions) => void; export type LogEntryAnomalies = LogEntryAnomaly[]; type LogEntryAnomaliesDatasets = GetLogEntryAnomaliesDatasetsSuccessResponsePayload['data']['datasets']; @@ -38,7 +38,7 @@ interface ReducerState { paginationCursor: Pagination['cursor'] | undefined; hasNextPage: boolean; paginationOptions: PaginationOptions; - sortOptions: Sort; + sortOptions: AnomaliesSort; timeRange: { start: number; end: number; @@ -53,7 +53,7 @@ type ReducerStateDefaults = Pick< type ReducerAction = | { type: 'changePaginationOptions'; payload: { paginationOptions: PaginationOptions } } - | { type: 'changeSortOptions'; payload: { sortOptions: Sort } } + | { type: 'changeSortOptions'; payload: { sortOptions: AnomaliesSort } } | { type: 'fetchNextPage' } | { type: 'fetchPreviousPage' } | { type: 'changeHasNextPage'; payload: { hasNextPage: boolean } } @@ -144,7 +144,7 @@ export const useLogEntryAnomaliesResults = ({ endTime: number; startTime: number; sourceId: string; - defaultSortOptions: Sort; + defaultSortOptions: AnomaliesSort; defaultPaginationOptions: Pick<Pagination, 'pageSize'>; onGetLogEntryAnomaliesDatasetsError?: (error: Error) => void; filteredDatasets?: string[]; @@ -225,7 +225,7 @@ export const useLogEntryAnomaliesResults = ({ ); const changeSortOptions = useCallback( - (nextSortOptions: Sort) => { + (nextSortOptions: AnomaliesSort) => { dispatch({ type: 'changeSortOptions', payload: { sortOptions: nextSortOptions } }); }, [dispatch] diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts index e809ab9cd5a6f..90b8b03a81602 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts @@ -6,7 +6,7 @@ import { useMemo, useState } from 'react'; -import { LogEntryExample } from '../../../../common/http_api'; +import { LogEntryExample } from '../../../../common/log_analysis'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { callGetLogEntryExamplesAPI } from './service_calls/get_log_entry_examples'; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx index 3fa89da5b5e51..011653fd6eb47 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx @@ -16,7 +16,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { isEmpty } from 'lodash'; import React, { useCallback, useContext, useMemo } from 'react'; -import { LogEntry } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; import { useViewportDimensions } from '../../../utils/use_viewport_dimensions'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx index ed34a32012bd2..71cfab79ba0cc 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/waffle/stepped_gradient_legend.tsx @@ -16,13 +16,12 @@ interface Props { bounds: InfraWaffleMapBounds; formatter: InfraFormatter; } - +type TickValue = 0 | 1; export const SteppedGradientLegend: React.FC<Props> = ({ legend, bounds, formatter }) => { return ( <LegendContainer> <Ticks> <TickLabel value={0} bounds={bounds} formatter={formatter} /> - <TickLabel value={0.5} bounds={bounds} formatter={formatter} /> <TickLabel value={1} bounds={bounds} formatter={formatter} /> </Ticks> <GradientContainer> @@ -39,7 +38,7 @@ export const SteppedGradientLegend: React.FC<Props> = ({ legend, bounds, formatt interface TickProps { bounds: InfraWaffleMapBounds; - value: number; + value: TickValue; formatter: InfraFormatter; } diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts index 49f4b56532936..9f1c2f90635a3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.test.ts @@ -37,14 +37,14 @@ describe('calculateBoundsFromNodes', () => { const bounds = calculateBoundsFromNodes(nodes); expect(bounds).toEqual({ min: 0.2, - max: 1.5, + max: 0.5, }); }); it('should have a minimum of 0 for only a single node', () => { const bounds = calculateBoundsFromNodes([nodes[0]]); expect(bounds).toEqual({ min: 0, - max: 1.5, + max: 0.5, }); }); it('should return zero for empty nodes', () => { diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.ts b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.ts index 6eb64971efbd7..ff1093a795a10 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.ts +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/lib/calculate_bounds_from_nodes.ts @@ -9,23 +9,17 @@ import { SnapshotNode } from '../../../../../common/http_api/snapshot_api'; import { InfraWaffleMapBounds } from '../../../../lib/lib'; export const calculateBoundsFromNodes = (nodes: SnapshotNode[]): InfraWaffleMapBounds => { - const maxValues = nodes.map((node) => { + const values = nodes.map((node) => { const metric = first(node.metrics); - if (!metric) return 0; - return metric.max; - }); - const minValues = nodes.map((node) => { - const metric = first(node.metrics); - if (!metric) return 0; - return metric.value; + return !metric || !metric.value ? 0 : metric.value; }); // if there is only one value then we need to set the bottom range to zero for min // otherwise the legend will look silly since both values are the same for top and // bottom. - if (minValues.length === 1) { - minValues.unshift(0); + if (values.length === 1) { + values.unshift(0); } - const maxValue = max(maxValues) || 0; - const minValue = min(minValues) || 0; + const maxValue = max(values) || 0; + const minValue = min(values) || 0; return { min: isFinite(minValue) ? minValue : 0, max: isFinite(maxValue) ? maxValue : 0 }; }; diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 2bbd0067642c0..809046ee1e17b 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -19,6 +19,8 @@ import { } from './types'; import { getLogsHasDataFetcher, getLogsOverviewDataFetcher } from './utils/logs_overview_fetchers'; import { createMetricsHasData, createMetricsFetchData } from './metrics_overview_fetchers'; +import { LOG_STREAM_EMBEDDABLE } from './components/log_stream/log_stream_embeddable'; +import { LogStreamEmbeddableFactoryDefinition } from './components/log_stream/log_stream_embeddable_factory'; export class Plugin implements InfraClientPluginClass { constructor(_context: PluginInitializerContext) {} @@ -46,6 +48,13 @@ export class Plugin implements InfraClientPluginClass { }); } + const getCoreServices = async () => (await core.getStartServices())[0]; + + pluginsSetup.embeddable.registerEmbeddableFactory( + LOG_STREAM_EMBEDDABLE, + new LogStreamEmbeddableFactoryDefinition(getCoreServices) + ); + core.application.register({ id: 'logs', title: i18n.translate('xpack.infra.logs.pluginTitle', { diff --git a/x-pack/plugins/infra/public/test_utils/entries.ts b/x-pack/plugins/infra/public/test_utils/entries.ts index 04c87d5f73902..96737fb175365 100644 --- a/x-pack/plugins/infra/public/test_utils/entries.ts +++ b/x-pack/plugins/infra/public/test_utils/entries.ts @@ -5,7 +5,7 @@ */ import faker from 'faker'; -import { LogEntry } from '../../common/http_api'; +import { LogEntry } from '../../common/log_entry'; import { LogSourceConfiguration } from '../containers/logs/log_source'; export const ENTRIES_EMPTY = { diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index f1052672978d5..037cfa4b7eb2d 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -7,6 +7,7 @@ import type { CoreSetup, CoreStart, Plugin as PluginClass } from 'kibana/public'; import type { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import type { EmbeddableSetup } from '../../../../src/plugins/embeddable/public'; import type { UsageCollectionSetup, UsageCollectionStart, @@ -33,6 +34,7 @@ export interface InfraClientSetupDeps { observability: ObservabilityPluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionSetup; + embeddable: EmbeddableSetup; } export interface InfraClientStartDeps { diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts index bb528ee5b18c5..c69104ad6177e 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts @@ -17,7 +17,7 @@ import { LogMessagePart, LogMessageFieldPart, LogMessageConstantPart, -} from '../../../common/http_api'; +} from '../../../common/log_entry'; export type LogEntryMessageSegment = InfraLogEntryFields.Message; export type LogEntryConstantMessageSegment = InfraLogEntryFields.InfraLogMessageConstantSegmentInlineFragment; diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts index abb004911214b..208316c693d4d 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts @@ -12,7 +12,7 @@ import { LogFieldColumn, LogMessagePart, LogMessageFieldPart, -} from '../../../common/http_api'; +} from '../../../common/log_entry'; export type LogEntryHighlightColumn = InfraLogEntryHighlightFields.Columns; export type LogEntryHighlightMessageColumn = InfraLogEntryHighlightFields.InfraLogEntryMessageColumnInlineFragment; diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 0b1df3abd465a..4c5debe58ed26 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -10,10 +10,9 @@ import type { InfraPluginRequestHandlerContext } from '../../../types'; import { LogEntriesSummaryBucket, LogEntriesSummaryHighlightsBucket, - LogEntry, - LogColumn, LogEntriesRequest, } from '../../../../common/http_api'; +import { LogEntry, LogColumn } from '../../../../common/log_entry'; import { InfraSourceConfiguration, InfraSources, diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts index 19ab82c9c5ac1..d04e036b33b21 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LogMessagePart } from '../../../../common/http_api/log_entries'; +import { LogMessagePart } from '../../../../common/log_entry'; import { JsonArray, JsonValue } from '../../../../../../../src/plugins/kibana_utils/common'; import { LogMessageFormattingCondition, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts index c6a4593912280..fbcc3671f08a2 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts @@ -12,12 +12,11 @@ import { logEntryCategoriesJobTypes, logEntryRateJobTypes, jobCustomSettingsRT, -} from '../../../common/log_analysis'; -import { - Sort, + LogEntryAnomalyDatasets, + AnomaliesSort, Pagination, - GetLogEntryAnomaliesRequestPayload, -} from '../../../common/http_api/log_analysis'; + isCategoryAnomaly, +} from '../../../common/log_analysis'; import type { MlSystem, MlAnomalyDetectors } from '../../types'; import { createLogEntryAnomaliesQuery, logEntryAnomaliesResponseRT } from './queries'; import { @@ -95,9 +94,9 @@ export async function getLogEntryAnomalies( sourceId: string, startTime: number, endTime: number, - sort: Sort, + sort: AnomaliesSort, pagination: Pagination, - datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] + datasets?: LogEntryAnomalyDatasets ) { const finalizeLogEntryAnomaliesSpan = startTracingSpan('get log entry anomalies'); @@ -131,7 +130,7 @@ export async function getLogEntryAnomalies( datasets ); - const data = anomalies.map((anomaly) => { + const parsedAnomalies = anomalies.map((anomaly) => { const { jobId } = anomaly; if (!anomaly.categoryId) { @@ -141,10 +140,41 @@ export async function getLogEntryAnomalies( } }); + const categoryIds = parsedAnomalies.reduce<number[]>((acc, anomaly) => { + return isCategoryAnomaly(anomaly) ? [...acc, parseInt(anomaly.categoryId, 10)] : acc; + }, []); + + const logEntryCategoriesCountJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { logEntryCategoriesById } = await fetchLogEntryCategories( + context, + logEntryCategoriesCountJobId, + categoryIds + ); + + const parsedAnomaliesWithExpandedCategoryInformation = parsedAnomalies.map((anomaly) => { + if (isCategoryAnomaly(anomaly)) { + if (logEntryCategoriesById[parseInt(anomaly.categoryId, 10)]) { + const { + _source: { regex, terms }, + } = logEntryCategoriesById[parseInt(anomaly.categoryId, 10)]; + return { ...anomaly, ...{ categoryRegex: regex, categoryTerms: terms } }; + } else { + return { ...anomaly, ...{ categoryRegex: '', categoryTerms: '' } }; + } + } else { + return anomaly; + } + }); + const logEntryAnomaliesSpan = finalizeLogEntryAnomaliesSpan(); return { - data, + data: parsedAnomaliesWithExpandedCategoryInformation, paginationCursors, hasMoreEntries, timing: { @@ -208,9 +238,9 @@ async function fetchLogEntryAnomalies( jobIds: string[], startTime: number, endTime: number, - sort: Sort, + sort: AnomaliesSort, pagination: Pagination, - datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] + datasets?: LogEntryAnomalyDatasets ) { // We'll request 1 extra entry on top of our pageSize to determine if there are // more entries to be fetched. This avoids scenarios where the client side can't diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index 7dd5aae9784f5..071a8a94e009b 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -5,14 +5,14 @@ */ import type { ILegacyScopedClusterClient } from 'src/core/server'; -import { LogEntryContext } from '../../../common/http_api'; +import { LogEntryContext } from '../../../common/log_entry'; import { compareDatasetsByMaximumAnomalyScore, getJobId, jobCustomSettingsRT, logEntryCategoriesJobTypes, + CategoriesSort, } from '../../../common/log_analysis'; -import { CategorySort } from '../../../common/http_api/log_analysis'; import { startTracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; @@ -51,7 +51,7 @@ export async function getTopLogEntryCategories( categoryCount: number, datasets: string[], histograms: HistogramParameters[], - sort: CategorySort + sort: CategoriesSort ) { const finalizeTopLogEntryCategoriesSpan = startTracingSpan('get top categories'); @@ -218,7 +218,7 @@ async function fetchTopLogEntryCategories( endTime: number, categoryCount: number, datasets: string[], - sort: CategorySort + sort: CategoriesSort ) { const finalizeEsSearchSpan = startTracingSpan('Fetch top categories from ES'); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts index e692ed019cf86..8e01cafcf62ae 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts @@ -14,10 +14,10 @@ import { createDatasetsFilters, } from './common'; import { - Sort, + AnomaliesSort, + LogEntryAnomalyDatasets, Pagination, - GetLogEntryAnomaliesRequestPayload, -} from '../../../../common/http_api/log_analysis'; +} from '../../../../common/log_analysis'; // TODO: Reassess validity of this against ML docs const TIEBREAKER_FIELD = '_doc'; @@ -32,9 +32,9 @@ export const createLogEntryAnomaliesQuery = ( jobIds: string[], startTime: number, endTime: number, - sort: Sort, + sort: AnomaliesSort, pagination: Pagination, - datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] + datasets?: LogEntryAnomalyDatasets ) => { const { field } = sort; const { pageSize } = pagination; @@ -118,7 +118,7 @@ export const logEntryAnomaliesResponseRT = rt.intersection([ export type LogEntryAnomaliesResponseRT = rt.TypeOf<typeof logEntryAnomaliesResponseRT>; -const parsePaginationCursor = (sort: Sort, pagination: Pagination) => { +const parsePaginationCursor = (sort: AnomaliesSort, pagination: Pagination) => { const { cursor } = pagination; const { direction } = sort; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts index 057054b427227..f1363900d3696 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -14,13 +14,13 @@ import { createDatasetsFilters, } from './common'; -import { CategorySort } from '../../../../common/http_api/log_analysis'; +import { CategoriesSort } from '../../../../common/log_analysis'; type CategoryAggregationOrder = | 'filter_record>maximum_record_score' | 'filter_model_plot>sum_actual'; const getAggregationOrderForSortField = ( - field: CategorySort['field'] + field: CategoriesSort['field'] ): CategoryAggregationOrder => { switch (field) { case 'maximumAnomalyScore': @@ -40,7 +40,7 @@ export const createTopLogEntryCategoriesQuery = ( endTime: number, size: number, datasets: string[], - sort: CategorySort + sort: CategoriesSort ) => ({ ...defaultRequestParameters, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts index ec2bc6e5ed739..42d126d4ef036 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts @@ -11,9 +11,8 @@ import { getLogEntryAnomaliesSuccessReponsePayloadRT, getLogEntryAnomaliesRequestPayloadRT, GetLogEntryAnomaliesRequestPayload, - Sort, - Pagination, } from '../../../../common/http_api/log_analysis'; +import { AnomaliesSort, Pagination } from '../../../../common/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; import { getLogEntryAnomalies } from '../../../lib/log_analysis'; @@ -98,7 +97,7 @@ const getSortAndPagination = ( sort: Partial<GetLogEntryAnomaliesRequestPayload['data']['sort']> = {}, pagination: Partial<GetLogEntryAnomaliesRequestPayload['data']['pagination']> = {} ): { - sort: Sort; + sort: AnomaliesSort; pagination: Pagination; } => { const sortDefaults = { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/README.md similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/README.md rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/README.md diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/constants.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/constants.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/constants.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/http_requests.helpers.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/http_requests.helpers.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/http_requests.helpers.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/pipeline_processors_editor.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/pipeline_processors_editor.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/processor.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/uri_parts.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors/uri_parts.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/uri_parts.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx similarity index 89% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx index 8fb51ade921a9..3fa245ff96d37 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/processors_editor.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors_editor.tsx @@ -9,7 +9,7 @@ import { notificationServiceMock, scopedHistoryMock } from 'src/core/public/mock import { LocationDescriptorObject } from 'history'; import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; -import { ProcessorsEditorContextProvider, Props, PipelineProcessorsEditor } from '../'; +import { ProcessorsEditorContextProvider, Props, PipelineEditor } from '../'; import { breadcrumbService, @@ -36,7 +36,7 @@ export const ProcessorsEditorWithDeps: React.FunctionComponent<Props> = (props) return ( <KibanaContextProvider services={appServices}> <ProcessorsEditorContextProvider {...props}> - <PipelineProcessorsEditor onLoadJson={jest.fn()} /> + <PipelineEditor onLoadJson={jest.fn()} /> </ProcessorsEditorContextProvider> </KibanaContextProvider> ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.helpers.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.helpers.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/__jest__/test_pipeline.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/test_pipeline.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/_shared.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/_shared.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/_shared.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/add_processor_button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/add_processor_button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/add_processor_button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/load_from_json/modal_provider.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/load_from_json/modal_provider.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx similarity index 96% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx index 7adc37d1897d1..fe3e6d79f84d7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/on_failure_processors_title.tsx @@ -14,7 +14,7 @@ export const OnFailureProcessorsTitle: FunctionComponent = () => { const { services } = useKibana(); return ( - <div className="pipelineProcessorsEditor__onFailureTitle"> + <div className="pipelineEditor__onFailureTitle"> <EuiTitle size="xs"> <h4> <FormattedMessage diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/context_menu.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/context_menu.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/context_menu.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/i18n_texts.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/i18n_texts.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/i18n_texts.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/i18n_texts.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/inline_text_input.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/inline_text_input.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.container.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.container.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.container.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/pipeline_processors_editor_item.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/types.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/types.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item/types.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item/types.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_status.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_status.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_status.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_status.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_tooltip/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_tooltip/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_tooltip/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_tooltip/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_toolip.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_toolip.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_toolip.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_toolip.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_tooltip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_tooltip.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_tooltip.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_tooltip/pipeline_processors_editor_item_tooltip.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_tooltip/processor_information.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_tooltip/processor_information.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/pipeline_processors_editor_item_tooltip/processor_information.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/pipeline_processors_editor_item_tooltip/processor_information.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/add_processor_form.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/add_processor_form.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/add_processor_form.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/documentation_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/documentation_button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/documentation_button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/documentation_button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/edit_processor_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/edit_processor_form.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/edit_processor_form.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/edit_processor_form.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/drag_and_drop_text_list.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/drag_and_drop_text_list.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/drag_and_drop_text_list.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/text_editor.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/text_editor.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/text_editor.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/text_editor.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/text_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/text_editor.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/text_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/text_editor.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/xjson_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/xjson_editor.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/field_components/xjson_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/field_components/xjson_editor.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_form.container.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_form.container.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_form.container.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_output/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_output/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_output/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_output/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_output/processor_output.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_output/processor_output.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_output/processor_output.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_output/processor_output.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_output/processor_output.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_output/processor_output.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_output/processor_output.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_output/processor_output.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_settings_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_settings_fields.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processor_settings_fields.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processor_settings_fields.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/append.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/append.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/append.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/append.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/bytes.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/bytes.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/bytes.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/bytes.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/circle.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/circle.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/circle.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/circle.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/common_processor_fields.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/field_name_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/field_name_field.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/field_name_field.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/field_name_field.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/ignore_missing_field.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/processor_type_field.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/processor_type_field.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/processor_type_field.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/properties_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/properties_field.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/properties_field.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/properties_field.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/target_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/target_field.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/common_fields/target_field.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/common_fields/target_field.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/convert.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/convert.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/convert.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/convert.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/csv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/csv.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/csv.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/custom.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/custom.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/custom.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/date.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/date.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/date_index_name.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/date_index_name.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/date_index_name.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/dissect.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/dissect.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dissect.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/dot_expander.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/dot_expander.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/dot_expander.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/drop.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/drop.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/drop.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/drop.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/enrich.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/enrich.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/enrich.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/enrich.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/fail.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/fail.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/fail.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/fail.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/foreach.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/foreach.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/foreach.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/foreach.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/geoip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/geoip.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geoip.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/grok.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/grok.test.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/grok.test.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/grok.test.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/grok.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/grok.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/grok.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/grok.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/gsub.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/gsub.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/gsub.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/html_strip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/html_strip.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/html_strip.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/html_strip.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/inference.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/inference.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/inference.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/join.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/join.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/join.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/join.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/json.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/json.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/json.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/json.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/kv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/kv.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/kv.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/lowercase.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/lowercase.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/lowercase.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/lowercase.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/pipeline.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/pipeline.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/pipeline.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/pipeline.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/remove.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/remove.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/remove.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/remove.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/rename.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/rename.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/rename.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/rename.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/script.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/script.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/script.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/script.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/set.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/set_security_user.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set_security_user.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/set_security_user.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/set_security_user.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/shared.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/shared.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/sort.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/sort.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/sort.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/sort.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/split.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/split.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/split.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/split.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/trim.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/trim.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/trim.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/trim.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/uppercase.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/uppercase.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/uppercase.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/uppercase.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/uri_parts.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/uri_parts.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/uri_parts.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/uri_parts.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/url_decode.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/url_decode.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/url_decode.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/url_decode.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/user_agent.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_form/processors/user_agent.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/user_agent.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_remove_modal.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_remove_modal.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_empty_prompt.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_empty_prompt.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_empty_prompt.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_empty_prompt.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_header.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_header.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_header.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_header.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/drop_zone_button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/drop_zone_button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/drop_zone_button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/private_tree.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/private_tree.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/private_tree.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/tree_node.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/components/tree_node.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/components/tree_node.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/processors_tree.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/processors_tree.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/processors_tree.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/processors_tree.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/processors_tree.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/utils.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_tree/utils.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processors_tree/utils.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/status_icons/error_icon.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/status_icons/error_icon.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/status_icons/error_icon.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/status_icons/error_icon.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/status_icons/error_ignored_icon.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/status_icons/error_ignored_icon.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/status_icons/error_ignored_icon.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/status_icons/error_ignored_icon.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/status_icons/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/status_icons/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/status_icons/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/status_icons/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/status_icons/skipped_icon.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/status_icons/skipped_icon.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/status_icons/skipped_icon.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/status_icons/skipped_icon.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/add_documents_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/add_documents_button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/add_documents_button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/add_documents_button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/documents_dropdown/documents_dropdown.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/documents_dropdown/documents_dropdown.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/documents_dropdown/documents_dropdown.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/documents_dropdown/documents_dropdown.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/documents_dropdown/documents_dropdown.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/documents_dropdown/documents_dropdown.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/documents_dropdown/documents_dropdown.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/documents_dropdown/documents_dropdown.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/documents_dropdown/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/documents_dropdown/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/documents_dropdown/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/documents_dropdown/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_output_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_output_button.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_output_button.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_output_button.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_actions.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_actions.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_actions.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_actions.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_flyout.container.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.container.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_flyout.container.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_flyout.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_flyout.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_flyout.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/add_documents_accordion.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/add_documents_accordion.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/add_documents_accordion.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx similarity index 98% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/add_documents_accordion.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx index 9519d849e5d90..cbbd032f25b3d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/add_documents_accordion.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx @@ -15,7 +15,7 @@ import { useKibana } from '../../../../../../../../shared_imports'; import { useIsMounted } from '../../../../../use_is_mounted'; import { AddDocumentForm } from '../add_document_form'; -import './add_documents_accordion.scss'; +import './add_docs_accordion.scss'; const DISCOVER_URL_GENERATOR_ID = 'DISCOVER_APP_URL_GENERATOR'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/index.ts new file mode 100644 index 0000000000000..5f7939690fa55 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AddDocumentsAccordion } from './add_docs_accordion'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_document_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_document_form.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_document_form.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_document_form.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/reset_documents_modal.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.scss similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.scss rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.scss diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx similarity index 99% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx index 6888f947b8606..dccc343e9359c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/tab_documents.tsx @@ -23,7 +23,7 @@ import { Form, } from '../../../../../../../shared_imports'; import { Document } from '../../../../types'; -import { AddDocumentsAccordion } from './add_documents_accordion'; +import { AddDocumentsAccordion } from './add_docs_accordion'; import { ResetDocumentsModal } from './reset_documents_modal'; import './tab_documents.scss'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_output.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_output.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/tab_output.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_output.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/test_pipeline_tabs.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/test_pipeline_tabs.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/test_pipeline/test_pipeline_tabs/test_pipeline_tabs.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/test_pipeline_tabs.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/constants.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/constants.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/constants.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/context.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/context.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/context.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/processors_context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/processors_context.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/processors_context.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/processors_context.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/test_pipeline_context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/test_pipeline_context.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context/test_pipeline_context.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/context/test_pipeline_context.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/deserialize.test.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.test.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/deserialize.test.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/deserialize.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/deserialize.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/deserialize.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/global_on_failure_processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/editors/global_on_failure_processors_editor.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/global_on_failure_processors_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/editors/global_on_failure_processors_editor.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/editors/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/editors/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/editors/processors_editor.tsx similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/editors/processors_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/editors/processors_editor.tsx diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/index.ts similarity index 86% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/index.ts index ae3dd9d673ebe..05de8c7079eab 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/index.ts @@ -12,4 +12,4 @@ export { SerializeResult } from './serialize'; export { OnDoneLoadJsonHandler } from './components'; -export { PipelineProcessorsEditor } from './pipeline_processors_editor'; +export { PipelineEditor } from './pipeline_editor'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/pipeline_editor.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/pipeline_editor.scss new file mode 100644 index 0000000000000..6a51f4f54f27c --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/pipeline_editor.scss @@ -0,0 +1,11 @@ +.pipelineEditor { + margin-bottom: $euiSizeXL; +} + +.pipelineEditor__container { + background-color: $euiColorLightestShade; +} + +.pipelineEditor__onFailureTitle { + padding-left: $euiSizeS; +} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/pipeline_editor.tsx similarity index 85% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/pipeline_editor.tsx index beb165973d3cd..ce079f87da6c5 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/pipeline_editor.tsx @@ -16,13 +16,13 @@ import { } from './components'; import { ProcessorsEditor, GlobalOnFailureProcessorsEditor } from './editors'; -import './pipeline_processors_editor.scss'; +import './pipeline_editor.scss'; interface Props { onLoadJson: OnDoneLoadJsonHandler; } -export const PipelineProcessorsEditor: React.FunctionComponent<Props> = ({ onLoadJson }) => { +export const PipelineEditor: React.FunctionComponent<Props> = ({ onLoadJson }) => { const { state: { processors: allProcessors }, } = usePipelineProcessorsContext(); @@ -52,12 +52,12 @@ export const PipelineProcessorsEditor: React.FunctionComponent<Props> = ({ onLoa } return ( - <div className="pipelineProcessorsEditor"> + <div className="pipelineEditor"> <EuiFlexGroup gutterSize="m" responsive={false} direction="column"> <EuiFlexItem grow={false}> <ProcessorsHeader onLoadJson={onLoadJson} hasProcessors={processors.length > 0} /> </EuiFlexItem> - <EuiFlexItem grow={false} className="pipelineProcessorsEditor__container"> + <EuiFlexItem grow={false} className="pipelineEditor__container"> {content} </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/constants.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/constants.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/constants.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/index.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/index.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.test.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.test.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.test.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/processors_reducer.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/processors_reducer.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/utils.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/processors_reducer/utils.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/processors_reducer/utils.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/serialize.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/serialize.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/serialize.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/types.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/types.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/types.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/use_is_mounted.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/use_is_mounted.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/use_is_mounted.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.test.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.test.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts similarity index 100% rename from x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/utils.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/utils.ts diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx index ffd82b0bbaf35..ac8612a36dd7e 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form.tsx @@ -11,7 +11,7 @@ import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from import { useForm, Form, FormConfig } from '../../../shared_imports'; import { Pipeline, Processor } from '../../../../common/types'; -import { OnUpdateHandlerArg, OnUpdateHandler } from '../pipeline_processors_editor'; +import { OnUpdateHandlerArg, OnUpdateHandler } from '../pipeline_editor'; import { PipelineRequestFlyout } from './pipeline_request_flyout'; import { PipelineFormFields } from './pipeline_form_fields'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index a7ffe7ba02caa..b1b2e04e7d0dc 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -16,8 +16,8 @@ import { ProcessorsEditorContextProvider, OnUpdateHandler, OnDoneLoadJsonHandler, - PipelineProcessorsEditor, -} from '../pipeline_processors_editor'; + PipelineEditor, +} from '../pipeline_editor'; interface Props { processors: Processor[]; @@ -119,7 +119,7 @@ export const PipelineFormFields: React.FunctionComponent<Props> = ({ onUpdate={onProcessorsUpdate} value={{ processors, onFailure }} > - <PipelineProcessorsEditor onLoadJson={onLoadJson} /> + <PipelineEditor onLoadJson={onLoadJson} /> </ProcessorsEditorContextProvider> </> ); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss deleted file mode 100644 index d5592b87dda51..0000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.scss +++ /dev/null @@ -1,11 +0,0 @@ -.pipelineProcessorsEditor { - margin-bottom: $euiSizeXL; - - &__container { - background-color: $euiColorLightestShade; - } - - &__onFailureTitle { - padding-left: $euiSizeS; - } -} diff --git a/x-pack/plugins/ingest_pipelines/tsconfig.json b/x-pack/plugins/ingest_pipelines/tsconfig.json new file mode 100644 index 0000000000000..5d78992600e81 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "__jest__/**/*", + "../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json"}, + { "path": "../../../src/plugins/kibana_react/tsconfig.json"}, + { "path": "../../../src/plugins/management/tsconfig.json"}, + { "path": "../../../src/plugins/share/tsconfig.json"}, + { "path": "../../../src/plugins/usage_collection/tsconfig.json"}, + ] +} diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 9df3f41fbd855..d473d728dc361 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -14,10 +14,26 @@ "dashboard", "uiActions", "embeddable", - "share" + "share", + "presentationUtil" ], - "optionalPlugins": ["usageCollection", "taskManager", "globalSearch", "savedObjectsTagging"], - "configPath": ["xpack", "lens"], - "extraPublicDirs": ["common/constants"], - "requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable", "presentationUtil"] + "optionalPlugins": [ + "usageCollection", + "taskManager", + "globalSearch", + "savedObjectsTagging" + ], + "configPath": [ + "xpack", + "lens" + ], + "extraPublicDirs": [ + "common/constants" + ], + "requiredBundles": [ + "savedObjects", + "kibanaUtils", + "kibanaReact", + "embeddable" + ] } diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 28e1f6da60742..2dcda656c779b 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -370,6 +370,11 @@ export function App({ state.persistedDoc?.state, ]); + const tagsIds = + state.persistedDoc && savedObjectsTagging + ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references) + : []; + const runSave = async ( saveProps: Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & { returnToOrigin: boolean; @@ -385,8 +390,11 @@ export function App({ } let references = lastKnownDoc.references; - if (savedObjectsTagging && saveProps.newTags) { - references = savedObjectsTagging.ui.updateTagsReferences(references, saveProps.newTags); + if (savedObjectsTagging) { + references = savedObjectsTagging.ui.updateTagsReferences( + references, + saveProps.newTags || tagsIds + ); } const docToSave = { @@ -586,11 +594,6 @@ export function App({ }, }); - const tagsIds = - state.persistedDoc && savedObjectsTagging - ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references) - : []; - return ( <> <div className="lnsApp"> @@ -707,7 +710,6 @@ export function App({ isVisible={state.isSaveModalVisible} originatingApp={state.isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined} allowByValueEmbeddables={dashboardFeatureFlag.allowByValueEmbeddables} - savedObjectsClient={savedObjectsClient} savedObjectsTagging={savedObjectsTagging} tagsIds={tagsIds} onSave={runSave} diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index e769e402ff0e1..c4961b80c5122 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { FC, useCallback } from 'react'; import { AppMountParameters, CoreSetup } from 'kibana/public'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; @@ -39,9 +39,15 @@ export async function mountApp( createEditorFrame: EditorFrameStart['createInstance']; getByValueFeatureFlag: () => Promise<DashboardFeatureFlagConfig>; attributeService: () => Promise<LensAttributeService>; + getPresentationUtilContext: () => Promise<FC>; } ) { - const { createEditorFrame, getByValueFeatureFlag, attributeService } = mountProps; + const { + createEditorFrame, + getByValueFeatureFlag, + attributeService, + getPresentationUtilContext, + } = mountProps; const [coreStart, startDependencies] = await core.getStartServices(); const { data, navigation, embeddable, savedObjectsTagging } = startDependencies; @@ -196,21 +202,26 @@ export async function mountApp( }); params.element.classList.add('lnsAppWrapper'); + + const PresentationUtilContext = await getPresentationUtilContext(); + render( <I18nProvider> <KibanaContextProvider services={lensServices}> - <HashRouter> - <Switch> - <Route exact path="/edit/:id" component={EditorRoute} /> - <Route - exact - path={`/${LENS_EDIT_BY_VALUE}`} - render={(routeProps) => <EditorRoute {...routeProps} editByValue />} - /> - <Route exact path="/" component={EditorRoute} /> - <Route path="/" component={NotFound} /> - </Switch> - </HashRouter> + <PresentationUtilContext> + <HashRouter> + <Switch> + <Route exact path="/edit/:id" component={EditorRoute} /> + <Route + exact + path={`/${LENS_EDIT_BY_VALUE}`} + render={(routeProps) => <EditorRoute {...routeProps} editByValue />} + /> + <Route exact path="/" component={EditorRoute} /> + <Route path="/" component={NotFound} /> + </Switch> + </HashRouter> + </PresentationUtilContext> </KibanaContextProvider> </I18nProvider>, params.element diff --git a/x-pack/plugins/lens/public/app_plugin/save_modal.tsx b/x-pack/plugins/lens/public/app_plugin/save_modal.tsx index 4fa35bd914889..a3ac7322db31f 100644 --- a/x-pack/plugins/lens/public/app_plugin/save_modal.tsx +++ b/x-pack/plugins/lens/public/app_plugin/save_modal.tsx @@ -7,8 +7,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { SavedObjectsStart } from '../../../../../src/core/public'; - import { Document } from '../persistence'; import type { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; @@ -29,8 +27,6 @@ export interface Props { originatingApp?: string; allowByValueEmbeddables: boolean; - savedObjectsClient: SavedObjectsStart['client']; - savedObjectsTagging?: SavedObjectTaggingPluginStart; tagsIds: string[]; @@ -51,7 +47,6 @@ export const SaveModal = (props: Props) => { const { originatingApp, savedObjectsTagging, - savedObjectsClient, tagsIds, lastKnownDoc, allowByValueEmbeddables, @@ -88,7 +83,6 @@ export const SaveModal = (props: Props) => { return ( <TagEnhancedSavedObjectSaveModalDashboard savedObjectsTagging={savedObjectsTagging} - savedObjectsClient={savedObjectsClient} initialTags={tagsIds} onSave={(saveProps) => { const saveToLibrary = saveProps.dashboardId === null; diff --git a/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_dashboard_wrapper.tsx b/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_dashboard_wrapper.tsx index 087cfdc9f3a8a..b191b8829347c 100644 --- a/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_dashboard_wrapper.tsx +++ b/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_dashboard_wrapper.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useMemo, useCallback } from 'react'; import { OnSaveProps } from '../../../../../src/plugins/saved_objects/public'; import { - DashboardSaveModalProps, + SaveModalDashboardProps, SavedObjectSaveModalDashboard, } from '../../../../../src/plugins/presentation_util/public'; import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; @@ -19,7 +19,7 @@ export type DashboardSaveProps = OnSaveProps & { }; export type TagEnhancedSavedObjectSaveModalDashboardProps = Omit< - DashboardSaveModalProps, + SaveModalDashboardProps, 'onSave' > & { initialTags: string[]; @@ -48,7 +48,7 @@ export const TagEnhancedSavedObjectSaveModalDashboard: FC<TagEnhancedSavedObject const tagEnhancedOptions = <>{tagSelectorOption}</>; - const tagEnhancedOnSave: DashboardSaveModalProps['onSave'] = useCallback( + const tagEnhancedOnSave: SaveModalDashboardProps['onSave'] = useCallback( (saveOptions) => { onSave({ ...saveOptions, diff --git a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap deleted file mode 100644 index 23460d442cfa8..0000000000000 --- a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap +++ /dev/null @@ -1,119 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`datatable_expression DatatableComponent it renders actions column when there are row actions 1`] = ` -<VisualizationContainer - reportTitle="My fanci metric chart" -> - <EuiBasicTable - className="lnsDataTable" - columns={ - Array [ - Object { - "field": "a", - "name": "a", - "render": [Function], - "sortable": true, - }, - Object { - "field": "b", - "name": "b", - "render": [Function], - "sortable": true, - }, - Object { - "field": "c", - "name": "c", - "render": [Function], - "sortable": true, - }, - Object { - "actions": Array [ - Object { - "description": "Table row context menu", - "icon": [Function], - "name": "More", - "onClick": [Function], - "type": "icon", - }, - ], - "name": "Actions", - }, - ] - } - data-test-subj="lnsDataTable" - items={ - Array [ - Object { - "a": "shoes", - "b": 1588024800000, - "c": 3, - "rowIndex": 0, - }, - ] - } - noItemsMessage="No items found" - onChange={[Function]} - responsive={true} - sorting={ - Object { - "allowNeutralSort": true, - "sort": undefined, - } - } - tableLayout="auto" - /> -</VisualizationContainer> -`; - -exports[`datatable_expression DatatableComponent it renders the title and value 1`] = ` -<VisualizationContainer - reportTitle="My fanci metric chart" -> - <EuiBasicTable - className="lnsDataTable" - columns={ - Array [ - Object { - "field": "a", - "name": "a", - "render": [Function], - "sortable": true, - }, - Object { - "field": "b", - "name": "b", - "render": [Function], - "sortable": true, - }, - Object { - "field": "c", - "name": "c", - "render": [Function], - "sortable": true, - }, - ] - } - data-test-subj="lnsDataTable" - items={ - Array [ - Object { - "a": "shoes", - "b": 1588024800000, - "c": 3, - "rowIndex": 0, - }, - ] - } - noItemsMessage="No items found" - onChange={[Function]} - responsive={true} - sorting={ - Object { - "allowNeutralSort": true, - "sort": undefined, - } - } - tableLayout="auto" - /> -</VisualizationContainer> -`; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap new file mode 100644 index 0000000000000..a4eb99a972b9b --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -0,0 +1,534 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatatableComponent it renders actions column when there are row actions 1`] = ` +<VisualizationContainer + className="lnsDataTableContainer" + reportTitle="My fanci metric chart" +> + <ContextProvider + value={ + Object { + "rowHasRowClickTriggerActions": Array [ + true, + true, + true, + ], + "table": Object { + "columns": Array [ + Object { + "id": "a", + "meta": Object { + "field": "a", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "terms", + }, + "type": "string", + }, + "name": "a", + }, + Object { + "id": "b", + "meta": Object { + "field": "b", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "date_histogram", + }, + "type": "date", + }, + "name": "b", + }, + Object { + "id": "c", + "meta": Object { + "field": "c", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "count", + }, + "type": "number", + }, + "name": "c", + }, + ], + "rows": Array [ + Object { + "a": "shoes", + "b": 1588024800000, + "c": 3, + }, + ], + "type": "datatable", + }, + } + } + > + <EuiDataGrid + aria-label="My fanci metric chart" + columnVisibility={ + Object { + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "a", + "b", + "c", + ], + } + } + columns={ + Array [ + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "a", + "displayAsText": "a", + "id": "a", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "b", + "displayAsText": "b", + "id": "b", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "c", + "displayAsText": "c", + "id": "c", + }, + ] + } + data-test-subj="lnsDataTable" + gridStyle={ + Object { + "border": "horizontal", + "header": "underline", + } + } + onColumnResize={[Function]} + renderCellValue={[Function]} + rowCount={1} + sorting={ + Object { + "columns": Array [], + "onSort": [Function], + } + } + toolbarVisibility={false} + trailingControlColumns={ + Array [ + Object { + "headerCellRender": [Function], + "id": "trailingControlColumn", + "rowCellRender": [Function], + "width": 40, + }, + ] + } + /> + </ContextProvider> +</VisualizationContainer> +`; + +exports[`DatatableComponent it renders the title and value 1`] = ` +<VisualizationContainer + className="lnsDataTableContainer" + reportTitle="My fanci metric chart" +> + <ContextProvider + value={ + Object { + "rowHasRowClickTriggerActions": undefined, + "table": Object { + "columns": Array [ + Object { + "id": "a", + "meta": Object { + "field": "a", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "terms", + }, + "type": "string", + }, + "name": "a", + }, + Object { + "id": "b", + "meta": Object { + "field": "b", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "date_histogram", + }, + "type": "date", + }, + "name": "b", + }, + Object { + "id": "c", + "meta": Object { + "field": "c", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "count", + }, + "type": "number", + }, + "name": "c", + }, + ], + "rows": Array [ + Object { + "a": "shoes", + "b": 1588024800000, + "c": 3, + }, + ], + "type": "datatable", + }, + } + } + > + <EuiDataGrid + aria-label="My fanci metric chart" + columnVisibility={ + Object { + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "a", + "b", + "c", + ], + } + } + columns={ + Array [ + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "a", + "displayAsText": "a", + "id": "a", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "b", + "displayAsText": "b", + "id": "b", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "c", + "displayAsText": "c", + "id": "c", + }, + ] + } + data-test-subj="lnsDataTable" + gridStyle={ + Object { + "border": "horizontal", + "header": "underline", + } + } + onColumnResize={[Function]} + renderCellValue={[Function]} + rowCount={1} + sorting={ + Object { + "columns": Array [], + "onSort": [Function], + } + } + toolbarVisibility={false} + trailingControlColumns={Array []} + /> + </ContextProvider> +</VisualizationContainer> +`; + +exports[`DatatableComponent it should not render actions on header when it is in read only mode 1`] = ` +<VisualizationContainer + className="lnsDataTableContainer" + reportTitle="My fanci metric chart" +> + <ContextProvider + value={ + Object { + "rowHasRowClickTriggerActions": Array [ + false, + false, + false, + ], + "table": Object { + "columns": Array [ + Object { + "id": "a", + "meta": Object { + "field": "a", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "terms", + }, + "type": "string", + }, + "name": "a", + }, + Object { + "id": "b", + "meta": Object { + "field": "b", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "date_histogram", + }, + "type": "date", + }, + "name": "b", + }, + Object { + "id": "c", + "meta": Object { + "field": "c", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "count", + }, + "type": "number", + }, + "name": "c", + }, + ], + "rows": Array [ + Object { + "a": "shoes", + "b": 1588024800000, + "c": 3, + }, + ], + "type": "datatable", + }, + } + } + > + <EuiDataGrid + aria-label="My fanci metric chart" + columnVisibility={ + Object { + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "a", + "b", + "c", + ], + } + } + columns={ + Array [ + Object { + "actions": Object { + "additional": undefined, + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": false, + "showSortDesc": false, + }, + "cellActions": undefined, + "display": "a", + "displayAsText": "a", + "id": "a", + }, + Object { + "actions": Object { + "additional": undefined, + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": false, + "showSortDesc": false, + }, + "cellActions": undefined, + "display": "b", + "displayAsText": "b", + "id": "b", + }, + Object { + "actions": Object { + "additional": undefined, + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": false, + "showSortDesc": false, + }, + "cellActions": undefined, + "display": "c", + "displayAsText": "c", + "id": "c", + }, + ] + } + data-test-subj="lnsDataTable" + gridStyle={ + Object { + "border": "horizontal", + "header": "underline", + } + } + onColumnResize={[Function]} + renderCellValue={[Function]} + rowCount={1} + sorting={ + Object { + "columns": Array [], + "onSort": [Function], + } + } + toolbarVisibility={false} + trailingControlColumns={Array []} + /> + </ContextProvider> +</VisualizationContainer> +`; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx new file mode 100644 index 0000000000000..a8328f5eefdca --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import type { FormatFactory } from '../../types'; +import type { DataContextType } from './types'; + +export const createGridCell = ( + formatters: Record<string, ReturnType<FormatFactory>>, + DataContext: React.Context<DataContextType> +) => ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const { table } = useContext(DataContext); + const rowValue = table?.rows[rowIndex][columnId]; + const content = formatters[columnId]?.convert(rowValue, 'html'); + + return ( + <span + /* + * dangerouslySetInnerHTML is necessary because the field formatter might produce HTML markup + * which is produced in a safe way. + */ + dangerouslySetInnerHTML={{ __html: content }} // eslint-disable-line react/no-danger + data-test-subj="lnsTableCellContent" + className="lnsDataTableCellContent" + /> + ); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx new file mode 100644 index 0000000000000..83a8d026f1315 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import type { Datatable, DatatableColumnMeta } from 'src/plugins/expressions'; +import type { FormatFactory } from '../../types'; +import type { DatatableColumns } from './types'; + +export const createGridColumns = ( + bucketColumns: string[], + table: Datatable, + handleFilterClick: ( + field: string, + value: unknown, + colIndex: number, + rowIndex: number, + negate?: boolean + ) => void, + isReadOnly: boolean, + columnConfig: DatatableColumns & { type: 'lens_datatable_columns' }, + visibleColumns: string[], + formatFactory: FormatFactory, + onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void +) => { + const columnsReverseLookup = table.columns.reduce< + Record<string, { name: string; index: number; meta?: DatatableColumnMeta }> + >((memo, { id, name, meta }, i) => { + memo[id] = { name, index: i, meta }; + return memo; + }, {}); + + const bucketLookup = new Set(bucketColumns); + + const getContentData = ({ + rowIndex, + columnId, + }: Pick<EuiDataGridColumnCellActionProps, 'rowIndex' | 'columnId'>) => { + const rowValue = table.rows[rowIndex][columnId]; + const column = columnsReverseLookup[columnId]; + const contentsIsDefined = rowValue != null; + + const cellContent = formatFactory(column?.meta?.params).convert(rowValue); + return { rowValue, contentsIsDefined, cellContent }; + }; + + return visibleColumns.map((field) => { + const filterable = bucketLookup.has(field); + const { name, index: colIndex } = columnsReverseLookup[field]; + + const cellActions = filterable + ? [ + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const { rowValue, contentsIsDefined, cellContent } = getContentData({ + rowIndex, + columnId, + }); + + const filterForText = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterForValueText', + { + defaultMessage: 'Filter for value', + } + ); + const filterForAriaLabel = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterForValueAriaLabel', + { + defaultMessage: 'Filter for value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + <Component + aria-label={filterForAriaLabel} + data-test-subj="lensDatatableFilterFor" + onClick={() => { + handleFilterClick(field, rowValue, colIndex, rowIndex); + closePopover(); + }} + iconType="plusInCircle" + > + {filterForText} + </Component> + ) + ); + }, + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const { rowValue, contentsIsDefined, cellContent } = getContentData({ + rowIndex, + columnId, + }); + + const filterOutText = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterOutValueText', + { + defaultMessage: 'Filter out value', + } + ); + const filterOutAriaLabel = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterOutValueAriaLabel', + { + defaultMessage: 'Filter out value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + <Component + data-test-subj="lensDatatableFilterOut" + aria-label={filterOutAriaLabel} + onClick={() => { + handleFilterClick(field, rowValue, colIndex, rowIndex, true); + closePopover(); + }} + iconType="minusInCircle" + > + {filterOutText} + </Component> + ) + ); + }, + ] + : undefined; + + const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) + ?.width; + + const columnDefinition: EuiDataGridColumn = { + id: field, + cellActions, + display: name, + displayAsText: name, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: isReadOnly + ? false + : { + label: i18n.translate('xpack.lens.table.sort.ascLabel', { + defaultMessage: 'Sort asc', + }), + }, + showSortDesc: isReadOnly + ? false + : { + label: i18n.translate('xpack.lens.table.sort.descLabel', { + defaultMessage: 'Sort desc', + }), + }, + additional: isReadOnly + ? undefined + : [ + { + color: 'text', + size: 'xs', + onClick: () => onColumnResize({ columnId: field, width: undefined }), + iconType: 'empty', + label: i18n.translate('xpack.lens.table.resize.reset', { + defaultMessage: 'Reset width', + }), + 'data-test-subj': 'lensDatatableResetWidth', + isDisabled: initialWidth == null, + }, + ], + }, + }; + + if (initialWidth) { + columnDefinition.initialWidth = initialWidth; + } + + return columnDefinition; + }); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts new file mode 100644 index 0000000000000..4779d42859a79 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const LENS_EDIT_SORT_ACTION = 'sort'; +export const LENS_EDIT_RESIZE_ACTION = 'resize'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts new file mode 100644 index 0000000000000..dad9aa30b7712 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { EuiDataGridSorting } from '@elastic/eui'; +import { Datatable } from 'src/plugins/expressions'; + +import { + createGridFilterHandler, + createGridResizeHandler, + createGridSortingConfig, +} from './table_actions'; +import { DatatableColumns, LensGridDirection } from './types'; + +function getDefaultConfig(): DatatableColumns & { + type: 'lens_datatable_columns'; +} { + return { + columnIds: [], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }; +} + +function createTableRef( + { withDate }: { withDate: boolean } = { withDate: false } +): React.MutableRefObject<Datatable> { + return { + current: { + type: 'datatable', + rows: [], + columns: [ + { + id: 'a', + name: 'field', + meta: { type: withDate ? 'date' : 'number', field: 'a' }, + }, + ], + }, + }; +} + +describe('Table actions', () => { + const onEditAction = jest.fn(); + + describe('Table filtering', () => { + it('should set a filter on click with the correct configuration', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef(); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: false, + timeFieldName: 'a', + }); + }); + + it('should set a negate filter on click with the correct confgiuration', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef(); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0, true); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: true, + timeFieldName: 'a', + }); + }); + + it('should set a time filter on click', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef({ withDate: true }); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: false, + timeFieldName: 'a', + }); + }); + + it('should set a negative time filter on click', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef({ withDate: true }); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0, true); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: true, + timeFieldName: undefined, + }); + }); + }); + describe('Table sorting', () => { + it('should create the right configuration for all types of sorting', () => { + const configs: Array<{ + input: { direction: LensGridDirection; sortBy: string }; + output: EuiDataGridSorting['columns']; + }> = [ + { input: { direction: 'asc', sortBy: 'a' }, output: [{ id: 'a', direction: 'asc' }] }, + { input: { direction: 'none', sortBy: 'a' }, output: [] }, + { input: { direction: 'asc', sortBy: '' }, output: [] }, + ]; + for (const { input, output } of configs) { + const { sortBy, direction } = input; + expect(createGridSortingConfig(sortBy, direction, onEditAction)).toMatchObject( + expect.objectContaining({ columns: output }) + ); + } + }); + + it('should return the correct next configuration value based on the current state', () => { + const sorter = createGridSortingConfig('a', 'none', onEditAction); + // Click on the 'a' column + sorter.onSort([{ id: 'a', direction: 'asc' }]); + + // Click on another column 'b' + sorter.onSort([ + { id: 'a', direction: 'asc' }, + { id: 'b', direction: 'asc' }, + ]); + + // Change the sorting of 'a' + sorter.onSort([{ id: 'a', direction: 'desc' }]); + + // Toggle the 'a' current sorting (remove sorting) + sorter.onSort([]); + + expect(onEditAction.mock.calls).toEqual([ + [ + { + action: 'sort', + columnId: 'a', + direction: 'asc', + }, + ], + [ + { + action: 'sort', + columnId: 'b', + direction: 'asc', + }, + ], + [ + { + action: 'sort', + columnId: 'a', + direction: 'desc', + }, + ], + [ + { + action: 'sort', + columnId: undefined, + direction: 'none', + }, + ], + ]); + }); + }); + describe('Table resize', () => { + const setColumnConfig = jest.fn(); + + it('should resize the table locally and globally with the given size', () => { + const columnConfig = getDefaultConfig(); + const resizer = createGridResizeHandler(columnConfig, setColumnConfig, onEditAction); + resizer({ columnId: 'a', width: 100 }); + + expect(setColumnConfig).toHaveBeenCalledWith({ + ...columnConfig, + columnWidth: [{ columnId: 'a', width: 100, type: 'lens_datatable_column_width' }], + }); + + expect(onEditAction).toHaveBeenCalledWith({ action: 'resize', columnId: 'a', width: 100 }); + }); + + it('should pull out the table custom width from the local state when passing undefined', () => { + const columnConfig = getDefaultConfig(); + columnConfig.columnWidth = [ + { columnId: 'a', width: 100, type: 'lens_datatable_column_width' }, + ]; + + const resizer = createGridResizeHandler(columnConfig, setColumnConfig, onEditAction); + resizer({ columnId: 'a', width: undefined }); + + expect(setColumnConfig).toHaveBeenCalledWith({ + ...columnConfig, + columnWidth: [], + }); + + expect(onEditAction).toHaveBeenCalledWith({ + action: 'resize', + columnId: 'a', + width: undefined, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts new file mode 100644 index 0000000000000..38534482b81fa --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import type { EuiDataGridSorting } from '@elastic/eui'; +import type { Datatable } from 'src/plugins/expressions'; +import type { LensFilterEvent } from '../../types'; +import type { + DatatableColumns, + LensGridDirection, + LensResizeAction, + LensSortAction, +} from './types'; + +import { desanitizeFilterContext } from '../../utils'; + +export const createGridResizeHandler = ( + columnConfig: DatatableColumns & { + type: 'lens_datatable_columns'; + }, + setColumnConfig: React.Dispatch< + React.SetStateAction< + DatatableColumns & { + type: 'lens_datatable_columns'; + } + > + >, + onEditAction: (data: LensResizeAction['data']) => void +) => (eventData: { columnId: string; width: number | undefined }) => { + // directly set the local state of the component to make sure the visualization re-renders immediately, + // re-layouting and taking up all of the available space. + setColumnConfig({ + ...columnConfig, + columnWidth: [ + ...(columnConfig.columnWidth || []).filter(({ columnId }) => columnId !== eventData.columnId), + ...(eventData.width !== undefined + ? [ + { + columnId: eventData.columnId, + width: eventData.width, + type: 'lens_datatable_column_width' as const, + }, + ] + : []), + ], + }); + return onEditAction({ + action: 'resize', + columnId: eventData.columnId, + width: eventData.width, + }); +}; + +export const createGridFilterHandler = ( + tableRef: React.MutableRefObject<Datatable>, + onClickValue: (data: LensFilterEvent['data']) => void +) => ( + field: string, + value: unknown, + colIndex: number, + rowIndex: number, + negate: boolean = false +) => { + const col = tableRef.current.columns[colIndex]; + const isDate = col.meta?.type === 'date'; + const timeFieldName = negate && isDate ? undefined : col?.meta?.field; + + const data: LensFilterEvent['data'] = { + negate, + data: [ + { + row: rowIndex, + column: colIndex, + value, + table: tableRef.current, + }, + ], + timeFieldName, + }; + + onClickValue(desanitizeFilterContext(data)); +}; + +export const createGridSortingConfig = ( + sortBy: string, + sortDirection: LensGridDirection, + onEditAction: (data: LensSortAction['data']) => void +): EuiDataGridSorting => ({ + columns: + !sortBy || sortDirection === 'none' + ? [] + : [ + { + id: sortBy, + direction: sortDirection, + }, + ], + onSort: (sortingCols) => { + const newSortValue: + | { + id: string; + direction: Exclude<LensGridDirection, 'none'>; + } + | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; + const isNewColumn = sortBy !== (newSortValue?.id || ''); + const nextDirection = newSortValue ? newSortValue.direction : 'none'; + + return onEditAction({ + action: 'sort', + columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, + direction: nextDirection, + }); + }, +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss new file mode 100644 index 0000000000000..5e5db2c645809 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss @@ -0,0 +1,3 @@ +.lnsDataTableContainer { + height: 100%; +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx new file mode 100644 index 0000000000000..df5dba749a60c --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -0,0 +1,425 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { mountWithIntl } from '@kbn/test/jest'; +import { EuiDataGrid } from '@elastic/eui'; +import { IAggType, IFieldFormat } from 'src/plugins/data/public'; +import { EmptyPlaceholder } from '../../shared_components'; +import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { DatatableComponent } from './table_basic'; +import { LensMultiTable } from '../../types'; +import { DatatableProps } from '../expression'; + +function sampleArgs() { + const indexPatternId = 'indexPatternId'; + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'string', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'terms', indexPatternId }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'date', + field: 'b', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + indexPatternId, + }, + }, + }, + { + id: 'c', + name: 'c', + meta: { + type: 'number', + source: 'esaggs', + field: 'c', + sourceParams: { indexPatternId, type: 'count' }, + }, + }, + ], + rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: 'My fanci metric chart', + columns: { + columnIds: ['a', 'b', 'c'], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }, + }; + + return { data, args }; +} + +function copyData(data: LensMultiTable): LensMultiTable { + return JSON.parse(JSON.stringify(data)); +} + +describe('DatatableComponent', () => { + let onDispatchEvent: jest.Mock; + + beforeEach(() => { + onDispatchEvent = jest.fn(); + }); + + test('it renders the title and value', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + <DatatableComponent + data={data} + args={args} + formatFactory={(x) => x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ) + ).toMatchSnapshot(); + }); + + test('it renders actions column when there are row actions', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + <DatatableComponent + data={data} + args={args} + formatFactory={(x) => x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + rowHasRowClickTriggerActions={[true, true, true]} + renderMode="edit" + /> + ) + ).toMatchSnapshot(); + }); + + test('it should not render actions on header when it is in read only mode', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + <DatatableComponent + data={data} + args={args} + formatFactory={(x) => x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + rowHasRowClickTriggerActions={[false, false, false]} + renderMode="display" + /> + ) + ).toMatchSnapshot(); + }); + + test('it invokes executeTriggerActions with correct context on click on top value', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={{ + ...data, + dateRange: { + fromDate: new Date('2020-04-20T05:00:00.000Z'), + toDate: new Date('2020-05-03T05:00:00.000Z'), + }, + }} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterOut"]').first().simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 'shoes', + }, + ], + negate: true, + timeFieldName: 'a', + }, + }); + }); + + test('it invokes executeTriggerActions with correct context on click on timefield', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={{ + ...data, + dateRange: { + fromDate: new Date('2020-04-20T05:00:00.000Z'), + toDate: new Date('2020-05-03T05:00:00.000Z'), + }, + }} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(3).simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 1, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'b', + }, + }); + }); + + test('it invokes executeTriggerActions with correct context on click on timefield from range', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'date', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'date_range', indexPatternId: 'a' }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'number', + source: 'esaggs', + sourceParams: { type: 'count', indexPatternId: 'a' }, + }, + }, + ], + rows: [{ a: 1588024800000, b: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: '', + columns: { + columnIds: ['a', 'b'], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }, + }; + + const wrapper = mountWithIntl( + <DatatableComponent + data={{ + ...data, + dateRange: { + fromDate: new Date('2020-04-20T05:00:00.000Z'), + toDate: new Date('2020-05-03T05:00:00.000Z'), + }, + }} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'a', + }, + }); + }); + + test('it shows emptyPlaceholder for undefined bucketed data', () => { + const { args, data } = sampleArgs(); + const emptyData: LensMultiTable = { + ...data, + tables: { + l1: { + ...data.tables.l1, + rows: [{ a: undefined, b: undefined, c: 0 }], + }, + }, + }; + + const component = shallow( + <DatatableComponent + data={emptyData} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn((type) => + type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) + )} + renderMode="edit" + /> + ); + expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); + }); + + test('it renders the table with the given sorting', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={data} + args={{ + ...args, + columns: { + ...args.columns, + sortBy: 'b', + sortDirection: 'desc', + }, + }} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ); + + expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ + { id: 'b', direction: 'desc' }, + ]); + + wrapper.find(EuiDataGrid).prop('sorting')!.onSort([]); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'edit', + data: { + action: 'sort', + columnId: undefined, + direction: 'none', + }, + }); + + wrapper + .find(EuiDataGrid) + .prop('sorting')! + .onSort([{ id: 'a', direction: 'asc' }]); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'edit', + data: { + action: 'sort', + columnId: 'a', + direction: 'asc', + }, + }); + }); + + test('it renders the table with the given sorting in readOnly mode', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={data} + args={{ + ...args, + columns: { + ...args.columns, + sortBy: 'b', + sortDirection: 'desc', + }, + }} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + /> + ); + + expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ + { id: 'b', direction: 'desc' }, + ]); + }); + + test('it should refresh the table header when the datatable data changes', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={data} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ); + // mnake a copy of the data, changing only the name of the first column + const newData = copyData(data); + newData.tables.l1.columns[0].name = 'new a'; + wrapper.setProps({ data: newData }); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="dataGridHeader"]').children().first().text()).toEqual( + 'new a' + ); + }); +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx new file mode 100644 index 0000000000000..171074d6e6797 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './table_basic.scss'; + +import React, { useCallback, useMemo, useRef, useState, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; +import { + EuiButtonIcon, + EuiDataGrid, + EuiDataGridControlColumn, + EuiDataGridColumn, + EuiDataGridSorting, + EuiDataGridStyle, +} from '@elastic/eui'; +import { FormatFactory, LensFilterEvent, LensTableRowContextMenuEvent } from '../../types'; +import { VisualizationContainer } from '../../visualization_container'; +import { EmptyPlaceholder } from '../../shared_components'; +import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { + DataContextType, + DatatableRenderProps, + LensSortAction, + LensResizeAction, + LensGridDirection, +} from './types'; +import { createGridColumns } from './columns'; +import { createGridCell } from './cell_value'; +import { + createGridFilterHandler, + createGridResizeHandler, + createGridSortingConfig, +} from './table_actions'; + +const DataContext = React.createContext<DataContextType>({}); + +const gridStyle: EuiDataGridStyle = { + border: 'horizontal', + header: 'underline', +}; + +export const DatatableComponent = (props: DatatableRenderProps) => { + const [firstTable] = Object.values(props.data.tables); + + const [columnConfig, setColumnConfig] = useState(props.args.columns); + const [firstLocalTable, updateTable] = useState(firstTable); + + useDeepCompareEffect(() => { + setColumnConfig(props.args.columns); + }, [props.args.columns]); + + useDeepCompareEffect(() => { + updateTable(firstTable); + }, [firstTable]); + + const firstTableRef = useRef(firstLocalTable); + firstTableRef.current = firstLocalTable; + + const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions?.some((x) => x); + + const { getType, dispatchEvent, renderMode, formatFactory } = props; + + const formatters: Record<string, ReturnType<FormatFactory>> = useMemo( + () => + firstLocalTable.columns.reduce( + (map, column) => ({ + ...map, + [column.id]: formatFactory(column.meta?.params), + }), + {} + ), + [firstLocalTable, formatFactory] + ); + + const onClickValue = useCallback( + (data: LensFilterEvent['data']) => { + dispatchEvent({ name: 'filter', data }); + }, + [dispatchEvent] + ); + + const onEditAction = useCallback( + (data: LensSortAction['data'] | LensResizeAction['data']) => { + if (renderMode === 'edit') { + dispatchEvent({ name: 'edit', data }); + } + }, + [dispatchEvent, renderMode] + ); + const onRowContextMenuClick = useCallback( + (data: LensTableRowContextMenuEvent['data']) => { + dispatchEvent({ name: 'tableRowContextMenuClick', data }); + }, + [dispatchEvent] + ); + + const handleFilterClick = useMemo(() => createGridFilterHandler(firstTableRef, onClickValue), [ + firstTableRef, + onClickValue, + ]); + + const bucketColumns = useMemo( + () => + columnConfig.columnIds.filter((_colId, index) => { + const col = firstTableRef.current.columns[index]; + return ( + col?.meta?.sourceParams?.type && + getType(col.meta.sourceParams.type as string)?.type === 'buckets' + ); + }), + [firstTableRef, columnConfig, getType] + ); + + const isEmpty = + firstLocalTable.rows.length === 0 || + (bucketColumns.length && + firstTable.rows.every((row) => bucketColumns.every((col) => row[col] == null))); + + const visibleColumns = useMemo(() => columnConfig.columnIds.filter((field) => !!field), [ + columnConfig, + ]); + + const { sortBy, sortDirection } = columnConfig; + + const isReadOnlySorted = renderMode !== 'edit'; + + const onColumnResize = useMemo( + () => createGridResizeHandler(columnConfig, setColumnConfig, onEditAction), + [onEditAction, setColumnConfig, columnConfig] + ); + + const columns: EuiDataGridColumn[] = useMemo( + () => + createGridColumns( + bucketColumns, + firstLocalTable, + handleFilterClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory, + onColumnResize + ), + [ + bucketColumns, + firstLocalTable, + handleFilterClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory, + onColumnResize, + ] + ); + + const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { + if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { + return []; + } + return [ + { + headerCellRender: () => null, + width: 40, + id: 'trailingControlColumn', + rowCellRender: function RowCellRender({ rowIndex }) { + const { rowHasRowClickTriggerActions } = useContext(DataContext); + return ( + <EuiButtonIcon + aria-label={i18n.translate('xpack.lens.table.actionsLabel', { + defaultMessage: 'Show actions', + })} + iconType={ + !!rowHasRowClickTriggerActions && !rowHasRowClickTriggerActions[rowIndex] + ? 'empty' + : 'boxesVertical' + } + color="text" + onClick={() => { + onRowContextMenuClick({ + rowIndex, + table: firstTableRef.current, + columns: columnConfig.columnIds, + }); + }} + /> + ); + }, + }, + ]; + }, [firstTableRef, onRowContextMenuClick, columnConfig, hasAtLeastOneRowClickAction]); + + const renderCellValue = useMemo(() => createGridCell(formatters, DataContext), [formatters]); + + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns: () => {} }), [ + visibleColumns, + ]); + + const sorting = useMemo<EuiDataGridSorting>( + () => createGridSortingConfig(sortBy, sortDirection as LensGridDirection, onEditAction), + [onEditAction, sortBy, sortDirection] + ); + + if (isEmpty) { + return <EmptyPlaceholder icon={LensIconChartDatatable} />; + } + + const dataGridAriaLabel = + props.args.title || + i18n.translate('xpack.lens.table.defaultAriaLabel', { + defaultMessage: 'Data table visualization', + }); + + return ( + <VisualizationContainer + className="lnsDataTableContainer" + reportTitle={props.args.title} + reportDescription={props.args.description} + > + <DataContext.Provider + value={{ + table: firstLocalTable, + rowHasRowClickTriggerActions: props.rowHasRowClickTriggerActions, + }} + > + <EuiDataGrid + aria-label={dataGridAriaLabel} + data-test-subj="lnsDataTable" + columns={columns} + columnVisibility={columnVisibility} + trailingControlColumns={trailingControlColumns} + rowCount={firstLocalTable.rows.length} + renderCellValue={renderCellValue} + gridStyle={gridStyle} + sorting={sorting} + onColumnResize={onColumnResize} + toolbarVisibility={false} + /> + </DataContext.Provider> + </VisualizationContainer> + ); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts new file mode 100644 index 0000000000000..4f1a1141fdaa8 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { Direction } from '@elastic/eui'; +import type { IAggType } from 'src/plugins/data/public'; +import type { Datatable, RenderMode } from 'src/plugins/expressions'; +import type { FormatFactory, ILensInterpreterRenderHandlers, LensEditEvent } from '../../types'; +import type { DatatableProps } from '../expression'; +import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION } from './constants'; + +export type LensGridDirection = 'none' | Direction; + +export interface LensSortActionData { + columnId: string | undefined; + direction: LensGridDirection; +} + +export interface LensResizeActionData { + columnId: string; + width: number | undefined; +} + +export type LensSortAction = LensEditEvent<typeof LENS_EDIT_SORT_ACTION>; +export type LensResizeAction = LensEditEvent<typeof LENS_EDIT_RESIZE_ACTION>; + +export interface DatatableColumns { + columnIds: string[]; + sortBy: string; + sortDirection: string; + columnWidth?: DatatableColumnWidthResult[]; +} + +export interface DatatableColumnWidth { + columnId: string; + width: number; +} + +export type DatatableColumnWidthResult = DatatableColumnWidth & { + type: 'lens_datatable_column_width'; +}; + +export type DatatableRenderProps = DatatableProps & { + formatFactory: FormatFactory; + dispatchEvent: ILensInterpreterRenderHandlers['event']; + getType: (name: string) => IAggType; + renderMode: RenderMode; + + /** + * A boolean for each table row, which is true if the row active + * ROW_CLICK_TRIGGER actions attached to it, otherwise false. + */ + rowHasRowClickTriggerActions?: boolean[]; +}; + +export interface DatatableRender { + type: 'render'; + as: 'lens_datatable_renderer'; + value: DatatableProps; +} + +export interface DataContextType { + table?: Datatable; + rowHasRowClickTriggerActions?: boolean[]; +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.scss b/x-pack/plugins/lens/public/datatable_visualization/expression.scss deleted file mode 100644 index 7d95d73143870..0000000000000 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.scss +++ /dev/null @@ -1,13 +0,0 @@ -.lnsDataTable { - align-self: flex-start; -} - -.lnsDataTable__filter { - opacity: 0; - transition: opacity $euiAnimSpeedNormal ease-in-out; -} - -.lnsDataTable__cell:hover .lnsDataTable__filter, -.lnsDataTable__filter:focus-within { - opacity: 1; -} diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index d0811e0ad05a6..60d9461a5e0d9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -4,18 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { shallow } from 'enzyme'; -import { mountWithIntl } from '@kbn/test/jest'; -import { getDatatable, DatatableComponent } from './expression'; +import { DatatableProps, getDatatable } from './expression'; import { LensMultiTable } from '../types'; -import { DatatableProps } from './expression'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { IFieldFormat } from '../../../../../src/plugins/data/public'; -import { IAggType } from 'src/plugins/data/public'; -import { EmptyPlaceholder } from '../shared_components'; -import { LensIconChartDatatable } from '../assets/chart_datatable'; -import { EuiBasicTable } from '@elastic/eui'; function sampleArgs() { const indexPatternId = 'indexPatternId'; @@ -78,14 +70,6 @@ function sampleArgs() { } describe('datatable_expression', () => { - let onClickValue: jest.Mock; - let onEditAction: jest.Mock; - - beforeEach(() => { - onClickValue = jest.fn(); - onEditAction = jest.fn(); - }); - describe('datatable renders', () => { test('it renders with the specified data and args', () => { const { data, args } = sampleArgs(); @@ -102,296 +86,4 @@ describe('datatable_expression', () => { }); }); }); - - describe('DatatableComponent', () => { - test('it renders the title and value', () => { - const { data, args } = sampleArgs(); - - expect( - shallow( - <DatatableComponent - data={data} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn()} - renderMode="edit" - /> - ) - ).toMatchSnapshot(); - }); - - test('it renders actions column when there are row actions', () => { - const { data, args } = sampleArgs(); - - expect( - shallow( - <DatatableComponent - data={data} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn()} - onRowContextMenuClick={() => undefined} - rowHasRowClickTriggerActions={[true, true, true]} - renderMode="edit" - /> - ) - ).toMatchSnapshot(); - }); - - test('it invokes executeTriggerActions with correct context on click on top value', () => { - const { args, data } = sampleArgs(); - - const wrapper = mountWithIntl( - <DatatableComponent - data={{ - ...data, - dateRange: { - fromDate: new Date('2020-04-20T05:00:00.000Z'), - toDate: new Date('2020-05-03T05:00:00.000Z'), - }, - }} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterOut"]').first().simulate('click'); - - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 0, - row: 0, - table: data.tables.l1, - value: 'shoes', - }, - ], - negate: true, - timeFieldName: 'a', - }); - }); - - test('it invokes executeTriggerActions with correct context on click on timefield', () => { - const { args, data } = sampleArgs(); - - const wrapper = mountWithIntl( - <DatatableComponent - data={{ - ...data, - dateRange: { - fromDate: new Date('2020-04-20T05:00:00.000Z'), - toDate: new Date('2020-05-03T05:00:00.000Z'), - }, - }} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(3).simulate('click'); - - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 1, - row: 0, - table: data.tables.l1, - value: 1588024800000, - }, - ], - negate: false, - timeFieldName: 'b', - }); - }); - - test('it invokes executeTriggerActions with correct context on click on timefield from range', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'date', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'date_range', indexPatternId: 'a' }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'number', - source: 'esaggs', - sourceParams: { type: 'count', indexPatternId: 'a' }, - }, - }, - ], - rows: [{ a: 1588024800000, b: 3 }], - }, - }, - }; - - const args: DatatableProps['args'] = { - title: '', - columns: { - columnIds: ['a', 'b'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, - }; - - const wrapper = mountWithIntl( - <DatatableComponent - data={{ - ...data, - dateRange: { - fromDate: new Date('2020-04-20T05:00:00.000Z'), - toDate: new Date('2020-05-03T05:00:00.000Z'), - }, - }} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); - - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 0, - row: 0, - table: data.tables.l1, - value: 1588024800000, - }, - ], - negate: false, - timeFieldName: 'a', - }); - }); - - test('it shows emptyPlaceholder for undefined bucketed data', () => { - const { args, data } = sampleArgs(); - const emptyData: LensMultiTable = { - ...data, - tables: { - l1: { - ...data.tables.l1, - rows: [{ a: undefined, b: undefined, c: 0 }], - }, - }, - }; - - const component = shallow( - <DatatableComponent - data={emptyData} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn((type) => - type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) - )} - renderMode="edit" - /> - ); - expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); - }); - - test('it renders the table with the given sorting', () => { - const { data, args } = sampleArgs(); - - const wrapper = mountWithIntl( - <DatatableComponent - data={data} - args={{ - ...args, - columns: { - ...args.columns, - sortBy: 'b', - sortDirection: 'desc', - }, - }} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - onEditAction={onEditAction} - getType={jest.fn()} - renderMode="edit" - /> - ); - - // there's currently no way to detect the sorting column via DOM - expect( - wrapper.exists('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]') - ).toBe(true); - // check that the sorting is passing the right next state for the same column - wrapper - .find('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]') - .first() - .simulate('click'); - - expect(onEditAction).toHaveBeenCalledWith({ - action: 'sort', - columnId: undefined, - direction: 'none', - }); - - // check that the sorting is passing the right next state for another column - wrapper - .find('[data-test-subj="tableHeaderSortButton"]') - .not('[className*="isSorted"]') - .first() - .simulate('click'); - - expect(onEditAction).toHaveBeenCalledWith({ - action: 'sort', - columnId: 'a', - direction: 'asc', - }); - }); - - test('it renders the table with the given sorting in readOnly mode', () => { - const { data, args } = sampleArgs(); - - const wrapper = mountWithIntl( - <DatatableComponent - data={data} - args={{ - ...args, - columns: { - ...args.columns, - sortBy: 'b', - sortDirection: 'desc', - }, - }} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - onEditAction={onEditAction} - getType={jest.fn()} - renderMode="display" - /> - ); - - expect(wrapper.find(EuiBasicTable).prop('sorting')).toMatchObject({ - sort: undefined, - allowNeutralSort: true, - }); - }); - }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 57289fc0ac169..e8a0abb0316db 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -4,62 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import './expression.scss'; - -import React, { useMemo } from 'react'; +import React from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; -import { - EuiBasicTable, - EuiFlexGroup, - EuiButtonIcon, - EuiFlexItem, - EuiToolTip, - Direction, - EuiScreenReaderOnly, - EuiIcon, - EuiBasicTableColumn, - EuiTableActionsColumnType, -} from '@elastic/eui'; -import { IAggType } from 'src/plugins/data/public'; -import { Datatable, DatatableColumnMeta, RenderMode } from 'src/plugins/expressions'; -import { - FormatFactory, - ILensInterpreterRenderHandlers, - LensEditEvent, - LensFilterEvent, - LensMultiTable, - LensTableRowContextMenuEvent, -} from '../types'; -import { +import type { IAggType } from 'src/plugins/data/public'; +import type { + DatatableColumnMeta, ExpressionFunctionDefinition, ExpressionRenderDefinition, -} from '../../../../../src/plugins/expressions/public'; -import { VisualizationContainer } from '../visualization_container'; -import { EmptyPlaceholder } from '../shared_components'; -import { desanitizeFilterContext } from '../utils'; -import { LensIconChartDatatable } from '../assets/chart_datatable'; +} from 'src/plugins/expressions'; import { getSortingCriteria } from './sorting'; -export const LENS_EDIT_SORT_ACTION = 'sort'; - -export interface LensSortActionData { - columnId: string | undefined; - direction: 'asc' | 'desc' | 'none'; -} - -type LensSortAction = LensEditEvent<typeof LENS_EDIT_SORT_ACTION>; - -// This is a way to circumvent the explicit "any" forbidden type -type TableRowField = Datatable['rows'][number] & { rowIndex: number }; +import { DatatableComponent } from './components/table_basic'; -export interface DatatableColumns { - columnIds: string[]; - sortBy: string; - sortDirection: string; -} +import type { FormatFactory, ILensInterpreterRenderHandlers, LensMultiTable } from '../types'; +import type { + DatatableRender, + DatatableColumns, + DatatableColumnWidth, + DatatableColumnWidthResult, +} from './components/types'; interface Args { title: string; @@ -72,27 +38,6 @@ export interface DatatableProps { args: Args; } -type DatatableRenderProps = DatatableProps & { - formatFactory: FormatFactory; - onClickValue: (data: LensFilterEvent['data']) => void; - onEditAction?: (data: LensSortAction['data']) => void; - getType: (name: string) => IAggType; - renderMode: RenderMode; - onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void; - - /** - * A boolean for each table row, which is true if the row active - * ROW_CLICK_TRIGGER actions attached to it, otherwise false. - */ - rowHasRowClickTriggerActions?: boolean[]; -}; - -export interface DatatableRender { - type: 'render'; - as: 'lens_datatable_renderer'; - value: DatatableProps; -} - function isRange(meta: { params?: { id?: string } } | undefined) { return meta?.params?.id === 'range'; } @@ -191,6 +136,11 @@ export const datatableColumns: ExpressionFunctionDefinition< multi: true, help: '', }, + columnWidth: { + types: ['lens_datatable_column_width'], + multi: true, + help: '', + }, }, fn: function fn(input: unknown, args: DatatableColumns) { return { @@ -200,6 +150,35 @@ export const datatableColumns: ExpressionFunctionDefinition< }, }; +export const datatableColumnWidth: ExpressionFunctionDefinition< + 'lens_datatable_column_width', + null, + DatatableColumnWidth, + DatatableColumnWidthResult +> = { + name: 'lens_datatable_column_width', + aliases: [], + type: 'lens_datatable_column_width', + help: '', + inputTypes: ['null'], + args: { + columnId: { + types: ['string'], + help: '', + }, + width: { + types: ['number'], + help: '', + }, + }, + fn: function fn(input: unknown, args: DatatableColumnWidth) { + return { + type: 'lens_datatable_column_width', + ...args, + }; + }, +}; + export const getDatatableRenderer = (dependencies: { formatFactory: FormatFactory; getType: Promise<(name: string) => IAggType>; @@ -217,18 +196,6 @@ export const getDatatableRenderer = (dependencies: { handlers: ILensInterpreterRenderHandlers ) => { const resolvedGetType = await dependencies.getType; - const onClickValue = (data: LensFilterEvent['data']) => { - handlers.event({ name: 'filter', data }); - }; - - const onEditAction = (data: LensSortAction['data']) => { - if (handlers.getRenderMode() === 'edit') { - handlers.event({ name: 'edit', data }); - } - }; - const onRowContextMenuClick = (data: LensTableRowContextMenuEvent['data']) => { - handlers.event({ name: 'tableRowContextMenuClick', data }); - }; const { hasCompatibleActions } = handlers; // An entry for each table row, whether it has any actions attached to @@ -263,10 +230,8 @@ export const getDatatableRenderer = (dependencies: { <DatatableComponent {...config} formatFactory={dependencies.formatFactory} - onClickValue={onClickValue} - onEditAction={onEditAction} + dispatchEvent={handlers.event} renderMode={handlers.getRenderMode()} - onRowContextMenuClick={onRowContextMenuClick} getType={resolvedGetType} rowHasRowClickTriggerActions={rowHasRowClickTriggerActions} /> @@ -279,281 +244,3 @@ export const getDatatableRenderer = (dependencies: { handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); }, }); - -function getNextOrderValue(currentValue: LensSortAction['data']['direction']) { - const states: Array<LensSortAction['data']['direction']> = ['asc', 'desc', 'none']; - const newStateIndex = (1 + states.findIndex((state) => state === currentValue)) % states.length; - return states[newStateIndex]; -} - -function getDirectionLongLabel(sortDirection: LensSortAction['data']['direction']) { - if (sortDirection === 'none') { - return sortDirection; - } - return sortDirection === 'asc' ? 'ascending' : 'descending'; -} - -function getHeaderSortingCell( - name: string, - columnId: string, - sorting: Omit<LensSortAction['data'], 'action'>, - sortingLabel: string -) { - if (columnId !== sorting.columnId || sorting.direction === 'none') { - return name || ''; - } - // This is a workaround to hijack the title value of the header cell - return ( - <span aria-sort={getDirectionLongLabel(sorting.direction)}> - {name || ''} - <EuiScreenReaderOnly> - <span>{sortingLabel}</span> - </EuiScreenReaderOnly> - <EuiIcon - className="euiTableSortIcon" - type={sorting.direction === 'asc' ? 'sortUp' : 'sortDown'} - size="m" - aria-label={sortingLabel} - /> - </span> - ); -} - -export function DatatableComponent(props: DatatableRenderProps) { - const [firstTable] = Object.values(props.data.tables); - const formatters: Record<string, ReturnType<FormatFactory>> = {}; - - firstTable.columns.forEach((column) => { - formatters[column.id] = props.formatFactory(column.meta?.params); - }); - - const { onClickValue, onEditAction, onRowContextMenuClick } = props; - const handleFilterClick = useMemo( - () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { - const col = firstTable.columns[colIndex]; - const isDate = col.meta?.type === 'date'; - const timeFieldName = negate && isDate ? undefined : col?.meta?.field; - const rowIndex = firstTable.rows.findIndex((row) => row[field] === value); - - const data: LensFilterEvent['data'] = { - negate, - data: [ - { - row: rowIndex, - column: colIndex, - value, - table: firstTable, - }, - ], - timeFieldName, - }; - onClickValue(desanitizeFilterContext(data)); - }, - [firstTable, onClickValue] - ); - - const bucketColumns = firstTable.columns - .filter((col) => { - return ( - col?.meta?.sourceParams?.type && - props.getType(col.meta.sourceParams.type as string)?.type === 'buckets' - ); - }) - .map((col) => col.id); - - const isEmpty = - firstTable.rows.length === 0 || - (bucketColumns.length && - firstTable.rows.every((row) => - bucketColumns.every((col) => typeof row[col] === 'undefined') - )); - - if (isEmpty) { - return <EmptyPlaceholder icon={LensIconChartDatatable} />; - } - - const visibleColumns = props.args.columns.columnIds.filter((field) => !!field); - const columnsReverseLookup = firstTable.columns.reduce< - Record<string, { name: string; index: number; meta?: DatatableColumnMeta }> - >((memo, { id, name, meta }, i) => { - memo[id] = { name, index: i, meta }; - return memo; - }, {}); - - const { sortBy, sortDirection } = props.args.columns; - - const sortedRows: TableRowField[] = - firstTable?.rows.map((row, rowIndex) => ({ ...row, rowIndex })) || []; - const isReadOnlySorted = props.renderMode !== 'edit'; - - const sortedInLabel = i18n.translate('xpack.lens.datatableSortedInReadOnlyMode', { - defaultMessage: 'Sorted in {sortValue} order', - values: { - sortValue: sortDirection === 'asc' ? 'ascending' : 'descending', - }, - }); - - const tableColumns: Array<EuiBasicTableColumn<TableRowField>> = visibleColumns.map((field) => { - const filterable = bucketColumns.includes(field); - const { name, index: colIndex, meta } = columnsReverseLookup[field]; - const fieldName = meta?.field; - const nameContent = !isReadOnlySorted - ? name - : getHeaderSortingCell( - name, - field, - { - columnId: sortBy, - direction: sortDirection as LensSortAction['data']['direction'], - }, - sortedInLabel - ); - return { - field, - name: nameContent, - sortable: !isReadOnlySorted, - render: (value: unknown) => { - const formattedValue = formatters[field]?.convert(value); - - if (filterable) { - return ( - <EuiFlexGroup - className="lnsDataTable__cell" - data-test-subj="lnsDataTableCellValueFilterable" - gutterSize="xs" - > - <EuiFlexItem grow={false}>{formattedValue}</EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiFlexGroup - responsive={false} - gutterSize="none" - alignItems="center" - className="lnsDataTable__filter" - > - <EuiToolTip - position="bottom" - content={i18n.translate('xpack.lens.includeValueButtonTooltip', { - defaultMessage: 'Include value', - })} - > - <EuiButtonIcon - iconType="plusInCircle" - color="text" - aria-label={i18n.translate('xpack.lens.includeValueButtonAriaLabel', { - defaultMessage: `Include {value}`, - values: { - value: `${fieldName ? `${fieldName}: ` : ''}${formattedValue}`, - }, - })} - data-test-subj="lensDatatableFilterFor" - onClick={() => handleFilterClick(field, value, colIndex)} - /> - </EuiToolTip> - <EuiFlexItem grow={false}> - <EuiToolTip - position="bottom" - content={i18n.translate('xpack.lens.excludeValueButtonTooltip', { - defaultMessage: 'Exclude value', - })} - > - <EuiButtonIcon - iconType="minusInCircle" - color="text" - aria-label={i18n.translate('xpack.lens.excludeValueButtonAriaLabel', { - defaultMessage: `Exclude {value}`, - values: { - value: `${fieldName ? `${fieldName}: ` : ''}${formattedValue}`, - }, - })} - data-test-subj="lensDatatableFilterOut" - onClick={() => handleFilterClick(field, value, colIndex, true)} - /> - </EuiToolTip> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - ); - } - return <span data-test-subj="lnsDataTableCellValue">{formattedValue}</span>; - }, - }; - }); - - if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) { - const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x); - if (hasAtLeastOneRowClickAction) { - const actions: EuiTableActionsColumnType<TableRowField> = { - name: i18n.translate('xpack.lens.datatable.actionsColumnName', { - defaultMessage: 'Actions', - }), - actions: [ - { - name: i18n.translate('xpack.lens.tableRowMore', { - defaultMessage: 'More', - }), - description: i18n.translate('xpack.lens.tableRowMoreDescription', { - defaultMessage: 'Table row context menu', - }), - type: 'icon', - icon: ({ rowIndex }: { rowIndex: number }) => { - if ( - !!props.rowHasRowClickTriggerActions && - !props.rowHasRowClickTriggerActions[rowIndex] - ) - return 'empty'; - return 'boxesVertical'; - }, - onClick: ({ rowIndex }) => { - onRowContextMenuClick({ - rowIndex, - table: firstTable, - columns: props.args.columns.columnIds, - }); - }, - }, - ], - }; - tableColumns.push(actions); - } - } - - return ( - <VisualizationContainer - reportTitle={props.args.title} - reportDescription={props.args.description} - > - <EuiBasicTable - className="lnsDataTable" - data-test-subj="lnsDataTable" - tableLayout="auto" - sorting={{ - sort: - !sortBy || sortDirection === 'none' || isReadOnlySorted - ? undefined - : { - field: sortBy, - direction: sortDirection as Direction, - }, - allowNeutralSort: true, // this flag enables the 3rd Neutral state on the column header - }} - onChange={(event: { sort?: { field: string } }) => { - if (event.sort && onEditAction) { - const isNewColumn = sortBy !== event.sort.field; - // unfortunately the neutral state is not propagated and we need to manually handle it - const nextDirection = getNextOrderValue( - (isNewColumn ? 'none' : sortDirection) as LensSortAction['data']['direction'] - ); - return onEditAction({ - action: 'sort', - columnId: nextDirection !== 'none' || isNewColumn ? event.sort.field : undefined, - direction: nextDirection, - }); - } - }} - columns={tableColumns} - items={sortedRows} - /> - </VisualizationContainer> - ); -} diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index 42d2ff6a220c0..cf23d56adb915 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -29,12 +29,14 @@ export class DatatableVisualization { const { getDatatable, datatableColumns, + datatableColumnWidth, getDatatableRenderer, datatableVisualization, } = await import('../async_services'); const resolvedFormatFactory = await formatFactory; expressions.registerFunction(() => datatableColumns); + expressions.registerFunction(() => datatableColumnWidth); expressions.registerFunction(() => getDatatable({ formatFactory: resolvedFormatFactory })); expressions.registerRenderer(() => getDatatableRenderer({ diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 088246ccf4b9c..f067093891d29 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -408,6 +408,7 @@ describe('Datatable Visualization', () => { columnIds: ['c', 'b'], sortBy: [''], sortDirection: ['none'], + columnWidth: [], }); }); @@ -467,4 +468,80 @@ describe('Datatable Visualization', () => { expect(error).toBeUndefined(); }); }); + + describe('#onEditAction', () => { + it('should add a sort column to the state', () => { + const currentState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'sort', columnId: 'saved', direction: 'none' }, + }) + ).toEqual({ + ...currentState, + sorting: { + columnId: 'saved', + direction: 'none', + }, + }); + }); + + it('should add a custom width to a column in the state', () => { + const currentState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'resize', columnId: 'saved', width: 500 }, + }) + ).toEqual({ + ...currentState, + columnWidth: [ + { + columnId: 'saved', + width: 500, + }, + ], + }); + }); + + it('should clear custom width value for the column from the state', () => { + const currentState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + columnWidth: [ + { + columnId: 'saved', + width: 500, + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'resize', columnId: 'saved', width: undefined }, + }) + ).toEqual({ + ...currentState, + columnWidth: [], + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index e4f787a265186..3df9e8a5145bc 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -6,13 +6,14 @@ import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; -import { +import type { SuggestionRequest, Visualization, VisualizationSuggestion, Operation, DatasourcePublicAPI, } from '../types'; +import type { DatatableColumnWidth } from './components/types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; export interface LayerState { @@ -26,6 +27,7 @@ export interface DatatableVisualizationState { columnId: string | undefined; direction: 'asc' | 'desc' | 'none'; }; + columnWidth?: DatatableColumnWidth[]; } function newLayerState(layerId: string): LayerState { @@ -239,6 +241,19 @@ export const datatableVisualization: Visualization<DatatableVisualizationState> columnIds: operations.map((o) => o.columnId), sortBy: [state.sorting?.columnId || ''], sortDirection: [state.sorting?.direction || 'none'], + columnWidth: (state.columnWidth || []).map((columnWidth) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_column_width', + arguments: { + columnId: [columnWidth.columnId], + width: [columnWidth.width], + }, + }, + ], + })), }, }, ], @@ -255,16 +270,28 @@ export const datatableVisualization: Visualization<DatatableVisualizationState> }, onEditAction(state, event) { - if (event.data.action !== 'sort') { - return state; + switch (event.data.action) { + case 'sort': + return { + ...state, + sorting: { + columnId: event.data.columnId, + direction: event.data.direction, + }, + }; + case 'resize': + return { + ...state, + columnWidth: [ + ...(state.columnWidth || []).filter(({ columnId }) => columnId !== event.data.columnId), + ...(event.data.width !== undefined + ? [{ columnId: event.data.columnId, width: event.data.width }] + : []), + ], + }; + default: + return state; } - return { - ...state, - sorting: { - columnId: event.data.columnId, - direction: event.data.direction, - }, - }; }, }; diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index dc53f3a2bc2a7..6423a9f6190a7 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -13,10 +13,8 @@ exports[`DragDrop items that have droppable=false get special styling when anoth <button className="lnsDragDrop lnsDragDrop-isDroppable lnsDragDrop-isNotDroppable" data-test-subj="lnsDragDrop" - onDragEnd={[Function]} onDragLeave={[Function]} onDragOver={[Function]} - onDragStart={[Function]} onDrop={[Function]} > Hello! @@ -24,11 +22,19 @@ exports[`DragDrop items that have droppable=false get special styling when anoth `; exports[`DragDrop renders if nothing is being dragged 1`] = ` -<button - class="lnsDragDrop lnsDragDrop-isDraggable" - data-test-subj="lnsDragDrop" - draggable="true" -> - Hello! -</button> +<div> + <button + aria-describedby="lnsDragDrop-keyboardInstructions" + aria-label="dragging" + class="euiScreenReaderOnly--showOnFocus lnsDragDrop__keyboardHandler" + data-test-subj="lnsDragDrop-keyboardHandler" + /> + <button + class="lnsDragDrop lnsDragDrop-isDraggable" + data-test-subj="lnsDragDrop" + draggable="true" + > + Hello! + </button> +</div> `; diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss index ded0b4552a4e5..d0a4019055d57 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.scss @@ -52,7 +52,7 @@ } } -.lnsDragDrop__reorderableContainer { +.lnsDragDrop__container { position: relative; } @@ -63,11 +63,18 @@ height: calc(100% + #{$lnsLayerPanelDimensionMargin}); } -.lnsDragDrop-isReorderable { +.lnsDragDrop-translatableDrop { + transform: translateY(0); transition: transform $euiAnimSpeedFast ease-in-out; pointer-events: none; } +.lnsDragDrop-translatableDrag { + transform: translateY(0); + transition: transform $euiAnimSpeedFast ease-in-out; + position: relative; +} + // Draggable item when it is moving .lnsDragDrop-isHidden { opacity: 0; diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx index 07b489d29ad06..9e1583b0c6e81 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -6,11 +6,33 @@ import React from 'react'; import { render, mount } from 'enzyme'; -import { DragDrop, ReorderableDragDrop, DropToHandler, DropHandler } from './drag_drop'; -import { ChildDragDropProvider, ReorderProvider } from './providers'; +import { DragDrop, DropHandler } from './drag_drop'; +import { + ChildDragDropProvider, + DragContextState, + ReorderProvider, + DragDropIdentifier, + ActiveDropTarget, +} from './providers'; +import { act } from 'react-dom/test-utils'; jest.useFakeTimers(); +const defaultContext = { + dragging: undefined, + setDragging: jest.fn(), + setActiveDropTarget: () => {}, + activeDropTarget: undefined, + keyboardMode: false, + setKeyboardMode: () => {}, + setA11yMessage: jest.fn(), +}; + +const dataTransfer = { + setData: jest.fn(), + getData: jest.fn(), +}; + describe('DragDrop', () => { const value = { id: '1', label: 'hello' }; test('renders if nothing is being dragged', () => { @@ -26,7 +48,7 @@ describe('DragDrop', () => { test('dragover calls preventDefault if droppable is true', () => { const preventDefault = jest.fn(); const component = mount( - <DragDrop droppable> + <DragDrop droppable value={value}> <button>Hello!</button> </DragDrop> ); @@ -39,7 +61,7 @@ describe('DragDrop', () => { test('dragover does not call preventDefault if droppable is false', () => { const preventDefault = jest.fn(); const component = mount( - <DragDrop> + <DragDrop value={value}> <button>Hello!</button> </DragDrop> ); @@ -51,13 +73,9 @@ describe('DragDrop', () => { test('dragstart sets dragging in the context', async () => { const setDragging = jest.fn(); - const dataTransfer = { - setData: jest.fn(), - getData: jest.fn(), - }; const component = mount( - <ChildDragDropProvider dragging={value} setDragging={setDragging}> + <ChildDragDropProvider {...defaultContext} dragging={value} setDragging={setDragging}> <DragDrop value={value} draggable={true} label="drag label"> <button>Hello!</button> </DragDrop> @@ -79,7 +97,11 @@ describe('DragDrop', () => { const onDrop = jest.fn(); const component = mount( - <ChildDragDropProvider dragging={{ id: '2', label: 'hi' }} setDragging={setDragging}> + <ChildDragDropProvider + {...defaultContext} + dragging={{ id: '2', label: 'hi' }} + setDragging={setDragging} + > <DragDrop onDrop={onDrop} droppable={true} value={value}> <button>Hello!</button> </DragDrop> @@ -93,7 +115,7 @@ describe('DragDrop', () => { expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); expect(setDragging).toBeCalledWith(undefined); - expect(onDrop).toBeCalledWith({ id: '2', label: 'hi' }); + expect(onDrop).toBeCalledWith({ id: '2', label: 'hi' }, { id: '1', label: 'hello' }); }); test('drop function is not called on droppable=false', async () => { @@ -103,7 +125,7 @@ describe('DragDrop', () => { const onDrop = jest.fn(); const component = mount( - <ChildDragDropProvider dragging={{ id: 'hi' }} setDragging={setDragging}> + <ChildDragDropProvider {...defaultContext} dragging={{ id: 'hi' }} setDragging={setDragging}> <DragDrop onDrop={onDrop} droppable={false} value={value}> <button>Hello!</button> </DragDrop> @@ -127,6 +149,7 @@ describe('DragDrop', () => { throw x; }} droppable + value={value} > <button>Hello!</button> </DragDrop> @@ -137,11 +160,11 @@ describe('DragDrop', () => { test('items that have droppable=false get special styling when another item is dragged', () => { const component = mount( - <ChildDragDropProvider dragging={value} setDragging={() => {}}> + <ChildDragDropProvider {...defaultContext} dragging={value}> <DragDrop value={value} draggable={true} label="a"> <button>Hello!</button> </DragDrop> - <DragDrop onDrop={(x: unknown) => {}} droppable={false}> + <DragDrop onDrop={(x: unknown) => {}} droppable={false} value={{ id: '2' }}> <button>Hello!</button> </DragDrop> </ChildDragDropProvider> @@ -153,17 +176,25 @@ describe('DragDrop', () => { test('additional styles are reflected in the className until drop', () => { let dragging: { id: '1' } | undefined; const getAdditionalClasses = jest.fn().mockReturnValue('additional'); + let activeDropTarget; + const component = mount( <ChildDragDropProvider + {...defaultContext} dragging={dragging} setDragging={() => { dragging = { id: '1' }; }} + setActiveDropTarget={(val) => { + activeDropTarget = { activeDropTarget: val }; + }} + activeDropTarget={activeDropTarget} > <DragDrop value={{ label: 'ignored', id: '3' }} draggable={true} label="a"> <button>Hello!</button> </DragDrop> <DragDrop + value={value} onDrop={(x: unknown) => {}} droppable getAdditionalClassesOnEnter={getAdditionalClasses} @@ -173,10 +204,6 @@ describe('DragDrop', () => { </ChildDragDropProvider> ); - const dataTransfer = { - setData: jest.fn(), - getData: jest.fn(), - }; component .find('[data-test-subj="lnsDragDrop"]') .first() @@ -184,40 +211,91 @@ describe('DragDrop', () => { jest.runAllTimers(); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); - expect(component.find('.additional')).toHaveLength(1); - - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); expect(component.find('.additional')).toHaveLength(0); + }); + + test('additional enter styles are reflected in the className until dragleave', () => { + let dragging: { id: '1' } | undefined; + const getAdditionalClasses = jest.fn().mockReturnValue('additional'); + const setActiveDropTarget = jest.fn(); + + const component = mount( + <ChildDragDropProvider + setA11yMessage={jest.fn()} + dragging={dragging} + setDragging={() => { + dragging = { id: '1' }; + }} + setActiveDropTarget={setActiveDropTarget} + activeDropTarget={ + ({ activeDropTarget: value } as unknown) as DragContextState['activeDropTarget'] + } + keyboardMode={false} + setKeyboardMode={(keyboardMode) => true} + > + <DragDrop value={{ label: 'ignored', id: '3' }} draggable={true} label="a"> + <button>Hello!</button> + </DragDrop> + <DragDrop + value={value} + onDrop={(x: unknown) => {}} + droppable + getAdditionalClassesOnEnter={getAdditionalClasses} + > + <button>Hello!</button> + </DragDrop> + </ChildDragDropProvider> + ); + + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + jest.runAllTimers(); component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); - component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); - expect(component.find('.additional')).toHaveLength(0); + expect(component.find('.additional')).toHaveLength(1); + + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); + expect(setActiveDropTarget).toBeCalledWith(undefined); }); describe('reordering', () => { const mountComponent = ( - dragging: { id: '1' } | undefined, - onDrop: DropHandler = jest.fn(), - dropTo: DropToHandler = jest.fn() - ) => - mount( - <ChildDragDropProvider - dragging={{ id: '1' }} - setDragging={() => { - dragging = { id: '1' }; - }} - > + dragContext: Partial<DragContextState> | undefined, + onDrop: DropHandler = jest.fn() + ) => { + let dragging = dragContext?.dragging; + let keyboardMode = !!dragContext?.keyboardMode; + let activeDropTarget = dragContext?.activeDropTarget; + const baseContext = { + dragging, + setDragging: (val?: DragDropIdentifier) => { + dragging = val; + }, + keyboardMode, + setKeyboardMode: jest.fn((mode) => { + keyboardMode = mode; + }), + setActiveDropTarget: (target?: DragDropIdentifier) => { + activeDropTarget = { activeDropTarget: target } as ActiveDropTarget; + }, + activeDropTarget, + setA11yMessage: jest.fn(), + }; + return mount( + <ChildDragDropProvider {...baseContext} {...dragContext}> <ReorderProvider id="groupId"> <DragDrop label="1" draggable - droppable + droppable={false} dragType="reorder" dropType="reorder" - itemsInGroup={['1', '2', '3']} + reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]} value={{ id: '1' }} onDrop={onDrop} - dropTo={dropTo} > <span>1</span> </DragDrop> @@ -227,12 +305,11 @@ describe('DragDrop', () => { droppable dragType="reorder" dropType="reorder" - itemsInGroup={['1', '2', '3']} + reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]} value={{ id: '2', }} onDrop={onDrop} - dropTo={dropTo} > <span>2</span> </DragDrop> @@ -242,132 +319,270 @@ describe('DragDrop', () => { droppable dragType="reorder" dropType="reorder" - itemsInGroup={['1', '2', '3']} + reorderableGroup={[{ id: '1' }, { id: '2' }, { id: '3' }]} value={{ id: '3', }} onDrop={onDrop} - dropTo={dropTo} > <span>3</span> </DragDrop> </ReorderProvider> </ChildDragDropProvider> ); - test(`ReorderableDragDrop component doesn't appear for groups of 1 or less`, () => { - let dragging; - const component = mount( - <ChildDragDropProvider - dragging={dragging} - setDragging={() => { - dragging = { id: '1' }; - }} - > - <ReorderProvider id="groupId"> - <DragDrop - label="1" - draggable - droppable - dragType="reorder" - dropType="reorder" - itemsInGroup={['1']} - value={{ id: '1' }} - onDrop={jest.fn()} - dropTo={jest.fn()} - > - <div /> - </DragDrop> - </ReorderProvider> - </ChildDragDropProvider> - ); - expect(component.find(ReorderableDragDrop)).toHaveLength(0); - }); - test(`Reorderable component renders properly`, () => { + }; + test(`Inactive reorderable group renders properly`, () => { const component = mountComponent(undefined, jest.fn()); - expect(component.find(ReorderableDragDrop)).toHaveLength(3); + expect(component.find('.lnsDragDrop-reorderable')).toHaveLength(3); }); - test(`Elements between dragged and drop get extra class to show the reorder effect when dragging`, () => { - const component = mountComponent({ id: '1' }, jest.fn()); - const dataTransfer = { - setData: jest.fn(), - getData: jest.fn(), - }; - component - .find(ReorderableDragDrop) - .first() - .find('[data-test-subj="lnsDragDrop"]') - .simulate('dragstart', { dataTransfer }); - jest.runAllTimers(); - component.find('[data-test-subj="lnsDragDrop-reorderableDrop"]').at(2).simulate('dragover'); + test(`Reorderable group with lifted element renders properly`, () => { + const setDragging = jest.fn(); + const setA11yMessage = jest.fn(); + const component = mountComponent( + { dragging: { id: '1' }, setA11yMessage, setDragging }, + jest.fn() + ); + act(() => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + jest.runAllTimers(); + }); + + expect(setDragging).toBeCalledWith({ id: '1' }); + expect(setA11yMessage).toBeCalledWith('You have lifted an item 1 in position 1'); + expect( + component + .find('[data-test-subj="lnsDragDrop-reorderableGroup"]') + .hasClass('lnsDragDrop-isActiveGroup') + ).toEqual(true); + }); + + test(`Reordered elements get extra styles to show the reorder effect when dragging`, () => { + const component = mountComponent({ dragging: { id: '1' } }, jest.fn()); + + act(() => { + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + jest.runAllTimers(); + }); + + component + .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') + .at(1) + .simulate('dragover'); expect( component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style') - ).toEqual({}); + ).toEqual(undefined); expect( - component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(1).prop('style') + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(0).prop('style') ).toEqual({ - transform: 'translateY(-40px)', + transform: 'translateY(-8px)', }); expect( - component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(2).prop('style') + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') ).toEqual({ - transform: 'translateY(-40px)', + transform: 'translateY(-8px)', }); - component.find('[data-test-subj="lnsDragDrop-reorderableDrop"]').at(2).simulate('dragleave'); + component + .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') + .at(1) + .simulate('dragleave'); expect( - component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(1).prop('style') - ).toEqual({}); + component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style') + ).toEqual(undefined); expect( - component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(2).prop('style') - ).toEqual({}); + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') + ).toEqual(undefined); }); + test(`Dropping an item runs onDrop function`, () => { + const setDragging = jest.fn(); + const setA11yMessage = jest.fn(); const preventDefault = jest.fn(); const stopPropagation = jest.fn(); const onDrop = jest.fn(); - const component = mountComponent({ id: '1' }, onDrop); + const component = mountComponent( + { dragging: { id: '1' }, setA11yMessage, setDragging }, + onDrop + ); component - .find('[data-test-subj="lnsDragDrop-reorderableDrop"]') + .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') .at(1) .simulate('drop', { preventDefault, stopPropagation }); + jest.runAllTimers(); + + expect(setA11yMessage).toBeCalledWith( + 'You have dropped the item. You have moved the item from position 1 to positon 3' + ); expect(preventDefault).toBeCalled(); expect(stopPropagation).toBeCalled(); - expect(onDrop).toBeCalledWith({ id: '1' }); + expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' }); }); - test(`Keyboard navigation: user can reorder an element`, () => { + + test(`Keyboard navigation: user can drop element to an activeDropTarget`, () => { const onDrop = jest.fn(); - const dropTo = jest.fn(); - const component = mountComponent({ id: '1' }, onDrop, dropTo); + const component = mountComponent( + { + dragging: { id: '1' }, + activeDropTarget: { activeDropTarget: { id: '3' } } as ActiveDropTarget, + keyboardMode: true, + }, + onDrop + ); const keyboardHandler = component - .find(ReorderableDragDrop) - .at(1) - .find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .simulate('focus'); + + act(() => { + keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); + keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); + keyboardHandler.simulate('keydown', { key: 'Enter' }); + }); + expect(onDrop).toBeCalledWith({ id: '1' }, { id: '3' }); + }); + + test(`Keyboard Navigation: Reordered elements get extra styles to show the reorder effect`, () => { + const setA11yMessage = jest.fn(); + const component = mountComponent( + { dragging: { id: '1' }, keyboardMode: true, setA11yMessage }, + jest.fn() + ); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); - expect(dropTo).toBeCalledWith('3'); - keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); - expect(dropTo).toBeCalledWith('1'); + expect( + component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style') + ).toEqual({ + transform: 'translateY(+8px)', + }); + expect( + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(0).prop('style') + ).toEqual({ + transform: 'translateY(-40px)', + }); + expect( + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') + ).toEqual(undefined); + expect(setA11yMessage).toBeCalledWith( + 'You have moved the item 1 from position 1 to position 2' + ); + + component + .find('[data-test-subj="lnsDragDrop-reorderableDropLayer"]') + .at(1) + .simulate('dragleave'); + expect( + component.find('[data-test-subj="lnsDragDrop-reorderableDrag"]').at(0).prop('style') + ).toEqual(undefined); + expect( + component.find('[data-test-subj="lnsDragDrop-translatableDrop"]').at(1).prop('style') + ).toEqual(undefined); }); + test(`Keyboard Navigation: User cannot move an element outside of the group`, () => { const onDrop = jest.fn(); - const dropTo = jest.fn(); - const component = mountComponent({ id: '1' }, onDrop, dropTo); - const keyboardHandler = component - .find(ReorderableDragDrop) - .first() - .find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const component = mountComponent( + { dragging: { id: '1' }, keyboardMode: true, setActiveDropTarget, setA11yMessage }, + onDrop + ); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); - expect(dropTo).not.toHaveBeenCalled(); + expect(setActiveDropTarget).not.toHaveBeenCalled(); + keyboardHandler.simulate('keydown', { key: 'Space' }); keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); - expect(dropTo).toBeCalledWith('2'); + + expect(setActiveDropTarget).toBeCalledWith({ id: '2' }); + expect(setA11yMessage).toBeCalledWith( + 'You have moved the item 1 from position 1 to position 2' + ); + }); + + test(`Keyboard Navigation: User cannot drop element to itself`, () => { + const setActiveDropTarget = jest.fn(); + const setA11yMessage = jest.fn(); + const component = mount( + <ChildDragDropProvider + {...defaultContext} + keyboardMode={true} + activeDropTarget={{ + activeDropTarget: { id: '2' }, + }} + dragging={{ id: '1' }} + setActiveDropTarget={setActiveDropTarget} + setA11yMessage={setA11yMessage} + > + <ReorderProvider id="groupId"> + <DragDrop + label="1" + draggable + droppable={false} + dragType="reorder" + dropType="reorder" + reorderableGroup={[{ id: '1' }, { id: '2' }]} + value={{ id: '1' }} + > + <span>1</span> + </DragDrop> + <DragDrop + label="2" + draggable + droppable + dragType="reorder" + dropType="reorder" + reorderableGroup={[{ id: '1' }, { id: '2' }]} + value={{ id: '2' }} + > + <span>2</span> + </DragDrop> + </ReorderProvider> + </ChildDragDropProvider> + ); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'ArrowUp' }); + expect(setActiveDropTarget).toBeCalledWith({ id: '1' }); + expect(setA11yMessage).toBeCalledWith('You have moved back the item 1 to position 1'); + }); + + test(`Keyboard Navigation: Doesn't call onDrop when movement is cancelled`, () => { + const setA11yMessage = jest.fn(); + const onDrop = jest.fn(); + + const component = mountComponent({ dragging: { id: '1' }, setA11yMessage }, onDrop); + const keyboardHandler = component.find('[data-test-subj="lnsDragDrop-keyboardHandler"]'); + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'Escape' }); + + jest.runAllTimers(); + + expect(onDrop).not.toHaveBeenCalled(); + expect(setA11yMessage).toBeCalledWith( + 'Movement cancelled. The item has returned to its starting position 1' + ); + keyboardHandler.simulate('keydown', { key: 'Space' }); + keyboardHandler.simulate('keydown', { key: 'ArrowDown' }); + keyboardHandler.simulate('blur'); + + expect(onDrop).not.toHaveBeenCalled(); + expect(setA11yMessage).toBeCalledWith( + 'Movement cancelled. The item has returned to its starting position 1' + ); }); }); }); diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 32facbf8e84a8..2dbcfab8d5738 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -5,11 +5,17 @@ */ import './drag_drop.scss'; -import React, { useState, useContext, useEffect } from 'react'; +import React, { useContext, useEffect, memo } from 'react'; import classNames from 'classnames'; import { keys, EuiScreenReaderOnly } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { DragContext, DragContextState, ReorderContext, ReorderState } from './providers'; +import { + DragDropIdentifier, + DragContext, + DragContextState, + ReorderContext, + ReorderState, + reorderAnnouncements, +} from './providers'; import { trackUiEvent } from '../lens_ui_telemetry'; export type DroppableEvent = React.DragEvent<HTMLElement>; @@ -17,12 +23,7 @@ export type DroppableEvent = React.DragEvent<HTMLElement>; /** * A function that handles a drop event. */ -export type DropHandler = (item: unknown) => void; - -/** - * A function that handles a dropTo event. - */ -export type DropToHandler = (dropTargetId: string) => void; +export type DropHandler = (dropped: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; /** * The base props to the DragDrop component. @@ -32,24 +33,20 @@ interface BaseProps { * The CSS class(es) for the root element. */ className?: string; - /** - * The event handler that fires when this item - * is dropped to the one with passed id - * + * The label for accessibility */ - dropTo?: DropToHandler; + label?: string; + /** * The event handler that fires when an item * is dropped onto this DragDrop component. */ onDrop?: DropHandler; /** - * The value associated with this item, if it is draggable. - * If this component is dragged, this will be the value of - * "dragging" in the root drag/drop context. + * The value associated with this item. */ - value?: DragContextState['dragging']; + value: DragDropIdentifier; /** * Optional comparison function to check whether a value is the dragged one @@ -60,7 +57,10 @@ interface BaseProps { * The React element which will be passed the draggable handlers */ children: React.ReactElement; - + /** + * Indicates whether or not this component is draggable. + */ + draggable?: boolean; /** * Indicates whether or not the currently dragged item * can be dropped onto this component. @@ -75,12 +75,12 @@ interface BaseProps { /** * The optional test subject associated with this DOM element. */ - 'data-test-subj'?: string; + dataTestSubj?: string; /** * items belonging to the same group that can be reordered */ - itemsInGroup?: string[]; + reorderableGroup?: DragDropIdentifier[]; /** * Indicates to the user whether the currently dragged item @@ -93,34 +93,46 @@ interface BaseProps { * replace something that is existing or add a new one */ dropType?: 'add' | 'replace' | 'reorder'; + + /** + * temporary flag to exclude the draggable elements that don't have keyboard nav yet. To be removed along with the feature development + */ + noKeyboardSupportYet?: boolean; } /** * The props for a draggable instance of that component. */ -interface DraggableProps extends BaseProps { - /** - * Indicates whether or not this component is draggable. - */ - draggable: true; +interface DragInnerProps extends BaseProps { /** * The label, which should be attached to the drag event, and which will e.g. * be used if the element will be dropped into a text field. */ - label: string; + label?: string; + isDragging: boolean; + keyboardMode: boolean; + setKeyboardMode: DragContextState['setKeyboardMode']; + setDragging: DragContextState['setDragging']; + setActiveDropTarget: DragContextState['setActiveDropTarget']; + setA11yMessage: DragContextState['setA11yMessage']; + activeDropTarget: DragContextState['activeDropTarget']; + onDragStart?: ( + target?: + | DroppableEvent['currentTarget'] + | React.KeyboardEvent<HTMLButtonElement>['currentTarget'] + ) => void; + onDragEnd?: () => void; + extraKeyboardHandler?: (e: React.KeyboardEvent<HTMLButtonElement>) => void; } /** * The props for a non-draggable instance of that component. */ -interface NonDraggableProps extends BaseProps { - /** - * Indicates whether or not this component is draggable. - */ - draggable?: false; -} +interface DropInnerProps extends BaseProps, DragContextState { + isDragging: boolean; -type Props = DraggableProps | NonDraggableProps; + isNotDroppable: boolean; +} /** * A draggable / droppable item. Items can be both draggable and droppable at @@ -129,40 +141,189 @@ type Props = DraggableProps | NonDraggableProps; * @param props */ -export const DragDrop = (props: Props) => { - const { dragging, setDragging } = useContext(DragContext); - const { value, draggable, droppable, isValueEqual } = props; +const lnsLayerPanelDimensionMargin = 8; - return ( - <DragDropInner - {...props} - dragging={droppable ? dragging : undefined} - setDragging={setDragging} - isDragging={ - !!(draggable && ((isValueEqual && isValueEqual(value, dragging)) || value === dragging)) +export const DragDrop = (props: BaseProps) => { + const { + dragging, + setDragging, + keyboardMode, + setKeyboardMode, + activeDropTarget, + setActiveDropTarget, + setA11yMessage, + } = useContext(DragContext); + + const { value, draggable, droppable, reorderableGroup } = props; + + const isDragging = !!(draggable && value.id === dragging?.id); + + const dragProps = { + ...props, + isDragging, + keyboardMode: isDragging ? keyboardMode : false, // optimization to not rerender all dragging components + activeDropTarget: isDragging ? activeDropTarget : undefined, // optimization to not rerender all dragging components + setKeyboardMode, + setDragging, + setActiveDropTarget, + setA11yMessage, + }; + + const dropProps = { + ...props, + setKeyboardMode, + keyboardMode, + dragging, + setDragging, + activeDropTarget, + setActiveDropTarget, + isDragging, + setA11yMessage, + isNotDroppable: + // If the configuration has provided a droppable flag, but this particular item is not + // droppable, then it should be less prominent. Ignores items that are both + // draggable and drop targets + !!(droppable === false && dragging && value.id !== dragging.id), + }; + + if (draggable && !droppable) { + if (reorderableGroup && reorderableGroup.length > 1) { + return ( + <ReorderableDrag + {...dragProps} + draggable={draggable} + reorderableGroup={reorderableGroup} + dragging={dragging} + /> + ); + } else { + return <DragInner {...dragProps} draggable={draggable} />; + } + } + if ( + reorderableGroup && + reorderableGroup.length > 1 && + reorderableGroup?.some((i) => i.id === value.id) + ) { + return <ReorderableDrop reorderableGroup={reorderableGroup} {...dropProps} />; + } + return <DropInner {...dropProps} />; +}; + +const DragInner = memo(function DragDropInner({ + dataTestSubj, + className, + value, + children, + setDragging, + setKeyboardMode, + setActiveDropTarget, + label = '', + keyboardMode, + isDragging, + activeDropTarget, + onDrop, + dragType, + onDragStart, + onDragEnd, + extraKeyboardHandler, + noKeyboardSupportYet, +}: DragInnerProps) { + const dragStart = (e?: DroppableEvent | React.KeyboardEvent<HTMLButtonElement>) => { + // Setting stopPropgagation causes Chrome failures, so + // we are manually checking if we've already handled this + // in a nested child, and doing nothing if so... + if (e && 'dataTransfer' in e && e.dataTransfer.getData('text')) { + return; + } + + // We only can reach the dragStart method if the element is draggable, + // so we know we have DraggableProps if we reach this code. + if (e && 'dataTransfer' in e) { + e.dataTransfer.setData('text', label); + } + + // Chrome causes issues if you try to render from within a + // dragStart event, so we drop a setTimeout to avoid that. + + const currentTarget = e?.currentTarget; + setTimeout(() => { + setDragging(value); + if (onDragStart) { + onDragStart(currentTarget); } - isNotDroppable={ - // If the configuration has provided a droppable flag, but this particular item is not - // droppable, then it should be less prominent. Ignores items that are both - // draggable and drop targets - droppable === false && Boolean(dragging) && value !== dragging + }); + }; + + const dragEnd = (e?: DroppableEvent) => { + e?.stopPropagation(); + setDragging(undefined); + setActiveDropTarget(undefined); + setKeyboardMode(false); + if (onDragEnd) { + onDragEnd(); + } + }; + + const dropToActiveDropTarget = () => { + if (isDragging && activeDropTarget?.activeDropTarget) { + trackUiEvent('drop_total'); + if (onDrop) { + onDrop(value, activeDropTarget.activeDropTarget); } - /> + } + }; + + return ( + <div className={className}> + {!noKeyboardSupportYet && ( + <EuiScreenReaderOnly showOnFocus> + <button + aria-label={label} + aria-describedby={`lnsDragDrop-keyboardInstructions`} + className="lnsDragDrop__keyboardHandler" + data-test-subj="lnsDragDrop-keyboardHandler" + onBlur={() => { + dragEnd(); + }} + onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => { + if (e.key === keys.ENTER || e.key === keys.SPACE) { + if (activeDropTarget) { + dropToActiveDropTarget(); + } + if (isDragging) { + dragEnd(); + } else { + dragStart(e); + setKeyboardMode(true); + } + } else if (e.key === keys.ESCAPE) { + dragEnd(); + } + if (extraKeyboardHandler) { + extraKeyboardHandler(e); + } + }} + /> + </EuiScreenReaderOnly> + )} + + {React.cloneElement(children, { + 'data-test-subj': dataTestSubj || 'lnsDragDrop', + className: classNames(children.props.className, 'lnsDragDrop', 'lnsDragDrop-isDraggable', { + 'lnsDragDrop-isHidden': isDragging && dragType === 'move' && !keyboardMode, + }), + draggable: true, + onDragEnd: dragEnd, + onDragStart: dragStart, + })} + </div> ); -}; +}); -const DragDropInner = React.memo(function DragDropInner( - props: Props & - DragContextState & { - isDragging: boolean; - isNotDroppable: boolean; - } -) { - const [state, setState] = useState({ - isActive: false, - dragEnterClassNames: '', - }); +const DropInner = memo(function DropInner(props: DropInnerProps) { const { + dataTestSubj, className, onDrop, value, @@ -175,10 +336,16 @@ const DragDropInner = React.memo(function DragDropInner( isNotDroppable, dragType = 'copy', dropType = 'add', - dropTo, - itemsInGroup, + keyboardMode, + setKeyboardMode, + activeDropTarget, + setActiveDropTarget, + getAdditionalClassesOnEnter, } = props; + const activeDropTargetMatches = + activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id; + const isMoveDragging = isDragging && dragType === 'move'; const classes = classNames( @@ -186,339 +353,364 @@ const DragDropInner = React.memo(function DragDropInner( { 'lnsDragDrop-isDraggable': draggable, 'lnsDragDrop-isDragging': isDragging, - 'lnsDragDrop-isHidden': isMoveDragging, + 'lnsDragDrop-isHidden': isMoveDragging && !keyboardMode, 'lnsDragDrop-isDroppable': !draggable, 'lnsDragDrop-isDropTarget': droppable && dragType !== 'reorder', - 'lnsDragDrop-isActiveDropTarget': droppable && state.isActive && dragType !== 'reorder', + 'lnsDragDrop-isActiveDropTarget': + droppable && activeDropTargetMatches && dragType !== 'reorder', 'lnsDragDrop-isNotDroppable': !isMoveDragging && isNotDroppable, - 'lnsDragDrop-isReplacing': droppable && state.isActive && dropType === 'replace', + 'lnsDragDrop-isReplacing': droppable && activeDropTargetMatches && dropType === 'replace', }, - state.dragEnterClassNames - ); - - const dragStart = (e: DroppableEvent) => { - // Setting stopPropgagation causes Chrome failures, so - // we are manually checking if we've already handled this - // in a nested child, and doing nothing if so... - if (e.dataTransfer.getData('text')) { - return; + getAdditionalClassesOnEnter && { + [getAdditionalClassesOnEnter()]: activeDropTargetMatches, } - - // We only can reach the dragStart method if the element is draggable, - // so we know we have DraggableProps if we reach this code. - e.dataTransfer.setData('text', (props as DraggableProps).label); - - // Chrome causes issues if you try to render from within a - // dragStart event, so we drop a setTimeout to avoid that. - setState({ ...state }); - setTimeout(() => setDragging(value)); - }; - - const dragEnd = (e: DroppableEvent) => { - e.stopPropagation(); - setDragging(undefined); - }; + ); const dragOver = (e: DroppableEvent) => { if (!droppable) { return; } - e.preventDefault(); // An optimization to prevent a bunch of React churn. - if (!state.isActive) { - setState({ - ...state, - isActive: true, - dragEnterClassNames: props.getAdditionalClassesOnEnter - ? props.getAdditionalClassesOnEnter() - : '', - }); + // todo: replace with custom function ? + if (!activeDropTargetMatches) { + setActiveDropTarget(value); } }; const dragLeave = () => { - setState({ ...state, isActive: false, dragEnterClassNames: '' }); + setActiveDropTarget(undefined); }; - const drop = (e: DroppableEvent) => { + const drop = (e: DroppableEvent | React.KeyboardEvent<HTMLButtonElement>) => { e.preventDefault(); e.stopPropagation(); - setState({ ...state, isActive: false, dragEnterClassNames: '' }); - setDragging(undefined); - - if (onDrop && droppable) { + if (onDrop && droppable && dragging) { trackUiEvent('drop_total'); - onDrop(dragging); + onDrop(dragging, value); } + setActiveDropTarget(undefined); + setDragging(undefined); + setKeyboardMode(false); }; + return ( + <> + {React.cloneElement(children, { + 'data-test-subj': dataTestSubj || 'lnsDragDrop', + className: classNames(children.props.className, classes, className), + onDragOver: dragOver, + onDragLeave: dragLeave, + onDrop: drop, + draggable, + })} + </> + ); +}); - const isReorderDragging = !!(dragging && itemsInGroup?.includes(dragging.id)); +const ReorderableDrag = memo(function ReorderableDrag( + props: DragInnerProps & { reorderableGroup: DragDropIdentifier[]; dragging?: DragDropIdentifier } +) { + const { + reorderState: { isReorderOn, reorderedItems, direction }, + setReorderState, + } = useContext(ReorderContext); - if ( - draggable && - itemsInGroup?.length && - itemsInGroup.length > 1 && - value?.id && - dropTo && - (!dragging || isReorderDragging) - ) { - const { label } = props as DraggableProps; - return ( - <ReorderableDragDrop - dropTo={dropTo} - label={label} - className={className} - dataTestSubj={props['data-test-subj'] || 'lnsDragDrop'} - draggingProps={{ - className: classNames(children.props.className, classes), - draggable, - onDragEnd: dragEnd, - onDragStart: dragStart, - isReorderDragging, - }} - dropProps={{ - onDrop: drop, - onDragOver: dragOver, - onDragLeave: dragLeave, - dragging, - droppable, - itemsInGroup, - id: value.id, - isActive: state.isActive, - }} - > - {children} - </ReorderableDragDrop> - ); - } - return React.cloneElement(children, { - 'data-test-subj': props['data-test-subj'] || 'lnsDragDrop', - className: classNames(children.props.className, classes, className), - onDragOver: dragOver, - onDragLeave: dragLeave, - onDrop: drop, - draggable, - onDragEnd: dragEnd, - onDragStart: dragStart, - }); -}); + const { + value, + setActiveDropTarget, + label = '', + keyboardMode, + isDragging, + activeDropTarget, + reorderableGroup, + onDrop, + setA11yMessage, + } = props; -const getKeyboardReorderMessageMoved = ( - itemLabel: string, - position: number, - prevPosition: number -) => - i18n.translate('xpack.lens.dragDrop.elementMoved', { - defaultMessage: `You have moved the item {itemLabel} from position {prevPosition} to position {position}`, - values: { - itemLabel, - position, - prevPosition, - }, - }); - -const getKeyboardReorderMessageLifted = (itemLabel: string, position: number) => - i18n.translate('xpack.lens.dragDrop.elementLifted', { - defaultMessage: `You have lifted an item {itemLabel} in position {position}`, - values: { - itemLabel, - position, - }, - }); + const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id); + + const isFocusInGroup = keyboardMode + ? isDragging && + (!activeDropTarget?.activeDropTarget || + reorderableGroup.some((i) => i.id === activeDropTarget?.activeDropTarget?.id)) + : isDragging; + + useEffect(() => { + setReorderState((s: ReorderState) => ({ + ...s, + isReorderOn: isFocusInGroup, + })); + }, [setReorderState, isFocusInGroup]); + + const onReorderableDragStart = ( + currentTarget?: + | DroppableEvent['currentTarget'] + | React.KeyboardEvent<HTMLButtonElement>['currentTarget'] + ) => { + if (currentTarget) { + const height = currentTarget.offsetHeight + lnsLayerPanelDimensionMargin; + setReorderState((s: ReorderState) => ({ + ...s, + draggingHeight: height, + })); + } -const lnsLayerPanelDimensionMargin = 8; + setA11yMessage(reorderAnnouncements.lifted(label, currentIndex + 1)); + }; -export const ReorderableDragDrop = ({ - draggingProps, - dropProps, - children, - label, - dropTo, - className, - dataTestSubj, -}: { - draggingProps: { - className: string; - draggable: Props['draggable']; - onDragEnd: (e: DroppableEvent) => void; - onDragStart: (e: DroppableEvent) => void; - isReorderDragging: boolean; + const onReorderableDragEnd = () => { + resetReorderState(); + setA11yMessage(reorderAnnouncements.cancelled(currentIndex + 1)); }; - dropProps: { - onDrop: (e: DroppableEvent) => void; - onDragOver: (e: DroppableEvent) => void; - onDragLeave: () => void; - dragging: DragContextState['dragging']; - droppable: DraggableProps['droppable']; - itemsInGroup: string[]; - id: string; - isActive: boolean; + + const onReorderableDrop = (dragging: DragDropIdentifier, target: DragDropIdentifier) => { + if (onDrop) { + onDrop(dragging, target); + const targetIndex = reorderableGroup.findIndex( + (i) => i.id === activeDropTarget?.activeDropTarget?.id + ); + + resetReorderState(); + setA11yMessage(reorderAnnouncements.dropped(targetIndex + 1, currentIndex + 1)); + } }; - children: React.ReactElement; - label: string; - dropTo: DropToHandler; - className?: string; - dataTestSubj: string; -}) => { - const { itemsInGroup, dragging, id, droppable } = dropProps; - const { reorderState, setReorderState } = useContext(ReorderContext); - const { isReorderOn, reorderedItems, draggingHeight, direction, groupId } = reorderState; - const currentIndex = itemsInGroup.indexOf(id); + const resetReorderState = () => + setReorderState((s: ReorderState) => ({ + ...s, + reorderedItems: [], + })); + + const extraKeyboardHandler = (e: React.KeyboardEvent<HTMLButtonElement>) => { + if (isReorderOn && keyboardMode) { + e.stopPropagation(); + e.preventDefault(); + let activeDropTargetIndex = reorderableGroup.findIndex((i) => i.id === value.id); + if (activeDropTarget?.activeDropTarget) { + const index = reorderableGroup.findIndex( + (i) => i.id === activeDropTarget.activeDropTarget?.id + ); + if (index !== -1) activeDropTargetIndex = index; + } + if (keys.ARROW_DOWN === e.key) { + if (activeDropTargetIndex < reorderableGroup.length - 1) { + setA11yMessage( + reorderAnnouncements.moved(label, activeDropTargetIndex + 2, currentIndex + 1) + ); + onReorderableDragOver(reorderableGroup[activeDropTargetIndex + 1]); + } + } else if (keys.ARROW_UP === e.key) { + if (activeDropTargetIndex > 0) { + setA11yMessage( + reorderAnnouncements.moved(label, activeDropTargetIndex, currentIndex + 1) + ); + + onReorderableDragOver(reorderableGroup[activeDropTargetIndex - 1]); + } + } + } + }; - useEffect( - () => + const onReorderableDragOver = (target: DragDropIdentifier) => { + let droppingIndex = currentIndex; + if (keyboardMode && 'id' in target) { + setActiveDropTarget(target); + droppingIndex = reorderableGroup.findIndex((i) => i.id === target.id); + } + const draggingIndex = reorderableGroup.findIndex((i) => i.id === value?.id); + if (draggingIndex === -1) { + return; + } + + if (draggingIndex === droppingIndex) { setReorderState((s: ReorderState) => ({ ...s, - isReorderOn: draggingProps.isReorderDragging, - })), - [draggingProps.isReorderDragging, setReorderState] - ); + reorderedItems: [], + })); + } + + setReorderState((s: ReorderState) => + draggingIndex < droppingIndex + ? { + ...s, + reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1), + direction: '-', + } + : { + ...s, + reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex), + direction: '+', + } + ); + }; + + const areItemsReordered = isDragging && keyboardMode && reorderedItems.length; return ( <div - className={classNames('lnsDragDrop__reorderableContainer', className)} - data-test-subj={dataTestSubj} - > - <EuiScreenReaderOnly showOnFocus> - <button - aria-label={label} - aria-describedby={`lnsDragDrop-reorderInstructions-${groupId}`} - className="lnsDragDrop__keyboardHandler" - data-test-subj="lnsDragDrop-keyboardHandler" - onBlur={() => { - setReorderState((s: ReorderState) => ({ - ...s, - isReorderOn: false, - keyboardReorderMessage: '', - })); - }} - onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => { - if (e.key === keys.ENTER || e.key === keys.SPACE) { - setReorderState((s: ReorderState) => ({ - ...s, - isReorderOn: !isReorderOn, - keyboardReorderMessage: isReorderOn - ? '' - : getKeyboardReorderMessageLifted(label, currentIndex + 1), - })); - } else if (e.key === keys.ESCAPE) { - setReorderState((s: ReorderState) => ({ - ...s, - isReorderOn: false, - keyboardReorderMessage: '', - })); + data-test-subj="lnsDragDrop-reorderableDrag" + className={ + isDragging + ? 'lnsDragDrop-reorderable lnsDragDrop-translatableDrag' + : 'lnsDragDrop-reorderable' + } + style={ + areItemsReordered + ? { + transform: `translateY(${direction === '+' ? '-' : '+'}${reorderedItems.reduce( + (acc, cur) => { + return acc + Number(cur.height || 0) + lnsLayerPanelDimensionMargin; + }, + 0 + )}px)`, } - if (isReorderOn) { - e.stopPropagation(); - e.preventDefault(); - - if (keys.ARROW_DOWN === e.key) { - if (currentIndex < itemsInGroup.length - 1) { - setReorderState((s: ReorderState) => ({ - ...s, - keyboardReorderMessage: getKeyboardReorderMessageMoved( - label, - currentIndex + 2, - currentIndex + 1 - ), - })); - dropTo(itemsInGroup[currentIndex + 1]); - } - } else if (keys.ARROW_UP === e.key) { - if (currentIndex > 0) { - setReorderState((s: ReorderState) => ({ - ...s, - keyboardReorderMessage: getKeyboardReorderMessageMoved( - label, - currentIndex, - currentIndex + 1 - ), - })); - dropTo(itemsInGroup[currentIndex - 1]); - } + : undefined + } + > + <DragInner + {...props} + extraKeyboardHandler={extraKeyboardHandler} + onDragStart={onReorderableDragStart} + onDragEnd={onReorderableDragEnd} + onDrop={onReorderableDrop} + /> + </div> + ); +}); + +const ReorderableDrop = memo(function ReorderableDrop( + props: DropInnerProps & { reorderableGroup: DragDropIdentifier[] } +) { + const { + onDrop, + value, + droppable, + dragging, + setDragging, + setKeyboardMode, + activeDropTarget, + setActiveDropTarget, + reorderableGroup, + setA11yMessage, + } = props; + + const currentIndex = reorderableGroup.findIndex((i) => i.id === value.id); + const activeDropTargetMatches = + activeDropTarget?.activeDropTarget && activeDropTarget.activeDropTarget.id === value.id; + + const { + reorderState: { isReorderOn, reorderedItems, draggingHeight, direction }, + setReorderState, + } = useContext(ReorderContext); + + const heightRef = React.useRef<HTMLDivElement>(null); + + const isReordered = + isReorderOn && reorderedItems.some((el) => el.id === value.id) && reorderedItems.length; + + useEffect(() => { + if (isReordered && heightRef.current?.clientHeight) { + setReorderState((s) => ({ + ...s, + reorderedItems: s.reorderedItems.map((el) => + el.id === value.id + ? { + ...el, + height: heightRef.current?.clientHeight, } - } - }} - /> - </EuiScreenReaderOnly> - {React.cloneElement(children, { - ['data-test-subj']: 'lnsDragDrop-reorderableDrag', - draggable: draggingProps.draggable, - onDragEnd: draggingProps.onDragEnd, - onDragStart: (e: DroppableEvent) => { - const height = e.currentTarget.offsetHeight + lnsLayerPanelDimensionMargin; - setReorderState((s: ReorderState) => ({ + : el + ), + })); + } + }, [isReordered, setReorderState, value.id]); + + const onReorderableDragOver = (e: DroppableEvent) => { + if (!droppable) { + return; + } + e.preventDefault(); + + // An optimization to prevent a bunch of React churn. + // todo: replace with custom function ? + if (!activeDropTargetMatches) { + setActiveDropTarget(value); + } + + const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging?.id); + + if (!dragging || draggingIndex === -1) { + return; + } + const droppingIndex = currentIndex; + if (draggingIndex === droppingIndex) { + setReorderState((s: ReorderState) => ({ + ...s, + reorderedItems: [], + })); + } + + setReorderState((s: ReorderState) => + draggingIndex < droppingIndex + ? { ...s, - draggingHeight: height, - })); - draggingProps.onDragStart(e); - }, - className: classNames( - draggingProps.className, - { - 'lnsDragDrop-isKeyboardModeActive': isReorderOn, - }, - { - 'lnsDragDrop-isReorderable': draggingProps.isReorderDragging, + reorderedItems: reorderableGroup.slice(draggingIndex + 1, droppingIndex + 1), + direction: '-', } - ), - style: reorderedItems.includes(id) - ? { - transform: `translateY(${direction}${draggingHeight}px)`, - } - : {}, - })} + : { + ...s, + reorderedItems: reorderableGroup.slice(droppingIndex, draggingIndex), + direction: '+', + } + ); + }; + + const onReorderableDrop = (e: DroppableEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setActiveDropTarget(undefined); + setDragging(undefined); + setKeyboardMode(false); + + if (onDrop && droppable && dragging) { + trackUiEvent('drop_total'); + + onDrop(dragging, value); + const draggingIndex = reorderableGroup.findIndex((i) => i.id === dragging.id); + // setTimeout ensures it will run after dragEnd messaging + setTimeout(() => + setA11yMessage(reorderAnnouncements.dropped(currentIndex + 1, draggingIndex + 1)) + ); + } + }; + + return ( + <div> <div - data-test-subj="lnsDragDrop-reorderableDrop" + style={ + reorderedItems.some((i) => i.id === value.id) + ? { + transform: `translateY(${direction}${draggingHeight}px)`, + } + : undefined + } + ref={heightRef} + data-test-subj="lnsDragDrop-translatableDrop" + className="lnsDragDrop-translatableDrop lnsDragDrop-reorderable" + > + <DropInner {...props} /> + </div> + + <div + data-test-subj="lnsDragDrop-reorderableDropLayer" className={classNames('lnsDragDrop', { ['lnsDragDrop__reorderableDrop']: dragging && droppable, })} - onDrop={(e) => { - dropProps.onDrop(e); - setReorderState((s: ReorderState) => ({ - ...s, - reorderedItems: [], - })); - }} - onDragOver={(e: DroppableEvent) => { - if (!droppable) { - return; - } - dropProps.onDragOver(e); - if (!dropProps.isActive) { - if (!dragging) { - return; - } - const draggingIndex = itemsInGroup.indexOf(dragging.id); - const droppingIndex = currentIndex; - if (draggingIndex === droppingIndex) { - setReorderState((s: ReorderState) => ({ - ...s, - reorderedItems: [], - })); - } - - setReorderState((s: ReorderState) => - draggingIndex < droppingIndex - ? { - ...s, - reorderedItems: itemsInGroup.slice(draggingIndex + 1, droppingIndex + 1), - direction: '-', - } - : { - ...s, - reorderedItems: itemsInGroup.slice(droppingIndex, draggingIndex), - direction: '+', - } - ); - } - }} + onDrop={onReorderableDrop} + onDragOver={onReorderableDragOver} onDragLeave={() => { - dropProps.onDragLeave(); setReorderState((s: ReorderState) => ({ ...s, reorderedItems: [], @@ -527,4 +719,4 @@ export const ReorderableDragDrop = ({ /> </div> ); -}; +}); diff --git a/x-pack/plugins/lens/public/drag_drop/providers.tsx b/x-pack/plugins/lens/public/drag_drop/providers.tsx index 5e0fc648454ad..86ff5054520af 100644 --- a/x-pack/plugins/lens/public/drag_drop/providers.tsx +++ b/x-pack/plugins/lens/public/drag_drop/providers.tsx @@ -9,12 +9,13 @@ import classNames from 'classnames'; import { EuiScreenReaderOnly, EuiPortal } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -export type Dragging = - | (Record<string, unknown> & { - id: string; - }) - | undefined; +export type DragDropIdentifier = Record<string, unknown> & { + id: string; +}; +export interface ActiveDropTarget { + activeDropTarget?: DragDropIdentifier; +} /** * The shape of the drag / drop context. */ @@ -22,12 +23,26 @@ export interface DragContextState { /** * The item being dragged or undefined. */ - dragging: Dragging; + dragging?: DragDropIdentifier; + /** + * keyboard mode + */ + keyboardMode: boolean; + /** + * keyboard mode + */ + setKeyboardMode: (mode: boolean) => void; /** * Set the item being dragged. */ - setDragging: (dragging: Dragging) => void; + setDragging: (dragging?: DragDropIdentifier) => void; + + activeDropTarget?: ActiveDropTarget; + + setActiveDropTarget: (newTarget?: DragDropIdentifier) => void; + + setA11yMessage: (message: string) => void; } /** @@ -38,28 +53,52 @@ export interface DragContextState { export const DragContext = React.createContext<DragContextState>({ dragging: undefined, setDragging: () => {}, + keyboardMode: false, + setKeyboardMode: () => {}, + activeDropTarget: undefined, + setActiveDropTarget: () => {}, + setA11yMessage: () => {}, }); /** * The argument to DragDropProvider. */ export interface ProviderProps { + /** + * keyboard mode + */ + keyboardMode: boolean; + /** + * keyboard mode + */ + setKeyboardMode: (mode: boolean) => void; + /** + * Set the item being dragged. + */ /** * The item being dragged. If unspecified, the provider will * behave as if it is the root provider. */ - dragging: Dragging; + dragging?: DragDropIdentifier; /** * Sets the item being dragged. If unspecified, the provider * will behave as if it is the root provider. */ - setDragging: (dragging: Dragging) => void; + setDragging: (dragging?: DragDropIdentifier) => void; + + activeDropTarget?: { + activeDropTarget?: DragDropIdentifier; + }; + + setActiveDropTarget: (newTarget?: DragDropIdentifier) => void; /** * The React children. */ children: React.ReactNode; + + setA11yMessage: (message: string) => void; } /** @@ -70,15 +109,60 @@ export interface ProviderProps { * @param props */ export function RootDragDropProvider({ children }: { children: React.ReactNode }) { - const [state, setState] = useState<{ dragging: Dragging }>({ + const [draggingState, setDraggingState] = useState<{ dragging?: DragDropIdentifier }>({ dragging: undefined, }); - const setDragging = useMemo(() => (dragging: Dragging) => setState({ dragging }), [setState]); + const [keyboardModeState, setKeyboardModeState] = useState(false); + const [a11yMessageState, setA11yMessageState] = useState(''); + const [activeDropTargetState, setActiveDropTargetState] = useState<{ + activeDropTarget?: DragDropIdentifier; + }>({ + activeDropTarget: undefined, + }); + + const setDragging = useMemo( + () => (dragging?: DragDropIdentifier) => setDraggingState({ dragging }), + [setDraggingState] + ); + + const setA11yMessage = useMemo(() => (message: string) => setA11yMessageState(message), [ + setA11yMessageState, + ]); + + const setActiveDropTarget = useMemo( + () => (activeDropTarget?: DragDropIdentifier) => + setActiveDropTargetState((s) => ({ ...s, activeDropTarget })), + [setActiveDropTargetState] + ); return ( - <ChildDragDropProvider dragging={state.dragging} setDragging={setDragging}> - {children} - </ChildDragDropProvider> + <div> + <ChildDragDropProvider + keyboardMode={keyboardModeState} + setKeyboardMode={setKeyboardModeState} + dragging={draggingState.dragging} + setA11yMessage={setA11yMessage} + setDragging={setDragging} + activeDropTarget={activeDropTargetState} + setActiveDropTarget={setActiveDropTarget} + > + {children} + </ChildDragDropProvider> + <EuiPortal> + <EuiScreenReaderOnly> + <div> + <p aria-live="assertive" aria-atomic={true}> + {a11yMessageState} + </p> + <p id={`lnsDragDrop-keyboardInstructions`}> + {i18n.translate('xpack.lens.dragDrop.keyboardInstructions', { + defaultMessage: `Press enter or space to start reordering the dimension group. When dragging, use arrow keys to reorder. Press enter or space again to finish.`, + })} + </p> + </div> + </EuiScreenReaderOnly> + </EuiPortal> + </div> ); } @@ -89,8 +173,36 @@ export function RootDragDropProvider({ children }: { children: React.ReactNode } * * @param props */ -export function ChildDragDropProvider({ dragging, setDragging, children }: ProviderProps) { - const value = useMemo(() => ({ dragging, setDragging }), [setDragging, dragging]); +export function ChildDragDropProvider({ + dragging, + setDragging, + setKeyboardMode, + keyboardMode, + activeDropTarget, + setActiveDropTarget, + setA11yMessage, + children, +}: ProviderProps) { + const value = useMemo( + () => ({ + setKeyboardMode, + keyboardMode, + dragging, + setDragging, + activeDropTarget, + setActiveDropTarget, + setA11yMessage, + }), + [ + setDragging, + dragging, + activeDropTarget, + setActiveDropTarget, + setKeyboardMode, + keyboardMode, + setA11yMessage, + ] + ); return <DragContext.Provider value={value}>{children}</DragContext.Provider>; } @@ -98,7 +210,7 @@ export interface ReorderState { /** * Ids of the elements that are translated up or down */ - reorderedItems: string[]; + reorderedItems: DragDropIdentifier[]; /** * Direction of the move of dragged element in the reordered list @@ -112,10 +224,6 @@ export interface ReorderState { * indicates that user is in keyboard mode */ isReorderOn: boolean; - /** - * aria-live message for changes in reordering - */ - keyboardReorderMessage: string; /** * reorder group needed for screen reader aria-described-by attribute */ @@ -135,7 +243,6 @@ export const ReorderContext = React.createContext<ReorderContextState>({ direction: '-', draggingHeight: 40, isReorderOn: false, - keyboardReorderMessage: '', groupId: '', }, setReorderState: () => () => {}, @@ -155,33 +262,70 @@ export function ReorderProvider({ direction: '-', draggingHeight: 40, isReorderOn: false, - keyboardReorderMessage: '', groupId: id, }); const setReorderState = useMemo(() => (dispatch: SetReorderStateDispatch) => setState(dispatch), [ setState, ]); - return ( - <div className={classNames(className, { 'lnsDragDrop-isActiveGroup': state.isReorderOn })}> + <div + data-test-subj="lnsDragDrop-reorderableGroup" + className={classNames(className, { + 'lnsDragDrop-isActiveGroup': state.isReorderOn && React.Children.count(children) > 1, + })} + > <ReorderContext.Provider value={{ reorderState: state, setReorderState }}> {children} </ReorderContext.Provider> - <EuiPortal> - <EuiScreenReaderOnly> - <div> - <p aria-live="assertive" aria-atomic={true}> - {state.keyboardReorderMessage} - </p> - <p id={`lnsDragDrop-reorderInstructions-${id}`}> - {i18n.translate('xpack.lens.dragDrop.reorderInstructions', { - defaultMessage: `Press space bar to start a drag. When dragging, use arrow keys to reorder. Press space bar again to finish.`, - })} - </p> - </div> - </EuiScreenReaderOnly> - </EuiPortal> </div> ); } + +export const reorderAnnouncements = { + moved: (itemLabel: string, position: number, prevPosition: number) => { + return prevPosition === position + ? i18n.translate('xpack.lens.dragDrop.elementMovedBack', { + defaultMessage: `You have moved back the item {itemLabel} to position {prevPosition}`, + values: { + itemLabel, + prevPosition, + }, + }) + : i18n.translate('xpack.lens.dragDrop.elementMoved', { + defaultMessage: `You have moved the item {itemLabel} from position {prevPosition} to position {position}`, + values: { + itemLabel, + position, + prevPosition, + }, + }); + }, + + lifted: (itemLabel: string, position: number) => + i18n.translate('xpack.lens.dragDrop.elementLifted', { + defaultMessage: `You have lifted an item {itemLabel} in position {position}`, + values: { + itemLabel, + position, + }, + }), + + cancelled: (position: number) => + i18n.translate('xpack.lens.dragDrop.abortMessageReorder', { + defaultMessage: + 'Movement cancelled. The item has returned to its starting position {position}', + values: { + position, + }, + }), + dropped: (position: number, prevPosition: number) => + i18n.translate('xpack.lens.dragDrop.dropMessageReorder', { + defaultMessage: + 'You have dropped the item. You have moved the item from position {prevPosition} to positon {position}', + values: { + position, + prevPosition, + }, + }), +}; diff --git a/x-pack/plugins/lens/public/drag_drop/readme.md b/x-pack/plugins/lens/public/drag_drop/readme.md index 1e812c7adac27..e48564a074986 100644 --- a/x-pack/plugins/lens/public/drag_drop/readme.md +++ b/x-pack/plugins/lens/public/drag_drop/readme.md @@ -30,7 +30,7 @@ In your child application, place a `ChildDragDropProvider` at the root of that, This enables your child application to share the same drag / drop context as the root application. -## Dragging +## DragDropIdentifier An item can be both draggable and droppable at the same time, but for simplicity's sake, we'll treat these two cases separately. @@ -88,7 +88,7 @@ The children `DragDrop` components must have props defined as in the example: droppable dragType="reorder" dropType="reorder" - itemsInGroup={fields.map((f) => f.id)} // consists ids of all reorderable elements in the group, eg. ['3', '5', '1'] + reorderableGroup={fields} // consists all reorderable elements in the group, eg. [{id:'3'}, {id:'5'}, {id:'1'}] value={{ id: f.id, }} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx index 70c4fb5567226..0ebcb5bb07482 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.test.tsx @@ -92,6 +92,14 @@ describe('ConfigPanel', () => { mockDatasource = createMockDatasource('ds1'); }); + // in what case is this test needed? + it('should fail to render layerPanels if the public API is out of date', () => { + const props = getDefaultProps(); + props.framePublicAPI.datasourceLayers = {}; + const component = mountWithIntl(<LayerPanels {...props} />); + expect(component.find(LayerPanel).exists()).toBe(false); + }); + describe('focus behavior when adding or removing layers', () => { it('should focus the only layer when resetting the layer', () => { const component = mountWithIntl(<LayerPanels {...getDefaultProps()} />); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index ec1a5c226d351..67c8a6b5e4abc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -134,37 +134,42 @@ export function LayerPanels( [dispatch] ); + const datasourcePublicAPIs = props.framePublicAPI.datasourceLayers; + return ( <EuiForm className="lnsConfigPanel"> - {layerIds.map((layerId, index) => ( - <LayerPanel - {...props} - setLayerRef={setLayerRef} - key={layerId} - layerId={layerId} - index={index} - visualizationState={visualizationState} - updateVisualization={setVisualizationState} - updateDatasource={updateDatasource} - updateAll={updateAll} - isOnlyLayer={layerIds.length === 1} - onRemoveLayer={() => { - dispatch({ - type: 'UPDATE_STATE', - subType: 'REMOVE_OR_CLEAR_LAYER', - updater: (state) => - removeLayer({ - activeVisualization, - layerId, - trackUiEvent, - datasourceMap, - state, - }), - }); - removeLayerRef(layerId); - }} - /> - ))} + {layerIds.map((layerId, layerIndex) => + datasourcePublicAPIs[layerId] ? ( + <LayerPanel + {...props} + activeVisualization={activeVisualization} + setLayerRef={setLayerRef} + key={layerId} + layerId={layerId} + layerIndex={layerIndex} + visualizationState={visualizationState} + updateVisualization={setVisualizationState} + updateDatasource={updateDatasource} + updateAll={updateAll} + isOnlyLayer={layerIds.length === 1} + onRemoveLayer={() => { + dispatch({ + type: 'UPDATE_STATE', + subType: 'REMOVE_OR_CLEAR_LAYER', + updater: (state) => + removeLayer({ + activeVisualization, + layerId, + trackUiEvent, + datasourceMap, + state, + }), + }); + removeLayerRef(layerId); + }} + /> + ) : null + )} {activeVisualization.appendLayer && visualizationState && ( <EuiFlexItem grow={true}> <EuiToolTip diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_button.tsx new file mode 100644 index 0000000000000..06f50d7f32440 --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_button.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButtonIcon, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ColorIndicator } from './color_indicator'; +import { PaletteIndicator } from './palette_indicator'; +import { VisualizationDimensionGroupConfig, AccessorConfig } from '../../../types'; + +const triggerLinkA11yText = (label: string) => + i18n.translate('xpack.lens.configure.editConfig', { + defaultMessage: 'Edit {label} configuration', + values: { label }, + }); + +export function DimensionButton({ + group, + children, + onClick, + onRemoveClick, + accessorConfig, + label, +}: { + group: VisualizationDimensionGroupConfig; + children: React.ReactElement; + onClick: (id: string) => void; + onRemoveClick: (id: string) => void; + accessorConfig: AccessorConfig; + label: string; +}) { + return ( + <> + <EuiLink + className="lnsLayerPanel__dimensionLink" + data-test-subj="lnsLayerPanel-dimensionLink" + onClick={() => onClick(accessorConfig.columnId)} + aria-label={triggerLinkA11yText(label)} + title={triggerLinkA11yText(label)} + > + <ColorIndicator accessorConfig={accessorConfig}>{children}</ColorIndicator> + </EuiLink> + <EuiButtonIcon + className="lnsLayerPanel__dimensionRemove" + data-test-subj="indexPattern-dimension-remove" + iconType="cross" + iconSize="s" + size="s" + color="danger" + aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', { + defaultMessage: 'Remove configuration from "{groupLabel}"', + values: { groupLabel: group.groupLabel }, + })} + title={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', { + defaultMessage: 'Remove configuration from "{groupLabel}"', + values: { groupLabel: group.groupLabel }, + })} + onClick={() => onRemoveClick(accessorConfig.columnId)} + /> + <PaletteIndicator accessorConfig={accessorConfig} /> + </> + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx new file mode 100644 index 0000000000000..8de57cb43b16f --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/draggable_dimension_button.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop'; +import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation } from '../../../types'; +import { LayerDatasourceDropProps } from './types'; + +const isFromTheSameGroup = (el1: DragDropIdentifier, el2?: DragDropIdentifier) => + el2 && isDraggedOperation(el2) && el1.groupId === el2.groupId && el1.columnId !== el2.columnId; + +const isSelf = (el1: DragDropIdentifier, el2?: DragDropIdentifier) => + isDraggedOperation(el2) && el1.columnId === el2.columnId; + +export function DraggableDimensionButton({ + layerId, + label, + accessorIndex, + groupIndex, + layerIndex, + columnId, + group, + onDrop, + children, + dragDropContext, + layerDatasourceDropProps, + layerDatasource, +}: { + dragDropContext: DragContextState; + layerId: string; + groupIndex: number; + layerIndex: number; + onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; + group: VisualizationDimensionGroupConfig; + label: string; + children: React.ReactElement; + layerDatasource: Datasource<unknown, unknown>; + layerDatasourceDropProps: LayerDatasourceDropProps; + accessorIndex: number; + columnId: string; +}) { + const value = useMemo(() => { + return { + columnId, + groupId: group.groupId, + layerId, + id: columnId, + }; + }, [columnId, group.groupId, layerId]); + + const { dragging } = dragDropContext; + + const isCurrentGroup = group.groupId === dragging?.groupId; + const isOperationDragged = isDraggedOperation(dragging); + const canHandleDrop = + Boolean(dragDropContext.dragging) && + layerDatasource.canHandleDrop({ + ...layerDatasourceDropProps, + columnId, + filterOperations: group.filterOperations, + }); + + const dragType = isSelf(value, dragging) + ? 'move' + : isOperationDragged && isCurrentGroup + ? 'reorder' + : 'copy'; + + const dropType = isOperationDragged ? (!isCurrentGroup ? 'replace' : 'reorder') : 'add'; + + const isCompatibleFromOtherGroup = !isCurrentGroup && canHandleDrop; + + const isDroppable = isOperationDragged + ? dragType === 'reorder' + ? isFromTheSameGroup(value, dragging) + : isCompatibleFromOtherGroup + : canHandleDrop; + + const reorderableGroup = useMemo( + () => + group.accessors.map((a) => ({ + columnId: a.columnId, + id: a.columnId, + groupId: group.groupId, + layerId, + })), + [group, layerId] + ); + + return ( + <div className="lnsLayerPanel__dimensionContainer" data-test-subj={group.dataTestSubj}> + <DragDrop + noKeyboardSupportYet={reorderableGroup.length < 2} // to be removed when navigating outside of groups is added + draggable + dragType={dragType} + dropType={dropType} + reorderableGroup={reorderableGroup.length > 1 ? reorderableGroup : undefined} + value={value} + label={label} + droppable={dragging && isDroppable} + onDrop={onDrop} + > + {children} + </DragDrop> + </div> + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx new file mode 100644 index 0000000000000..88e1663d0b49c --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/empty_dimension_button.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { generateId } from '../../../id_generator'; +import { DragDrop, DragDropIdentifier, DragContextState } from '../../../drag_drop'; +import { Datasource, VisualizationDimensionGroupConfig, isDraggedOperation } from '../../../types'; +import { LayerDatasourceDropProps } from './types'; + +export function EmptyDimensionButton({ + dragDropContext, + group, + layerDatasource, + layerDatasourceDropProps, + layerId, + groupIndex, + layerIndex, + onClick, + onDrop, +}: { + dragDropContext: DragContextState; + layerId: string; + groupIndex: number; + layerIndex: number; + onClick: (id: string) => void; + onDrop: (droppedItem: DragDropIdentifier, dropTarget: DragDropIdentifier) => void; + group: VisualizationDimensionGroupConfig; + + layerDatasource: Datasource<unknown, unknown>; + layerDatasourceDropProps: LayerDatasourceDropProps; +}) { + const handleDrop = (droppedItem: DragDropIdentifier) => onDrop(droppedItem, value); + + const value = useMemo(() => { + const newId = generateId(); + return { + columnId: newId, + groupId: group.groupId, + layerId, + isNew: true, + id: newId, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [group.accessors.length, group.groupId, layerId]); + + return ( + <div className="lnsLayerPanel__dimensionContainer" data-test-subj={group.dataTestSubj}> + <DragDrop + value={value} + onDrop={handleDrop} + droppable={ + Boolean(dragDropContext.dragging) && + // Verify that the dragged item is not coming from the same group + // since this would be a duplicate + (!isDraggedOperation(dragDropContext.dragging) || + dragDropContext.dragging.groupId !== group.groupId) && + layerDatasource.canHandleDrop({ + ...layerDatasourceDropProps, + columnId: value.columnId, + filterOperations: group.filterOperations, + }) + } + > + <div className="lnsLayerPanel__dimension lnsLayerPanel__dimension--empty"> + <EuiButtonEmpty + className="lnsLayerPanel__triggerText" + color="text" + size="xs" + iconType="plusInCircleFilled" + contentProps={{ + className: 'lnsLayerPanel__triggerTextContent', + }} + aria-label={i18n.translate('xpack.lens.indexPattern.removeColumnAriaLabel', { + defaultMessage: 'Drop a field or click to add to {groupLabel}', + values: { groupLabel: group.groupLabel }, + })} + data-test-subj="lns-empty-dimension" + onClick={() => { + onClick(value.columnId); + }} + > + <FormattedMessage + id="xpack.lens.configure.emptyConfig" + defaultMessage="Drop a field or click to add" + /> + </EuiButtonEmpty> + </div> + </DragDrop> + </div> + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss index 2ed91b962ff11..ec4c2adba8fd7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.scss @@ -76,6 +76,7 @@ .lnsLayerPanel__dimensionContainer { margin: 0 $euiSizeS $euiSizeS; + position: relative; &:last-child { margin-bottom: 0; @@ -127,12 +128,13 @@ } } -.lnsLayerPanel__dimensionLink { +// Added .lnsLayerPanel__dimension specificity required for animation style override +.lnsLayerPanel__dimension .lnsLayerPanel__dimensionLink { width: 100%; &:focus { - background-color: transparent !important; // sass-lint:disable-line no-important - outline: none !important; // sass-lint:disable-line no-important + background-color: transparent; + animation: none !important; // sass-lint:disable-line no-important } &:focus .lnsLayerPanel__triggerTextLabel, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index cab07150b6d56..d93cbbb58835e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -12,7 +12,7 @@ import { createMockDatasource, DatasourceMock, } from '../../mocks'; -import { ChildDragDropProvider, DroppableEvent } from '../../../drag_drop'; +import { ChildDragDropProvider, DroppableEvent, DragDrop } from '../../../drag_drop'; import { EuiFormRow } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test/jest'; import { Visualization } from '../../../types'; @@ -22,9 +22,20 @@ import { generateId } from '../../../id_generator'; jest.mock('../../../id_generator'); +const defaultContext = { + dragging: undefined, + setDragging: jest.fn(), + setActiveDropTarget: () => {}, + activeDropTarget: undefined, + keyboardMode: false, + setKeyboardMode: () => {}, + setA11yMessage: jest.fn(), +}; + describe('LayerPanel', () => { let mockVisualization: jest.Mocked<Visualization>; let mockVisualization2: jest.Mocked<Visualization>; + let mockDatasource: DatasourceMock; function getDefaultProps() { @@ -34,11 +45,7 @@ describe('LayerPanel', () => { }; return { layerId: 'first', - activeVisualizationId: 'vis1', - visualizationMap: { - vis1: mockVisualization, - vis2: mockVisualization2, - }, + activeVisualization: mockVisualization, activeDatasourceId: 'ds1', datasourceMap: { ds1: mockDatasource, @@ -58,7 +65,7 @@ describe('LayerPanel', () => { onRemoveLayer: jest.fn(), dispatch: jest.fn(), core: coreMock.createStart(), - index: 0, + layerIndex: 0, setLayerRef: jest.fn(), }; } @@ -92,20 +99,6 @@ describe('LayerPanel', () => { mockDatasource = createMockDatasource('ds1'); }); - it('should fail to render if the public API is out of date', () => { - const props = getDefaultProps(); - props.framePublicAPI.datasourceLayers = {}; - const component = mountWithIntl(<LayerPanel {...props} />); - expect(component.isEmptyRender()).toBe(true); - }); - - it('should fail to render if the active visualization is missing', () => { - const component = mountWithIntl( - <LayerPanel {...getDefaultProps()} activeVisualizationId="missing" /> - ); - expect(component.isEmptyRender()).toBe(true); - }); - describe('layer reset and remove', () => { it('should show the reset button when single layer', () => { const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />); @@ -147,8 +140,7 @@ describe('LayerPanel', () => { }); const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />); - - const group = component.find('DragDrop[data-test-subj="lnsGroup"]'); + const group = component.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]'); expect(group).toHaveLength(1); }); @@ -167,8 +159,7 @@ describe('LayerPanel', () => { }); const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />); - - const group = component.find('DragDrop[data-test-subj="lnsGroup"]'); + const group = component.find('.lnsLayerPanel__dimensionContainer[data-test-subj="lnsGroup"]'); expect(group).toHaveLength(1); }); @@ -231,50 +222,6 @@ describe('LayerPanel', () => { expect(panel.props.children).toHaveLength(2); }); - it('should keep the DimensionContainer open when configuring a new dimension', () => { - /** - * The ID generation system for new dimensions has been messy before, so - * this tests that the ID used in the first render is used to keep the container - * open in future renders - */ - (generateId as jest.Mock).mockReturnValueOnce(`newid`); - (generateId as jest.Mock).mockReturnValueOnce(`bad`); - mockVisualization.getConfiguration.mockReturnValueOnce({ - groups: [ - { - groupLabel: 'A', - groupId: 'a', - accessors: [], - filterOperations: () => true, - supportsMoreColumns: true, - dataTestSubj: 'lnsGroup', - }, - ], - }); - // Normally the configuration would change in response to a state update, - // but this test is updating it directly - mockVisualization.getConfiguration.mockReturnValueOnce({ - groups: [ - { - groupLabel: 'A', - groupId: 'a', - accessors: [{ columnId: 'newid' }], - filterOperations: () => true, - supportsMoreColumns: false, - dataTestSubj: 'lnsGroup', - }, - ], - }); - - const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />); - act(() => { - component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); - }); - component.update(); - - expect(component.find('EuiFlyoutHeader').exists()).toBe(true); - }); - it('should not update the visualization if the datasource is incomplete', () => { (generateId as jest.Mock).mockReturnValueOnce(`newid`); const updateAll = jest.fn(); @@ -338,6 +285,50 @@ describe('LayerPanel', () => { expect(updateAll).toHaveBeenCalled(); }); + it('should keep the DimensionContainer open when configuring a new dimension', () => { + /** + * The ID generation system for new dimensions has been messy before, so + * this tests that the ID used in the first render is used to keep the container + * open in future renders + */ + (generateId as jest.Mock).mockReturnValueOnce(`newid`); + (generateId as jest.Mock).mockReturnValueOnce(`bad`); + mockVisualization.getConfiguration.mockReturnValueOnce({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + // Normally the configuration would change in response to a state update, + // but this test is updating it directly + mockVisualization.getConfiguration.mockReturnValueOnce({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'newid' }], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const component = mountWithIntl(<LayerPanel {...getDefaultProps()} />); + act(() => { + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + }); + component.update(); + + expect(component.find('EuiFlyoutHeader').exists()).toBe(true); + }); + it('should close the DimensionContainer when the active visualization changes', () => { /** * The ID generation system for new dimensions has been messy before, so @@ -361,7 +352,7 @@ describe('LayerPanel', () => { }); // Normally the configuration would change in response to a state update, // but this test is updating it directly - mockVisualization.getConfiguration.mockReturnValueOnce({ + mockVisualization.getConfiguration.mockReturnValue({ groups: [ { groupLabel: 'A', @@ -382,7 +373,7 @@ describe('LayerPanel', () => { component.update(); expect(component.find('EuiFlyoutHeader').exists()).toBe(true); act(() => { - component.setProps({ activeVisualizationId: 'vis2' }); + component.setProps({ activeVisualization: mockVisualization2 }); }); component.update(); expect(component.find('EuiFlyoutHeader').exists()).toBe(false); @@ -452,7 +443,7 @@ describe('LayerPanel', () => { const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' }; const component = mountWithIntl( - <ChildDragDropProvider dragging={draggingField} setDragging={jest.fn()}> + <ChildDragDropProvider {...defaultContext} dragging={draggingField}> <LayerPanel {...getDefaultProps()} /> </ChildDragDropProvider> ); @@ -465,7 +456,7 @@ describe('LayerPanel', () => { }) ); - component.find('DragDrop[data-test-subj="lnsGroup"]').first().simulate('drop'); + component.find('[data-test-subj="lnsGroup"] DragDrop').first().simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ @@ -495,7 +486,7 @@ describe('LayerPanel', () => { const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a', id: '1' }; const component = mountWithIntl( - <ChildDragDropProvider dragging={draggingField} setDragging={jest.fn()}> + <ChildDragDropProvider {...defaultContext} dragging={draggingField}> <LayerPanel {...getDefaultProps()} /> </ChildDragDropProvider> ); @@ -505,10 +496,14 @@ describe('LayerPanel', () => { ); expect( - component.find('DragDrop[data-test-subj="lnsGroup"]').first().prop('droppable') + component.find('[data-test-subj="lnsGroup"] DragDrop').first().prop('droppable') ).toEqual(false); - component.find('DragDrop[data-test-subj="lnsGroup"]').first().simulate('drop'); + component + .find('[data-test-subj="lnsGroup"] DragDrop') + .first() + .find('.lnsLayerPanel__dimension') + .simulate('drop'); expect(mockDatasource.onDrop).not.toHaveBeenCalled(); }); @@ -542,12 +537,11 @@ describe('LayerPanel', () => { const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; const component = mountWithIntl( - <ChildDragDropProvider dragging={draggingOperation} setDragging={jest.fn()}> + <ChildDragDropProvider {...defaultContext} dragging={draggingOperation}> <LayerPanel {...getDefaultProps()} /> </ChildDragDropProvider> ); - expect(mockDatasource.canHandleDrop).toHaveBeenCalledTimes(2); expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( expect.objectContaining({ dragDropContext: expect.objectContaining({ @@ -557,7 +551,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the pre-populated dimension - component.find('DragDrop[data-test-subj="lnsGroupB"]').at(0).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop').at(0).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'b', @@ -568,7 +562,7 @@ describe('LayerPanel', () => { ); // Simulate drop on the empty dimension - component.find('DragDrop[data-test-subj="lnsGroupB"]').at(1).simulate('drop'); + component.find('[data-test-subj="lnsGroupB"] DragDrop').at(1).simulate('drop'); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ columnId: 'newid', @@ -596,18 +590,55 @@ describe('LayerPanel', () => { const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; const component = mountWithIntl( - <ChildDragDropProvider dragging={draggingOperation} setDragging={jest.fn()}> + <ChildDragDropProvider {...defaultContext} dragging={draggingOperation}> + <LayerPanel {...getDefaultProps()} /> + </ChildDragDropProvider> + ); + + component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, { + layerId: 'first', + columnId: 'b', + groupId: 'a', + id: 'b', + }); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + groupId: 'a', + droppedItem: draggingOperation, + }) + ); + }); + + it('should copy when dropping on empty slot in the same group', () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [{ columnId: 'a' }, { columnId: 'b' }], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a', id: 'a' }; + + const component = mountWithIntl( + <ChildDragDropProvider {...defaultContext} dragging={draggingOperation}> <LayerPanel {...getDefaultProps()} /> </ChildDragDropProvider> ); - expect(mockDatasource.canHandleDrop).not.toHaveBeenCalled(); - component.find('DragDrop[data-test-subj="lnsGroup"]').at(1).prop('onDrop')!( + component.find('[data-test-subj="lnsGroup"] DragDrop').at(2).prop('onDrop')!( (draggingOperation as unknown) as DroppableEvent ); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ - isReorder: true, + groupId: 'a', + droppedItem: draggingOperation, + isNew: true, }) ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 999f75686b1cb..a1b13878851ee 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -5,66 +5,35 @@ */ import './layer_panel.scss'; -import React, { useContext, useState, useEffect } from 'react'; -import { - EuiPanel, - EuiSpacer, - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, - EuiFormRow, - EuiLink, -} from '@elastic/eui'; +import React, { useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { EuiPanel, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; import { NativeRenderer } from '../../../native_renderer'; -import { StateSetter, isDraggedOperation } from '../../../types'; -import { DragContext, DragDrop, ChildDragDropProvider, ReorderProvider } from '../../../drag_drop'; +import { StateSetter, Visualization } from '../../../types'; +import { + DragContext, + DragDropIdentifier, + ChildDragDropProvider, + ReorderProvider, +} from '../../../drag_drop'; import { LayerSettings } from './layer_settings'; import { trackUiEvent } from '../../../lens_ui_telemetry'; -import { generateId } from '../../../id_generator'; -import { ConfigPanelWrapperProps, ActiveDimensionState } from './types'; +import { LayerPanelProps, ActiveDimensionState } from './types'; import { DimensionContainer } from './dimension_container'; -import { ColorIndicator } from './color_indicator'; -import { PaletteIndicator } from './palette_indicator'; - -const triggerLinkA11yText = (label: string) => - i18n.translate('xpack.lens.configure.editConfig', { - defaultMessage: 'Click to edit configuration for {label} or drag to move', - values: { label }, - }); +import { RemoveLayerButton } from './remove_layer_button'; +import { EmptyDimensionButton } from './empty_dimension_button'; +import { DimensionButton } from './dimension_button'; +import { DraggableDimensionButton } from './draggable_dimension_button'; const initialActiveDimensionState = { isNew: false, }; -function isConfiguration( - value: unknown -): value is { columnId: string; groupId: string; layerId: string } { - return ( - Boolean(value) && - typeof value === 'object' && - 'columnId' in value! && - 'groupId' in value && - 'layerId' in value - ); -} - -function isSameConfiguration(config1: unknown, config2: unknown) { - return ( - isConfiguration(config1) && - isConfiguration(config2) && - config1.columnId === config2.columnId && - config1.groupId === config2.groupId && - config1.layerId === config2.layerId - ); -} - export function LayerPanel( - props: Exclude<ConfigPanelWrapperProps, 'state' | 'setState'> & { + props: Exclude<LayerPanelProps, 'state' | 'setState'> & { + activeVisualization: Visualization; layerId: string; - index: number; + layerIndex: number; isOnlyLayer: boolean; updateVisualization: StateSetter<unknown>; updateDatasource: (datasourceId: string, newState: unknown) => void; @@ -82,26 +51,25 @@ export function LayerPanel( initialActiveDimensionState ); - const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer, setLayerRef, index } = props; + const { + framePublicAPI, + layerId, + isOnlyLayer, + onRemoveLayer, + setLayerRef, + layerIndex, + activeVisualization, + updateVisualization, + updateDatasource, + } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; useEffect(() => { setActiveDimension(initialActiveDimensionState); - }, [props.activeVisualizationId]); + }, [activeVisualization.id]); - const setLayerRefMemoized = React.useCallback((el) => setLayerRef(layerId, el), [ - layerId, - setLayerRef, - ]); + const setLayerRefMemoized = useCallback((el) => setLayerRef(layerId, el), [layerId, setLayerRef]); - if ( - !datasourcePublicAPI || - !props.activeVisualizationId || - !props.visualizationMap[props.activeVisualizationId] - ) { - return null; - } - const activeVisualization = props.visualizationMap[props.activeVisualizationId]; const layerVisualizationConfigProps = { layerId, dragDropContext, @@ -110,18 +78,23 @@ export function LayerPanel( dateRange: props.framePublicAPI.dateRange, activeData: props.framePublicAPI.activeData, }; + const datasourceId = datasourcePublicAPI.datasourceId; const layerDatasourceState = props.datasourceStates[datasourceId].state; - const layerDatasource = props.datasourceMap[datasourceId]; - const layerDatasourceDropProps = { - layerId, - dragDropContext, - state: layerDatasourceState, - setState: (newState: unknown) => { - props.updateDatasource(datasourceId, newState); - }, - }; + const layerDatasourceDropProps = useMemo( + () => ({ + layerId, + dragDropContext, + state: layerDatasourceState, + setState: (newState: unknown) => { + updateDatasource(datasourceId, newState); + }, + }), + [layerId, dragDropContext, layerDatasourceState, datasourceId, updateDatasource] + ); + + const layerDatasource = props.datasourceMap[datasourceId]; const layerDatasourceConfigProps = { ...layerDatasourceDropProps, @@ -135,10 +108,68 @@ export function LayerPanel( const { activeId, activeGroup } = activeDimension; const columnLabelMap = layerDatasource.uniqueLabels(layerDatasourceConfigProps.state); + + const { setDimension, removeDimension } = activeVisualization; + const layerDatasourceOnDrop = layerDatasource.onDrop; + + const onDrop = useMemo(() => { + return (droppedItem: DragDropIdentifier, targetItem: DragDropIdentifier) => { + const { columnId, groupId, layerId: targetLayerId, isNew } = (targetItem as unknown) as { + groupId: string; + columnId: string; + layerId: string; + isNew?: boolean; + }; + + const filterOperations = + groups.find(({ groupId: gId }) => gId === targetItem.groupId)?.filterOperations || + (() => false); + + const dropResult = layerDatasourceOnDrop({ + ...layerDatasourceDropProps, + droppedItem, + columnId, + groupId, + layerId: targetLayerId, + isNew, + filterOperations, + }); + if (dropResult) { + updateVisualization( + setDimension({ + columnId, + groupId, + layerId: targetLayerId, + prevState: props.visualizationState, + }) + ); + + if (typeof dropResult === 'object') { + // When a column is moved, we delete the reference to the old + updateVisualization( + removeDimension({ + columnId: dropResult.deleted, + layerId: targetLayerId, + prevState: props.visualizationState, + }) + ); + } + } + }; + }, [ + groups, + layerDatasourceOnDrop, + props.visualizationState, + updateVisualization, + setDimension, + removeDimension, + layerDatasourceDropProps, + ]); + return ( <ChildDragDropProvider {...dragDropContext}> <section tabIndex={-1} ref={setLayerRefMemoized} className="lnsLayerPanel"> - <EuiPanel data-test-subj={`lns-layerPanel-${index}`} paddingSize="s"> + <EuiPanel data-test-subj={`lns-layerPanel-${layerIndex}`} paddingSize="s"> <EuiFlexGroup gutterSize="s" alignItems="flexStart" responsive={false}> <EuiFlexItem grow={false} className="lnsLayerPanel__settingsFlexItem"> <LayerSettings @@ -193,10 +224,8 @@ export function LayerPanel( <EuiSpacer size="m" /> - {groups.map((group) => { - const newId = generateId(); + {groups.map((group, groupIndex) => { const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; - return ( <EuiFormRow className={ @@ -222,261 +251,86 @@ export function LayerPanel( } > <> - <ReorderProvider - id={`${layerId}-${group.groupId}`} - className={'lnsLayerPanel__group'} - > - {group.accessors.map((accessorConfig) => { - const accessor = accessorConfig.columnId; - const { dragging } = dragDropContext; - const dragType = - isDraggedOperation(dragging) && accessor === dragging.columnId - ? 'move' - : isDraggedOperation(dragging) && group.groupId === dragging.groupId - ? 'reorder' - : 'copy'; - - const dropType = isDraggedOperation(dragging) - ? group.groupId !== dragging.groupId - ? 'replace' - : 'reorder' - : 'add'; - - const isFromCompatibleGroup = - dragging?.groupId !== group.groupId && - layerDatasource.canHandleDrop({ - ...layerDatasourceDropProps, - columnId: accessor, - filterOperations: group.filterOperations, - }); - - const isFromTheSameGroup = - isDraggedOperation(dragging) && - dragging.groupId === group.groupId && - dragging.columnId !== accessor; - - const isDroppable = isDraggedOperation(dragging) - ? dragType === 'reorder' - ? isFromTheSameGroup - : isFromCompatibleGroup - : layerDatasource.canHandleDrop({ - ...layerDatasourceDropProps, - columnId: accessor, - filterOperations: group.filterOperations, - }); + <ReorderProvider id={group.groupId} className={'lnsLayerPanel__group'}> + {group.accessors.map((accessorConfig, accessorIndex) => { + const { columnId } = accessorConfig; return ( - <DragDrop - key={accessor} - draggable={!activeId} - dragType={dragType} - dropType={dropType} - data-test-subj={group.dataTestSubj} - itemsInGroup={group.accessors.map((a) => - typeof a === 'string' ? a : a.columnId - )} - className={'lnsLayerPanel__dimensionContainer'} - value={{ - columnId: accessor, - groupId: group.groupId, - layerId, - id: accessor, - }} - isValueEqual={isSameConfiguration} - label={columnLabelMap[accessor]} - droppable={dragging && isDroppable} - dropTo={(dropTargetId: string) => { - layerDatasource.onDrop({ - isReorder: true, - ...layerDatasourceDropProps, - droppedItem: { - columnId: accessor, - groupId: group.groupId, - layerId, - id: accessor, - }, - columnId: dropTargetId, - filterOperations: group.filterOperations, - }); - }} - onDrop={(droppedItem) => { - const isReorder = - isDraggedOperation(droppedItem) && - droppedItem.groupId === group.groupId && - droppedItem.columnId !== accessor; - - const dropResult = layerDatasource.onDrop({ - isReorder, - ...layerDatasourceDropProps, - droppedItem, - columnId: accessor, - filterOperations: group.filterOperations, - }); - if (typeof dropResult === 'object') { - // When a column is moved, we delete the reference to the old - props.updateVisualization( - activeVisualization.removeDimension({ - layerId, - columnId: dropResult.deleted, - prevState: props.visualizationState, - }) - ); - } - }} + <DraggableDimensionButton + accessorIndex={accessorIndex} + columnId={columnId} + dragDropContext={dragDropContext} + group={group} + groupIndex={groupIndex} + key={columnId} + layerDatasourceDropProps={layerDatasourceDropProps} + label={columnLabelMap[columnId]} + layerDatasource={layerDatasource} + layerIndex={layerIndex} + layerId={layerId} + onDrop={onDrop} > <div className="lnsLayerPanel__dimension"> - <EuiLink - className="lnsLayerPanel__dimensionLink" - data-test-subj="lnsLayerPanel-dimensionLink" - onClick={() => { - if (activeId) { - setActiveDimension(initialActiveDimensionState); - } else { - setActiveDimension({ - isNew: false, - activeGroup: group, - activeId: accessor, - }); - } + <DimensionButton + accessorConfig={accessorConfig} + label={columnLabelMap[accessorConfig.columnId]} + group={group} + onClick={(id: string) => { + setActiveDimension({ + isNew: false, + activeGroup: group, + activeId: id, + }); }} - aria-label={triggerLinkA11yText(columnLabelMap[accessor])} - title={triggerLinkA11yText(columnLabelMap[accessor])} - > - <ColorIndicator accessorConfig={accessorConfig}> - <NativeRenderer - render={layerDatasource.renderDimensionTrigger} - nativeProps={{ - ...layerDatasourceConfigProps, - columnId: accessor, - filterOperations: group.filterOperations, - }} - /> - </ColorIndicator> - </EuiLink> - <EuiButtonIcon - className="lnsLayerPanel__dimensionRemove" - data-test-subj="indexPattern-dimension-remove" - iconType="cross" - iconSize="s" - size="s" - color="danger" - aria-label={i18n.translate( - 'xpack.lens.indexPattern.removeColumnLabel', - { - defaultMessage: 'Remove configuration from "{groupLabel}"', - values: { groupLabel: group.groupLabel }, - } - )} - title={i18n.translate('xpack.lens.indexPattern.removeColumnLabel', { - defaultMessage: 'Remove configuration from "{groupLabel}"', - values: { groupLabel: group.groupLabel }, - })} - onClick={() => { + onRemoveClick={(id: string) => { trackUiEvent('indexpattern_dimension_removed'); props.updateAll( datasourceId, layerDatasource.removeColumn({ layerId, - columnId: accessor, + columnId: id, prevState: layerDatasourceState, }), activeVisualization.removeDimension({ layerId, - columnId: accessor, + columnId: id, prevState: props.visualizationState, }) ); }} - /> - <PaletteIndicator accessorConfig={accessorConfig} /> + > + <NativeRenderer + render={layerDatasource.renderDimensionTrigger} + nativeProps={{ + ...layerDatasourceConfigProps, + columnId: accessorConfig.columnId, + filterOperations: group.filterOperations, + }} + /> + </DimensionButton> </div> - </DragDrop> + </DraggableDimensionButton> ); })} </ReorderProvider> {group.supportsMoreColumns ? ( - <div className={'lnsLayerPanel__dimensionContainer'}> - <DragDrop - data-test-subj={group.dataTestSubj} - droppable={ - Boolean(dragDropContext.dragging) && - // Verify that the dragged item is not coming from the same group - // since this would be a reorder - (!isDraggedOperation(dragDropContext.dragging) || - dragDropContext.dragging.groupId !== group.groupId) && - layerDatasource.canHandleDrop({ - ...layerDatasourceDropProps, - columnId: newId, - filterOperations: group.filterOperations, - }) - } - onDrop={(droppedItem) => { - const dropResult = layerDatasource.onDrop({ - ...layerDatasourceDropProps, - droppedItem, - columnId: newId, - filterOperations: group.filterOperations, - }); - if (dropResult) { - props.updateVisualization( - activeVisualization.setDimension({ - layerId, - groupId: group.groupId, - columnId: newId, - prevState: props.visualizationState, - }) - ); - - if (typeof dropResult === 'object') { - // When a column is moved, we delete the reference to the old - props.updateVisualization( - activeVisualization.removeDimension({ - layerId, - columnId: dropResult.deleted, - prevState: props.visualizationState, - }) - ); - } - } - }} - > - <div className="lnsLayerPanel__dimension lnsLayerPanel__dimension--empty"> - <EuiButtonEmpty - className="lnsLayerPanel__triggerText" - color="text" - size="xs" - iconType="plusInCircleFilled" - contentProps={{ - className: 'lnsLayerPanel__triggerTextContent', - }} - aria-label={i18n.translate( - 'xpack.lens.indexPattern.removeColumnAriaLabel', - { - defaultMessage: 'Drop a field or click to add to {groupLabel}', - values: { groupLabel: group.groupLabel }, - } - )} - data-test-subj="lns-empty-dimension" - onClick={() => { - if (activeId) { - setActiveDimension(initialActiveDimensionState); - } else { - setActiveDimension({ - isNew: true, - activeGroup: group, - activeId: newId, - }); - } - }} - > - <FormattedMessage - id="xpack.lens.configure.emptyConfig" - defaultMessage="Drop a field or click to add" - /> - </EuiButtonEmpty> - </div> - </DragDrop> - </div> + <EmptyDimensionButton + dragDropContext={dragDropContext} + group={group} + groupIndex={groupIndex} + layerId={layerId} + layerIndex={layerIndex} + layerDatasource={layerDatasource} + layerDatasourceDropProps={layerDatasourceDropProps} + onClick={(id) => { + setActiveDimension({ + activeGroup: group, + activeId: id, + isNew: true, + }); + }} + onDrop={onDrop} + /> ) : null} </> </EuiFormRow> @@ -572,44 +426,11 @@ export function LayerPanel( <EuiFlexGroup justifyContent="center"> <EuiFlexItem grow={false}> - <EuiButtonEmpty - size="xs" - iconType="trash" - color="danger" - data-test-subj="lnsLayerRemove" - aria-label={ - isOnlyLayer - ? i18n.translate('xpack.lens.resetLayerAriaLabel', { - defaultMessage: 'Reset layer {index}', - values: { index: index + 1 }, - }) - : i18n.translate('xpack.lens.deleteLayerAriaLabel', { - defaultMessage: `Delete layer {index}`, - values: { index: index + 1 }, - }) - } - onClick={() => { - // If we don't blur the remove / clear button, it remains focused - // which is a strange UX in this case. e.target.blur doesn't work - // due to who knows what, but probably event re-writing. Additionally, - // activeElement does not have blur so, we need to do some casting + safeguards. - const el = (document.activeElement as unknown) as { blur: () => void }; - - if (el?.blur) { - el.blur(); - } - - onRemoveLayer(); - }} - > - {isOnlyLayer - ? i18n.translate('xpack.lens.resetLayer', { - defaultMessage: 'Reset layer', - }) - : i18n.translate('xpack.lens.deleteLayer', { - defaultMessage: `Delete layer`, - })} - </EuiButtonEmpty> + <RemoveLayerButton + onRemoveLayer={onRemoveLayer} + layerIndex={layerIndex} + isOnlyLayer={isOnlyLayer} + /> </EuiFlexItem> </EuiFlexGroup> </EuiPanel> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx new file mode 100644 index 0000000000000..526e2fcefe19d --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/remove_layer_button.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export function RemoveLayerButton({ + onRemoveLayer, + layerIndex, + isOnlyLayer, +}: { + onRemoveLayer: () => void; + layerIndex: number; + isOnlyLayer: boolean; +}) { + return ( + <EuiButtonEmpty + size="xs" + iconType="trash" + color="danger" + data-test-subj="lnsLayerRemove" + aria-label={ + isOnlyLayer + ? i18n.translate('xpack.lens.resetLayerAriaLabel', { + defaultMessage: 'Reset layer {index}', + values: { index: layerIndex + 1 }, + }) + : i18n.translate('xpack.lens.deleteLayerAriaLabel', { + defaultMessage: `Delete layer {index}`, + values: { index: layerIndex + 1 }, + }) + } + onClick={() => { + // If we don't blur the remove / clear button, it remains focused + // which is a strange UX in this case. e.target.blur doesn't work + // due to who knows what, but probably event re-writing. Additionally, + // activeElement does not have blur so, we need to do some casting + safeguards. + const el = (document.activeElement as unknown) as { blur: () => void }; + + if (el?.blur) { + el.blur(); + } + + onRemoveLayer(); + }} + > + {isOnlyLayer + ? i18n.translate('xpack.lens.resetLayer', { + defaultMessage: 'Reset layer', + }) + : i18n.translate('xpack.lens.deleteLayer', { + defaultMessage: `Delete layer`, + })} + </EuiButtonEmpty> + ); +} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts index c172c6da6848c..0a53fc741c207 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -12,7 +12,7 @@ import { DatasourceDimensionEditorProps, VisualizationDimensionGroupConfig, } from '../../../types'; - +import { DragContextState } from '../../../drag_drop'; export interface ConfigPanelWrapperProps { activeDatasourceId: string; visualizationState: unknown; @@ -31,6 +31,30 @@ export interface ConfigPanelWrapperProps { core: DatasourceDimensionEditorProps['core']; } +export interface LayerPanelProps { + activeDatasourceId: string; + visualizationState: unknown; + datasourceMap: Record<string, Datasource>; + activeVisualization: Visualization; + dispatch: (action: Action) => void; + framePublicAPI: FramePublicAPI; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; + core: DatasourceDimensionEditorProps['core']; +} + +export interface LayerDatasourceDropProps { + layerId: string; + dragDropContext: DragContextState; + state: unknown; + setState: (newState: unknown) => void; +} + export interface ActiveDimensionState { isNew: boolean; activeId?: string; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index 69bdff0151f6c..c45dc82a3aeb2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; import { NativeRenderer } from '../../native_renderer'; import { Action } from './state_management'; -import { DragContext, Dragging } from '../../drag_drop'; +import { DragContext, DragDropIdentifier } from '../../drag_drop'; import { StateSetter, FramePublicAPI, DatasourceDataPanelProps, Datasource } from '../../types'; import { Query, Filter } from '../../../../../../src/plugins/data/public'; @@ -26,8 +26,8 @@ interface DataPanelWrapperProps { query: Query; dateRange: FramePublicAPI['dateRange']; filters: Filter[]; - dropOntoWorkspace: (field: Dragging) => void; - hasSuggestionForField: (field: Dragging) => boolean; + dropOntoWorkspace: (field: DragDropIdentifier) => void; + hasSuggestionForField: (field: DragDropIdentifier) => boolean; } export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index c0728bd030a0a..7daf1ebb17b97 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -1338,10 +1338,14 @@ describe('editor_frame', () => { instance.update(); act(() => { - instance.find(DragDrop).filter('[data-test-subj="mockVisA"]').prop('onDrop')!({ - indexPatternId: '1', - field: {}, - }); + instance.find('[data-test-subj="mockVisA"]').find(DragDrop).prop('onDrop')!( + { + indexPatternId: '1', + field: {}, + id: '1', + }, + { id: 'lnsWorkspace' } + ); }); expect(mockVisualization2.getConfiguration).toHaveBeenCalledWith( @@ -1435,10 +1439,14 @@ describe('editor_frame', () => { instance.update(); act(() => { - instance.find(DragDrop).filter('[data-test-subj="lnsWorkspace"]').prop('onDrop')!({ - indexPatternId: '1', - field: {}, - }); + instance.find(DragDrop).filter('[dataTestSubj="lnsWorkspace"]').prop('onDrop')!( + { + indexPatternId: '1', + field: {}, + id: '1', + }, + { id: 'lnsWorkspace' } + ); }); expect(mockVisualization3.getConfiguration).toHaveBeenCalledWith( 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 b6df0caa07577..c3412c32c2184 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 @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useReducer, useState } from 'react'; +import React, { useEffect, useReducer, useState, useCallback } from 'react'; import { CoreSetup, CoreStart } from 'kibana/public'; import { PaletteRegistry } from 'src/plugins/charts/public'; import { ReactExpressionRendererType } from '../../../../../../src/plugins/expressions/public'; @@ -16,7 +16,7 @@ import { FrameLayout } from './frame_layout'; import { SuggestionPanel } from './suggestion_panel'; import { WorkspacePanel } from './workspace_panel'; import { Document } from '../../persistence/saved_object_store'; -import { Dragging, RootDragDropProvider } from '../../drag_drop'; +import { DragDropIdentifier, RootDragDropProvider } from '../../drag_drop'; import { getSavedObjectFormat } from './save'; import { generateId } from '../../id_generator'; import { Filter, Query, SavedQuery } from '../../../../../../src/plugins/data/public'; @@ -260,7 +260,7 @@ export function EditorFrame(props: EditorFrameProps) { ); const getSuggestionForField = React.useCallback( - (field: Dragging) => { + (field: DragDropIdentifier) => { const { activeDatasourceId, datasourceStates } = state; const activeVisualizationId = state.visualization.activeId; const visualizationState = state.visualization.state; @@ -290,12 +290,12 @@ export function EditorFrame(props: EditorFrameProps) { ] ); - const hasSuggestionForField = React.useCallback( - (field: Dragging) => getSuggestionForField(field) !== undefined, + const hasSuggestionForField = useCallback( + (field: DragDropIdentifier) => getSuggestionForField(field) !== undefined, [getSuggestionForField] ); - const dropOntoWorkspace = React.useCallback( + const dropOntoWorkspace = useCallback( (field) => { const suggestion = getSuggestionForField(field); if (suggestion) { 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 5cdc5ce592497..95dbf8264c588 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 @@ -19,7 +19,7 @@ import { DatasourcePublicAPI, } from '../../types'; import { Action } from './state_management'; -import { Dragging } from '../../drag_drop'; +import { DragDropIdentifier } from '../../drag_drop'; export interface Suggestion { visualizationId: string; @@ -231,7 +231,7 @@ export function getTopSuggestionForField( visualizationState: unknown, datasource: Datasource, datasourceStates: Record<string, { state: unknown; isLoading: boolean }>, - field: Dragging + field: DragDropIdentifier ) { const hasData = Object.values(datasourceLayers).some( (datasourceLayer) => datasourceLayer.getTableSpec().length > 0 diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index ddb2640d50d59..2f94d8e65dce6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -784,7 +784,15 @@ describe('workspace_panel', () => { function initComponent(draggingContext = draggedField) { instance = mount( - <ChildDragDropProvider dragging={draggingContext} setDragging={() => {}}> + <ChildDragDropProvider + dragging={draggingContext} + setDragging={() => {}} + setActiveDropTarget={() => {}} + activeDropTarget={undefined} + keyboardMode={false} + setKeyboardMode={() => {}} + setA11yMessage={() => {}} + > <WorkspacePanel activeDatasourceId={'mock'} datasourceStates={{ @@ -822,7 +830,7 @@ describe('workspace_panel', () => { }); initComponent(); - instance.find(DragDrop).prop('onDrop')!(draggedField); + instance.find(DragDrop).prop('onDrop')!(draggedField, { id: 'lnsWorkspace' }); expect(mockDispatch).toHaveBeenCalledWith({ type: 'SWITCH_VISUALIZATION', 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 5fc7b80a3d0ce..0c1fa932da09c 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 @@ -39,7 +39,7 @@ import { isLensFilterEvent, isLensEditEvent, } from '../../../types'; -import { DragDrop, DragContext, Dragging } from '../../../drag_drop'; +import { DragDrop, DragContext, DragDropIdentifier } from '../../../drag_drop'; import { Suggestion, switchToSuggestion } from '../suggestion_helpers'; import { buildExpression } from '../expression_helpers'; import { debouncedComponent } from '../../../debounced_component'; @@ -75,7 +75,7 @@ export interface WorkspacePanelProps { plugins: { uiActions?: UiActionsStart; data: DataPublicPluginStart }; title?: string; visualizeTriggerFieldContext?: VisualizeFieldContext; - getSuggestionForField: (field: Dragging) => Suggestion | undefined; + getSuggestionForField: (field: DragDropIdentifier) => Suggestion | undefined; } interface WorkspaceState { @@ -83,8 +83,10 @@ interface WorkspaceState { expandError: boolean; } +const workspaceDropValue = { id: 'lnsWorkspace' }; + // Exported for testing purposes only. -export function WorkspacePanel({ +export const WorkspacePanel = React.memo(function WorkspacePanel({ activeDatasourceId, activeVisualizationId, visualizationMap, @@ -102,7 +104,8 @@ export function WorkspacePanel({ }: WorkspacePanelProps) { const dragDropContext = useContext(DragContext); - const suggestionForDraggedField = getSuggestionForField(dragDropContext.dragging); + const suggestionForDraggedField = + dragDropContext.dragging && getSuggestionForField(dragDropContext.dragging); const [localState, setLocalState] = useState<WorkspaceState>({ expressionBuildError: undefined, @@ -296,10 +299,11 @@ export function WorkspacePanel({ > <DragDrop className="lnsWorkspacePanel__dragDrop" - data-test-subj="lnsWorkspace" + dataTestSubj="lnsWorkspace" draggable={false} droppable={Boolean(suggestionForDraggedField)} onDrop={onDrop} + value={workspaceDropValue} > <div> {renderVisualization()} @@ -308,7 +312,7 @@ export function WorkspacePanel({ </DragDrop> </WorkspacePanelWrapper> ); -} +}); export const InnerVisualizationWrapper = ({ expression, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 8e41abf23e934..794ccd6936c90 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -278,7 +278,10 @@ describe('IndexPattern Data Panel', () => { {...defaultProps} state={state} setState={setStateSpy} - dragDropContext={{ dragging: { id: '1' }, setDragging: () => {} }} + dragDropContext={{ + ...createMockedDragDropContext(), + dragging: { id: '1' }, + }} /> ); @@ -297,7 +300,10 @@ describe('IndexPattern Data Panel', () => { indexPatterns: {}, }} setState={jest.fn()} - dragDropContext={{ dragging: { id: '1' }, setDragging: () => {} }} + dragDropContext={{ + ...createMockedDragDropContext(), + dragging: { id: '1' }, + }} changeIndexPattern={jest.fn()} /> ); @@ -329,7 +335,10 @@ describe('IndexPattern Data Panel', () => { ...defaultProps, changeIndexPattern: jest.fn(), setState, - dragDropContext: { dragging: { id: '1' }, setDragging: () => {} }, + dragDropContext: { + ...createMockedDragDropContext(), + dragging: { id: '1' }, + }, dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, state: { indexPatternRefs: [], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 4031cae548a10..c3dbcdc3e0573 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -426,6 +426,23 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ ); }, [unfilteredFieldGroups, localState.nameFilter, localState.typeFilter]); + const checkFieldExists = useCallback( + (field) => + field.type === 'document' || + fieldExists(existingFields, currentIndexPattern.title, field.name), + [existingFields, currentIndexPattern.title] + ); + + const { nameFilter, typeFilter } = localState; + + const filter = useMemo( + () => ({ + nameFilter, + typeFilter, + }), + [nameFilter, typeFilter] + ); + const fieldProps = useMemo( () => ({ core, @@ -586,17 +603,11 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ </EuiScreenReaderOnly> <EuiFlexItem> <FieldList - exists={(field) => - field.type === 'document' || - fieldExists(existingFields, currentIndexPattern.title, field.name) - } + exists={checkFieldExists} fieldProps={fieldProps} fieldGroups={fieldGroups} hasSyncedExistingFields={!!hasSyncedExistingFields} - filter={{ - nameFilter: localState.nameFilter, - typeFilter: localState.typeFilter, - }} + filter={filter} currentIndexPatternId={currentIndexPatternId} existenceFetchFailed={existenceFetchFailed} existFieldsInIndex={!!allFields.length} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index 6be03a92a445e..477f14848c08e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -316,6 +316,7 @@ describe('IndexPatternDimensionEditorPanel', () => { droppedItem: dragging, columnId: 'col2', filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -352,6 +353,7 @@ describe('IndexPatternDimensionEditorPanel', () => { droppedItem: dragging, columnId: 'col2', filterOperations: (op: OperationMetadata) => op.isBucketed, + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -387,6 +389,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, droppedItem: dragging, filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -438,6 +441,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, }, }, + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -473,6 +477,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, droppedItem: dragging, columnId: 'col2', + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -538,6 +543,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, droppedItem: dragging, state: testState, + groupId: '1', }); expect(setState).toBeCalledTimes(1); @@ -600,6 +606,7 @@ describe('IndexPatternDimensionEditorPanel', () => { droppedItem: dragging, state: testState, filterOperations: (op: OperationMetadata) => op.dataType === 'number', + groupId: 'a', }; const stateWithColumnOrder = (columnOrder: string[]) => { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts index e4eabafc6938e..0308d5e9103bf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.ts @@ -8,6 +8,7 @@ import { DatasourceDimensionDropProps, DatasourceDimensionDropHandlerProps, isDraggedOperation, + DraggedOperation, } from '../../types'; import { IndexPatternColumn } from '../indexpattern'; import { insertOrReplaceColumn } from '../operations'; @@ -15,7 +16,15 @@ import { mergeLayer } from '../state_helpers'; import { hasField, isDraggedField } from '../utils'; import { IndexPatternPrivateState, IndexPatternField } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; -import { getOperationSupportMatrix } from './operation_support'; +import { getOperationSupportMatrix, OperationSupportMatrix } from './operation_support'; + +type DropHandlerProps<T = DraggedOperation> = Pick< + DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>, + 'columnId' | 'setState' | 'state' | 'layerId' | 'droppedItem' +> & { + droppedItem: T; + operationSupportMatrix: OperationSupportMatrix; +}; export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPrivateState>) { const operationSupportMatrix = getOperationSupportMatrix(props); @@ -29,11 +38,11 @@ export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPr if (isDraggedField(dragging)) { const currentColumn = props.state.layers[props.layerId].columns[props.columnId]; - return ( + return Boolean( layerIndexPatternId === dragging.indexPatternId && - Boolean(hasOperationForField(dragging.field)) && - (!currentColumn || - (hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name)) + Boolean(hasOperationForField(dragging.field)) && + (!currentColumn || + (hasField(currentColumn) && currentColumn.sourceField !== dragging.field.name)) ); } @@ -59,74 +68,80 @@ function reorderElements(items: string[], dest: string, src: string) { return result; } -export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) { - const operationSupportMatrix = getOperationSupportMatrix(props); - const { setState, state, layerId, columnId, droppedItem } = props; - - if (isDraggedOperation(droppedItem) && props.isReorder) { - const dropEl = columnId; +const onReorderDrop = ({ columnId, setState, state, layerId, droppedItem }: DropHandlerProps) => { + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: reorderElements( + state.layers[layerId].columnOrder, + columnId, + droppedItem.columnId + ), + }, + }) + ); - setState( - mergeLayer({ - state, - layerId, - newLayer: { - columnOrder: reorderElements( - state.layers[layerId].columnOrder, - dropEl, - droppedItem.columnId - ), - }, - }) - ); - - return true; + return true; +}; + +const onMoveDropToCompatibleGroup = ({ + columnId, + setState, + state, + layerId, + droppedItem, +}: DropHandlerProps) => { + const layer = state.layers[layerId]; + const op = { ...layer.columns[droppedItem.columnId] }; + const newColumns = { ...layer.columns }; + delete newColumns[droppedItem.columnId]; + newColumns[columnId] = op; + + const newColumnOrder = [...layer.columnOrder]; + const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); + const newIndex = newColumnOrder.findIndex((c) => c === columnId); + + if (newIndex === -1) { + newColumnOrder[oldIndex] = columnId; + } else { + newColumnOrder.splice(oldIndex, 1); } + // Time to replace + setState( + mergeLayer({ + state, + layerId, + newLayer: { + columnOrder: newColumnOrder, + columns: newColumns, + }, + }) + ); + return { deleted: droppedItem.columnId }; +}; + +const onFieldDrop = ({ + columnId, + setState, + state, + layerId, + droppedItem, + operationSupportMatrix, +}: DropHandlerProps<unknown>) => { function hasOperationForField(field: IndexPatternField) { return Boolean(operationSupportMatrix.operationByField[field.name]); } - if (isDraggedOperation(droppedItem) && droppedItem.layerId === layerId) { - const layer = state.layers[layerId]; - const op = { ...layer.columns[droppedItem.columnId] }; - if (!props.filterOperations(op)) { - return false; - } - - const newColumns = { ...layer.columns }; - delete newColumns[droppedItem.columnId]; - newColumns[columnId] = op; - - const newColumnOrder = [...layer.columnOrder]; - const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); - const newIndex = newColumnOrder.findIndex((c) => c === columnId); - - if (newIndex === -1) { - newColumnOrder[oldIndex] = columnId; - } else { - newColumnOrder.splice(oldIndex, 1); - } - - // Time to replace - setState( - mergeLayer({ - state, - layerId, - newLayer: { - columnOrder: newColumnOrder, - columns: newColumns, - }, - }) - ); - return { deleted: droppedItem.columnId }; - } - if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { // TODO: What do we do if we couldn't find a column? return false; } + // dragged field, not operation + const operationsForNewField = operationSupportMatrix.operationByField[droppedItem.field.name]; if (!operationsForNewField || operationsForNewField.size === 0) { @@ -159,6 +174,56 @@ export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPr const hasData = Object.values(state.layers).some(({ columns }) => columns.length); trackUiEvent(hasData ? 'drop_non_empty' : 'drop_empty'); setState(mergeLayer({ state, layerId, newLayer })); - return true; +}; + +export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) { + const operationSupportMatrix = getOperationSupportMatrix(props); + const { setState, state, droppedItem, columnId, layerId, groupId, isNew } = props; + + if (!isDraggedOperation(droppedItem)) { + return onFieldDrop({ + columnId, + setState, + state, + layerId, + droppedItem, + operationSupportMatrix, + }); + } + const isExistingFromSameGroup = + droppedItem.groupId === groupId && droppedItem.columnId !== columnId && !isNew; + + // reorder in the same group + if (isExistingFromSameGroup) { + return onReorderDrop({ + columnId, + setState, + state, + layerId, + droppedItem, + operationSupportMatrix, + }); + } + + // replace or move to compatible group + const isFromOtherGroup = droppedItem.groupId !== groupId && droppedItem.layerId === layerId; + + if (isFromOtherGroup) { + const layer = state.layers[layerId]; + const op = { ...layer.columns[droppedItem.columnId] }; + + if (props.filterOperations(op)) { + return onMoveDropToCompatibleGroup({ + columnId, + setState, + state, + layerId, + droppedItem, + operationSupportMatrix, + }); + } + } + + return false; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx index 1019b2c33e0e5..881e7a7228762 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.test.tsx @@ -95,6 +95,8 @@ describe('IndexPattern Field Item', () => { }, exists: true, chartsThemeService, + groupIndex: 0, + itemIndex: 0, dropOntoWorkspace: () => {}, hasSuggestionForField: () => false, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 740b557b668b7..ff335a0da56ee 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -6,7 +6,7 @@ import './field_item.scss'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useMemo } from 'react'; import DateMath from '@elastic/datemath'; import { EuiButtonGroup, @@ -48,7 +48,7 @@ import { import { FieldButton } from '../../../../../src/plugins/kibana_react/public'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { DraggedField } from './indexpattern'; -import { DragDrop, Dragging } from '../drag_drop'; +import { DragDrop, DragDropIdentifier } from '../drag_drop'; import { DatasourceDataPanelProps, DataType } from '../types'; import { BucketedAggregation, FieldStatsResponse } from '../../common'; import { IndexPattern, IndexPatternField } from './types'; @@ -69,6 +69,8 @@ export interface FieldItemProps { chartsThemeService: ChartsPluginSetup['theme']; filters: Filter[]; hideDetails?: boolean; + itemIndex: number; + groupIndex: number; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; } @@ -106,7 +108,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { const [infoIsOpen, setOpen] = useState(false); const dropOntoWorkspaceAndClose = useCallback( - (droppedField: Dragging) => { + (droppedField: DragDropIdentifier) => { dropOntoWorkspace(droppedField); setOpen(false); }, @@ -163,10 +165,11 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { } } - const value = React.useMemo( + const value = useMemo( () => ({ field, indexPatternId: indexPattern.id, id: field.name } as DraggedField), [field, indexPattern.id] ); + const lensFieldIcon = <LensFieldIcon type={field.type as DataType} />; const lensInfoIcon = ( <EuiIconTip @@ -200,10 +203,11 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { container={document.querySelector<HTMLElement>('.application') || undefined} button={ <DragDrop + noKeyboardSupportYet + draggable label={field.displayName} value={value} - data-test-subj={`lnsFieldListPanelField-${field.name}`} - draggable + dataTestSubj={`lnsFieldListPanelField-${field.name}`} > <FieldButton className={`lnsFieldItem lnsFieldItem--${field.type} lnsFieldItem--${ @@ -215,7 +219,7 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) { ['aria-label']: i18n.translate( 'xpack.lens.indexPattern.fieldStatsButtonAriaLabel', { - defaultMessage: '{fieldName}: {fieldType}. Hit enter for a field preview.', + defaultMessage: 'Preview {fieldName}: {fieldType}', values: { fieldName: field.displayName, fieldType: field.type, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx index 7bcd44e3d25d0..d9fba160bc37b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_list.tsx @@ -40,7 +40,7 @@ function getDisplayedFieldsLength( .reduce((allFieldCount, [, { fields }]) => allFieldCount + fields.length, 0); } -export function FieldList({ +export const FieldList = React.memo(function FieldList({ exists, fieldGroups, existenceFetchFailed, @@ -135,13 +135,15 @@ export function FieldList({ {Object.entries(fieldGroups) .filter(([, { showInAccordion }]) => !showInAccordion) .flatMap(([, { fields }]) => - fields.map((field) => ( + fields.map((field, index) => ( <FieldItem {...fieldProps} exists={exists(field)} field={field} hideDetails={true} key={field.name} + itemIndex={index} + groupIndex={0} dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} /> @@ -151,7 +153,7 @@ export function FieldList({ <EuiSpacer size="s" /> {Object.entries(fieldGroups) .filter(([, { showInAccordion }]) => showInAccordion) - .map(([key, fieldGroup]) => ( + .map(([key, fieldGroup], index) => ( <Fragment key={key}> <FieldsAccordion dropOntoWorkspace={dropOntoWorkspace} @@ -168,6 +170,7 @@ export function FieldList({ isFiltered={fieldGroup.fieldCount !== fieldGroup.fields.length} paginatedFields={paginatedFields[key]} fieldProps={fieldProps} + groupIndex={index + 1} onToggle={(open) => { setAccordionState((s) => ({ ...s, @@ -198,4 +201,4 @@ export function FieldList({ </div> </div> ); -} +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.test.tsx index e2f615217bb4a..dca3de24014bc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.test.tsx @@ -72,6 +72,7 @@ describe('Fields Accordion', () => { fieldProps, renderCallout: <div id="lens-test-callout">Callout</div>, exists: () => true, + groupIndex: 0, dropOntoWorkspace: () => {}, hasSuggestionForField: () => false, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx index 11adf1a128c1b..11710ffa18068 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx @@ -5,7 +5,7 @@ */ import './datapanel.scss'; -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiText, @@ -50,11 +50,12 @@ export interface FieldsAccordionProps { exists: (field: IndexPatternField) => boolean; showExistenceFetchError?: boolean; hideDetails?: boolean; + groupIndex: number; dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; } -export const InnerFieldsAccordion = function InnerFieldsAccordion({ +export const FieldsAccordion = memo(function InnerFieldsAccordion({ initialIsOpen, onToggle, id, @@ -69,28 +70,72 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ exists, hideDetails, showExistenceFetchError, + groupIndex, dropOntoWorkspace, hasSuggestionForField, }: FieldsAccordionProps) { const renderField = useCallback( - (field: IndexPatternField) => ( + (field: IndexPatternField, index) => ( <FieldItem {...fieldProps} key={field.name} field={field} exists={exists(field)} hideDetails={hideDetails} + itemIndex={index} + groupIndex={groupIndex} dropOntoWorkspace={dropOntoWorkspace} hasSuggestionForField={hasSuggestionForField} /> ), - [fieldProps, exists, hideDetails, dropOntoWorkspace, hasSuggestionForField] + [fieldProps, exists, hideDetails, dropOntoWorkspace, hasSuggestionForField, groupIndex] ); - const titleClassname = classNames({ - // eslint-disable-next-line @typescript-eslint/naming-convention - lnsInnerIndexPatternDataPanel__titleTooltip: !!helpTooltip, - }); + const renderButton = useMemo(() => { + const titleClassname = classNames({ + // eslint-disable-next-line @typescript-eslint/naming-convention + lnsInnerIndexPatternDataPanel__titleTooltip: !!helpTooltip, + }); + return ( + <EuiText size="xs"> + <strong className={titleClassname}>{label}</strong> + {!!helpTooltip && ( + <EuiIconTip + aria-label={helpTooltip} + type="questionInCircle" + color="subdued" + size="s" + position="right" + content={helpTooltip} + iconProps={{ + className: 'eui-alignTop', + }} + /> + )} + </EuiText> + ); + }, [label, helpTooltip]); + + const extraAction = useMemo(() => { + return showExistenceFetchError ? ( + <EuiIconTip + aria-label={i18n.translate('xpack.lens.indexPattern.existenceErrorAriaLabel', { + defaultMessage: 'Existence fetch failed', + })} + type="alert" + color="warning" + content={i18n.translate('xpack.lens.indexPattern.existenceErrorLabel', { + defaultMessage: "Field information can't be loaded", + })} + /> + ) : hasLoaded ? ( + <EuiNotificationBadge size="m" color={isFiltered ? 'accent' : 'subdued'}> + {fieldsCount} + </EuiNotificationBadge> + ) : ( + <EuiLoadingSpinner size="m" /> + ); + }, [showExistenceFetchError, hasLoaded, isFiltered, fieldsCount]); return ( <EuiAccordion @@ -98,44 +143,8 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ onToggle={onToggle} data-test-subj={id} id={id} - buttonContent={ - <EuiText size="xs"> - <strong className={titleClassname}>{label}</strong> - {!!helpTooltip && ( - <EuiIconTip - aria-label={helpTooltip} - type="questionInCircle" - color="subdued" - size="s" - position="right" - content={helpTooltip} - iconProps={{ - className: 'eui-alignTop', - }} - /> - )} - </EuiText> - } - extraAction={ - showExistenceFetchError ? ( - <EuiIconTip - aria-label={i18n.translate('xpack.lens.indexPattern.existenceErrorAriaLabel', { - defaultMessage: 'Existence fetch failed', - })} - type="alert" - color="warning" - content={i18n.translate('xpack.lens.indexPattern.existenceErrorLabel', { - defaultMessage: "Field information can't be loaded", - })} - /> - ) : hasLoaded ? ( - <EuiNotificationBadge size="m" color={isFiltered ? 'accent' : 'subdued'}> - {fieldsCount} - </EuiNotificationBadge> - ) : ( - <EuiLoadingSpinner size="m" /> - ) - } + buttonContent={renderButton} + extraAction={extraAction} > <EuiSpacer size="s" /> {hasLoaded && @@ -148,6 +157,4 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ ))} </EuiAccordion> ); -}; - -export const FieldsAccordion = memo(InnerFieldsAccordion); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index e51cd36156d1b..7f77a7ce199b6 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -51,11 +51,11 @@ import { mergeLayer } from './state_helpers'; import { Datasource, StateSetter } from '../types'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { deleteColumn, isReferenced } from './operations'; -import { Dragging } from '../drag_drop/providers'; +import { DragDropIdentifier } from '../drag_drop/providers'; export { OperationType, IndexPatternColumn, deleteColumn } from './operations'; -export type DraggedField = Dragging & { +export type DraggedField = DragDropIdentifier & { field: IndexPatternField; indexPatternId: string; }; @@ -167,7 +167,7 @@ export function getIndexPatternDatasource({ }); }, - toExpression, + toExpression: (state, layerId) => toExpression(state, layerId, uiSettings), renderDataPanel( domElement: Element, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index 4aea9e8ac67a9..67ddbe8c45ab7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -247,5 +247,10 @@ export function createMockedDragDropContext(): jest.Mocked<DragContextState> { return { dragging: undefined, setDragging: jest.fn(), + activeDropTarget: undefined, + setActiveDropTarget: jest.fn(), + keyboardMode: false, + setKeyboardMode: jest.fn(), + setA11yMessage: jest.fn(), }; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index abd033c0db4cf..22275533b9554 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -83,9 +83,11 @@ const indexPattern2: IndexPattern = { ]), }; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultOptions = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1y', @@ -200,7 +202,8 @@ describe('date_histogram', () => { layer.columns.col1 as DateHistogramIndexPatternColumn, 'col1', indexPattern1, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ @@ -252,7 +255,8 @@ describe('date_histogram', () => { }, ]), }, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx index 86767fbc8b469..3657013fa0bfa 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/filters/filters.test.tsx @@ -16,9 +16,11 @@ import type { IndexPatternLayer } from '../../../types'; import { createMockedIndexPattern } from '../../../mocks'; import { FilterPopover } from './filter_popover'; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultProps = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1d', toDate: 'now' }, data: dataPluginMock.createStartContract(), @@ -84,7 +86,8 @@ describe('filters', () => { layer.columns.col1 as FiltersIndexPatternColumn, 'col1', createMockedIndexPattern(), - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ 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 1cdaff53c5458..0c0aa34bb40b3 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 @@ -239,7 +239,8 @@ interface FieldlessOperationDefinition<C extends BaseIndexPatternColumn> { column: C, columnId: string, indexPattern: IndexPattern, - layer: IndexPatternLayer + layer: IndexPatternLayer, + uiSettings: IUiSettingsClient ) => ExpressionAstFunction; } @@ -283,7 +284,8 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn> { column: C, columnId: string, indexPattern: IndexPattern, - layer: IndexPatternLayer + layer: IndexPatternLayer, + uiSettings: IUiSettingsClient ) => ExpressionAstFunction; /** * Optional function to return the suffix used for ES bucket paths and esaggs column id. diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx index 96b12a714e613..8d5ab50770111 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/last_value.test.tsx @@ -15,9 +15,11 @@ import { LastValueIndexPatternColumn } from './last_value'; import { lastValueOperation } from './index'; import type { IndexPattern, IndexPatternLayer } from '../../types'; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultProps = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1d', toDate: 'now' }, data: dataPluginMock.createStartContract(), @@ -70,7 +72,8 @@ describe('last_value', () => { { ...lastValueColumn, params: { ...lastValueColumn.params } }, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx index c22eec62ea1ab..a340e17121e90 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/percentile.test.tsx @@ -17,9 +17,11 @@ import { EuiFieldNumber } from '@elastic/eui'; import { act } from 'react-dom/test-utils'; import { EuiFormRow } from '@elastic/eui'; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultProps = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1d', toDate: 'now' }, data: dataPluginMock.createStartContract(), @@ -72,7 +74,8 @@ describe('percentile', () => { percentileColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx index ad5c146ff6624..d9698252177b0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/range_editor.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -190,15 +190,6 @@ export const RangeEditor = ({ }) => { const [isAdvancedEditor, toggleAdvancedEditor] = useState(params.type === MODES.Range); - // if the maxBars in the params is set to auto refresh it with the default value only on bootstrap - useEffect(() => { - if (!isAdvancedEditor) { - if (params.maxBars !== maxBars) { - setParam('maxBars', maxBars); - } - } - }, [maxBars, params.maxBars, setParam, isAdvancedEditor]); - if (isAdvancedEditor) { return ( <AdvancedRangeEditor diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx index 987c8971aa310..c55dd90c6be7b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.test.tsx @@ -51,13 +51,15 @@ dataPluginMockValue.fieldFormats.deserialize = jest.fn().mockImplementation(({ p type ReactMouseEvent = React.MouseEvent<HTMLAnchorElement, MouseEvent> & React.MouseEvent<HTMLButtonElement, MouseEvent>; +// need this for MAX_HISTOGRAM value +const uiSettingsMock = ({ + get: jest.fn().mockReturnValue(100), +} as unknown) as IUiSettingsClient; + const sourceField = 'MyField'; const defaultOptions = { storage: {} as IStorageWrapper, - // need this for MAX_HISTOGRAM value - uiSettings: ({ - get: () => 100, - } as unknown) as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1y', @@ -143,7 +145,8 @@ describe('ranges', () => { layer.columns.col1 as RangeIndexPatternColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toMatchInlineSnapshot(` Object { @@ -166,6 +169,9 @@ describe('ranges', () => { "interval": Array [ "auto", ], + "maxBars": Array [ + 49.5, + ], "min_doc_count": Array [ false, ], @@ -186,7 +192,8 @@ describe('ranges', () => { layer.columns.col1 as RangeIndexPatternColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( @@ -206,7 +213,8 @@ describe('ranges', () => { layer.columns.col1 as RangeIndexPatternColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( @@ -226,7 +234,8 @@ describe('ranges', () => { layer.columns.col1 as RangeIndexPatternColumn, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect((esAggsFn as { arguments: unknown }).arguments).toEqual( @@ -275,7 +284,7 @@ describe('ranges', () => { it('should start update the state with the default maxBars value', () => { const updateLayerSpy = jest.fn(); - mount( + const instance = mount( <InlineOptions {...defaultOptions} layer={layer} @@ -285,19 +294,7 @@ describe('ranges', () => { /> ); - expect(updateLayerSpy).toHaveBeenCalledWith({ - ...layer, - columns: { - ...layer.columns, - col1: { - ...layer.columns.col1, - params: { - ...layer.columns.col1.params, - maxBars: GRANULARITY_DEFAULT_VALUE, - }, - }, - }, - }); + expect(instance.find(EuiRange).prop('value')).toEqual(String(GRANULARITY_DEFAULT_VALUE)); }); it('should update state when changing Max bars number', () => { @@ -313,8 +310,6 @@ describe('ranges', () => { /> ); - // There's a useEffect in the component that updates the value on bootstrap - // because there's a debouncer, wait a bit before calling onChange act(() => { jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4); @@ -358,8 +353,6 @@ describe('ranges', () => { /> ); - // There's a useEffect in the component that updates the value on bootstrap - // because there's a debouncer, wait a bit before calling onChange act(() => { jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4); // minus button @@ -368,6 +361,7 @@ describe('ranges', () => { .find('button') .prop('onClick')!({} as ReactMouseEvent); jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4); + instance.update(); }); expect(updateLayerSpy).toHaveBeenCalledWith({ @@ -391,6 +385,7 @@ describe('ranges', () => { .find('button') .prop('onClick')!({} as ReactMouseEvent); jest.advanceTimersByTime(TYPING_DEBOUNCE_TIME * 4); + instance.update(); }); expect(updateLayerSpy).toHaveBeenCalledWith({ @@ -788,7 +783,7 @@ describe('ranges', () => { instance.find(EuiLink).first().prop('onClick')!({} as ReactMouseEvent); }); - expect(updateLayerSpy.mock.calls[1][0].columns.col1.params.format).toEqual({ + expect(updateLayerSpy.mock.calls[0][0].columns.col1.params.format).toEqual({ id: 'custom', params: { decimals: 3 }, }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx index aa5cc8255a584..d8622a5aedf7d 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/ranges/ranges.tsx @@ -132,7 +132,7 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field sourceField: field.name, }; }, - toEsAggsFn: (column, columnId) => { + toEsAggsFn: (column, columnId, indexPattern, layer, uiSettings) => { const { sourceField, params } = column; if (params.type === MODES.Range) { return buildExpressionFunction<AggFunctionsMapping['aggRange']>('aggRange', { @@ -158,13 +158,15 @@ export const rangeOperation: OperationDefinition<RangeIndexPatternColumn, 'field ), }).toAst(); } + const maxBarsDefaultValue = + (uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS) - MIN_HISTOGRAM_BARS) / 2; + return buildExpressionFunction<AggFunctionsMapping['aggHistogram']>('aggHistogram', { id: columnId, enabled: true, schema: 'segment', field: sourceField, - // fallback to 0 in case of empty string - maxBars: params.maxBars === AUTO_BARS ? undefined : params.maxBars, + maxBars: params.maxBars === AUTO_BARS ? maxBarsDefaultValue : params.maxBars, interval: 'auto', has_extended_bounds: false, min_doc_count: false, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx index d60992bda2e2a..3e25e127b37f4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/terms.test.tsx @@ -17,9 +17,11 @@ import type { TermsIndexPatternColumn } from '.'; import { termsOperation } from '../index'; import { IndexPattern, IndexPatternLayer } from '../../../types'; +const uiSettingsMock = {} as IUiSettingsClient; + const defaultProps = { storage: {} as IStorageWrapper, - uiSettings: {} as IUiSettingsClient, + uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, dateRange: { fromDate: 'now-1d', toDate: 'now' }, data: dataPluginMock.createStartContract(), @@ -66,7 +68,8 @@ describe('terms', () => { { ...termsColumn, params: { ...termsColumn.params, otherBucket: true } }, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ @@ -89,7 +92,8 @@ describe('terms', () => { }, 'col1', {} as IndexPattern, - layer + layer, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ @@ -129,7 +133,8 @@ describe('terms', () => { }, }, }, - } + }, + uiSettingsMock ); expect(esAggsFn).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 94cf13a5c50a4..63f8bfb97d8f8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -2181,7 +2181,7 @@ describe('state_helpers', () => { expect(errors).toHaveLength(1); }); - it('should consider incompleteColumns before layer columns', () => { + it('should ignore incompleteColumns when checking for errors', () => { const savedRef = jest.fn().mockReturnValue(['error 1']); const incompleteRef = jest.fn(); operationDefinitionMap.testReference.getErrorMessage = savedRef; @@ -2206,9 +2206,9 @@ describe('state_helpers', () => { }, indexPattern ); - expect(savedRef).not.toHaveBeenCalled(); - expect(incompleteRef).toHaveBeenCalled(); - expect(errors).toBeUndefined(); + expect(savedRef).toHaveBeenCalled(); + expect(incompleteRef).not.toHaveBeenCalled(); + expect(errors).toHaveLength(1); delete operationDefinitionMap.testIncompleteReference; }); 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 10618cc754556..7c0036de62124 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 @@ -870,12 +870,7 @@ export function getErrorMessages( ): string[] | undefined { const errors: string[] = Object.entries(layer.columns) .flatMap(([columnId, column]) => { - // If we're transitioning to another operation, check for "new" incompleteColumns rather - // than "old" saved operation on the layer - const columnFinalRef = - layer.incompleteColumns?.[columnId]?.operationType || column.operationType; - const def = operationDefinitionMap[columnFinalRef]; - + const def = operationDefinitionMap[column.operationType]; if (def.getErrorMessage) { return def.getErrorMessage(layer, columnId, indexPattern); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts index 38f51f24aae7d..c9ee77a9f5e15 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/to_expression.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import type { IUiSettingsClient } from 'kibana/public'; import { EsaggsExpressionFunctionDefinition, IndexPatternLoadExpressionFunctionDefinition, @@ -24,7 +25,8 @@ import { getEsAggsSuffix } from './operations/definitions/helpers'; function getExpressionForLayer( layer: IndexPatternLayer, - indexPattern: IndexPattern + indexPattern: IndexPattern, + uiSettings: IUiSettingsClient ): ExpressionAstExpression | null { const { columns, columnOrder } = layer; if (columnOrder.length === 0) { @@ -44,7 +46,7 @@ function getExpressionForLayer( aggs.push( buildExpression({ type: 'expression', - chain: [def.toEsAggsFn(col, colId, indexPattern, layer)], + chain: [def.toEsAggsFn(col, colId, indexPattern, layer, uiSettings)], }) ); } @@ -184,11 +186,16 @@ function getExpressionForLayer( return null; } -export function toExpression(state: IndexPatternPrivateState, layerId: string) { +export function toExpression( + state: IndexPatternPrivateState, + layerId: string, + uiSettings: IUiSettingsClient +) { if (state.layers[layerId]) { return getExpressionForLayer( state.layers[layerId], - state.indexPatterns[state.layers[layerId].indexPatternId] + state.indexPatterns[state.layers[layerId].indexPatternId], + uiSettings ); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 3fb7186aeac59..9848551e7873f 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -17,6 +17,7 @@ import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/ import { UrlForwardingSetup } from '../../../../src/plugins/url_forwarding/public'; import { GlobalSearchPluginSetup } from '../../global_search/public'; import { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public'; import { EditorFrameService } from './editor_frame_service'; import { @@ -71,6 +72,7 @@ export interface LensPluginStartDependencies { embeddable: EmbeddableStart; charts: ChartsPluginStart; savedObjectsTagging?: SavedObjectTaggingPluginStart; + presentationUtil: PresentationUtilPluginStart; } export interface LensPublicStart { @@ -172,6 +174,12 @@ export class LensPlugin { return deps.dashboard.dashboardFeatureFlagConfig; }; + const getPresentationUtilContext = async () => { + const [, deps] = await core.getStartServices(); + const { ContextProvider } = deps.presentationUtil; + return ContextProvider; + }; + core.application.register({ id: 'lens', title: NOT_INTERNATIONALIZED_PRODUCT_NAME, @@ -183,6 +191,7 @@ export class LensPlugin { createEditorFrame: this.createEditorFrame!, attributeService: this.attributeService!, getByValueFeatureFlag, + getPresentationUtilContext, }); }, }); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 9feed918635b3..8f202faeb9ee8 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -16,16 +16,20 @@ import { Datatable, SerializedFieldFormat, } from '../../../../src/plugins/expressions/public'; -import { DragContextState, Dragging } from './drag_drop'; +import { DragContextState, DragDropIdentifier } from './drag_drop'; import { Document } from './persistence'; import { DateRange } from '../common'; import { Query, Filter, SavedQuery, IFieldFormat } from '../../../../src/plugins/data/public'; import { VisualizeFieldContext } from '../../../../src/plugins/ui_actions/public'; import { RangeSelectContext, ValueClickContext } from '../../../../src/plugins/embeddable/public'; +import { + LENS_EDIT_SORT_ACTION, + LENS_EDIT_RESIZE_ACTION, +} from './datatable_visualization/components/constants'; import type { LensSortActionData, - LENS_EDIT_SORT_ACTION, -} from './datatable_visualization/expression'; + LensResizeActionData, +} from './datatable_visualization/components/types'; export type ErrorCallback = (e: { message: string }) => void; @@ -222,8 +226,8 @@ export interface DatasourceDataPanelProps<T = unknown> { query: Query; dateRange: DateRange; filters: Filter[]; - dropOntoWorkspace: (field: Dragging) => void; - hasSuggestionForField: (field: Dragging) => boolean; + dropOntoWorkspace: (field: DragDropIdentifier) => void; + hasSuggestionForField: (field: DragDropIdentifier) => boolean; } interface SharedDimensionProps { @@ -297,6 +301,8 @@ export type DatasourceDimensionDropProps<T> = SharedDimensionProps & { export type DatasourceDimensionDropHandlerProps<T> = DatasourceDimensionDropProps<T> & { droppedItem: unknown; + groupId: string; + isNew?: boolean; }; export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip'; @@ -641,6 +647,7 @@ export interface LensBrushEvent { // Use same technique as TriggerContext interface LensEditContextMapping { [LENS_EDIT_SORT_ACTION]: LensSortActionData; + [LENS_EDIT_RESIZE_ACTION]: LensResizeActionData; } type LensEditSupportedActions = keyof LensEditContextMapping; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index b8bca09bb353c..91fa2f5921d2f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -5,7 +5,7 @@ */ import './xy_config_panel.scss'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useState, memo } from 'react'; import { i18n } from '@kbn/i18n'; import { Position } from '@elastic/charts'; import { debounce } from 'lodash'; @@ -179,8 +179,7 @@ function getValueLabelDisableReason({ defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', }); } - -export function XyToolbar(props: VisualizationToolbarProps<State>) { +export const XyToolbar = memo(function XyToolbar(props: VisualizationToolbarProps<State>) { const { state, setState, frame } = props; const hasNonBarSeries = state?.layers.some(({ seriesType }) => @@ -485,7 +484,8 @@ export function XyToolbar(props: VisualizationToolbarProps<State>) { </EuiFlexItem> </EuiFlexGroup> ); -} +}); + const idPrefix = htmlIdGenerator()(); export function DimensionEditor( @@ -653,7 +653,7 @@ const ColorPicker = ({ } }; - const updateColorInState: EuiColorPickerProps['onChange'] = React.useMemo( + const updateColorInState: EuiColorPickerProps['onChange'] = useMemo( () => debounce((text, output) => { const newYConfigs = [...(layer.yConfig || [])]; diff --git a/x-pack/plugins/license_management/tsconfig.json b/x-pack/plugins/license_management/tsconfig.json new file mode 100644 index 0000000000000..e6cb0101ee838 --- /dev/null +++ b/x-pack/plugins/license_management/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + "common/**/*", + "__jest__/**/*", + "__mocks__/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/telemetry_management_section/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/telemetry/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../licensing/tsconfig.json"}, + { "path": "../features/tsconfig.json"}, + { "path": "../security/tsconfig.json"}, + ] +} diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index b86d48bfccdab..c8db433a37235 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -85,6 +85,7 @@ export enum SOURCE_TYPES { REGIONMAP_FILE = 'REGIONMAP_FILE', GEOJSON_FILE = 'GEOJSON_FILE', MVT_SINGLE_LAYER = 'MVT_SINGLE_LAYER', + TABLE_SOURCE = 'TABLE_SOURCE', } export enum FIELD_ORIGIN { diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index b00281588734d..c9391e1aac749 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -18,6 +18,7 @@ export type MapFilters = { filters: Filter[]; query?: MapQuery; refreshTimerLastTriggeredAt?: string; + searchSessionId?: string; timeFilters: TimeRange; zoom: number; }; diff --git a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts index b67f05cb169fd..65cc145e20c89 100644 --- a/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/layer_descriptor_types.ts @@ -8,11 +8,11 @@ import { Query } from 'src/plugins/data/public'; import { StyleDescriptor, VectorStyleDescriptor } from './style_property_descriptor_types'; import { DataRequestDescriptor } from './data_request_descriptor_types'; -import { AbstractSourceDescriptor, ESTermSourceDescriptor } from './source_descriptor_types'; +import { AbstractSourceDescriptor, TermJoinSourceDescriptor } from './source_descriptor_types'; export type JoinDescriptor = { leftField?: string; - right: ESTermSourceDescriptor; + right: TermJoinSourceDescriptor; }; export type LayerDescriptor = { diff --git a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts index b849b42429cf6..dca7ae766f375 100644 --- a/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/source_descriptor_types.ts @@ -8,7 +8,14 @@ import { FeatureCollection } from 'geojson'; import { Query } from 'src/plugins/data/public'; import { SortDirection } from 'src/plugins/data/common/search'; -import { AGG_TYPE, GRID_RESOLUTION, RENDER_AS, SCALING_TYPES, MVT_FIELD_TYPE } from '../constants'; +import { + AGG_TYPE, + GRID_RESOLUTION, + RENDER_AS, + SCALING_TYPES, + MVT_FIELD_TYPE, + SOURCE_TYPES, +} from '../constants'; export type AttributionDescriptor = { attributionText?: string; @@ -105,6 +112,7 @@ export type ESTermSourceDescriptor = AbstractESAggSourceDescriptor & { term: string; // term field name whereQuery?: Query; size?: number; + type: SOURCE_TYPES.ES_TERM_SOURCE; }; export type KibanaRegionmapSourceDescriptor = AbstractSourceDescriptor & { @@ -156,14 +164,24 @@ export type TiledSingleLayerVectorSourceDescriptor = AbstractSourceDescriptor & tooltipProperties: string[]; }; -export type GeoJsonFileFieldDescriptor = { +export type InlineFieldDescriptor = { name: string; type: 'string' | 'number'; }; export type GeojsonFileSourceDescriptor = { - __fields?: GeoJsonFileFieldDescriptor[]; + __fields?: InlineFieldDescriptor[]; __featureCollection: FeatureCollection; name: string; type: string; }; + +export type TableSourceDescriptor = { + id: string; + type: SOURCE_TYPES.TABLE_SOURCE; + __rows: Array<{ [key: string]: string | number }>; + __columns: InlineFieldDescriptor[]; + term: string; +}; + +export type TermJoinSourceDescriptor = ESTermSourceDescriptor | TableSourceDescriptor; diff --git a/x-pack/plugins/maps/common/migrations/add_type_to_termjoin.test.ts b/x-pack/plugins/maps/common/migrations/add_type_to_termjoin.test.ts new file mode 100644 index 0000000000000..c9ab4b00d8923 --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/add_type_to_termjoin.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { addTypeToTermJoin } from './add_type_to_termjoin'; +import { LAYER_TYPE, SOURCE_TYPES } from '../constants'; +import { LayerDescriptor } from '../descriptor_types'; + +describe('addTypeToTermJoin', () => { + test('Should handle missing type attribute', () => { + const layerListJSON = JSON.stringify(([ + { + type: LAYER_TYPE.VECTOR, + joins: [ + { + right: {}, + }, + { + right: { + type: SOURCE_TYPES.TABLE_SOURCE, + }, + }, + { + right: { + type: SOURCE_TYPES.ES_TERM_SOURCE, + }, + }, + ], + }, + ] as unknown) as LayerDescriptor[]); + + const attributes = { + title: 'my map', + layerListJSON, + }; + + const { layerListJSON: migratedLayerListJSON } = addTypeToTermJoin({ attributes }); + const migratedLayerList = JSON.parse(migratedLayerListJSON!); + expect(migratedLayerList[0].joins[0].right.type).toEqual(SOURCE_TYPES.ES_TERM_SOURCE); + expect(migratedLayerList[0].joins[1].right.type).toEqual(SOURCE_TYPES.TABLE_SOURCE); + expect(migratedLayerList[0].joins[2].right.type).toEqual(SOURCE_TYPES.ES_TERM_SOURCE); + }); +}); diff --git a/x-pack/plugins/maps/common/migrations/add_type_to_termjoin.ts b/x-pack/plugins/maps/common/migrations/add_type_to_termjoin.ts new file mode 100644 index 0000000000000..84e13eb6c3947 --- /dev/null +++ b/x-pack/plugins/maps/common/migrations/add_type_to_termjoin.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MapSavedObjectAttributes } from '../map_saved_object_type'; +import { JoinDescriptor, LayerDescriptor } from '../descriptor_types'; +import { LAYER_TYPE, SOURCE_TYPES } from '../constants'; + +// enforce type property on joins. It's possible older saved-objects do not have this correctly filled in +// e.g. sample-data was missing the right.type field. +// This is just to be safe. +export function addTypeToTermJoin({ + attributes, +}: { + attributes: MapSavedObjectAttributes; +}): MapSavedObjectAttributes { + if (!attributes || !attributes.layerListJSON) { + return attributes; + } + + const layerList: LayerDescriptor[] = JSON.parse(attributes.layerListJSON); + + layerList.forEach((layer: LayerDescriptor) => { + if (layer.type !== LAYER_TYPE.VECTOR) { + return; + } + + if (!layer.joins) { + return; + } + layer.joins.forEach((join: JoinDescriptor) => { + if (!join.right) { + return; + } + + if (typeof join.right.type === 'undefined') { + join.right.type = SOURCE_TYPES.ES_TERM_SOURCE; + } + }); + }); + + return { + ...attributes, + layerListJSON: JSON.stringify(layerList), + }; +} diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index 2536601d0e6b1..744cc18c36f3e 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -2,7 +2,10 @@ "id": "maps", "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "maps"], + "configPath": [ + "xpack", + "maps" + ], "requiredPlugins": [ "licensing", "features", @@ -17,11 +20,21 @@ "mapsLegacy", "usageCollection", "savedObjects", - "share" + "share", + "presentationUtil" + ], + "optionalPlugins": [ + "home", + "savedObjectsTagging" ], - "optionalPlugins": ["home", "savedObjectsTagging"], "ui": true, "server": true, - "extraPublicDirs": ["common/constants"], - "requiredBundles": ["kibanaReact", "kibanaUtils", "home", "presentationUtil"] + "extraPublicDirs": [ + "common/constants" + ], + "requiredBundles": [ + "kibanaReact", + "kibanaUtils", + "home" + ] } diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.js b/x-pack/plugins/maps/public/actions/map_actions.test.js index 1d1f8a511c4fa..c091aba14687a 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.js +++ b/x-pack/plugins/maps/public/actions/map_actions.test.js @@ -260,6 +260,7 @@ describe('map_actions', () => { $state: { store: 'appState' }, }, ]; + const searchSessionId = '1234'; beforeEach(() => { //Mocks the "previous" state @@ -272,6 +273,9 @@ describe('map_actions', () => { require('../selectors/map_selectors').getFilters = () => { return filters; }; + require('../selectors/map_selectors').getSearchSessionId = () => { + return searchSessionId; + }; require('../selectors/map_selectors').getMapSettings = () => { return { autoFitToDataBounds: false, @@ -288,12 +292,14 @@ describe('map_actions', () => { const setQueryAction = await setQuery({ query: newQuery, filters, + searchSessionId, }); await setQueryAction(dispatchMock, getStoreMock); expect(dispatchMock.mock.calls).toEqual([ [ { + searchSessionId, timeFilters, query: newQuery, filters, @@ -304,11 +310,25 @@ describe('map_actions', () => { ]); }); + it('should dispatch query action when searchSessionId changes', async () => { + const setQueryAction = await setQuery({ + timeFilters, + query, + filters, + searchSessionId: '5678', + }); + await setQueryAction(dispatchMock, getStoreMock); + + // dispatchMock calls: dispatch(SET_QUERY) and dispatch(syncDataForAllLayers()) + expect(dispatchMock.mock.calls.length).toEqual(2); + }); + it('should not dispatch query action when nothing changes', async () => { const setQueryAction = await setQuery({ timeFilters, query, filters, + searchSessionId, }); await setQueryAction(dispatchMock, getStoreMock); diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index 64c35bd207439..afb3df5be73de 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -19,6 +19,7 @@ import { getQuery, getTimeFilters, getLayerList, + getSearchSessionId, } from '../selectors/map_selectors'; import { CLEAR_GOTO, @@ -225,11 +226,13 @@ export function setQuery({ timeFilters, filters = [], forceRefresh = false, + searchSessionId, }: { filters?: Filter[]; query?: Query; timeFilters?: TimeRange; forceRefresh?: boolean; + searchSessionId?: string; }) { return async ( dispatch: ThunkDispatch<MapStoreState, void, AnyAction>, @@ -249,12 +252,14 @@ export function setQuery({ queryLastTriggeredAt: forceRefresh ? generateQueryTimestamp() : prevTriggeredAt, }, filters: filters ? filters : getFilters(getState()), + searchSessionId, }; const prevQueryContext = { timeFilters: getTimeFilters(getState()), query: getQuery(getState()), filters: getFilters(getState()), + searchSessionId: getSearchSessionId(getState()), }; if (_.isEqual(nextQueryContext, prevQueryContext)) { diff --git a/x-pack/plugins/maps/public/classes/fields/geojson_file_field.ts b/x-pack/plugins/maps/public/classes/fields/inline_field.ts similarity index 80% rename from x-pack/plugins/maps/public/classes/fields/geojson_file_field.ts rename to x-pack/plugins/maps/public/classes/fields/inline_field.ts index ae42b09d491c5..287edbd07cce8 100644 --- a/x-pack/plugins/maps/public/classes/fields/geojson_file_field.ts +++ b/x-pack/plugins/maps/public/classes/fields/inline_field.ts @@ -7,10 +7,9 @@ import { FIELD_ORIGIN } from '../../../common/constants'; import { IField, AbstractField } from './field'; import { IVectorSource } from '../sources/vector_source'; -import { GeoJsonFileSource } from '../sources/geojson_file_source'; -export class GeoJsonFileField extends AbstractField implements IField { - private readonly _source: GeoJsonFileSource; +export class InlineField<T extends IVectorSource> extends AbstractField implements IField { + private readonly _source: T; private readonly _dataType: string; constructor({ @@ -20,7 +19,7 @@ export class GeoJsonFileField extends AbstractField implements IField { dataType, }: { fieldName: string; - source: GeoJsonFileSource; + source: T; origin: FIELD_ORIGIN; dataType: string; }) { diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.test.js b/x-pack/plugins/maps/public/classes/joins/inner_join.test.js index ca40ab1ea7db7..bca5954e73d7b 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.test.js +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.test.js @@ -5,11 +5,13 @@ */ import { InnerJoin } from './inner_join'; +import { SOURCE_TYPES } from '../../../common/constants'; jest.mock('../../kibana_services', () => {}); jest.mock('../layers/vector_layer/vector_layer', () => {}); const rightSource = { + type: SOURCE_TYPES.ES_TERM_SOURCE, id: 'd3625663-5b34-4d50-a784-0d743f676a0c', indexPatternId: '90943e30-9a47-11e8-b64d-95841ca0b247', indexPatternTitle: 'kibana_sample_data_logs', diff --git a/x-pack/plugins/maps/public/classes/joins/inner_join.ts b/x-pack/plugins/maps/public/classes/joins/inner_join.ts index 32bd767aa94d8..95e163709dff9 100644 --- a/x-pack/plugins/maps/public/classes/joins/inner_join.ts +++ b/x-pack/plugins/maps/public/classes/joins/inner_join.ts @@ -9,29 +9,51 @@ import { Feature, GeoJsonProperties } from 'geojson'; import { ESTermSource } from '../sources/es_term_source'; import { getComputedFieldNamePrefix } from '../styles/vector/style_util'; import { - META_DATA_REQUEST_ID_SUFFIX, FORMATTERS_DATA_REQUEST_ID_SUFFIX, + META_DATA_REQUEST_ID_SUFFIX, + SOURCE_TYPES, } from '../../../common/constants'; -import { JoinDescriptor } from '../../../common/descriptor_types'; +import { + ESTermSourceDescriptor, + JoinDescriptor, + TableSourceDescriptor, + TermJoinSourceDescriptor, +} from '../../../common/descriptor_types'; import { IVectorSource } from '../sources/vector_source'; import { IField } from '../fields/field'; import { PropertiesMap } from '../../../common/elasticsearch_util'; +import { ITermJoinSource } from '../sources/term_join_source'; +import { TableSource } from '../sources/table_source'; +import { Adapters } from '../../../../../../src/plugins/inspector/common/adapters'; + +function createJoinTermSource( + descriptor: Partial<TermJoinSourceDescriptor> | undefined, + inspectorAdapters: Adapters | undefined +): ITermJoinSource | undefined { + if (!descriptor) { + return; + } + + if ( + descriptor.type === SOURCE_TYPES.ES_TERM_SOURCE && + 'indexPatternId' in descriptor && + 'term' in descriptor + ) { + return new ESTermSource(descriptor as ESTermSourceDescriptor, inspectorAdapters); + } else if (descriptor.type === SOURCE_TYPES.TABLE_SOURCE) { + return new TableSource(descriptor as TableSourceDescriptor, inspectorAdapters); + } +} export class InnerJoin { private readonly _descriptor: JoinDescriptor; - private readonly _rightSource?: ESTermSource; + private readonly _rightSource?: ITermJoinSource; private readonly _leftField?: IField; constructor(joinDescriptor: JoinDescriptor, leftSource: IVectorSource) { this._descriptor = joinDescriptor; const inspectorAdapters = leftSource.getInspectorAdapters(); - if ( - joinDescriptor.right && - 'indexPatternId' in joinDescriptor.right && - 'term' in joinDescriptor.right - ) { - this._rightSource = new ESTermSource(joinDescriptor.right, inspectorAdapters); - } + this._rightSource = createJoinTermSource(this._descriptor.right, inspectorAdapters); this._leftField = joinDescriptor.leftField ? leftSource.createField({ fieldName: joinDescriptor.leftField }) : undefined; @@ -47,8 +69,8 @@ export class InnerJoin { return this._leftField && this._rightSource ? this._rightSource.hasCompleteConfig() : false; } - getJoinFields() { - return this._rightSource ? this._rightSource.getMetricFields() : []; + getJoinFields(): IField[] { + return this._rightSource ? this._rightSource.getRightFields() : []; } // Source request id must be static and unique because the re-fetch logic uses the id to locate the previous request. @@ -77,7 +99,7 @@ export class InnerJoin { if (!feature.properties || !this._leftField || !this._rightSource) { return false; } - const rightMetricFields = this._rightSource.getMetricFields(); + const rightMetricFields: IField[] = this._rightSource.getRightFields(); // delete feature properties added by previous join for (let j = 0; j < rightMetricFields.length; j++) { const metricPropertyKey = rightMetricFields[j].getName(); @@ -106,7 +128,7 @@ export class InnerJoin { } } - getRightJoinSource(): ESTermSource { + getRightJoinSource(): ITermJoinSource { if (!this._rightSource) { throw new Error('Cannot get rightSource from InnerJoin with incomplete config'); } diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 5b33738a91a28..88150da84f23f 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -316,7 +316,10 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { const abortController = new AbortController(); syncContext.registerCancelCallback(requestToken, () => abortController.abort()); const searchSource = await this._documentSource.makeSearchSource(searchFilters, 0); - const resp = await searchSource.fetch({ abortSignal: abortController.signal }); + const resp = await searchSource.fetch({ + abortSignal: abortController.signal, + sessionId: syncContext.dataFilters.searchSessionId, + }); const maxResultWindow = await this._documentSource.getMaxResultWindow(); isSyncClustered = resp.hits.total > maxResultWindow; const countData = { isSyncClustered } as CountData; diff --git a/x-pack/plugins/maps/public/classes/layers/layer.test.ts b/x-pack/plugins/maps/public/classes/layers/layer.test.ts index e669ddf13e5ac..d8e6a4906a63a 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer.test.ts @@ -7,7 +7,13 @@ import { AbstractLayer } from './layer'; import { ISource } from '../sources/source'; -import { AGG_TYPE, FIELD_ORIGIN, LAYER_STYLE_TYPE, VECTOR_STYLES } from '../../../common/constants'; +import { + AGG_TYPE, + FIELD_ORIGIN, + LAYER_STYLE_TYPE, + SOURCE_TYPES, + VECTOR_STYLES, +} from '../../../common/constants'; import { ESTermSourceDescriptor, VectorStyleDescriptor } from '../../../common/descriptor_types'; import { getDefaultDynamicProperties } from '../styles/vector/vector_style_defaults'; @@ -73,7 +79,7 @@ describe('cloneDescriptor', () => { indexPatternTitle: 'logs-*', metrics: [{ type: AGG_TYPE.COUNT }], term: 'myTermField', - type: 'joinSource', + type: SOURCE_TYPES.ES_TERM_SOURCE, applyGlobalQuery: true, applyGlobalTime: true, }, diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index fe13e4f0ac2f6..1596c392e8d63 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -20,11 +20,13 @@ import { MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, MIN_ZOOM, SOURCE_DATA_REQUEST_ID, + SOURCE_TYPES, STYLE_TYPE, } from '../../../common/constants'; import { copyPersistentState } from '../../reducers/util'; import { AggDescriptor, + ESTermSourceDescriptor, JoinDescriptor, LayerDescriptor, MapExtent, @@ -158,6 +160,14 @@ export class AbstractLayer implements ILayer { if (clonedDescriptor.joins) { clonedDescriptor.joins.forEach((joinDescriptor: JoinDescriptor) => { + if (joinDescriptor.right && joinDescriptor.right.type === SOURCE_TYPES.TABLE_SOURCE) { + throw new Error( + 'Cannot clone table-source. Should only be used in MapEmbeddable, not in UX' + ); + } + const termSourceDescriptor: ESTermSourceDescriptor = joinDescriptor.right as ESTermSourceDescriptor; + + // todo: must tie this to generic thing const originalJoinId = joinDescriptor.right.id!; // right.id is uuid used to track requests in inspector @@ -166,8 +176,8 @@ export class AbstractLayer implements ILayer { // Update all data driven styling properties using join fields if (clonedDescriptor.style && 'properties' in clonedDescriptor.style) { const metrics = - joinDescriptor.right.metrics && joinDescriptor.right.metrics.length - ? joinDescriptor.right.metrics + termSourceDescriptor.metrics && termSourceDescriptor.metrics.length + ? termSourceDescriptor.metrics : [{ type: AGG_TYPE.COUNT }]; metrics.forEach((metricsDescriptor: AggDescriptor) => { const originalJoinKey = getJoinAggKey({ diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 0cb24be445c6e..e3a80a4c9eb5d 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -63,6 +63,7 @@ import { ITooltipProperty } from '../../tooltips/tooltip_property'; import { IDynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; import { IESSource } from '../../sources/es_source'; import { PropertiesMap } from '../../../../common/elasticsearch_util'; +import { ITermJoinSource } from '../../sources/term_join_source'; interface SourceResult { refreshed: boolean; @@ -574,7 +575,7 @@ export class VectorLayer extends AbstractLayer { dynamicStyleProps: this.getCurrentStyle() .getDynamicPropertiesArray() .filter((dynamicStyleProp) => { - const matchingField = joinSource.getMetricFieldForName(dynamicStyleProp.getFieldName()); + const matchingField = joinSource.getFieldByName(dynamicStyleProp.getFieldName()); return ( dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.JOIN && !!matchingField && @@ -599,7 +600,7 @@ export class VectorLayer extends AbstractLayer { }: { dataRequestId: string; dynamicStyleProps: Array<IDynamicStyleProperty<DynamicStylePropertyOptions>>; - source: IVectorSource; + source: IVectorSource | ITermJoinSource; sourceQuery?: MapQuery; style: IVectorStyle; } & DataRequestContext) { @@ -616,6 +617,7 @@ export class VectorLayer extends AbstractLayer { sourceQuery, isTimeAware: this.getCurrentStyle().isTimeAware() && (await source.isTimeAware()), timeFilters: dataFilters.timeFilters, + searchSessionId: dataFilters.searchSessionId, } as VectorStyleRequestMeta; const prevDataRequest = this.getDataRequest(dataRequestId); const canSkipFetch = canSkipStyleMetaUpdate({ prevDataRequest, nextMeta }); @@ -635,6 +637,7 @@ export class VectorLayer extends AbstractLayer { registerCancelCallback: registerCancelCallback.bind(null, requestToken), sourceQuery: nextMeta.sourceQuery, timeFilters: nextMeta.timeFilters, + searchSessionId: dataFilters.searchSessionId, }); stopLoading(dataRequestId, requestToken, styleMeta, nextMeta); } catch (error) { @@ -677,7 +680,7 @@ export class VectorLayer extends AbstractLayer { fields: style .getDynamicPropertiesArray() .filter((dynamicStyleProp) => { - const matchingField = joinSource.getMetricFieldForName(dynamicStyleProp.getFieldName()); + const matchingField = joinSource.getFieldByName(dynamicStyleProp.getFieldName()); return dynamicStyleProp.getFieldOrigin() === FIELD_ORIGIN.JOIN && !!matchingField; }) .map((dynamicStyleProp) => { @@ -697,7 +700,7 @@ export class VectorLayer extends AbstractLayer { }: { dataRequestId: string; fields: IField[]; - source: IVectorSource; + source: IVectorSource | ITermJoinSource; } & DataRequestContext) { if (fields.length === 0) { return; diff --git a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts index 77177dd47a166..5cb299ac33ff8 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_agg_source/es_agg_source.ts @@ -23,7 +23,6 @@ export interface IESAggSource extends IESSource { getAggKey(aggType: AGG_TYPE, fieldName: string): string; getAggLabel(aggType: AGG_TYPE, fieldLabel: string): string; getMetricFields(): IESAggField[]; - hasMatchingMetricField(fieldName: string): boolean; getMetricFieldForName(fieldName: string): IESAggField | null; getValueAggsDsl(indexPattern: IndexPattern): { [key: string]: unknown }; } @@ -74,11 +73,6 @@ export abstract class AbstractESAggSource extends AbstractESSource implements IE throw new Error('Cannot create a new field from just a fieldname for an es_agg_source.'); } - hasMatchingMetricField(fieldName: string): boolean { - const matchingField = this.getMetricFieldForName(fieldName); - return !!matchingField; - } - getMetricFieldForName(fieldName: string): IESAggField | null { const targetMetricField = this.getMetricFields().find((metricField: IESAggField) => { return metricField.getName() === fieldName; diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx index 6ec51b8e118cb..24b7e0dec519c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.tsx @@ -195,6 +195,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle async _compositeAggRequest({ searchSource, + searchSessionId, indexPattern, precision, layerName, @@ -204,6 +205,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bufferedExtent, }: { searchSource: ISearchSource; + searchSessionId?: string; indexPattern: IndexPattern; precision: number; layerName: string; @@ -280,6 +282,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle values: { requestId }, } ), + searchSessionId, }); features.push(...convertCompositeRespToGeoJson(esResponse, this._descriptor.requestType)); @@ -325,6 +328,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle // see https://github.com/elastic/kibana/pull/57875#issuecomment-590515482 for explanation on using separate code paths async _nonCompositeAggRequest({ searchSource, + searchSessionId, indexPattern, precision, layerName, @@ -332,6 +336,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bufferedExtent, }: { searchSource: ISearchSource; + searchSessionId?: string; indexPattern: IndexPattern; precision: number; layerName: string; @@ -348,6 +353,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle requestDescription: i18n.translate('xpack.maps.source.esGrid.inspectorDescription', { defaultMessage: 'Elasticsearch geo grid aggregation request', }), + searchSessionId, }); return convertRegularRespToGeoJson(esResponse, this._descriptor.requestType); @@ -373,6 +379,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle bucketsPerGrid === 1 ? await this._nonCompositeAggRequest({ searchSource, + searchSessionId: searchFilters.searchSessionId, indexPattern, precision: searchFilters.geogridPrecision || 0, layerName, @@ -381,6 +388,7 @@ export class ESGeoGridSource extends AbstractESAggSource implements ITiledSingle }) : await this._compositeAggRequest({ searchSource, + searchSessionId: searchFilters.searchSessionId, indexPattern, precision: searchFilters.geogridPrecision || 0, layerName, diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx index 9c851dcedb3fa..916a8a291e6b4 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/es_geo_line_source.tsx @@ -213,6 +213,7 @@ export class ESGeoLineSource extends AbstractESAggSource { requestDescription: i18n.translate('xpack.maps.source.esGeoLine.entityRequestDescription', { defaultMessage: 'Elasticsearch terms request to fetch entities within map buffer.', }), + searchSessionId: searchFilters.searchSessionId, }); const entityBuckets: Array<{ key: string; doc_count: number }> = _.get( entityResp, @@ -282,6 +283,7 @@ export class ESGeoLineSource extends AbstractESAggSource { defaultMessage: 'Elasticsearch geo_line request to fetch tracks for entities. Tracks are not filtered by map buffer.', }), + searchSessionId: searchFilters.searchSessionId, }); const { featureCollection, numTrimmedTracks } = convertToGeoJson( tracksResp, diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index 504212ea1ea84..98d3ba6267c6d 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -148,6 +148,7 @@ export class ESPewPewSource extends AbstractESAggSource { requestDescription: i18n.translate('xpack.maps.source.pewPew.inspectorDescription', { defaultMessage: 'Source-destination connections request', }), + searchSessionId: searchFilters.searchSessionId, }); const { featureCollection } = convertToLines(esResponse); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx index 5a923f0ce4292..b70a433f2c729 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.tsx @@ -325,6 +325,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye searchSource, registerCancelCallback, requestDescription: 'Elasticsearch document top hits request', + searchSessionId: searchFilters.searchSessionId, }); const allHits: any[] = []; @@ -391,6 +392,7 @@ export class ESSearchSource extends AbstractESSource implements ITiledSingleLaye searchSource, registerCancelCallback, requestDescription: 'Elasticsearch document request', + searchSessionId: searchFilters.searchSessionId, }); return { diff --git a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts index 967131e900fc6..64a5cd575a19d 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_source/es_source.ts @@ -57,6 +57,7 @@ export interface IESSource extends IVectorSource { registerCancelCallback, sourceQuery, timeFilters, + searchSessionId, }: { layerName: string; style: IVectorStyle; @@ -64,6 +65,7 @@ export interface IESSource extends IVectorSource { registerCancelCallback: (callback: () => void) => void; sourceQuery?: MapQuery; timeFilters: TimeRange; + searchSessionId?: string; }): Promise<object>; } @@ -151,17 +153,19 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource } async _runEsQuery({ + registerCancelCallback, + requestDescription, requestId, requestName, - requestDescription, + searchSessionId, searchSource, - registerCancelCallback, }: { + registerCancelCallback: (callback: () => void) => void; + requestDescription: string; requestId: string; requestName: string; - requestDescription: string; + searchSessionId?: string; searchSource: ISearchSource; - registerCancelCallback: (callback: () => void) => void; }): Promise<any> { const abortController = new AbortController(); registerCancelCallback(() => abortController.abort()); @@ -172,6 +176,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource inspectorRequest = inspectorAdapters.requests.start(requestName, { id: requestId, description: requestDescription, + searchSessionId, }); } @@ -186,7 +191,10 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource } }); } - resp = await searchSource.fetch({ abortSignal: abortController.signal }); + resp = await searchSource.fetch({ + abortSignal: abortController.signal, + sessionId: searchSessionId, + }); if (inspectorRequest) { const responseStats = search.getResponseInspectorStats(resp, searchSource); inspectorRequest.stats(responseStats).ok({ json: resp }); @@ -404,6 +412,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource registerCancelCallback, sourceQuery, timeFilters, + searchSessionId, }: { layerName: string; style: IVectorStyle; @@ -411,6 +420,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource registerCancelCallback: (callback: () => void) => void; sourceQuery?: MapQuery; timeFilters: TimeRange; + searchSessionId?: string; }): Promise<object> { const promises = dynamicStyleProps.map((dynamicStyleProp) => { return dynamicStyleProp.getFieldMetaRequest(); @@ -456,6 +466,7 @@ export class AbstractESSource extends AbstractVectorSource implements IESSource 'Elasticsearch request retrieving field metadata used for calculating symbolization bands.', } ), + searchSessionId, }); return resp.aggregations; diff --git a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts index 12f1ef4829a4a..c7107964568c9 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_term_source/es_term_source.ts @@ -30,6 +30,8 @@ import { import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; import { PropertiesMap } from '../../../../common/elasticsearch_util'; import { isValidStringConfig } from '../../util/valid_string_config'; +import { ITermJoinSource } from '../term_join_source/term_join_source'; +import { IField } from '../../fields/field'; const TERMS_AGG_NAME = 'join'; const TERMS_BUCKET_KEYS_TO_IGNORE = ['key', 'doc_count']; @@ -47,7 +49,7 @@ export function extractPropertiesMap(rawEsData: any, countPropertyName: string): return propertiesMap; } -export class ESTermSource extends AbstractESAggSource { +export class ESTermSource extends AbstractESAggSource implements ITermJoinSource { static type = SOURCE_TYPES.ES_TERM_SOURCE; static createDescriptor(descriptor: Partial<ESTermSourceDescriptor>): ESTermSourceDescriptor { @@ -79,7 +81,7 @@ export class ESTermSource extends AbstractESAggSource { }); } - hasCompleteConfig() { + hasCompleteConfig(): boolean { return _.has(this._descriptor, 'indexPatternId') && _.has(this._descriptor, 'term'); } @@ -147,6 +149,7 @@ export class ESTermSource extends AbstractESAggSource { rightSource: `${this._descriptor.indexPatternTitle}:${this._termField.getName()}`, }, }), + searchSessionId: searchFilters.searchSessionId, }); const countPropertyName = this.getAggKey(AGG_TYPE.COUNT); @@ -173,4 +176,8 @@ export class ESTermSource extends AbstractESAggSource { } : null; } + + getRightFields(): IField[] { + return this.getMetricFields(); + } } diff --git a/x-pack/plugins/maps/public/classes/sources/geojson_file_source/geojson_file_source.ts b/x-pack/plugins/maps/public/classes/sources/geojson_file_source/geojson_file_source.ts index 69d84dc65d382..35464b24185d0 100644 --- a/x-pack/plugins/maps/public/classes/sources/geojson_file_source/geojson_file_source.ts +++ b/x-pack/plugins/maps/public/classes/sources/geojson_file_source/geojson_file_source.ts @@ -8,15 +8,15 @@ import { Feature, FeatureCollection } from 'geojson'; import { AbstractVectorSource, BoundsFilters, GeoJsonWithMeta } from '../vector_source'; import { EMPTY_FEATURE_COLLECTION, FIELD_ORIGIN, SOURCE_TYPES } from '../../../../common/constants'; import { - GeoJsonFileFieldDescriptor, + InlineFieldDescriptor, GeojsonFileSourceDescriptor, MapExtent, } from '../../../../common/descriptor_types'; import { registerSource } from '../source_registry'; import { IField } from '../../fields/field'; import { getFeatureCollectionBounds } from '../../util/get_feature_collection_bounds'; -import { GeoJsonFileField } from '../../fields/geojson_file_field'; import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; +import { InlineField } from '../../fields/inline_field'; function getFeatureCollection( geoJson: Feature | FeatureCollection | null | undefined @@ -56,14 +56,14 @@ export class GeoJsonFileSource extends AbstractVectorSource { super(normalizedDescriptor, inspectorAdapters); } - _getFields(): GeoJsonFileFieldDescriptor[] { + _getFields(): InlineFieldDescriptor[] { const fields = (this._descriptor as GeojsonFileSourceDescriptor).__fields; return fields ? fields : []; } createField({ fieldName }: { fieldName: string }): IField { const fields = this._getFields(); - const descriptor: GeoJsonFileFieldDescriptor | undefined = fields.find((field) => { + const descriptor: InlineFieldDescriptor | undefined = fields.find((field) => { return field.name === fieldName; }); @@ -74,7 +74,7 @@ export class GeoJsonFileSource extends AbstractVectorSource { )} ` ); } - return new GeoJsonFileField({ + return new InlineField<GeoJsonFileSource>({ fieldName: descriptor.name, source: this, origin: FIELD_ORIGIN.SOURCE, @@ -84,8 +84,8 @@ export class GeoJsonFileSource extends AbstractVectorSource { async getFields(): Promise<IField[]> { const fields = this._getFields(); - return fields.map((field: GeoJsonFileFieldDescriptor) => { - return new GeoJsonFileField({ + return fields.map((field: InlineFieldDescriptor) => { + return new InlineField<GeoJsonFileSource>({ fieldName: field.name, source: this, origin: FIELD_ORIGIN.SOURCE, diff --git a/x-pack/plugins/maps/public/classes/sources/table_source/index.ts b/x-pack/plugins/maps/public/classes/sources/table_source/index.ts new file mode 100644 index 0000000000000..7258e6b464cd0 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/table_source/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TableSource } from './table_source'; diff --git a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.test.ts b/x-pack/plugins/maps/public/classes/sources/table_source/table_source.test.ts new file mode 100644 index 0000000000000..9409eefa4ae07 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/table_source/table_source.test.ts @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TableSource } from './table_source'; +import { FIELD_ORIGIN } from '../../../../common/constants'; +import { + MapFilters, + MapQuery, + VectorJoinSourceRequestMeta, + VectorSourceSyncMeta, +} from '../../../../common/descriptor_types'; + +describe('TableSource', () => { + describe('getName', () => { + it('should get default display name', async () => { + const tableSource = new TableSource({}); + expect((await tableSource.getDisplayName()).startsWith('table source')).toBe(true); + }); + }); + + describe('getPropertiesMap', () => { + it('should roll up results', async () => { + const tableSource = new TableSource({ + term: 'iso', + __rows: [ + { + iso: 'US', + population: 100, + }, + { + iso: 'CN', + population: 400, + foo: 'bar', // ignore this prop, not defined in `__columns` + }, + { + // ignore this row, cannot be joined + population: 400, + }, + { + // row ignored since it's not first row with key 'US' + iso: 'US', + population: -1, + }, + ], + __columns: [ + { + name: 'iso', + type: 'string', + }, + { + name: 'population', + type: 'number', + }, + ], + }); + + const propertiesMap = await tableSource.getPropertiesMap( + ({} as unknown) as VectorJoinSourceRequestMeta, + 'a', + 'b', + () => {} + ); + + expect(propertiesMap.size).toEqual(2); + expect(propertiesMap.get('US')).toEqual({ + population: 100, + }); + expect(propertiesMap.get('CN')).toEqual({ + population: 400, + }); + }); + }); + + describe('getTermField', () => { + it('should throw when no match', async () => { + const tableSource = new TableSource({ + term: 'foobar', + __columns: [ + { + name: 'iso', + type: 'string', + }, + { + name: 'population', + type: 'number', + }, + ], + }); + + expect(() => { + tableSource.getTermField(); + }).toThrow(); + }); + + it('should return field', async () => { + const tableSource = new TableSource({ + term: 'iso', + __columns: [ + { + name: 'iso', + type: 'string', + }, + { + name: 'population', + type: 'number', + }, + ], + }); + + const termField = tableSource.getTermField(); + expect(termField.getName()).toEqual('iso'); + expect(await termField.getDataType()).toEqual('string'); + }); + }); + + describe('getRightFields', () => { + it('should return columns', async () => { + const tableSource = new TableSource({ + term: 'foobar', + __columns: [ + { + name: 'iso', + type: 'string', + }, + { + name: 'population', + type: 'number', + }, + ], + }); + + const rightFields = tableSource.getRightFields(); + expect(rightFields[0].getName()).toEqual('iso'); + expect(await rightFields[0].getDataType()).toEqual('string'); + expect(rightFields[0].getOrigin()).toEqual(FIELD_ORIGIN.JOIN); + expect(rightFields[0].getSource()).toEqual(tableSource); + + expect(rightFields[1].getName()).toEqual('population'); + expect(await rightFields[1].getDataType()).toEqual('number'); + expect(rightFields[1].getOrigin()).toEqual(FIELD_ORIGIN.JOIN); + expect(rightFields[1].getSource()).toEqual(tableSource); + }); + }); + + describe('getFieldByName', () => { + it('should return columns', async () => { + const tableSource = new TableSource({ + term: 'foobar', + __columns: [ + { + name: 'iso', + type: 'string', + }, + { + name: 'population', + type: 'number', + }, + ], + }); + + const field = tableSource.getFieldByName('iso'); + expect(field!.getName()).toEqual('iso'); + expect(await field!.getDataType()).toEqual('string'); + expect(field!.getOrigin()).toEqual(FIELD_ORIGIN.JOIN); + expect(field!.getSource()).toEqual(tableSource); + }); + }); + + describe('getGeoJsonWithMeta', () => { + it('should throw - not implemented', async () => { + const tableSource = new TableSource({}); + + let didThrow = false; + try { + await tableSource.getGeoJsonWithMeta( + 'foobar', + ({} as unknown) as MapFilters & { + applyGlobalQuery: boolean; + applyGlobalTime: boolean; + fieldNames: string[]; + geogridPrecision?: number; + sourceQuery?: MapQuery; + sourceMeta: VectorSourceSyncMeta; + }, + () => {}, + () => { + return false; + } + ); + } catch (e) { + didThrow = true; + } finally { + expect(didThrow).toBe(true); + } + }); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts b/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts new file mode 100644 index 0000000000000..d157c4f5df60a --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/table_source/table_source.ts @@ -0,0 +1,218 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { FIELD_ORIGIN, SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; +import { + MapExtent, + MapFilters, + MapQuery, + TableSourceDescriptor, + VectorJoinSourceRequestMeta, + VectorSourceSyncMeta, +} from '../../../../common/descriptor_types'; +import { Adapters } from '../../../../../../../src/plugins/inspector/common/adapters'; +import { ITermJoinSource } from '../term_join_source'; +import { BucketProperties, PropertiesMap } from '../../../../common/elasticsearch_util'; +import { IField } from '../../fields/field'; +import { Query } from '../../../../../../../src/plugins/data/common/query'; +import { + AbstractVectorSource, + BoundsFilters, + GeoJsonWithMeta, + IVectorSource, + SourceTooltipConfig, +} from '../vector_source'; +import { DataRequest } from '../../util/data_request'; +import { InlineField } from '../../fields/inline_field'; + +export class TableSource extends AbstractVectorSource implements ITermJoinSource, IVectorSource { + static type = SOURCE_TYPES.TABLE_SOURCE; + + static createDescriptor(descriptor: Partial<TableSourceDescriptor>): TableSourceDescriptor { + return { + type: SOURCE_TYPES.TABLE_SOURCE, + __rows: descriptor.__rows || [], + __columns: descriptor.__columns || [], + term: descriptor.term || '', + id: descriptor.id || uuid(), + }; + } + + readonly _descriptor: TableSourceDescriptor; + + constructor(descriptor: Partial<TableSourceDescriptor>, inspectorAdapters?: Adapters) { + const sourceDescriptor = TableSource.createDescriptor(descriptor); + super(sourceDescriptor, inspectorAdapters); + this._descriptor = sourceDescriptor; + } + + async getDisplayName(): Promise<string> { + // no need to localize. this is never rendered. + return `table source ${uuid()}`; + } + + getSyncMeta(): VectorSourceSyncMeta | null { + return null; + } + + async getPropertiesMap( + searchFilters: VectorJoinSourceRequestMeta, + leftSourceName: string, + leftFieldName: string, + registerCancelCallback: (callback: () => void) => void + ): Promise<PropertiesMap> { + const propertiesMap: PropertiesMap = new Map<string, BucketProperties>(); + + const fieldNames = await this.getFieldNames(); + + for (let i = 0; i < this._descriptor.__rows.length; i++) { + const row: { [key: string]: string | number } = this._descriptor.__rows[i]; + let propKey: string | number | undefined; + const props: { [key: string]: string | number } = {}; + for (const key in row) { + if (row.hasOwnProperty(key)) { + if (key === this._descriptor.term && row[key]) { + propKey = row[key]; + } + if (fieldNames.indexOf(key) >= 0 && key !== this._descriptor.term) { + props[key] = row[key]; + } + } + } + if (propKey && !propertiesMap.has(propKey.toString())) { + // If propKey is not a primary key in the table, this will favor the first match + propertiesMap.set(propKey.toString(), props); + } + } + + return propertiesMap; + } + + getTermField(): IField { + const column = this._descriptor.__columns.find((c) => { + return c.name === this._descriptor.term; + }); + + if (!column) { + throw new Error( + `Cannot find column for ${this._descriptor.term} in ${JSON.stringify( + this._descriptor.__columns + )}` + ); + } + + return new InlineField<TableSource>({ + fieldName: column.name, + source: this, + origin: FIELD_ORIGIN.JOIN, + dataType: column.type, + }); + } + + getWhereQuery(): Query | undefined { + return undefined; + } + + hasCompleteConfig(): boolean { + return true; + } + + getId(): string { + return this._descriptor.id; + } + + getRightFields(): IField[] { + return this._descriptor.__columns.map((column) => { + return new InlineField<TableSource>({ + fieldName: column.name, + source: this, + origin: FIELD_ORIGIN.JOIN, + dataType: column.type, + }); + }); + } + + getFieldNames(): string[] { + return this._descriptor.__columns.map((column) => { + return column.name; + }); + } + + canFormatFeatureProperties(): boolean { + return false; + } + + createField({ fieldName }: { fieldName: string }): IField { + const field = this.getFieldByName(fieldName); + if (!field) { + throw new Error(`Cannot find field for ${fieldName}`); + } + return field; + } + + async getBoundsForFilters( + boundsFilters: BoundsFilters, + registerCancelCallback: (callback: () => void) => void + ): Promise<MapExtent | null> { + return null; + } + + getFieldByName(fieldName: string): IField | null { + const column = this._descriptor.__columns.find((c) => { + return c.name === fieldName; + }); + + if (!column) { + return null; + } + + return new InlineField<TableSource>({ + fieldName: column.name, + source: this, + origin: FIELD_ORIGIN.JOIN, + dataType: column.type, + }); + } + + getFields(): Promise<IField[]> { + throw new Error('must implement'); + } + + // The below is the IVectorSource interface. + // Could be useful to implement, e.g. to preview raw csv data + async getGeoJsonWithMeta( + layerName: string, + searchFilters: MapFilters & { + applyGlobalQuery: boolean; + applyGlobalTime: boolean; + fieldNames: string[]; + geogridPrecision?: number; + sourceQuery?: MapQuery; + sourceMeta: VectorSourceSyncMeta; + }, + registerCancelCallback: (callback: () => void) => void, + isRequestStillActive: () => boolean + ): Promise<GeoJsonWithMeta> { + throw new Error('TableSource cannot return GeoJson'); + } + + async getLeftJoinFields(): Promise<IField[]> { + throw new Error('TableSource cannot be used as a left-layer in a term join'); + } + + getSourceTooltipContent(sourceDataRequest?: DataRequest): SourceTooltipConfig { + throw new Error('must add tooltip content'); + } + + async getSupportedShapeTypes(): Promise<VECTOR_SHAPE_TYPE[]> { + return []; + } + + isBoundsAware(): boolean { + return false; + } +} diff --git a/x-pack/plugins/maps/public/classes/sources/term_join_source/index.ts b/x-pack/plugins/maps/public/classes/sources/term_join_source/index.ts new file mode 100644 index 0000000000000..1879d64d3b207 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/term_join_source/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ITermJoinSource } from './term_join_source'; diff --git a/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts b/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts new file mode 100644 index 0000000000000..534ac9f200362 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/term_join_source/term_join_source.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GeoJsonProperties } from 'geojson'; +import { IField } from '../../fields/field'; +import { Query } from '../../../../../../../src/plugins/data/common/query'; +import { + VectorJoinSourceRequestMeta, + VectorSourceSyncMeta, +} from '../../../../common/descriptor_types'; +import { PropertiesMap } from '../../../../common/elasticsearch_util'; +import { ITooltipProperty } from '../../tooltips/tooltip_property'; +import { ISource } from '../source'; + +export interface ITermJoinSource extends ISource { + hasCompleteConfig(): boolean; + getTermField(): IField; + getWhereQuery(): Query | undefined; + getPropertiesMap( + searchFilters: VectorJoinSourceRequestMeta, + leftSourceName: string, + leftFieldName: string, + registerCancelCallback: (callback: () => void) => void + ): Promise<PropertiesMap>; + getSyncMeta(): VectorSourceSyncMeta | null; + getId(): string; + getRightFields(): IField[]; + getTooltipProperties(properties: GeoJsonProperties): Promise<ITooltipProperty[]>; + getFieldByName(fieldName: string): IField | null; +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx index 882247e375ddc..96494a346e625 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx @@ -97,7 +97,7 @@ export class DynamicStyleProperty<T> } const join = this._layer.getValidJoins().find((validJoin: InnerJoin) => { - return validJoin.getRightJoinSource().hasMatchingMetricField(fieldName); + return !!validJoin.getRightJoinSource().getFieldByName(fieldName); }); return join ? join.getSourceMetaDataRequestId() : null; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index 9bf4cafd66407..126f19b7012f8 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -620,7 +620,7 @@ export class VectorStyle implements IVectorStyle { dataRequestId = SOURCE_FORMATTERS_DATA_REQUEST_ID; } else { const targetJoin = this._layer.getValidJoins().find((join) => { - return join.getRightJoinSource().hasMatchingMetricField(fieldName); + return !!join.getRightJoinSource().getFieldByName(fieldName); }); if (targetJoin) { dataRequestId = targetJoin.getSourceFormattersDataRequestId(); @@ -841,7 +841,7 @@ export class VectorStyle implements IVectorStyle { this._iconOrientationProperty.syncIconRotationWithMb(symbolLayerId, mbMap); } - _makeField(fieldDescriptor?: StylePropertyField) { + _makeField(fieldDescriptor?: StylePropertyField): IField | null { if (!fieldDescriptor || !fieldDescriptor.name) { return null; } @@ -852,10 +852,10 @@ export class VectorStyle implements IVectorStyle { return this._source.getFieldByName(fieldDescriptor.name); } else if (fieldDescriptor.origin === FIELD_ORIGIN.JOIN) { const targetJoin = this._layer.getValidJoins().find((join) => { - return join.getRightJoinSource().hasMatchingMetricField(fieldDescriptor.name); + return !!join.getRightJoinSource().getFieldByName(fieldDescriptor.name); }); return targetJoin - ? targetJoin.getRightJoinSource().getMetricFieldForName(fieldDescriptor.name) + ? targetJoin.getRightJoinSource().getFieldByName(fieldDescriptor.name) : null; } else { throw new Error(`Unknown origin-type ${fieldDescriptor.origin}`); diff --git a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts index a7919ad058e4b..d7a5eea151602 100644 --- a/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts +++ b/x-pack/plugins/maps/public/classes/util/can_skip_fetch.ts @@ -113,6 +113,7 @@ export async function canSkipSourceUpdate({ if (isQueryAware) { updateDueToApplyGlobalQuery = prevMeta.applyGlobalQuery !== nextMeta.applyGlobalQuery; updateDueToSourceQuery = !_.isEqual(prevMeta.sourceQuery, nextMeta.sourceQuery); + if (nextMeta.applyGlobalQuery) { updateDueToQuery = !_.isEqual(prevMeta.query, nextMeta.query); updateDueToFilters = !_.isEqual(prevMeta.filters, nextMeta.filters); @@ -123,6 +124,11 @@ export async function canSkipSourceUpdate({ } } + let updateDueToSearchSessionId = false; + if (timeAware || isQueryAware) { + updateDueToSearchSessionId = prevMeta.searchSessionId !== nextMeta.searchSessionId; + } + let updateDueToPrecisionChange = false; if (isGeoGridPrecisionAware) { updateDueToPrecisionChange = !_.isEqual(prevMeta.geogridPrecision, nextMeta.geogridPrecision); @@ -146,7 +152,8 @@ export async function canSkipSourceUpdate({ !updateDueToSourceQuery && !updateDueToApplyGlobalQuery && !updateDueToPrecisionChange && - !updateDueToSourceMetaChange + !updateDueToSourceMetaChange && + !updateDueToSearchSessionId ); } @@ -174,8 +181,14 @@ export function canSkipStyleMetaUpdate({ ? !_.isEqual(prevMeta.timeFilters, nextMeta.timeFilters) : false; + const updateDueToSearchSessionId = prevMeta.searchSessionId !== nextMeta.searchSessionId; + return ( - !updateDueToFields && !updateDueToSourceQuery && !updateDueToIsTimeAware && !updateDueToTime + !updateDueToFields && + !updateDueToSourceQuery && + !updateDueToIsTimeAware && + !updateDueToTime && + !updateDueToSearchSessionId ); } diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx index e2529fff66f3b..78a9f82bb698f 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx @@ -26,7 +26,7 @@ const ADD_LAYER_STEP_LABEL = i18n.translate('xpack.maps.addLayerPanel.addLayer', }); const SELECT_WIZARD_LABEL = ADD_LAYER_STEP_LABEL; -interface Props { +export interface Props { addPreviewLayers: (layerDescriptors: LayerDescriptor[]) => void; closeFlyout: () => void; hasPreviewLayers: boolean; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx index 1bcee961db9e1..ce5c0ed5fdcad 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx @@ -5,7 +5,6 @@ */ import React, { Fragment } from 'react'; -import _ from 'lodash'; import uuid from 'uuid/v4'; import { @@ -24,8 +23,9 @@ import { Join } from './resources/join'; import { ILayer } from '../../../classes/layers/layer'; import { JoinDescriptor } from '../../../../common/descriptor_types'; import { IField } from '../../../classes/fields/field'; +import { SOURCE_TYPES } from '../../../../common/constants'; -interface Props { +export interface Props { joins: JoinDescriptor[]; layer: ILayer; layerDisplayName: string; @@ -44,19 +44,25 @@ export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDispla onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]); }; - return ( - <Fragment key={index}> - <EuiSpacer size="m" /> - <Join - join={joinDescriptor} - layer={layer} - onChange={handleOnChange} - onRemove={handleOnRemove} - leftFields={leftJoinFields} - leftSourceName={layerDisplayName} - /> - </Fragment> - ); + if (joinDescriptor.right.type === SOURCE_TYPES.TABLE_SOURCE) { + throw new Error( + 'PEBKAC - Table sources cannot be edited in the UX and should only be used in MapEmbeddable' + ); + } else { + return ( + <Fragment key={index}> + <EuiSpacer size="m" /> + <Join + join={joinDescriptor} + layer={layer} + onChange={handleOnChange} + onRemove={handleOnRemove} + leftFields={leftJoinFields} + leftSourceName={layerDisplayName} + /> + </Fragment> + ); + } }); }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join.js index 507b32fa39fd8..a46b27b62a19e 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/join.js @@ -17,6 +17,7 @@ import { GlobalTimeCheckbox } from '../../../../components/global_time_checkbox' import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; import { getIndexPatternService } from '../../../../kibana_services'; +import { SOURCE_TYPES } from '../../../../../common/constants'; export class Join extends Component { state = { @@ -85,6 +86,7 @@ export class Join extends Component { ...restOfRight, indexPatternId, indexPatternTitle, + type: SOURCE_TYPES.ES_TERM_SOURCE, }, }); }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.tsx index 33d684b320208..c0462f824cd06 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.tsx +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.tsx @@ -21,7 +21,7 @@ import { AlphaSlider } from '../../../components/alpha_slider'; import { ValidatedDualRange } from '../../../../../../../src/plugins/kibana_react/public'; import { ILayer } from '../../../classes/layers/layer'; -interface Props { +export interface Props { layer: ILayer; updateLabel: (layerId: string, label: string) => void; updateMinZoom: (layerId: string, minZoom: number) => void; diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 93476f6e14da5..36d07e3870818 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -35,7 +35,7 @@ import 'mapbox-gl/dist/mapbox-gl.css'; const RENDER_COMPLETE_EVENT = 'renderComplete'; -interface Props { +export interface Props { addFilters: ((filters: Filter[]) => Promise<void>) | null; getFilterActions?: () => Promise<Action[]>; getActionContext?: () => ActionExecutionContext; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx index 726e2c3be7846..9cbbdec5e3d17 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx @@ -23,7 +23,7 @@ import { SpatialFiltersPanel } from './spatial_filters_panel'; import { DisplayPanel } from './display_panel'; import { MapCenter } from '../../../common/descriptor_types'; -interface Props { +export interface Props { cancelChanges: () => void; center: MapCenter; hasMapSettingsChanges: boolean; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index 820453f166a46..21a8abcbaa4e9 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -49,7 +49,7 @@ import mbWorkerUrl from '!!file-loader!mapbox-gl/dist/mapbox-gl-csp-worker'; mapboxgl.workerUrl = mbWorkerUrl; mapboxgl.setRTLTextPlugin(mbRtlPlugin); -interface Props { +export interface Props { isMapReady: boolean; settings: MapSettings; layerList: ILayer[]; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx index 3f56d8d50b0f0..edf626612cb69 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx @@ -10,7 +10,7 @@ import { EuiButtonIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ILayer } from '../../../classes/layers/layer'; -interface Props { +export interface Props { layerList: ILayer[]; fitToBounds: () => void; } diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx index ef320c73bce2f..a0f3aa40e75dd 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx @@ -50,7 +50,7 @@ const DRAW_DISTANCE_LABEL_SHORT = i18n.translate( } ); -interface Props { +export interface Props { cancelDraw: () => void; geoFields: GeoFieldWithIndex[]; initiateDraw: (drawState: DrawState) => void; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx index 4d669dfbe235e..fd0a0d55d2c1b 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { ILayer } from '../../../../../../classes/layers/layer'; import { TOCEntryButton } from '../toc_entry_button'; -interface Props { +export interface Props { cloneLayer: (layerId: string) => void; displayName: string; editLayer: () => void; diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 67981ab3aede5..623e548aa85fa 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -68,7 +68,7 @@ import { MapEmbeddableInput, MapEmbeddableOutput, } from './types'; -export { MapEmbeddableInput }; +export { MapEmbeddableInput, MapEmbeddableOutput }; export class MapEmbeddable extends Embeddable<MapEmbeddableInput, MapEmbeddableOutput> @@ -82,6 +82,7 @@ export class MapEmbeddable private _prevQuery?: Query; private _prevRefreshConfig?: RefreshInterval; private _prevFilters?: Filter[]; + private _prevSearchSessionId?: string; private _domNode?: HTMLElement; private _unsubscribeFromStore?: Unsubscribe; private _isInitialized = false; @@ -99,9 +100,7 @@ export class MapEmbeddable this._savedMap = new SavedMap({ mapEmbeddableInput: initialInput }); this._initializeSaveMap(); - this._subscription = this.getUpdated$().subscribe(() => - this.onContainerStateChanged(this.input) - ); + this._subscription = this.getUpdated$().subscribe(() => this.onUpdate()); } private async _initializeSaveMap() { @@ -135,6 +134,7 @@ export class MapEmbeddable timeRange: this.input.timeRange, filters: this.input.filters, forceRefresh: false, + searchSessionId: this.input.searchSessionId, }); if (this.input.refreshConfig) { this._dispatchSetRefreshConfig(this.input.refreshConfig); @@ -201,25 +201,24 @@ export class MapEmbeddable return getInspectorAdapters(this._savedMap.getStore().getState()); } - onContainerStateChanged(containerState: MapEmbeddableInput) { + onUpdate() { if ( - !_.isEqual(containerState.timeRange, this._prevTimeRange) || - !_.isEqual(containerState.query, this._prevQuery) || - !esFilters.onlyDisabledFiltersChanged(containerState.filters, this._prevFilters) + !_.isEqual(this.input.timeRange, this._prevTimeRange) || + !_.isEqual(this.input.query, this._prevQuery) || + !esFilters.onlyDisabledFiltersChanged(this.input.filters, this._prevFilters) || + this.input.searchSessionId !== this._prevSearchSessionId ) { this._dispatchSetQuery({ - query: containerState.query, - timeRange: containerState.timeRange, - filters: containerState.filters, + query: this.input.query, + timeRange: this.input.timeRange, + filters: this.input.filters, forceRefresh: false, + searchSessionId: this.input.searchSessionId, }); } - if ( - containerState.refreshConfig && - !_.isEqual(containerState.refreshConfig, this._prevRefreshConfig) - ) { - this._dispatchSetRefreshConfig(containerState.refreshConfig); + if (this.input.refreshConfig && !_.isEqual(this.input.refreshConfig, this._prevRefreshConfig)) { + this._dispatchSetRefreshConfig(this.input.refreshConfig); } } @@ -228,21 +227,25 @@ export class MapEmbeddable timeRange, filters = [], forceRefresh, + searchSessionId, }: { query?: Query; timeRange?: TimeRange; filters?: Filter[]; forceRefresh: boolean; + searchSessionId?: string; }) { this._prevTimeRange = timeRange; this._prevQuery = query; this._prevFilters = filters; + this._prevSearchSessionId = searchSessionId; this._savedMap.getStore().dispatch<any>( setQuery({ filters: filters.filter((filter) => !filter.meta.disabled), query, timeFilters: timeRange, forceRefresh, + searchSessionId, }) ); } @@ -380,10 +383,11 @@ export class MapEmbeddable reload() { this._dispatchSetQuery({ - query: this._prevQuery, - timeRange: this._prevTimeRange, - filters: this._prevFilters ?? [], + query: this.input.query, + timeRange: this.input.timeRange, + filters: this.input.filters, forceRefresh: true, + searchSessionId: this.input.searchSessionId, }); } diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 99c9311a2a454..56e342a95be51 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -49,6 +49,7 @@ export const getSearchService = () => pluginsStart.data.search; export const getEmbeddableService = () => pluginsStart.embeddable; export const getNavigateToApp = () => coreStart.application.navigateToApp; export const getSavedObjectsTagging = () => pluginsStart.savedObjectsTagging; +export const getPresentationUtilContext = () => pluginsStart.presentationUtil.ContextProvider; // xpack.maps.* kibana.yml settings from this plugin let mapAppConfig: MapsConfigType; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 4173328a41d57..5bd0bd7346ab1 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -55,6 +55,7 @@ import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/public'; import { StartContract as FileUploadStartContract } from '../../maps_file_upload/public'; import { SavedObjectsStart } from '../../../../src/plugins/saved_objects/public'; +import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { getIsEnterprisePlus, registerLicensedFeatures, @@ -86,6 +87,7 @@ export interface MapsPluginStartDependencies { savedObjects: SavedObjectsStart; dashboard: DashboardStart; savedObjectsTagging?: SavedObjectTaggingPluginStart; + presentationUtil: PresentationUtilPluginStart; } /** diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index 273d1de6fddfe..52df65d6d2ecc 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -34,6 +34,7 @@ export type MapContext = { refreshConfig?: MapRefreshConfig; refreshTimerLastTriggeredAt?: string; drawState?: DrawState; + searchSessionId?: string; }; export type MapSettings = { diff --git a/x-pack/plugins/maps/public/reducers/map.js b/x-pack/plugins/maps/public/reducers/map.js index 1395f2c5ce2fe..f068abee48b93 100644 --- a/x-pack/plugins/maps/public/reducers/map.js +++ b/x-pack/plugins/maps/public/reducers/map.js @@ -240,7 +240,7 @@ export function map(state = DEFAULT_MAP_STATE, action) { }; return { ...state, mapState: { ...state.mapState, ...newMapState } }; case SET_QUERY: - const { query, timeFilters, filters } = action; + const { query, timeFilters, filters, searchSessionId } = action; return { ...state, mapState: { @@ -248,6 +248,7 @@ export function map(state = DEFAULT_MAP_STATE, action) { query, timeFilters, filters, + searchSessionId, }, }; case SET_REFRESH_CONFIG: diff --git a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx index 817fbf3656103..c0a378f38fc13 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx @@ -52,7 +52,7 @@ import { unsavedChangesWarning, } from '../saved_map'; -interface Props { +export interface Props { savedMap: SavedMap; // saveCounter used to trigger MapApp render after SaveMap.save saveCounter: number; @@ -83,7 +83,7 @@ interface Props { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } -interface State { +export interface State { initialized: boolean; indexPatterns: IndexPattern[]; savedQuery?: SavedQuery; diff --git a/x-pack/plugins/maps/public/routes/map_page/top_nav_config.tsx b/x-pack/plugins/maps/public/routes/map_page/top_nav_config.tsx index 7010c281d24c6..803b9defe9a24 100644 --- a/x-pack/plugins/maps/public/routes/map_page/top_nav_config.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/top_nav_config.tsx @@ -16,6 +16,7 @@ import { getSavedObjectsClient, getCoreOverlays, getSavedObjectsTagging, + getPresentationUtilContext, } from '../../kibana_services'; import { checkForDuplicateTitle, @@ -185,7 +186,7 @@ export function getTopNavConfig({ defaultMessage: 'map', }), }; - + const PresentationUtilContext = getPresentationUtilContext(); const saveModal = savedMap.getOriginatingApp() || !getIsAllowByValueEmbeddables() ? ( <SavedObjectSaveModalOrigin @@ -195,14 +196,10 @@ export function getTopNavConfig({ options={tagSelector} /> ) : ( - <SavedObjectSaveModalDashboard - {...saveModalProps} - savedObjectsClient={getSavedObjectsClient()} - tagOptions={tagSelector} - /> + <SavedObjectSaveModalDashboard {...saveModalProps} tagOptions={tagSelector} /> ); - showSaveModal(saveModal, getCoreI18n().Context); + showSaveModal(saveModal, getCoreI18n().Context, PresentationUtilContext); }, }); diff --git a/x-pack/plugins/maps/public/routes/map_page/url_state/global_sync.ts b/x-pack/plugins/maps/public/routes/map_page/url_state/global_sync.ts index 7fefc6662ada7..398c05b8ed69a 100644 --- a/x-pack/plugins/maps/public/routes/map_page/url_state/global_sync.ts +++ b/x-pack/plugins/maps/public/routes/map_page/url_state/global_sync.ts @@ -30,6 +30,6 @@ export function updateGlobalState(newState: MapsGlobalState, flushUrlState = fal ...newState, }); if (flushUrlState) { - kbnUrlStateStorage.flush({ replace: true }); + kbnUrlStateStorage.kbnUrlControls.flush(true); } } diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 8876b9536ce92..21ce5993b7c89 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -169,6 +169,9 @@ export const getQuery = ({ map }: MapStoreState): MapQuery | undefined => map.ma export const getFilters = ({ map }: MapStoreState): Filter[] => map.mapState.filters; +export const getSearchSessionId = ({ map }: MapStoreState): string | undefined => + map.mapState.searchSessionId; + export const isUsingSearch = (state: MapStoreState): boolean => { const filters = getFilters(state).filter((filter) => !filter.meta.disabled); const queryString = _.get(getQuery(state), 'query', ''); @@ -220,7 +223,17 @@ export const getDataFilters = createSelector( getRefreshTimerLastTriggeredAt, getQuery, getFilters, - (mapExtent, mapBuffer, mapZoom, timeFilters, refreshTimerLastTriggeredAt, query, filters) => { + getSearchSessionId, + ( + mapExtent, + mapBuffer, + mapZoom, + timeFilters, + refreshTimerLastTriggeredAt, + query, + filters, + searchSessionId + ) => { return { extent: mapExtent, buffer: mapBuffer, @@ -229,6 +242,7 @@ export const getDataFilters = createSelector( refreshTimerLastTriggeredAt, query, filters, + searchSessionId, }; } ); diff --git a/x-pack/plugins/maps/public/url_generator.ts b/x-pack/plugins/maps/public/url_generator.ts index be6a7f5fe6fa7..7f4215f4b1275 100644 --- a/x-pack/plugins/maps/public/url_generator.ts +++ b/x-pack/plugins/maps/public/url_generator.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import rison from 'rison-node'; import { TimeRange, diff --git a/x-pack/plugins/maps/server/sample_data/ecommerce_saved_objects.js b/x-pack/plugins/maps/server/sample_data/ecommerce_saved_objects.js index a3dbf8b1438fa..d1aa044676e00 100644 --- a/x-pack/plugins/maps/server/sample_data/ecommerce_saved_objects.js +++ b/x-pack/plugins/maps/server/sample_data/ecommerce_saved_objects.js @@ -70,6 +70,7 @@ const layerList = [ { leftField: 'iso2', right: { + type: 'ES_TERM_SOURCE', id: '741db9c6-8ebb-4ea9-9885-b6b4ac019d14', indexPatternTitle: 'kibana_sample_data_ecommerce', term: 'geoip.country_iso_code', @@ -134,6 +135,7 @@ const layerList = [ { leftField: 'name', right: { + type: 'ES_TERM_SOURCE', id: '30a0ec24-49b6-476a-b4ed-6c1636333695', indexPatternTitle: 'kibana_sample_data_ecommerce', term: 'geoip.region_name', @@ -198,6 +200,7 @@ const layerList = [ { leftField: 'label_en', right: { + type: 'ES_TERM_SOURCE', id: 'e325c9da-73fa-4b3b-8b59-364b99370826', indexPatternTitle: 'kibana_sample_data_ecommerce', term: 'geoip.region_name', @@ -262,6 +265,7 @@ const layerList = [ { leftField: 'label_en', right: { + type: 'ES_TERM_SOURCE', id: '612d805d-8533-43a9-ac0e-cbf51fe63dcd', indexPatternTitle: 'kibana_sample_data_ecommerce', term: 'geoip.region_name', diff --git a/x-pack/plugins/maps/server/sample_data/web_logs_saved_objects.js b/x-pack/plugins/maps/server/sample_data/web_logs_saved_objects.js index ec445567de21c..010f06e00ca3f 100644 --- a/x-pack/plugins/maps/server/sample_data/web_logs_saved_objects.js +++ b/x-pack/plugins/maps/server/sample_data/web_logs_saved_objects.js @@ -70,6 +70,7 @@ const layerList = [ { leftField: 'iso2', right: { + type: 'ES_TERM_SOURCE', id: '673ff994-fc75-4c67-909b-69fcb0e1060e', indexPatternTitle: 'kibana_sample_data_logs', term: 'geo.src', diff --git a/x-pack/plugins/maps/server/saved_objects/migrations.js b/x-pack/plugins/maps/server/saved_objects/migrations.js index 653f07772ee58..346bc5eff1657 100644 --- a/x-pack/plugins/maps/server/saved_objects/migrations.js +++ b/x-pack/plugins/maps/server/saved_objects/migrations.js @@ -14,6 +14,7 @@ import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_ import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; import { removeBoundsFromSavedObject } from '../../common/migrations/remove_bounds'; import { setDefaultAutoFitToBounds } from '../../common/migrations/set_default_auto_fit_to_bounds'; +import { addTypeToTermJoin } from '../../common/migrations/add_type_to_termjoin'; export const migrations = { map: { @@ -79,6 +80,14 @@ export const migrations = { '7.10.0': (doc) => { const attributes = setDefaultAutoFitToBounds(doc); + return { + ...doc, + attributes, + }; + }, + '7.12.0': (doc) => { + const attributes = addTypeToTermJoin(doc); + return { ...doc, attributes, diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json new file mode 100644 index 0000000000000..b70459c690c07 --- /dev/null +++ b/x-pack/plugins/maps/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "config.ts", + "../../../typings/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/maps_legacy/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../maps_file_upload/tsconfig.json" }, + { "path": "../saved_objects_tagging/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/maps_file_upload/tsconfig.json b/x-pack/plugins/maps_file_upload/tsconfig.json new file mode 100644 index 0000000000000..f068d62b71739 --- /dev/null +++ b/x-pack/plugins/maps_file_upload/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": ["common/**/*", "public/**/*", "server/**/*", "mappings.ts"], + "references": [ + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" } + ] +} diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/tsconfig.json b/x-pack/plugins/maps_legacy_licensing/tsconfig.json similarity index 59% rename from x-pack/plugins/vis_type_timeseries_enhanced/tsconfig.json rename to x-pack/plugins/maps_legacy_licensing/tsconfig.json index c5ec5571917bd..90e8265515a16 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/tsconfig.json +++ b/x-pack/plugins/maps_legacy_licensing/tsconfig.json @@ -7,9 +7,8 @@ "declaration": true, "declarationMap": true }, - "include": ["*.ts", "server/**/*"], + "include": ["public/**/*"], "references": [ - { "path": "../../../src/core/tsconfig.json" }, - { "path": "../../../src/plugins/vis_type_timeseries/tsconfig.json" } + { "path": "../licensing/tsconfig.json" }, ] } diff --git a/x-pack/plugins/ml/common/util/job_utils.ts b/x-pack/plugins/ml/common/util/job_utils.ts index 4f4d9851c4957..d20ad4a368948 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -78,6 +78,18 @@ export function isTimeSeriesViewDetector(job: CombinedJob, detectorIndex: number ); } +// Returns a flag to indicate whether the specified job is suitable for embedded map viewing. +export function isMappableJob(job: CombinedJob, detectorIndex: number): boolean { + let isMappable = false; + const { detectors } = job.analysis_config; + if (detectorIndex >= 0 && detectorIndex < detectors.length) { + const dtr = detectors[detectorIndex]; + const functionName = dtr.function; + isMappable = functionName === ML_JOB_AGGREGATION.LAT_LONG; + } + return isMappable; +} + // Returns a flag to indicate whether the source data can be plotted in a time // series chart for the specified detector. export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex: number): boolean { diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 8ec9b8ee976d4..1c47512e0b3de 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -24,7 +24,8 @@ "security", "spaces", "management", - "licenseManagement" + "licenseManagement", + "maps" ], "server": true, "ui": true, @@ -35,7 +36,8 @@ "dashboard", "savedObjects", "home", - "spaces" + "spaces", + "maps" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/ml/public/application/_index.scss b/x-pack/plugins/ml/public/application/_index.scss index 7512c180970ad..da1c226a665f6 100644 --- a/x-pack/plugins/ml/public/application/_index.scss +++ b/x-pack/plugins/ml/public/application/_index.scss @@ -30,6 +30,7 @@ @import 'components/navigation_menu/index'; @import 'components/rule_editor/index'; // SASSTODO: This file overwrites EUI directly @import 'components/stats_bar/index'; + @import 'components/ml_embedded_map/index'; // Hacks are last so they can overwrite anything above if needed @import 'hacks'; diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index d3a055f957c3a..68ee32e24391c 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -77,6 +77,8 @@ const App: FC<AppProps> = ({ coreStart, deps, appMountParams }) => { security: deps.security, licenseManagement: deps.licenseManagement, storage: localStorage, + embeddable: deps.embeddable, + maps: deps.maps, ...coreStart, }; @@ -118,6 +120,7 @@ export const renderApp = ( http: coreStart.http, security: deps.security, urlGenerators: deps.share.urlGenerators, + maps: deps.maps, }); appMountParams.onAppLeave((actions) => actions.default()); diff --git a/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js index 274a5ff0ffbb4..935f1f7f54df8 100644 --- a/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js +++ b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useEffect } from 'react'; -import { PropTypes } from 'prop-types'; +import React, { Fragment, useState, useEffect, useMemo } from 'react'; import { + htmlIdGenerator, EuiCheckbox, EuiSearchBar, EuiFlexGroup, @@ -25,6 +25,7 @@ import { EuiTableRowCellCheckbox, EuiTableHeaderMobile, } from '@elastic/eui'; +import { PropTypes } from 'prop-types'; import { Pager } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; @@ -179,6 +180,8 @@ export function CustomSelectionTable({ return indexOfUnselectedItem === -1; } + const selectAllCheckboxId = useMemo(() => htmlIdGenerator()(), []); + function renderSelectAll(mobile) { const selectAll = i18n.translate('xpack.ml.jobSelector.customTable.selectAllCheckboxLabel', { defaultMessage: 'Select all', @@ -186,7 +189,7 @@ export function CustomSelectionTable({ return ( <EuiCheckbox - id="selectAllCheckbox" + id={`${mobile ? `mobile-` : ''}${selectAllCheckboxId}`} label={mobile ? selectAll : null} checked={areAllItemsSelected()} onChange={toggleAll} diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/_index.scss b/x-pack/plugins/ml/public/application/components/ml_embedded_map/_index.scss new file mode 100644 index 0000000000000..6d0d30dae670e --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/_index.scss @@ -0,0 +1 @@ +@import 'ml_embedded_map'; diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/_ml_embedded_map.scss b/x-pack/plugins/ml/public/application/components/ml_embedded_map/_ml_embedded_map.scss new file mode 100644 index 0000000000000..495fc40ddb27c --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/_ml_embedded_map.scss @@ -0,0 +1,8 @@ +.mlEmbeddedMapContent { + width: 100%; + height: 100%; + display: flex; + flex: 1 1 100%; + z-index: 1; + min-height: 0; // Absolute must for Firefox to scroll contents +} diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/index.ts b/x-pack/plugins/ml/public/application/components/ml_embedded_map/index.ts new file mode 100644 index 0000000000000..ce5dfb5171a6d --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { MlEmbeddedMapComponent } from './ml_embedded_map'; diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx new file mode 100644 index 0000000000000..12c7d6ac69bb1 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useRef, useState } from 'react'; + +import { htmlIdGenerator } from '@elastic/eui'; +import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { INITIAL_LOCATION } from '../../../../../maps/common/constants'; +import { + MapEmbeddable, + MapEmbeddableInput, + MapEmbeddableOutput, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../maps/public/embeddable'; +import { MAP_SAVED_OBJECT_TYPE, RenderTooltipContentParams } from '../../../../../maps/public'; +import { + EmbeddableFactory, + ErrorEmbeddable, + isErrorEmbeddable, + ViewMode, +} from '../../../../../../../src/plugins/embeddable/public'; +import { useMlKibana } from '../../contexts/kibana'; + +export function MlEmbeddedMapComponent({ + layerList, + mapEmbeddableInput, + renderTooltipContent, +}: { + layerList: LayerDescriptor[]; + mapEmbeddableInput?: MapEmbeddableInput; + renderTooltipContent?: (params: RenderTooltipContentParams) => JSX.Element; +}) { + const [embeddable, setEmbeddable] = useState<ErrorEmbeddable | MapEmbeddable | undefined>(); + + const embeddableRoot: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(null); + const baseLayers = useRef<LayerDescriptor[]>(); + + const { + services: { embeddable: embeddablePlugin, maps: mapsPlugin }, + } = useMlKibana(); + + const factory: + | EmbeddableFactory<MapEmbeddableInput, MapEmbeddableOutput, MapEmbeddable> + | undefined = embeddablePlugin + ? embeddablePlugin.getEmbeddableFactory(MAP_SAVED_OBJECT_TYPE) + : undefined; + + // Update the layer list with updated geo points upon refresh + useEffect(() => { + async function updateIndexPatternSearchLayer() { + if ( + embeddable && + !isErrorEmbeddable(embeddable) && + Array.isArray(layerList) && + Array.isArray(baseLayers.current) + ) { + embeddable.setLayerList([...baseLayers.current, ...layerList]); + } + } + updateIndexPatternSearchLayer(); + }, [embeddable, layerList]); + + useEffect(() => { + async function setupEmbeddable() { + if (!factory) { + // eslint-disable-next-line no-console + console.error('Map embeddable not found.'); + return; + } + const input: MapEmbeddableInput = { + id: htmlIdGenerator()(), + attributes: { title: '' }, + filters: [], + hidePanelTitles: true, + refreshConfig: { + value: 0, + pause: false, + }, + viewMode: ViewMode.VIEW, + isLayerTOCOpen: false, + hideFilterActions: true, + // can use mapSettings to center map on anomalies + mapSettings: { + disableInteractive: false, + hideToolbarOverlay: false, + hideLayerControl: false, + hideViewControl: false, + initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent + autoFitToDataBounds: true, // this will auto-fit when there are changes to the filter and/or query + }, + }; + + const embeddableObject = await factory.create(input); + + if (embeddableObject && !isErrorEmbeddable(embeddableObject)) { + const basemapLayerDescriptor = mapsPlugin + ? await mapsPlugin.createLayerDescriptors.createBasemapLayerDescriptor() + : null; + + if (basemapLayerDescriptor) { + baseLayers.current = [basemapLayerDescriptor]; + await embeddableObject.setLayerList(baseLayers.current); + } + } + + setEmbeddable(embeddableObject); + } + + setupEmbeddable(); + // we want this effect to execute exactly once after the component mounts + }, []); + + useEffect(() => { + if (embeddable && !isErrorEmbeddable(embeddable) && mapEmbeddableInput !== undefined) { + embeddable.updateInput(mapEmbeddableInput); + } + }, [embeddable, mapEmbeddableInput]); + + useEffect(() => { + if (embeddable && !isErrorEmbeddable(embeddable) && renderTooltipContent !== undefined) { + embeddable.setRenderTooltipContent(renderTooltipContent); + } + }, [embeddable, renderTooltipContent]); + + // We can only render after embeddable has already initialized + useEffect(() => { + if (embeddableRoot.current && embeddable) { + embeddable.render(embeddableRoot.current); + } + }, [embeddable, embeddableRoot]); + + if (!embeddablePlugin) { + // eslint-disable-next-line no-console + console.error('Embeddable start plugin not found'); + return null; + } + if (!mapsPlugin) { + // eslint-disable-next-line no-console + console.error('Maps start plugin not found'); + return null; + } + + return ( + <div + data-test-subj="mlEmbeddedMapContent" + className="mlEmbeddedMapContent" + ref={embeddableRoot} + /> + ); +} diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts index 4f564dde8cb43..903fe5b6ed985 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/index.ts @@ -4,5 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { useScatterplotFieldOptions } from './use_scatterplot_field_options'; export { LEGEND_TYPES } from './scatterplot_matrix_vega_lite_spec'; export { ScatterplotMatrix } from './scatterplot_matrix'; +export type { ScatterplotMatrixViewProps as ScatterplotMatrixProps } from './scatterplot_matrix_view'; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx index b1ee9afb17788..a90fe924b91ac 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.tsx @@ -4,316 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useEffect, useState, FC } from 'react'; +import React, { FC, Suspense } from 'react'; -// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. -// @ts-ignore -import { compile } from 'vega-lite/build-es5/vega-lite'; -import { parse, View, Warn } from 'vega'; -import { Handler } from 'vega-tooltip'; +import type { ScatterplotMatrixViewProps } from './scatterplot_matrix_view'; +import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; -import { - htmlIdGenerator, - EuiComboBox, - EuiComboBoxOptionOption, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiLoadingSpinner, - EuiSelect, - EuiSpacer, - EuiSwitch, - EuiText, -} from '@elastic/eui'; +const ScatterplotMatrixLazy = React.lazy(() => import('./scatterplot_matrix_view')); -import { i18n } from '@kbn/i18n'; - -import type { SearchResponse7 } from '../../../../common/types/es_client'; - -import { useMlApiContext } from '../../contexts/kibana'; - -import { getProcessedFields } from '../data_grid'; -import { useCurrentEuiTheme } from '../color_range_legend'; - -import { - getScatterplotMatrixVegaLiteSpec, - LegendType, - OUTLIER_SCORE_FIELD, -} from './scatterplot_matrix_vega_lite_spec'; - -import './scatterplot_matrix.scss'; - -const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; -const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; - -const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { - defaultMessage: 'On', -}); -const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { - defaultMessage: 'Off', -}); - -const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); - -interface ScatterplotMatrixProps { - fields: string[]; - index: string; - resultsField?: string; - color?: string; - legendType?: LegendType; -} - -export const ScatterplotMatrix: FC<ScatterplotMatrixProps> = ({ - fields: allFields, - index, - resultsField, - color, - legendType, -}) => { - const { esSearch } = useMlApiContext(); - - // dynamicSize is optionally used for outlier charts where the scatterplot marks - // are sized according to outlier_score - const [dynamicSize, setDynamicSize] = useState<boolean>(false); - - // used to give the use the option to customize the fields used for the matrix axes - const [fields, setFields] = useState<string[]>([]); - - useEffect(() => { - const defaultFields = - allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS - ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) - : allFields; - setFields(defaultFields); - }, [allFields]); - - // the amount of documents to be fetched - const [fetchSize, setFetchSize] = useState<number>(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); - // flag to add a random score to the ES query to fetch documents - const [randomizeQuery, setRandomizeQuery] = useState<boolean>(false); - - const [isLoading, setIsLoading] = useState<boolean>(false); - - // contains the fetched documents and columns to be passed on to the Vega spec. - const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); - - // formats the array of field names for EuiComboBox - const fieldOptions = useMemo( - () => - allFields.map((d) => ({ - label: d, - })), - [allFields] - ); - - const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { - setFields(newFields.map((d) => d.label)); - }; - - const fetchSizeOnChange = (e: React.ChangeEvent<HTMLSelectElement>) => { - setFetchSize( - Math.min( - Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), - SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE - ) - ); - }; - - const randomizeQueryOnChange = () => { - setRandomizeQuery(!randomizeQuery); - }; - - const dynamicSizeOnChange = () => { - setDynamicSize(!dynamicSize); - }; - - const { euiTheme } = useCurrentEuiTheme(); - - useEffect(() => { - async function fetchSplom(options: { didCancel: boolean }) { - setIsLoading(true); - try { - const queryFields = [ - ...fields, - ...(color !== undefined ? [color] : []), - ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), - ]; - - const query = randomizeQuery - ? { - function_score: { - random_score: { seed: 10, field: '_seq_no' }, - }, - } - : { match_all: {} }; - - const resp: SearchResponse7 = await esSearch({ - index, - body: { - fields: queryFields, - _source: false, - query, - from: 0, - size: fetchSize, - }, - }); - - if (!options.didCancel) { - const items = resp.hits.hits.map((d) => - getProcessedFields(d.fields, (key: string) => - key.startsWith(`${resultsField}.feature_importance`) - ) - ); - - setSplom({ columns: fields, items }); - setIsLoading(false); - } - } catch (e) { - // TODO error handling - setIsLoading(false); - } - } - - const options = { didCancel: false }; - fetchSplom(options); - return () => { - options.didCancel = true; - }; - // stringify the fields array, otherwise the comparator will trigger on new but identical instances. - }, [fetchSize, JSON.stringify(fields), index, randomizeQuery, resultsField]); - - const htmlId = useMemo(() => htmlIdGenerator()(), []); - - useEffect(() => { - if (splom === undefined) { - return; - } - - const { items, columns } = splom; - - const values = - resultsField !== undefined - ? items - : items.map((d) => { - d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; - return d; - }); - - const vegaSpec = getScatterplotMatrixVegaLiteSpec( - values, - columns, - euiTheme, - resultsField, - color, - legendType, - dynamicSize - ); - - const vgSpec = compile(vegaSpec).spec; - - const view = new View(parse(vgSpec)) - .logLevel(Warn) - .renderer('canvas') - .tooltip(new Handler().call) - .initialize(`#${htmlId}`); - - view.runAsync(); // evaluate and render the view - }, [resultsField, splom, color, legendType, dynamicSize]); - - return ( - <> - {splom === undefined ? ( - <EuiText textAlign="center"> - <EuiSpacer size="l" /> - <EuiLoadingSpinner size="l" /> - <EuiSpacer size="l" /> - </EuiText> - ) : ( - <> - <EuiFlexGroup> - <EuiFlexItem> - <EuiFormRow - label={i18n.translate('xpack.ml.splom.fieldSelectionLabel', { - defaultMessage: 'Fields', - })} - display="rowCompressed" - fullWidth - > - <EuiComboBox - compressed - fullWidth - placeholder={i18n.translate('xpack.ml.splom.fieldSelectionPlaceholder', { - defaultMessage: 'Select fields', - })} - options={fieldOptions} - selectedOptions={fields.map((d) => ({ - label: d, - }))} - onChange={fieldsOnChange} - isClearable={true} - data-test-subj="mlScatterplotMatrixFieldsComboBox" - /> - </EuiFormRow> - </EuiFlexItem> - <EuiFlexItem style={{ width: '200px' }} grow={false}> - <EuiFormRow - label={i18n.translate('xpack.ml.splom.SampleSizeLabel', { - defaultMessage: 'Sample size', - })} - display="rowCompressed" - fullWidth - > - <EuiSelect - compressed - options={sampleSizeOptions} - value={fetchSize} - onChange={fetchSizeOnChange} - /> - </EuiFormRow> - </EuiFlexItem> - <EuiFlexItem style={{ width: '120px' }} grow={false}> - <EuiFormRow - label={i18n.translate('xpack.ml.splom.RandomScoringLabel', { - defaultMessage: 'Random scoring', - })} - display="rowCompressed" - fullWidth - > - <EuiSwitch - name="mlScatterplotMatrixRandomizeQuery" - label={randomizeQuery ? TOGGLE_ON : TOGGLE_OFF} - checked={randomizeQuery} - onChange={randomizeQueryOnChange} - disabled={isLoading} - /> - </EuiFormRow> - </EuiFlexItem> - {resultsField !== undefined && legendType === undefined && ( - <EuiFlexItem style={{ width: '120px' }} grow={false}> - <EuiFormRow - label={i18n.translate('xpack.ml.splom.dynamicSizeLabel', { - defaultMessage: 'Dynamic size', - })} - display="rowCompressed" - fullWidth - > - <EuiSwitch - name="mlScatterplotMatrixDynamicSize" - label={dynamicSize ? TOGGLE_ON : TOGGLE_OFF} - checked={dynamicSize} - onChange={dynamicSizeOnChange} - disabled={isLoading} - /> - </EuiFormRow> - </EuiFlexItem> - )} - </EuiFlexGroup> - - <div id={htmlId} className="mlScatterplotMatrix" /> - </> - )} - </> - ); -}; +export const ScatterplotMatrix: FC<ScatterplotMatrixViewProps> = (props) => ( + <Suspense fallback={<ScatterplotMatrixLoading />}> + <ScatterplotMatrixLazy {...props} /> + </Suspense> +); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx new file mode 100644 index 0000000000000..ccd4153769e9c --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_loading.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; + +export const ScatterplotMatrixLoading = () => { + return ( + <EuiText textAlign="center"> + <EuiSpacer size="l" /> + <EuiLoadingSpinner size="l" /> + <EuiSpacer size="l" /> + </EuiText> + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts index dd467161ff489..eada64b7a03ca 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.test.ts @@ -163,6 +163,7 @@ describe('getScatterplotMatrixVegaLiteSpec()', () => { type: 'nominal', }); expect(vegaLiteSpec.spec.encoding.tooltip).toEqual([ + { field: 'the-color-field', type: 'nominal' }, { field: 'x', type: 'quantitative' }, { field: 'y', type: 'quantitative' }, ]); diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts index 9e0834dd8b922..c943e5d1b06e3 100644 --- a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec.ts @@ -35,6 +35,8 @@ export const getColorSpec = ( color?: string, legendType?: LegendType ) => { + // For outlier detection result pages coloring is done based on a threshold. + // This returns a Vega spec using a conditional to return the color. if (outliers) { return { condition: { @@ -45,6 +47,8 @@ export const getColorSpec = ( }; } + // Based on the type of the color field, + // this returns either a continuous or categorical color spec. if (color !== undefined && legendType !== undefined) { return { field: color, @@ -80,6 +84,8 @@ export const getScatterplotMatrixVegaLiteSpec = ( }); } + const colorSpec = getColorSpec(euiTheme, outliers, color, legendType); + return { $schema: 'https://vega.github.io/schema/vega-lite/v4.17.0.json', background: 'transparent', @@ -115,10 +121,10 @@ export const getScatterplotMatrixVegaLiteSpec = ( : { type: 'circle', opacity: 0.75, size: 8 }), }, encoding: { - color: getColorSpec(euiTheme, outliers, color, legendType), + color: colorSpec, ...(dynamicSize ? { - stroke: getColorSpec(euiTheme, outliers, color, legendType), + stroke: colorSpec, opacity: { condition: { value: 1, @@ -163,6 +169,7 @@ export const getScatterplotMatrixVegaLiteSpec = ( scale: { zero: false }, }, tooltip: [ + ...(color !== undefined ? [{ type: colorSpec.type, field: color }] : []), ...columns.map((d) => ({ type: LEGEND_TYPES.QUANTITATIVE, field: d })), ...(outliers ? [{ type: LEGEND_TYPES.QUANTITATIVE, field: OUTLIER_SCORE_FIELD, format: '.3f' }] diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss similarity index 100% rename from x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix.scss rename to x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.scss diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx new file mode 100644 index 0000000000000..0c065c1154a98 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/scatterplot_matrix_view.tsx @@ -0,0 +1,323 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo, useEffect, useState, FC } from 'react'; + +// There is still an issue with Vega Lite's typings with the strict mode Kibana is using. +// @ts-ignore +import { compile } from 'vega-lite/build-es5/vega-lite'; +import { parse, View, Warn } from 'vega'; +import { Handler } from 'vega-tooltip'; + +import { + htmlIdGenerator, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSwitch, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import type { SearchResponse7 } from '../../../../common/types/es_client'; +import type { ResultsSearchQuery } from '../../data_frame_analytics/common/analytics'; + +import { useMlApiContext } from '../../contexts/kibana'; + +import { getProcessedFields } from '../data_grid'; +import { useCurrentEuiTheme } from '../color_range_legend'; + +import { ScatterplotMatrixLoading } from './scatterplot_matrix_loading'; + +import { + getScatterplotMatrixVegaLiteSpec, + LegendType, + OUTLIER_SCORE_FIELD, +} from './scatterplot_matrix_vega_lite_spec'; + +import './scatterplot_matrix_view.scss'; + +const SCATTERPLOT_MATRIX_DEFAULT_FIELDS = 4; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE = 1000; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE = 1; +const SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE = 10000; + +const TOGGLE_ON = i18n.translate('xpack.ml.splom.toggleOn', { + defaultMessage: 'On', +}); +const TOGGLE_OFF = i18n.translate('xpack.ml.splom.toggleOff', { + defaultMessage: 'Off', +}); + +const sampleSizeOptions = [100, 1000, 10000].map((d) => ({ value: d, text: '' + d })); + +export interface ScatterplotMatrixViewProps { + fields: string[]; + index: string; + resultsField?: string; + color?: string; + legendType?: LegendType; + searchQuery?: ResultsSearchQuery; +} + +export const ScatterplotMatrixView: FC<ScatterplotMatrixViewProps> = ({ + fields: allFields, + index, + resultsField, + color, + legendType, + searchQuery, +}) => { + const { esSearch } = useMlApiContext(); + + // dynamicSize is optionally used for outlier charts where the scatterplot marks + // are sized according to outlier_score + const [dynamicSize, setDynamicSize] = useState<boolean>(false); + + // used to give the use the option to customize the fields used for the matrix axes + const [fields, setFields] = useState<string[]>([]); + + useEffect(() => { + const defaultFields = + allFields.length > SCATTERPLOT_MATRIX_DEFAULT_FIELDS + ? allFields.slice(0, SCATTERPLOT_MATRIX_DEFAULT_FIELDS) + : allFields; + setFields(defaultFields); + }, [allFields]); + + // the amount of documents to be fetched + const [fetchSize, setFetchSize] = useState<number>(SCATTERPLOT_MATRIX_DEFAULT_FETCH_SIZE); + // flag to add a random score to the ES query to fetch documents + const [randomizeQuery, setRandomizeQuery] = useState<boolean>(false); + + const [isLoading, setIsLoading] = useState<boolean>(false); + + // contains the fetched documents and columns to be passed on to the Vega spec. + const [splom, setSplom] = useState<{ items: any[]; columns: string[] } | undefined>(); + + // formats the array of field names for EuiComboBox + const fieldOptions = useMemo( + () => + allFields.map((d) => ({ + label: d, + })), + [allFields] + ); + + const fieldsOnChange = (newFields: EuiComboBoxOptionOption[]) => { + setFields(newFields.map((d) => d.label)); + }; + + const fetchSizeOnChange = (e: React.ChangeEvent<HTMLSelectElement>) => { + setFetchSize( + Math.min( + Math.max(parseInt(e.target.value, 10), SCATTERPLOT_MATRIX_DEFAULT_FETCH_MIN_SIZE), + SCATTERPLOT_MATRIX_DEFAULT_FETCH_MAX_SIZE + ) + ); + }; + + const randomizeQueryOnChange = () => { + setRandomizeQuery(!randomizeQuery); + }; + + const dynamicSizeOnChange = () => { + setDynamicSize(!dynamicSize); + }; + + const { euiTheme } = useCurrentEuiTheme(); + + useEffect(() => { + async function fetchSplom(options: { didCancel: boolean }) { + setIsLoading(true); + try { + const queryFields = [ + ...fields, + ...(color !== undefined ? [color] : []), + ...(legendType !== undefined ? [] : [`${resultsField}.${OUTLIER_SCORE_FIELD}`]), + ]; + + const queryFallback = searchQuery !== undefined ? searchQuery : { match_all: {} }; + const query = randomizeQuery + ? { + function_score: { + query: queryFallback, + random_score: { seed: 10, field: '_seq_no' }, + }, + } + : queryFallback; + + const resp: SearchResponse7 = await esSearch({ + index, + body: { + fields: queryFields, + _source: false, + query, + from: 0, + size: fetchSize, + }, + }); + + if (!options.didCancel) { + const items = resp.hits.hits.map((d) => + getProcessedFields(d.fields, (key: string) => + key.startsWith(`${resultsField}.feature_importance`) + ) + ); + + setSplom({ columns: fields, items }); + setIsLoading(false); + } + } catch (e) { + // TODO error handling + setIsLoading(false); + } + } + + const options = { didCancel: false }; + fetchSplom(options); + return () => { + options.didCancel = true; + }; + // stringify the fields array and search, otherwise the comparator will trigger on new but identical instances. + }, [fetchSize, JSON.stringify({ fields, searchQuery }), index, randomizeQuery, resultsField]); + + const htmlId = useMemo(() => htmlIdGenerator()(), []); + + useEffect(() => { + if (splom === undefined) { + return; + } + + const { items, columns } = splom; + + const values = + resultsField !== undefined + ? items + : items.map((d) => { + d[`${resultsField}.${OUTLIER_SCORE_FIELD}`] = 0; + return d; + }); + + const vegaSpec = getScatterplotMatrixVegaLiteSpec( + values, + columns, + euiTheme, + resultsField, + color, + legendType, + dynamicSize + ); + + const vgSpec = compile(vegaSpec).spec; + + const view = new View(parse(vgSpec)) + .logLevel(Warn) + .renderer('canvas') + .tooltip(new Handler().call) + .initialize(`#${htmlId}`); + + view.runAsync(); // evaluate and render the view + }, [resultsField, splom, color, legendType, dynamicSize]); + + return ( + <> + {splom === undefined ? ( + <ScatterplotMatrixLoading /> + ) : ( + <> + <EuiFlexGroup> + <EuiFlexItem> + <EuiFormRow + label={i18n.translate('xpack.ml.splom.fieldSelectionLabel', { + defaultMessage: 'Fields', + })} + display="rowCompressed" + fullWidth + > + <EuiComboBox + compressed + fullWidth + placeholder={i18n.translate('xpack.ml.splom.fieldSelectionPlaceholder', { + defaultMessage: 'Select fields', + })} + options={fieldOptions} + selectedOptions={fields.map((d) => ({ + label: d, + }))} + onChange={fieldsOnChange} + isClearable={true} + data-test-subj="mlScatterplotMatrixFieldsComboBox" + /> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem style={{ width: '200px' }} grow={false}> + <EuiFormRow + label={i18n.translate('xpack.ml.splom.sampleSizeLabel', { + defaultMessage: 'Sample size', + })} + display="rowCompressed" + fullWidth + > + <EuiSelect + compressed + options={sampleSizeOptions} + value={fetchSize} + onChange={fetchSizeOnChange} + /> + </EuiFormRow> + </EuiFlexItem> + <EuiFlexItem style={{ width: '120px' }} grow={false}> + <EuiFormRow + label={i18n.translate('xpack.ml.splom.randomScoringLabel', { + defaultMessage: 'Random scoring', + })} + display="rowCompressed" + fullWidth + > + <EuiSwitch + name="mlScatterplotMatrixRandomizeQuery" + label={randomizeQuery ? TOGGLE_ON : TOGGLE_OFF} + checked={randomizeQuery} + onChange={randomizeQueryOnChange} + disabled={isLoading} + /> + </EuiFormRow> + </EuiFlexItem> + {resultsField !== undefined && legendType === undefined && ( + <EuiFlexItem style={{ width: '120px' }} grow={false}> + <EuiFormRow + label={i18n.translate('xpack.ml.splom.dynamicSizeLabel', { + defaultMessage: 'Dynamic size', + })} + display="rowCompressed" + fullWidth + > + <EuiSwitch + name="mlScatterplotMatrixDynamicSize" + label={dynamicSize ? TOGGLE_ON : TOGGLE_OFF} + checked={dynamicSize} + onChange={dynamicSizeOnChange} + disabled={isLoading} + /> + </EuiFormRow> + </EuiFlexItem> + )} + </EuiFlexGroup> + + <div id={htmlId} className="mlScatterplotMatrix" data-test-subj="mlScatterplotMatrix" /> + </> + )} + </> + ); +}; + +// required for dynamic import using React.lazy() +// eslint-disable-next-line import/no-default-export +export default ScatterplotMatrixView; diff --git a/x-pack/plugins/ml/public/application/components/scatterplot_matrix/use_scatterplot_field_options.ts b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/use_scatterplot_field_options.ts new file mode 100644 index 0000000000000..f5eedbc03951f --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/scatterplot_matrix/use_scatterplot_field_options.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo } from 'react'; + +import type { IndexPattern } from '../../../../../../../src/plugins/data/public'; + +import { ML__INCREMENTAL_ID } from '../../data_frame_analytics/common/fields'; + +export const useScatterplotFieldOptions = ( + indexPattern?: IndexPattern, + includes?: string[], + excludes?: string[], + resultsField = '' +): string[] => { + return useMemo(() => { + const fields: string[] = []; + + if (indexPattern === undefined || includes === undefined) { + return fields; + } + + if (includes.length > 1) { + fields.push( + ...includes.filter((d) => + indexPattern.fields.some((f) => f.name === d && f.type === 'number') + ) + ); + } else { + fields.push( + ...indexPattern.fields + .filter( + (f) => + f.type === 'number' && + !indexPattern.metaFields.includes(f.name) && + !f.name.startsWith(`${resultsField}.`) && + f.name !== ML__INCREMENTAL_ID + ) + .map((f) => f.name) + ); + } + + return Array.isArray(excludes) && excludes.length > 0 + ? fields.filter((f) => !excludes.includes(f)) + : fields; + }, [indexPattern, includes, excludes]); +}; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 4a7550788db56..a97fd513c9c99 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -15,12 +15,16 @@ import { LicenseManagementUIPluginSetup } from '../../../../../license_managemen import { SharePluginStart } from '../../../../../../../src/plugins/share/public'; import { MlServicesContext } from '../../app'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; +import type { EmbeddableStart } from '../../../../../../../src/plugins/embeddable/public'; +import { MapsStartApi } from '../../../../../maps/public'; interface StartPlugins { data: DataPublicPluginStart; security?: SecurityPluginSetup; licenseManagement?: LicenseManagementUIPluginSetup; share: SharePluginStart; + embeddable: EmbeddableStart; + maps?: MapsStartApi; } export type StartServices = CoreStart & StartPlugins & { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts new file mode 100644 index 0000000000000..8850d42577bd9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/get_scatterplot_matrix_legend_type.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ANALYSIS_CONFIG_TYPE } from './analytics'; + +import { AnalyticsJobType } from '../pages/analytics_management/hooks/use_create_analytics_form/state'; + +import { LEGEND_TYPES } from '../../components/scatterplot_matrix/scatterplot_matrix_vega_lite_spec'; + +export const getScatterplotMatrixLegendType = (jobType: AnalyticsJobType | 'unknown') => { + switch (jobType) { + case ANALYSIS_CONFIG_TYPE.CLASSIFICATION: + return LEGEND_TYPES.NOMINAL; + case ANALYSIS_CONFIG_TYPE.REGRESSION: + return LEGEND_TYPES.QUANTITATIVE; + default: + return undefined; + } +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 7ba3e910ddd32..d03f73ad13575 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -41,6 +41,7 @@ export { export { getIndexData } from './get_index_data'; export { getIndexFields } from './get_index_fields'; +export { getScatterplotMatrixLegendType } from './get_scatterplot_matrix_legend_type'; export { useResultsViewConfig } from './use_results_view_config'; export { DataFrameAnalyticsConfig } from '../../../../common/types/data_frame_analytics'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts index 185513f75a12c..361a1262a59f8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/use_results_view_config.ts @@ -102,6 +102,12 @@ export const useResultsViewConfig = (jobId: string) => { try { indexP = await mlContext.indexPatterns.get(destIndexPatternId); + + // Force refreshing the fields list here because a user directly coming + // from the job creation wizard might land on the page without the + // index pattern being fully initialized because it was created + // before the analytics job populated the destination index. + await mlContext.indexPatterns.refreshFields(indexP); } catch (e) { indexP = undefined; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx index a5991f77e88e2..4b86f5ca12896 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -27,10 +27,10 @@ import { TRAINING_PERCENT_MAX, FieldSelectionItem, } from '../../../../common/analytics'; +import { getScatterplotMatrixLegendType } from '../../../../common/get_scatterplot_matrix_legend_type'; import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; import { Messages } from '../shared'; import { - AnalyticsJobType, DEFAULT_MODEL_MEMORY_LIMIT, State, } from '../../../analytics_management/hooks/use_create_analytics_form/state'; @@ -51,18 +51,7 @@ import { SEARCH_QUERY_LANGUAGE } from '../../../../../../../common/constants/sea import { ExplorationQueryBarProps } from '../../../analytics_exploration/components/exploration_query_bar/exploration_query_bar'; import { Query } from '../../../../../../../../../../src/plugins/data/common/query'; -import { LEGEND_TYPES, ScatterplotMatrix } from '../../../../../components/scatterplot_matrix'; - -const getScatterplotMatrixLegendType = (jobType: AnalyticsJobType) => { - switch (jobType) { - case ANALYSIS_CONFIG_TYPE.CLASSIFICATION: - return LEGEND_TYPES.NOMINAL; - case ANALYSIS_CONFIG_TYPE.REGRESSION: - return LEGEND_TYPES.QUANTITATIVE; - default: - return undefined; - } -}; +import { ScatterplotMatrix } from '../../../../../components/scatterplot_matrix'; const requiredFieldsErrorText = i18n.translate( 'xpack.ml.dataframe.analytics.createWizard.requiredFieldsErrorMessage', @@ -498,6 +487,7 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({ : undefined } legendType={getScatterplotMatrixLegendType(jobType)} + searchQuery={jobConfigQuery} /> </EuiPanel> <EuiSpacer /> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx index a35a314bec985..0be9e00b70f93 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useRef } from 'react'; - +import React, { FC, Fragment, useEffect, useMemo, useRef } from 'react'; +import { debounce } from 'lodash'; import { EuiCallOut, EuiCodeEditor, @@ -22,6 +22,9 @@ import { XJsonMode } from '../../../../../../../shared_imports'; const xJsonMode = new XJsonMode(); +import { useNotifications } from '../../../../../contexts/kibana'; +import { ml } from '../../../../../services/ml_api_service'; +import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; import { CreateStep } from '../create_step'; import { ANALYTICS_STEPS } from '../../page'; @@ -42,11 +45,33 @@ export const CreateAnalyticsAdvancedEditor: FC<CreateAnalyticsFormProps> = (prop } = state.form; const forceInput = useRef<HTMLInputElement | null>(null); + const { toasts } = useNotifications(); const onChange = (str: string) => { setAdvancedEditorRawString(str); }; + const debouncedJobIdCheck = useMemo( + () => + debounce(async () => { + try { + const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true); + setFormState({ jobIdExists: results[jobId] }); + } catch (e) { + toasts.addDanger( + i18n.translate( + 'xpack.ml.dataframe.analytics.create.advancedEditor.errorCheckingJobIdExists', + { + defaultMessage: 'The following error occurred checking if job id exists: {error}', + values: { error: extractErrorMessage(e) }, + } + ) + ); + } + }, 400), + [jobId] + ); + // Temp effect to close the context menu popover on Clone button click useEffect(() => { if (forceInput.current === null) { @@ -57,6 +82,18 @@ export const CreateAnalyticsAdvancedEditor: FC<CreateAnalyticsFormProps> = (prop forceInput.current.dispatchEvent(evt); }, []); + useEffect(() => { + if (jobIdValid === true) { + debouncedJobIdCheck(); + } else if (typeof jobId === 'string' && jobId.trim() === '' && jobIdExists === true) { + setFormState({ jobIdExists: false }); + } + + return () => { + debouncedJobIdCheck.cancel(); + }; + }, [jobId]); + return ( <EuiForm className="mlDataFrameAnalyticsCreateForm"> <EuiFormRow diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx index 448dcd8b2e1ba..872580f47d0b7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useRef, useEffect, useState } from 'react'; +import React, { FC, Fragment, useRef, useEffect, useMemo, useState } from 'react'; import { debounce } from 'lodash'; import { EuiFieldText, @@ -94,6 +94,36 @@ export const DetailsStepForm: FC<CreateAnalyticsStepProps> = ({ } }, 400); + const debouncedJobIdCheck = useMemo( + () => + debounce(async () => { + try { + const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true); + setFormState({ jobIdExists: results[jobId] }); + } catch (e) { + notifications.toasts.addDanger( + i18n.translate('xpack.ml.dataframe.analytics.create.errorCheckingJobIdExists', { + defaultMessage: 'The following error occurred checking if job id exists: {error}', + values: { error: extractErrorMessage(e) }, + }) + ); + } + }, 400), + [jobId] + ); + + useEffect(() => { + if (jobIdValid === true) { + debouncedJobIdCheck(); + } else if (typeof jobId === 'string' && jobId.trim() === '' && jobIdExists === true) { + setFormState({ jobIdExists: false }); + } + + return () => { + debouncedJobIdCheck.cancel(); + }; + }, [jobId]); + useEffect(() => { if (destinationIndexNameValid === true) { debouncedIndexCheck(); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx index 5ec8963e0fc25..8c51c95d7fd63 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/expandable_section/expandable_section_splom.tsx @@ -12,17 +12,14 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import { ScatterplotMatrix } from '../../../../../components/scatterplot_matrix'; +import { + ScatterplotMatrix, + ScatterplotMatrixProps, +} from '../../../../../components/scatterplot_matrix'; import { ExpandableSection } from './expandable_section'; -interface ExpandableSectionSplomProps { - fields: string[]; - index: string; - resultsField?: string; -} - -export const ExpandableSectionSplom: FC<ExpandableSectionSplomProps> = (props) => { +export const ExpandableSectionSplom: FC<ScatterplotMatrixProps> = (props) => { const splomSectionHeaderItems = undefined; const splomSectionContent = ( <> diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx index 1329644322f33..46715af0ef0cb 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_page_wrapper/exploration_page_wrapper.tsx @@ -9,16 +9,21 @@ import React, { FC, useCallback, useState } from 'react'; import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { getAnalysisType, getDependentVar } from '../../../../../../../common/util/analytics_utils'; + +import { useScatterplotFieldOptions } from '../../../../../components/scatterplot_matrix'; + import { defaultSearchQuery, + getScatterplotMatrixLegendType, useResultsViewConfig, DataFrameAnalyticsConfig, } from '../../../../common'; -import { ResultsSearchQuery } from '../../../../common/analytics'; +import { ResultsSearchQuery, ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; import { DataFrameTaskStateType } from '../../../analytics_management/components/analytics_list/common'; -import { ExpandableSectionAnalytics } from '../expandable_section'; +import { ExpandableSectionAnalytics, ExpandableSectionSplom } from '../expandable_section'; import { ExplorationResultsTable } from '../exploration_results_table'; import { ExplorationQueryBar } from '../exploration_query_bar'; import { JobConfigErrorCallout } from '../job_config_error_callout'; @@ -99,6 +104,14 @@ export const ExplorationPageWrapper: FC<Props> = ({ language: pageUrlState.queryLanguage, }; + const resultsField = jobConfig?.dest.results_field ?? ''; + const scatterplotFieldOptions = useScatterplotFieldOptions( + indexPattern, + jobConfig?.analyzed_fields.includes, + jobConfig?.analyzed_fields.excludes, + resultsField + ); + if (indexPatternErrorMessage !== undefined) { return ( <EuiPanel grow={false}> @@ -125,6 +138,9 @@ export const ExplorationPageWrapper: FC<Props> = ({ ); } + const jobType = + jobConfig && jobConfig.analysis ? getAnalysisType(jobConfig?.analysis) : undefined; + return ( <> {typeof jobConfig?.description !== 'undefined' && ( @@ -179,6 +195,27 @@ export const ExplorationPageWrapper: FC<Props> = ({ <EvaluatePanel jobConfig={jobConfig} jobStatus={jobStatus} searchQuery={searchQuery} /> )} + {isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />} + {isLoadingJobConfig === false && + jobConfig !== undefined && + isInitialized === true && + typeof jobConfig?.id === 'string' && + scatterplotFieldOptions.length > 1 && + typeof jobConfig?.analysis !== 'undefined' && ( + <ExpandableSectionSplom + fields={scatterplotFieldOptions} + index={jobConfig?.dest.index} + color={ + jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || + jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION + ? getDependentVar(jobConfig.analysis) + : undefined + } + legendType={getScatterplotMatrixLegendType(jobType)} + searchQuery={searchQuery} + /> + )} + {isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />} {isLoadingJobConfig === false && jobConfig !== undefined && diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx index 26eee9bc95d73..7e11e0bd97015 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/outlier_exploration.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, FC, useCallback } from 'react'; +import React, { useCallback, useState, FC } from 'react'; import { EuiCallOut, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; @@ -15,6 +15,7 @@ import { COLOR_RANGE, COLOR_RANGE_SCALE, } from '../../../../../components/color_range_legend'; +import { useScatterplotFieldOptions } from '../../../../../components/scatterplot_matrix'; import { SavedSearchQuery } from '../../../../../contexts/ml'; import { defaultSearchQuery, isOutlierAnalysis, useResultsViewConfig } from '../../../../common'; @@ -90,6 +91,13 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) = (d) => d.id === `${resultsField}.${FEATURE_INFLUENCE}.feature_name` ) === -1; + const scatterplotFieldOptions = useScatterplotFieldOptions( + indexPattern, + jobConfig?.analyzed_fields.includes, + jobConfig?.analyzed_fields.excludes, + resultsField + ); + if (indexPatternErrorMessage !== undefined) { return ( <EuiPanel grow={false}> @@ -126,11 +134,12 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) = </> )} {typeof jobConfig?.id === 'string' && <ExpandableSectionAnalytics jobId={jobConfig?.id} />} - {typeof jobConfig?.id === 'string' && jobConfig?.analyzed_fields.includes.length > 1 && ( + {typeof jobConfig?.id === 'string' && scatterplotFieldOptions.length > 1 && ( <ExpandableSectionSplom - fields={jobConfig?.analyzed_fields.includes} + fields={scatterplotFieldOptions} index={jobConfig?.dest.index} resultsField={jobConfig?.dest.results_field} + searchQuery={searchQuery} /> )} {showLegacyFeatureInfluenceFormatCallout && ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts index a277ae6e6a66e..998460d75f6f0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/reducer.ts @@ -499,7 +499,6 @@ export function reducer(state: State, action: Action): State { } if (action.payload.jobId !== undefined) { - newFormState.jobIdExists = state.jobIds.some((id) => newFormState.jobId === id); newFormState.jobIdEmpty = newFormState.jobId === ''; newFormState.jobIdValid = isJobIdValid(newFormState.jobId); newFormState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)( @@ -542,12 +541,6 @@ export function reducer(state: State, action: Action): State { case ACTION.SET_JOB_CONFIG: return validateAdvancedEditor({ ...state, jobConfig: action.payload }); - case ACTION.SET_JOB_IDS: { - const newState = { ...state, jobIds: action.jobIds }; - newState.form.jobIdExists = newState.jobIds.some((id) => newState.form.jobId === id); - return newState; - } - case ACTION.SWITCH_TO_ADVANCED_EDITOR: const jobConfig = getJobConfigFromFormState(state.form); const shouldDisableSwitchToForm = isAdvancedConfig(jobConfig); @@ -562,7 +555,7 @@ export function reducer(state: State, action: Action): State { }); case ACTION.SWITCH_TO_FORM: - const { jobConfig: config, jobIds } = state; + const { jobConfig: config } = state; const { jobId } = state.form; // @ts-ignore const formState = getFormStateFromJobConfig(config, false); @@ -571,7 +564,6 @@ export function reducer(state: State, action: Action): State { formState.jobId = jobId; } - formState.jobIdExists = jobIds.some((id) => formState.jobId === id); formState.jobIdEmpty = jobId === ''; formState.jobIdValid = isJobIdValid(jobId); formState.jobIdInvalidMaxLength = !!maxLengthValidator(JOB_ID_MAX_LENGTH)(jobId); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 0b88f52e555c0..f5bfd3075f26b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -14,11 +14,7 @@ import { ml } from '../../../../../services/ml_api_service'; import { useMlContext } from '../../../../../contexts/ml'; import { DuplicateIndexPatternError } from '../../../../../../../../../../src/plugins/data/public'; -import { - useRefreshAnalyticsList, - DataFrameAnalyticsId, - DataFrameAnalyticsConfig, -} from '../../../../common'; +import { useRefreshAnalyticsList, DataFrameAnalyticsConfig } from '../../../../common'; import { extractCloningConfig, isAdvancedConfig } from '../../components/action_clone'; import { ActionDispatchers, ACTION } from './actions'; @@ -80,9 +76,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.SET_IS_JOB_STARTED, isJobStarted }); }; - const setJobIds = (jobIds: DataFrameAnalyticsId[]) => - dispatch({ type: ACTION.SET_JOB_IDS, jobIds }); - const resetRequestMessages = () => dispatch({ type: ACTION.RESET_REQUEST_MESSAGES }); const resetForm = () => dispatch({ type: ACTION.RESET_FORM }); @@ -180,25 +173,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { }; const prepareFormValidation = async () => { - // re-fetch existing analytics job IDs and indices for form validation - try { - setJobIds( - (await ml.dataFrameAnalytics.getDataFrameAnalytics()).data_frame_analytics.map( - (job: DataFrameAnalyticsConfig) => job.id - ) - ); - } catch (e) { - addRequestMessage({ - error: extractErrorMessage(e), - message: i18n.translate( - 'xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList', - { - defaultMessage: 'An error occurred getting the existing data frame analytics job IDs:', - } - ), - }); - } - try { // Set the existing index pattern titles. const indexPatternsMap: SourceIndexMap = {}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx index 77f31ae9c2322..a8d8eb18776ec 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { BooleanContent, DateContent, - GeoPointContent, IpContent, KeywordContent, OtherContent, TextContent, NumberContent, } from '../../../stats_table/components/field_data_expanded_row'; +import { GeoPointContent } from './geo_point_content/geo_point_content'; import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; import type { FileBasedFieldVisConfig } from '../../../stats_table/types/field_vis_config'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts new file mode 100644 index 0000000000000..8a442e345014d --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Feature, Point } from 'geojson'; +import { euiPaletteColorBlind } from '@elastic/eui'; +import { DEFAULT_GEO_REGEX } from './geo_point_content'; +import { SOURCE_TYPES } from '../../../../../../../../maps/common/constants'; + +export const convertWKTGeoToLonLat = ( + value: string | number +): { lat: number; lon: number } | undefined => { + if (typeof value === 'string') { + const trimmedValue = value.trim().replace('POINT (', '').replace(')', ''); + const regExpSerializer = DEFAULT_GEO_REGEX; + const parsed = regExpSerializer.exec(trimmedValue.trim()); + + if (parsed?.groups?.lat != null && parsed?.groups?.lon != null) { + return { + lat: parseFloat(parsed.groups.lat.trim()), + lon: parseFloat(parsed.groups.lon.trim()), + }; + } + } +}; + +export const DEFAULT_POINT_COLOR = euiPaletteColorBlind()[0]; +export const getGeoPointsLayer = ( + features: Array<Feature<Point>>, + pointColor: string = DEFAULT_POINT_COLOR +) => { + return { + id: 'geo_points', + label: 'Geo points', + sourceDescriptor: { + type: SOURCE_TYPES.GEOJSON_FILE, + __featureCollection: { + features, + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: 'STATIC', + options: { + color: pointColor, + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx new file mode 100644 index 0000000000000..a498b786229bb --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useMemo } from 'react'; + +import { EuiFlexItem } from '@elastic/eui'; +import { Feature, Point } from 'geojson'; +import type { FieldDataRowProps } from '../../../../stats_table/types/field_data_row'; +import { DocumentStatsTable } from '../../../../stats_table/components/field_data_expanded_row/document_stats'; +import { MlEmbeddedMapComponent } from '../../../../../components/ml_embedded_map'; +import { convertWKTGeoToLonLat, getGeoPointsLayer } from './format_utils'; +import { ExpandedRowContent } from '../../../../stats_table/components/field_data_expanded_row/expanded_row_content'; +import { ExamplesList } from '../../../../index_based/components/field_data_row/examples_list'; + +export const DEFAULT_GEO_REGEX = RegExp('(?<lat>.+) (?<lon>.+)'); + +export const GeoPointContent: FC<FieldDataRowProps> = ({ config }) => { + const formattedResults = useMemo(() => { + const { stats } = config; + + if (stats === undefined || stats.topValues === undefined) return null; + if (Array.isArray(stats.topValues)) { + const geoPointsFeatures: Array<Feature<Point>> = []; + + // reformatting the top values from POINT (-2.359207 51.37837) to (-2.359207, 51.37837) + const formattedExamples = []; + + for (let i = 0; i < stats.topValues.length; i++) { + const value = stats.topValues[i]; + const coordinates = convertWKTGeoToLonLat(value.key); + if (coordinates) { + const formattedGeoPoint = `(${coordinates.lat}, ${coordinates.lon})`; + formattedExamples.push(coordinates); + + geoPointsFeatures.push({ + type: 'Feature', + id: `ml-${config.fieldName}-${i}`, + geometry: { + type: 'Point', + coordinates: [coordinates.lat, coordinates.lon], + }, + properties: { + value: formattedGeoPoint, + count: value.doc_count, + }, + }); + } + } + + if (geoPointsFeatures.length > 0) { + return { + examples: formattedExamples, + layerList: [getGeoPointsLayer(geoPointsFeatures)], + }; + } + } + }, [config]); + return ( + <ExpandedRowContent dataTestSubj={'mlDVGeoPointContent'}> + <DocumentStatsTable config={config} /> + {formattedResults && Array.isArray(formattedResults.examples) && ( + <EuiFlexItem> + <ExamplesList examples={formattedResults.examples} /> + </EuiFlexItem> + )} + {formattedResults && Array.isArray(formattedResults.layerList) && ( + <EuiFlexItem + className={'mlDataVisualizerMapWrapper'} + data-test-subj={'mlDataVisualizerEmbeddedMap'} + > + <MlEmbeddedMapComponent layerList={formattedResults.layerList} /> + </EuiFlexItem> + )} + </ExpandedRowContent> + ); +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/index.ts new file mode 100644 index 0000000000000..d3a50db45ec57 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { GeoPointContent } from './geo_point_content'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/_index.scss b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/_index.scss index 957ca0eec24d3..c1191ad270e4c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/_index.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/_index.scss @@ -1 +1 @@ -@import 'file_datavisualizer_view' +@import 'file_datavisualizer_view'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx index 7018f73ff6c32..6d58efee36f36 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx @@ -6,23 +6,31 @@ import React from 'react'; +import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; +import { LoadingIndicator } from '../field_data_row/loading_indicator'; +import { NotInDocsContent } from '../field_data_row/content_types'; import { FieldVisConfig } from '../../../stats_table/types'; import { BooleanContent, DateContent, - GeoPointContent, IpContent, KeywordContent, NumberContent, OtherContent, TextContent, } from '../../../stats_table/components/field_data_expanded_row'; +import { CombinedQuery, GeoPointContent } from './geo_point_content'; +import { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; -import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; -import { LoadingIndicator } from '../field_data_row/loading_indicator'; -import { NotInDocsContent } from '../field_data_row/content_types'; - -export const IndexBasedDataVisualizerExpandedRow = ({ item }: { item: FieldVisConfig }) => { +export const IndexBasedDataVisualizerExpandedRow = ({ + item, + indexPattern, + combinedQuery, +}: { + item: FieldVisConfig; + indexPattern: IndexPattern | undefined; + combinedQuery: CombinedQuery; +}) => { const config = item; const { loading, type, existsInDocs, fieldName } = config; @@ -42,7 +50,13 @@ export const IndexBasedDataVisualizerExpandedRow = ({ item }: { item: FieldVisCo return <DateContent config={config} />; case ML_JOB_FIELD_TYPES.GEO_POINT: - return <GeoPointContent config={config} />; + return ( + <GeoPointContent + config={config} + indexPattern={indexPattern} + combinedQuery={combinedQuery} + /> + ); case ML_JOB_FIELD_TYPES.IP: return <IpContent config={config} />; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx new file mode 100644 index 0000000000000..94db5d1ba686c --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useEffect, useState } from 'react'; + +import { EuiFlexItem } from '@elastic/eui'; +import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; +import { FieldVisConfig } from '../../../stats_table/types'; +import { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import { MlEmbeddedMapComponent } from '../../../../components/ml_embedded_map'; +import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; +import { ES_GEO_FIELD_TYPE } from '../../../../../../../maps/common/constants'; +import { LayerDescriptor } from '../../../../../../../maps/common/descriptor_types'; +import { useMlKibana } from '../../../../contexts/kibana'; +import { DocumentStatsTable } from '../../../stats_table/components/field_data_expanded_row/document_stats'; +import { ExpandedRowContent } from '../../../stats_table/components/field_data_expanded_row/expanded_row_content'; + +export interface CombinedQuery { + searchString: string | { [key: string]: any }; + searchQueryLanguage: string; +} +export const GeoPointContent: FC<{ + config: FieldVisConfig; + indexPattern: IndexPattern | undefined; + combinedQuery: CombinedQuery; +}> = ({ config, indexPattern, combinedQuery }) => { + const { stats } = config; + const [layerList, setLayerList] = useState<LayerDescriptor[]>([]); + const { + services: { maps: mapsPlugin }, + } = useMlKibana(); + + // Update the layer list with updated geo points upon refresh + useEffect(() => { + async function updateIndexPatternSearchLayer() { + if ( + indexPattern?.id !== undefined && + config !== undefined && + config.fieldName !== undefined && + config.type === ML_JOB_FIELD_TYPES.GEO_POINT + ) { + const params = { + indexPatternId: indexPattern.id, + geoFieldName: config.fieldName, + geoFieldType: config.type as ES_GEO_FIELD_TYPE.GEO_POINT, + query: { + query: combinedQuery.searchString, + language: combinedQuery.searchQueryLanguage, + }, + }; + const searchLayerDescriptor = mapsPlugin + ? await mapsPlugin.createLayerDescriptors.createESSearchSourceLayerDescriptor(params) + : null; + if (searchLayerDescriptor) { + setLayerList([...layerList, searchLayerDescriptor]); + } + } + } + updateIndexPatternSearchLayer(); + }, [indexPattern, config.fieldName, combinedQuery]); + + if (stats?.examples === undefined) return null; + return ( + <ExpandedRowContent dataTestSubj={'mlDVIndexBasedMapContent'}> + <DocumentStatsTable config={config} /> + + <EuiFlexItem> + <ExamplesList examples={stats.examples} /> + </EuiFlexItem> + <EuiFlexItem className={'mlDataVisualizerMapWrapper'}> + <MlEmbeddedMapComponent layerList={layerList} /> + </EuiFlexItem> + </ExpandedRowContent> + ); +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/examples_list/examples_list.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/examples_list/examples_list.tsx index 1e8f7586258d5..a59c6e0fe4183 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/examples_list/examples_list.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/examples_list/examples_list.tsx @@ -15,25 +15,29 @@ interface Props { } export const ExamplesList: FC<Props> = ({ examples }) => { - if ( - examples === undefined || - examples === null || - !Array.isArray(examples) || - examples.length === 0 - ) { + if (examples === undefined || examples === null || !Array.isArray(examples)) { return null; } - - const examplesContent = examples.map((example, i) => { - return ( - <EuiListGroupItem - className="mlFieldDataCard__codeContent" - size="s" - key={`example_${i}`} - label={typeof example === 'string' ? example : JSON.stringify(example)} + let examplesContent; + if (examples.length === 0) { + examplesContent = ( + <FormattedMessage + id="xpack.ml.fieldDataCard.examplesList.noExamplesMessage" + defaultMessage="No examples were obtained for this field" /> ); - }); + } else { + examplesContent = examples.map((example, i) => { + return ( + <EuiListGroupItem + className="mlFieldDataCard__codeContent" + size="s" + key={`example_${i}`} + label={typeof example === 'string' ? example : JSON.stringify(example)} + /> + ); + }); + } return ( <div data-test-subj="mlFieldDataExamplesList"> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/top_values/top_values.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/top_values/top_values.tsx index bd83aaa1f6149..8ad48a3e60d3a 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/top_values/top_values.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/top_values/top_values.tsx @@ -19,9 +19,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import classNames from 'classnames'; import { kibanaFieldFormat } from '../../../../../formatters/kibana_field_format'; import { roundToDecimalPlace } from '../../../../../formatters/round_to_decimal_place'; +import { ExpandedRowFieldHeader } from '../../../../stats_table/components/expanded_row_field_header'; +import { FieldVisStats } from '../../../../stats_table/types'; interface Props { - stats: any; + stats: FieldVisStats | undefined; fieldFormat?: any; barColor?: 'primary' | 'secondary' | 'danger' | 'subdued' | 'accent'; compressed?: boolean; @@ -37,6 +39,7 @@ function getPercentLabel(docCount: number, topValuesSampleSize: number): string } export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed }) => { + if (stats === undefined) return null; const { topValues, topValuesSampleSize, @@ -46,51 +49,64 @@ export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed } = stats; const progressBarMax = isTopValuesSampled === true ? topValuesSampleSize : count; return ( - <div data-test-subj="mlFieldDataTopValues" className={'mlFieldDataTopValuesContainer'}> - {Array.isArray(topValues) && - topValues.map((value: any) => ( - <EuiFlexGroup gutterSize="xs" alignItems="center" key={value.key}> - <EuiFlexItem - grow={false} - className={classNames( - 'eui-textTruncate', - 'mlTopValuesValueLabelContainer', - `mlTopValuesValueLabelContainer--${compressed === true ? 'small' : 'large'}` + <EuiFlexItem data-test-subj={'mlTopValues'}> + <ExpandedRowFieldHeader> + <FormattedMessage id="xpack.ml.fieldDataCard.topValuesLabel" defaultMessage="Top values" /> + </ExpandedRowFieldHeader> + + <div data-test-subj="mlFieldDataTopValues" className={'mlFieldDataTopValuesContainer'}> + {Array.isArray(topValues) && + topValues.map((value) => ( + <EuiFlexGroup gutterSize="xs" alignItems="center" key={value.key}> + <EuiFlexItem + grow={false} + className={classNames( + 'eui-textTruncate', + 'mlTopValuesValueLabelContainer', + `mlTopValuesValueLabelContainer--${compressed === true ? 'small' : 'large'}` + )} + > + <EuiToolTip content={kibanaFieldFormat(value.key, fieldFormat)} position="right"> + <EuiText size="xs" textAlign={'right'} color="subdued"> + {kibanaFieldFormat(value.key, fieldFormat)} + </EuiText> + </EuiToolTip> + </EuiFlexItem> + <EuiFlexItem data-test-subj="mlFieldDataTopValueBar"> + <EuiProgress + value={value.doc_count} + max={progressBarMax} + color={barColor} + size="m" + /> + </EuiFlexItem> + {progressBarMax !== undefined && ( + <EuiFlexItem + grow={false} + className={classNames('eui-textTruncate', 'mlTopValuesPercentLabelContainer')} + > + <EuiText size="xs" textAlign="left" color="subdued"> + {getPercentLabel(value.doc_count, progressBarMax)} + </EuiText> + </EuiFlexItem> )} - > - <EuiToolTip content={kibanaFieldFormat(value.key, fieldFormat)} position="right"> - <EuiText size="xs" textAlign={'right'} color="subdued"> - {kibanaFieldFormat(value.key, fieldFormat)} - </EuiText> - </EuiToolTip> - </EuiFlexItem> - <EuiFlexItem data-test-subj="mlFieldDataTopValueBar"> - <EuiProgress value={value.doc_count} max={progressBarMax} color={barColor} size="m" /> - </EuiFlexItem> - <EuiFlexItem - grow={false} - className={classNames('eui-textTruncate', 'mlTopValuesPercentLabelContainer')} - > - <EuiText size="xs" textAlign="left" color="subdued"> - {getPercentLabel(value.doc_count, progressBarMax)} - </EuiText> - </EuiFlexItem> - </EuiFlexGroup> - ))} - {isTopValuesSampled === true && ( - <Fragment> - <EuiSpacer size="xs" /> - <EuiText size="xs" textAlign={'left'}> - <FormattedMessage - id="xpack.ml.fieldDataCard.topValues.calculatedFromSampleDescription" - defaultMessage="Calculated from sample of {topValuesSamplerShardSize} documents per shard" - values={{ - topValuesSamplerShardSize, - }} - /> - </EuiText> - </Fragment> - )} - </div> + </EuiFlexGroup> + ))} + {isTopValuesSampled === true && ( + <Fragment> + <EuiSpacer size="xs" /> + <EuiText size="xs" textAlign={'left'}> + <FormattedMessage + id="xpack.ml.fieldDataCard.topValues.calculatedFromSampleDescription" + defaultMessage="Calculated from sample of {topValuesSamplerShardSize} documents per shard" + values={{ + topValuesSamplerShardSize, + }} + /> + </EuiText> + </Fragment> + )} + </div> + </EuiFlexItem> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index e5b243d524034..8cf5e28ad3b5b 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useMemo, useState } from 'react'; +import React, { FC, Fragment, useEffect, useMemo, useState, useCallback } from 'react'; import { merge } from 'rxjs'; import { EuiFlexGroup, @@ -112,19 +112,6 @@ export const getDefaultDataVisualizerListState = (): Required<DataVisualizerInde showEmptyFields: false, }); -function getItemIdToExpandedRowMap( - itemIds: string[], - items: FieldVisConfig[] -): ItemIdToExpandedRowMap { - return itemIds.reduce((m: ItemIdToExpandedRowMap, fieldName: string) => { - const item = items.find((fieldVisConfig) => fieldVisConfig.fieldName === fieldName); - if (item !== undefined) { - m[fieldName] = <IndexBasedDataVisualizerExpandedRow item={item} />; - } - return m; - }, {} as ItemIdToExpandedRowMap); -} - export const Page: FC = () => { const mlContext = useMlContext(); const restorableDefaults = getDefaultDataVisualizerListState(); @@ -678,6 +665,26 @@ export const Page: FC = () => { } return { visibleFieldsCount: _visibleFieldsCount, totalFieldsCount: _totalFieldsCount }; }, [overallStats, showEmptyFields]); + + const getItemIdToExpandedRowMap = useCallback( + function (itemIds: string[], items: FieldVisConfig[]): ItemIdToExpandedRowMap { + return itemIds.reduce((m: ItemIdToExpandedRowMap, fieldName: string) => { + const item = items.find((fieldVisConfig) => fieldVisConfig.fieldName === fieldName); + if (item !== undefined) { + m[fieldName] = ( + <IndexBasedDataVisualizerExpandedRow + item={item} + indexPattern={currentIndexPattern} + combinedQuery={{ searchQueryLanguage, searchString }} + /> + ); + } + return m; + }, {} as ItemIdToExpandedRowMap); + }, + [currentIndexPattern, searchQuery] + ); + const { services: { docLinks }, } = useMlKibana(); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_field_data_row.scss b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_field_data_row.scss index 2ab26f1564a1f..1832b0f78b895 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_field_data_row.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_field_data_row.scss @@ -60,6 +60,21 @@ @include euiCodeFont; } + .mlFieldDataCard__geoContent { + z-index: auto; + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + position: relative; + .embPanel__content { + display: flex; + flex: 1 1 100%; + z-index: 1; + min-height: 0; // Absolute must for Firefox to scroll contents + } + } + .mlFieldDataCard__stats { padding: $euiSizeS $euiSizeS 0 $euiSizeS; text-align: center; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_index.scss b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_index.scss index 6e7e66db9e03a..9e838c180713f 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_index.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_index.scss @@ -1,5 +1,6 @@ -@import 'components/field_data_expanded_row/number_content'; +@import 'components/field_data_expanded_row/index'; @import 'components/field_count_stats/index'; +@import 'components/field_data_row/index'; .mlDataVisualizerFieldExpandedRow { padding-left: $euiSize * 4; @@ -37,6 +38,7 @@ } .mlDataVisualizerSummaryTable { max-width: 350px; + min-width: 250px; .euiTableRow > .euiTableRowCell { border-bottom: 0; } @@ -45,6 +47,10 @@ } } .mlDataVisualizerSummaryTableWrapper { - max-width: 350px; + max-width: 300px; + } + .mlDataVisualizerMapWrapper { + min-height: 300px; + min-width: 600px; } } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/_index.scss b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/_index.scss index fdc591a140fea..799beec093cca 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/_index.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/_index.scss @@ -1 +1,7 @@ @import 'number_content'; + +.mlDataVisualizerExpandedRow { + @include euiBreakpoint('xs', 's', 'm') { + flex-direction: column; + } +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/boolean_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/boolean_content.tsx index a75920dd09b34..70e98153fd55c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/boolean_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/boolean_content.tsx @@ -5,7 +5,7 @@ */ import React, { FC, ReactNode, useMemo } from 'react'; -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiBasicTable, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { Axis, BarSeries, Chart, Settings } from '@elastic/charts'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -16,6 +16,7 @@ import { getTFPercentage } from '../../utils'; import { roundToDecimalPlace } from '../../../../formatters/round_to_decimal_place'; import { useDataVizChartTheme } from '../../hooks'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; function getPercentLabel(value: number): string { if (value === 0) { @@ -85,7 +86,7 @@ export const BooleanContent: FC<FieldDataRowProps> = ({ config }) => { ); return ( - <EuiFlexGroup data-test-subj={'mlDVBooleanContent'} gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVBooleanContent'}> <DocumentStatsTable config={config} /> <EuiFlexItem className={'mlDataVisualizerSummaryTableWrapper'}> @@ -138,6 +139,6 @@ export const BooleanContent: FC<FieldDataRowProps> = ({ config }) => { /> </Chart> </EuiFlexItem> - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/date_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/date_content.tsx index 8d122df628381..a6e7901df4e8e 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/date_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/date_content.tsx @@ -5,7 +5,7 @@ */ import React, { FC, ReactNode } from 'react'; -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiBasicTable, EuiFlexItem } from '@elastic/eui'; // @ts-ignore import { formatDate } from '@elastic/eui/lib/services/format'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -14,6 +14,7 @@ import { i18n } from '@kbn/i18n'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; const TIME_FORMAT = 'MMM D YYYY, HH:mm:ss.SSS'; interface SummaryTableItem { function: string; @@ -66,7 +67,7 @@ export const DateContent: FC<FieldDataRowProps> = ({ config }) => { ]; return ( - <EuiFlexGroup data-test-subj={'mlDVDateContent'} gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVDateContent'}> <DocumentStatsTable config={config} /> <EuiFlexItem className={'mlDataVisualizerSummaryTableWrapper'}> <ExpandedRowFieldHeader>{summaryTableTitle}</ExpandedRowFieldHeader> @@ -80,6 +81,6 @@ export const DateContent: FC<FieldDataRowProps> = ({ config }) => { tableLayout="auto" /> </EuiFlexItem> - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/document_stats.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/document_stats.tsx index 177ac722166f7..fd23e9d51bf4e 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/document_stats.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/document_stats.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiFlexItem } from '@elastic/eui'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { FieldDataRowProps } from '../../types'; +import { roundToDecimalPlace } from '../../../../formatters/round_to_decimal_place'; const metaTableColumns = [ { @@ -59,7 +60,7 @@ export const DocumentStatsTable: FC<FieldDataRowProps> = ({ config }) => { defaultMessage="percentage" /> ), - value: `${(count / sampleCount) * 100}%`, + value: `${roundToDecimalPlace((count / sampleCount) * 100)}%`, }, { function: 'distinctValues', diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/expanded_row_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/expanded_row_content.tsx new file mode 100644 index 0000000000000..6c14bd7a64a94 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/expanded_row_content.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, ReactNode } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; + +interface Props { + children: ReactNode; + dataTestSubj: string; +} +export const ExpandedRowContent: FC<Props> = ({ children, dataTestSubj }) => { + return ( + <EuiFlexGroup + data-test-subj={dataTestSubj} + gutterSize={'xl'} + className={'mlDataVisualizerExpandedRow'} + > + {children} + </EuiFlexGroup> + ); +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/geo_point_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/geo_point_content.tsx deleted file mode 100644 index 993c7a94f5e06..0000000000000 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/geo_point_content.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC } from 'react'; - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import type { FieldDataRowProps } from '../../types/field_data_row'; -import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; -import { DocumentStatsTable } from './document_stats'; -import { TopValues } from '../../../index_based/components/field_data_row/top_values'; - -export const GeoPointContent: FC<FieldDataRowProps> = ({ config }) => { - const { stats } = config; - if (stats === undefined || (stats?.examples === undefined && stats?.topValues === undefined)) - return null; - - return ( - <EuiFlexGroup data-test-subj={'mlDVGeoPointContent'} gutterSize={'xl'}> - <DocumentStatsTable config={config} /> - {Array.isArray(stats.examples) && ( - <EuiFlexItem> - <ExamplesList examples={stats.examples!} /> - </EuiFlexItem> - )} - {Array.isArray(stats.topValues) && ( - <EuiFlexItem> - <TopValues stats={stats} barColor="secondary" /> - </EuiFlexItem> - )} - </EuiFlexGroup> - ); -}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/index.ts index c6cd50f6bc2e9..b9b1e181343b7 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/index.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/index.ts @@ -6,7 +6,7 @@ export { BooleanContent } from './boolean_content'; export { DateContent } from './date_content'; -export { GeoPointContent } from './geo_point_content'; +export { GeoPointContent } from '../../../file_based/components/expanded_row/geo_point_content/geo_point_content'; export { KeywordContent } from './keyword_content'; export { IpContent } from './ip_content'; export { NumberContent } from './number_content'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/ip_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/ip_content.tsx index 79492bb44a2dc..183268d6a7ef8 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/ip_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/ip_content.tsx @@ -5,14 +5,10 @@ */ import React, { FC } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; - import type { FieldDataRowProps } from '../../types/field_data_row'; import { TopValues } from '../../../index_based/components/field_data_row/top_values'; -import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; export const IpContent: FC<FieldDataRowProps> = ({ config }) => { const { stats } = config; @@ -22,17 +18,9 @@ export const IpContent: FC<FieldDataRowProps> = ({ config }) => { const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; return ( - <EuiFlexGroup gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVIPContent'}> <DocumentStatsTable config={config} /> - <EuiFlexItem> - <ExpandedRowFieldHeader> - <FormattedMessage - id="xpack.ml.fieldDataCard.cardIp.topValuesLabel" - defaultMessage="Top values" - /> - </ExpandedRowFieldHeader> - <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" /> - </EuiFlexItem> - </EuiFlexGroup> + <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" /> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx index 634f5b55513a3..d11ecc7d8804b 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx @@ -5,31 +5,20 @@ */ import React, { FC } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { TopValues } from '../../../index_based/components/field_data_row/top_values'; -import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; export const KeywordContent: FC<FieldDataRowProps> = ({ config }) => { const { stats } = config; const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; return ( - <EuiFlexGroup data-test-subj={'mlDVKeywordContent'} gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVKeywordContent'}> <DocumentStatsTable config={config} /> - <EuiFlexItem> - <ExpandedRowFieldHeader> - <FormattedMessage - id="xpack.ml.fieldDataCard.cardKeyword.topValuesLabel" - defaultMessage="Top values" - /> - </ExpandedRowFieldHeader> - <EuiSpacer size="xs" /> - <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" /> - </EuiFlexItem> - </EuiFlexGroup> + <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" /> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/number_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/number_content.tsx index d05de26d3c5d4..56811e61ad891 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/number_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/number_content.tsx @@ -5,7 +5,7 @@ */ import React, { FC, ReactNode, useEffect, useState } from 'react'; -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiBasicTable, EuiFlexItem, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -20,6 +20,7 @@ import { import { TopValues } from '../../../index_based/components/field_data_row/top_values'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; const METRIC_DISTRIBUTION_CHART_WIDTH = 325; const METRIC_DISTRIBUTION_CHART_HEIGHT = 200; @@ -97,7 +98,7 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => { } ); return ( - <EuiFlexGroup data-test-subj={'mlDVNumberContent'} gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVNumberContent'}> <DocumentStatsTable config={config} /> <EuiFlexItem className={'mlDataVisualizerSummaryTableWrapper'}> <ExpandedRowFieldHeader>{summaryTableTitle}</ExpandedRowFieldHeader> @@ -112,24 +113,7 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => { </EuiFlexItem> {stats && ( - <EuiFlexItem data-test-subj={'mlTopValues'}> - <EuiFlexItem grow={false}> - <ExpandedRowFieldHeader> - <FormattedMessage - id="xpack.ml.fieldDataCardExpandedRow.numberContent.topValuesTitle" - defaultMessage="Top values" - /> - </ExpandedRowFieldHeader> - </EuiFlexItem> - <EuiFlexItem> - <TopValues - stats={stats} - fieldFormat={fieldFormat} - barColor="secondary" - compressed={true} - /> - </EuiFlexItem> - </EuiFlexItem> + <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" compressed={true} /> )} {distribution && ( <EuiFlexItem data-test-subj={'mlMetricDistribution'}> @@ -164,6 +148,6 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => { </EuiFlexItem> </EuiFlexItem> )} - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/other_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/other_content.tsx index a6d7398990cd3..08884619db2d6 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/other_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/other_content.tsx @@ -5,18 +5,18 @@ */ import React, { FC } from 'react'; -import { EuiFlexGroup } from '@elastic/eui'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; export const OtherContent: FC<FieldDataRowProps> = ({ config }) => { const { stats } = config; if (stats === undefined) return null; return ( - <EuiFlexGroup gutterSize={'xl'} data-test-subj={'mlDVOtherContent'}> + <ExpandedRowContent dataTestSubj={'mlDVOtherContent'}> <DocumentStatsTable config={config} /> {Array.isArray(stats.examples) && <ExamplesList examples={stats.examples} />} - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/text_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/text_content.tsx index 55639ecc5761f..c0a5ca18d03b5 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/text_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/text_content.tsx @@ -5,13 +5,14 @@ */ import React, { FC, Fragment } from 'react'; -import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; +import { ExpandedRowContent } from './expanded_row_content'; export const TextContent: FC<FieldDataRowProps> = ({ config }) => { const { stats } = config; @@ -23,7 +24,7 @@ export const TextContent: FC<FieldDataRowProps> = ({ config }) => { const numExamples = examples.length; return ( - <EuiFlexGroup gutterSize={'xl'} data-test-subj={'mlDVTextContent'}> + <ExpandedRowContent dataTestSubj={'mlDVTextContent'}> <EuiFlexItem> {numExamples > 0 && <ExamplesList examples={examples} />} {numExamples === 0 && ( @@ -59,6 +60,6 @@ export const TextContent: FC<FieldDataRowProps> = ({ config }) => { </Fragment> )} </EuiFlexItem> - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/_index.scss b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/_index.scss new file mode 100644 index 0000000000000..27483feb573b8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/_index.scss @@ -0,0 +1,3 @@ +.mlDataVisualizerColumnHeaderIcon { + max-width: $euiSizeM; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/distinct_values.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/distinct_values.tsx index 819d5278c0d78..44c028d1ba8b5 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/distinct_values.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/distinct_values.tsx @@ -12,7 +12,7 @@ export const DistinctValues = ({ cardinality }: { cardinality?: number }) => { if (cardinality === undefined) return null; return ( <EuiFlexGroup alignItems={'center'}> - <EuiFlexItem style={{ maxWidth: 10 }}> + <EuiFlexItem className={'mlDataVisualizerColumnHeaderIcon'}> <EuiIcon type="database" size={'s'} /> </EuiFlexItem> <EuiText size={'s'}> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/document_stats.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/document_stats.tsx index 9c89d74fa751b..b223fd5d98c1a 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/document_stats.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/document_stats.tsx @@ -21,7 +21,7 @@ export const DocumentStat = ({ config }: FieldDataRowProps) => { return ( <EuiFlexGroup alignItems={'center'}> - <EuiFlexItem style={{ maxWidth: 10 }}> + <EuiFlexItem className={'mlDataVisualizerColumnHeaderIcon'}> <EuiIcon type="document" size={'s'} /> </EuiFlexItem> <EuiText size={'s'}> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/number_content_preview.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/number_content_preview.tsx index 3a84ae644cb4e..996fffd225f96 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/number_content_preview.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/number_content_preview.tsx @@ -14,6 +14,7 @@ import { } from '../metric_distribution_chart'; import { formatSingleValue } from '../../../../formatters/format_value'; import { FieldVisConfig } from '../../types'; +import { kibanaFieldFormat } from '../../../../formatters/kibana_field_format'; const METRIC_DISTRIBUTION_CHART_WIDTH = 150; const METRIC_DISTRIBUTION_CHART_HEIGHT = 80; @@ -59,14 +60,16 @@ export const IndexBasedNumberContentPreview: FC<NumberContentPreviewProps> = ({ <> <EuiSpacer size="s" /> <EuiFlexGroup direction={'row'} data-test-subj={`${dataTestSubj}-legend`}> - <EuiFlexItem className={'mlDataGridChart__legend'}>{legendText.min}</EuiFlexItem> + <EuiFlexItem className={'mlDataGridChart__legend'}> + {kibanaFieldFormat(legendText.min, fieldFormat)} + </EuiFlexItem> <EuiFlexItem className={classNames( 'mlDataGridChart__legend', 'mlDataGridChart__legend--numeric' )} > - {legendText.max} + {kibanaFieldFormat(legendText.max, fieldFormat)} </EuiFlexItem> </EuiFlexGroup> </> diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx new file mode 100644 index 0000000000000..fc1621e962f36 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { Dictionary } from '../../../../common/types/common'; +import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { getMLAnomaliesActualLayer, getMLAnomaliesTypicalLayer } from './map_config'; +import { MlEmbeddedMapComponent } from '../../components/ml_embedded_map'; +interface Props { + seriesConfig: Dictionary<any>; +} + +export function EmbeddedMapComponentWrapper({ seriesConfig }: Props) { + const [layerList, setLayerList] = useState<LayerDescriptor[]>([]); + + useEffect(() => { + if (seriesConfig.mapData && seriesConfig.mapData.length > 0) { + setLayerList([ + getMLAnomaliesActualLayer(seriesConfig.mapData), + getMLAnomaliesTypicalLayer(seriesConfig.mapData), + ]); + } + }, [seriesConfig]); + + return ( + <div data-test-subj="xpack.ml.explorer.embeddedMap" style={{ width: '100%', height: 300 }}> + <MlEmbeddedMapComponent layerList={layerList} /> + </div> + ); +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 774372f678c9b..9921b5f991844 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -22,6 +22,7 @@ import { } from '../../util/chart_utils'; import { ExplorerChartDistribution } from './explorer_chart_distribution'; import { ExplorerChartSingleMetric } from './explorer_chart_single_metric'; +import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map'; import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; @@ -30,6 +31,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MlTooltipComponent } from '../../components/chart_tooltip'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; import { ExplorerChartsErrorCallOuts } from './explorer_charts_error_callouts'; @@ -43,6 +45,9 @@ const textViewButton = i18n.translate( defaultMessage: 'Open in Single Metric Viewer', } ); +const mapsPluginMessage = i18n.translate('xpack.ml.explorer.charts.mapsPluginMissingMessage', { + defaultMessage: 'maps or embeddable start plugin not found', +}); // create a somewhat unique ID // from charts metadata for React's key attribute @@ -67,8 +72,8 @@ function ExplorerChartContainer({ useEffect(() => { let isCancelled = false; const generateLink = async () => { - const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); - if (!isCancelled) { + if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) { + const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); setExplorerSeriesLink(singleMetricViewerLink); } }; @@ -150,6 +155,18 @@ function ExplorerChartContainer({ </EuiFlexItem> </EuiFlexGroup> {(() => { + if (chartType === CHART_TYPE.GEO_MAP) { + return ( + <MlTooltipComponent> + {(tooltipService) => ( + <EmbeddedMapComponentWrapper + seriesConfig={series} + tooltipService={tooltipService} + /> + )} + </MlTooltipComponent> + ); + } if ( chartType === CHART_TYPE.EVENT_DISTRIBUTION || chartType === CHART_TYPE.POPULATION_DISTRIBUTION @@ -167,18 +184,20 @@ function ExplorerChartContainer({ </MlTooltipComponent> ); } - return ( - <MlTooltipComponent> - {(tooltipService) => ( - <ExplorerChartSingleMetric - tooManyBuckets={tooManyBuckets} - seriesConfig={series} - severity={severity} - tooltipService={tooltipService} - /> - )} - </MlTooltipComponent> - ); + if (chartType === CHART_TYPE.SINGLE_METRIC) { + return ( + <MlTooltipComponent> + {(tooltipService) => ( + <ExplorerChartSingleMetric + tooManyBuckets={tooManyBuckets} + seriesConfig={series} + severity={severity} + tooltipService={tooltipService} + /> + )} + </MlTooltipComponent> + ); + } })()} </React.Fragment> ); @@ -199,8 +218,31 @@ export const ExplorerChartsContainerUI = ({ share: { urlGenerators: { getUrlGenerator }, }, + embeddable: embeddablePlugin, + maps: mapsPlugin, }, } = kibana; + + let seriesToPlotFiltered; + + if (!embeddablePlugin || !mapsPlugin) { + seriesToPlotFiltered = []; + // Show missing plugin callout + seriesToPlot.forEach((series) => { + if (series.functionDescription === 'lat_long') { + if (errorMessages[mapsPluginMessage] === undefined) { + errorMessages[mapsPluginMessage] = new Set([series.jobId]); + } else { + errorMessages[mapsPluginMessage].add(series.jobId); + } + } else { + seriesToPlotFiltered.push(series); + } + }); + } + + const seriesToUse = seriesToPlotFiltered !== undefined ? seriesToPlotFiltered : seriesToPlot; + const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]); // <EuiFlexGrid> doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. @@ -208,13 +250,13 @@ export const ExplorerChartsContainerUI = ({ const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow; - const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series)); + const wrapLabel = seriesToUse.some((series) => isLabelLengthAboveThreshold(series)); return ( <> <ExplorerChartsErrorCallOuts errorMessagesByType={errorMessages} /> <EuiFlexGrid columns={chartsColumns}> - {seriesToPlot.length > 0 && - seriesToPlot.map((series) => ( + {seriesToUse.length > 0 && + seriesToUse.map((series) => ( <EuiFlexItem key={getChartId(series)} className="ml-explorer-chart-container" diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js index 3dc1c0234584d..077e60db4760a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js @@ -22,12 +22,14 @@ import { isSourceDataChartableForDetector, isModelPlotChartableForDetector, isModelPlotEnabled, + isMappableJob, } from '../../../../common/util/job_utils'; import { mlResultsService } from '../../services/results_service'; import { mlJobService } from '../../services/job_service'; import { explorerService } from '../explorer_dashboard_service'; import { CHART_TYPE } from '../explorer_constants'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { i18n } from '@kbn/i18n'; import { SWIM_LANE_LABEL_WIDTH } from '../swimlane_container'; @@ -77,7 +79,50 @@ export const anomalyDataChange = function ( // For now just take first 6 (or 8 if 4 charts per row). const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6); const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); + const hasGeoData = recordsToPlot.find( + (record) => + (record.function_description || recordsToPlot.function) === ML_JOB_AGGREGATION.LAT_LONG + ); + const seriesConfigs = recordsToPlot.map(buildConfig); + const seriesConfigsNoGeoData = []; + + // initialize the charts with loading indicators + data.seriesToPlot = seriesConfigs.map((config) => ({ + ...config, + loading: true, + chartData: null, + })); + + const mapData = []; + + if (hasGeoData !== undefined) { + for (let i = 0; i < seriesConfigs.length; i++) { + const config = seriesConfigs[i]; + let records; + if (config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) { + if (config.entityFields.length) { + records = [ + recordsToPlot.find((record) => { + const entityFieldName = config.entityFields[0].fieldName; + const entityFieldValue = config.entityFields[0].fieldValue; + return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue; + }), + ]; + } else { + records = recordsToPlot; + } + + mapData.push({ + ...config, + loading: false, + mapData: records, + }); + } else { + seriesConfigsNoGeoData.push(config); + } + } + } // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. data.tooManyBuckets = false; @@ -92,13 +137,6 @@ export const anomalyDataChange = function ( ); data.tooManyBuckets = tooManyBuckets; - // initialize the charts with loading indicators - data.seriesToPlot = seriesConfigs.map((config) => ({ - ...config, - loading: true, - chartData: null, - })); - data.errorMessages = errorMessages; explorerService.setCharts({ ...data }); @@ -269,22 +307,27 @@ export const anomalyDataChange = function ( // only after that trigger data processing and page render. // TODO - if query returns no results e.g. source data has been deleted, // display a message saying 'No data between earliest/latest'. - const seriesPromises = seriesConfigs.map((seriesConfig) => - Promise.all([ - getMetricData(seriesConfig, chartRange), - getRecordsForCriteria(seriesConfig, chartRange), - getScheduledEvents(seriesConfig, chartRange), - getEventDistribution(seriesConfig, chartRange), - ]) - ); + const seriesPromises = []; + // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses + const seriesCongifsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs; + seriesCongifsForPromises.forEach((seriesConfig) => { + seriesPromises.push( + Promise.all([ + getMetricData(seriesConfig, chartRange), + getRecordsForCriteria(seriesConfig, chartRange), + getScheduledEvents(seriesConfig, chartRange), + getEventDistribution(seriesConfig, chartRange), + ]) + ); + }); function processChartData(response, seriesIndex) { const metricData = response[0].results; const records = response[1].records; - const jobId = seriesConfigs[seriesIndex].jobId; + const jobId = seriesCongifsForPromises[seriesIndex].jobId; const scheduledEvents = response[2].events[jobId]; const eventDistribution = response[3]; - const chartType = getChartType(seriesConfigs[seriesIndex]); + const chartType = getChartType(seriesCongifsForPromises[seriesIndex]); // Sort records in ascending time order matching up with chart data records.sort((recordA, recordB) => { @@ -409,16 +452,25 @@ export const anomalyDataChange = function ( ); const overallChartLimits = chartLimits(allDataPoints); - data.seriesToPlot = response.map((d, i) => ({ - ...seriesConfigs[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: selectedEarliestMs, - selectedLatest: selectedLatestMs, - chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i]), - })); + data.seriesToPlot = response.map((d, i) => { + return { + ...seriesCongifsForPromises[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: selectedEarliestMs, + selectedLatest: selectedLatestMs, + chartLimits: USE_OVERALL_CHART_LIMITS + ? overallChartLimits + : chartLimits(processedData[i]), + }; + }); + + if (mapData.length) { + // push map data in if it's available + data.seriesToPlot.push(...mapData); + } explorerService.setCharts({ ...data }); }) .catch((error) => { @@ -447,7 +499,10 @@ function processRecordsForDisplay(anomalyRecords) { return; } - let isChartable = isSourceDataChartableForDetector(job, record.detector_index); + let isChartable = + isSourceDataChartableForDetector(job, record.detector_index) || + isMappableJob(job, record.detector_index); + if (isChartable === false) { if (isModelPlotChartableForDetector(job, record.detector_index)) { // Check if model plot is enabled for this job. diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts new file mode 100644 index 0000000000000..451fa602315d7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FIELD_ORIGIN, STYLE_TYPE } from '../../../../../maps/common/constants'; +import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../../common'; + +const FEATURE = 'Feature'; +const POINT = 'Point'; +const SEVERITY_COLOR_RAMP = [ + { + stop: ANOMALY_THRESHOLD.LOW, + color: SEVERITY_COLORS.WARNING, + }, + { + stop: ANOMALY_THRESHOLD.MINOR, + color: SEVERITY_COLORS.MINOR, + }, + { + stop: ANOMALY_THRESHOLD.MAJOR, + color: SEVERITY_COLORS.MAJOR, + }, + { + stop: ANOMALY_THRESHOLD.CRITICAL, + color: SEVERITY_COLORS.CRITICAL, + }, +]; + +function getAnomalyFeatures(anomalies: any[], type: 'actual_point' | 'typical_point') { + const anomalyFeatures = []; + for (let i = 0; i < anomalies.length; i++) { + const anomaly = anomalies[i]; + const geoResults = anomaly.geo_results || (anomaly?.causes && anomaly?.causes[0]?.geo_results); + const coordinateStr = geoResults && geoResults[type]; + if (coordinateStr !== undefined) { + // Must reverse coordinates here. Map expects [lon, lat] - anomalies are stored as [lat, lon] for lat_lon jobs + const coordinates = coordinateStr + .split(',') + .map((point: string) => Number(point)) + .reverse(); + + anomalyFeatures.push({ + type: FEATURE, + geometry: { + type: POINT, + coordinates, + }, + properties: { + record_score: Math.floor(anomaly.record_score), + [type]: coordinates.map((point: number) => point.toFixed(2)), + }, + }); + } + } + return anomalyFeatures; +} + +export const getMLAnomaliesTypicalLayer = (anomalies: any) => { + return { + id: 'anomalies_typical_layer', + label: 'Typical', + sourceDescriptor: { + id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854e', + type: 'GEOJSON_FILE', + __featureCollection: { + features: getAnomalyFeatures(anomalies, 'typical_point'), + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: 'STATIC', + options: { + color: '#98A2B2', + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; + +export const getMLAnomaliesActualLayer = (anomalies: any) => { + return { + id: 'anomalies_actual_layer', + label: 'Actual', + sourceDescriptor: { + id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854d', + type: 'GEOJSON_FILE', + __fields: [ + { + name: 'record_score', + type: 'number', + }, + ], + __featureCollection: { + features: getAnomalyFeatures(anomalies, 'actual_point'), + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + customColorRamp: SEVERITY_COLOR_RAMP, + field: { + name: 'record_score', + origin: FIELD_ORIGIN.SOURCE, + }, + useCustomColorRamp: true, + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 3f5f016fc365a..2178c837458e9 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -48,6 +48,7 @@ export const CHART_TYPE = { EVENT_DISTRIBUTION: 'event_distribution', POPULATION_DISTRIBUTION: 'population_distribution', SINGLE_METRIC: 'single_metric', + GEO_MAP: 'geo_map', }; export const MAX_CATEGORY_EXAMPLES = 10; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index f6889c9a6f24c..4ba9d4ea14f10 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -511,6 +511,7 @@ export async function loadAnomaliesTableData( const entityFields = getEntityFieldList(anomaly.source); isChartable = isModelPlotEnabled(job, anomaly.detectorIndex, entityFields); } + anomaly.isTimeSeriesViewRecord = isChartable; if (mlJobService.customUrlsByJob[jobId] !== undefined) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx index 280ac85a5a2bc..470fe11759d27 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx @@ -46,7 +46,7 @@ export const FieldDescription: FC = memo(({ children }) => { description={ <FormattedMessage id="xpack.ml.newJob.wizard.pickFieldsStep.advancedDetectorModal.fieldSelect.description" - defaultMessage="Required for functions: sum, mean, median, max, min, info_content, distinct_count." + defaultMessage="Required for functions: sum, mean, median, max, min, info_content, distinct_count, lat_long." /> } > diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 298dcad4ce488..7b246e557d7a5 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -45,6 +45,11 @@ interface DeleteDataFrameAnalyticsWithIndexResponse { destIndexDeleted: DeleteDataFrameAnalyticsWithIndexStatus; destIndexPatternDeleted: DeleteDataFrameAnalyticsWithIndexStatus; } +interface JobsExistsResponse { + results: { + [jobId: string]: boolean; + }; +} export const dataFrameAnalytics = { getDataFrameAnalytics(analyticsId?: string) { @@ -98,6 +103,14 @@ export const dataFrameAnalytics = { query: { treatAsRoot, type }, }); }, + jobsExists(analyticsIds: string[], allSpaces: boolean = false) { + const body = JSON.stringify({ analyticsIds, allSpaces }); + return http<JobsExistsResponse>({ + path: `${basePath()}/data_frame/analytics/jobs_exist`, + method: 'POST', + body, + }); + }, evaluateDataFrameAnalytics(evaluateConfig: any) { const body = JSON.stringify(evaluateConfig); return http<any>({ diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index 514449385bf0b..3747e84f43765 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -156,7 +156,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { } body.aggs.byTime.aggs = {}; - if (metricFieldName !== undefined && metricFieldName !== '') { + + if (metricFieldName !== undefined && metricFieldName !== '' && metricFunction) { const metricAgg: any = { [metricFunction]: {}, }; diff --git a/x-pack/plugins/ml/public/application/util/chart_config_builder.js b/x-pack/plugins/ml/public/application/util/chart_config_builder.js index a30280f1220c0..a306211defc87 100644 --- a/x-pack/plugins/ml/public/application/util/chart_config_builder.js +++ b/x-pack/plugins/ml/public/application/util/chart_config_builder.js @@ -24,7 +24,10 @@ export function buildConfigFromDetector(job, detectorIndex) { const config = { jobId: job.job_id, detectorIndex: detectorIndex, - metricFunction: mlFunctionToESAggregation(detector.function), + metricFunction: + detector.function === ML_JOB_AGGREGATION.LAT_LONG + ? ML_JOB_AGGREGATION.LAT_LONG + : mlFunctionToESAggregation(detector.function), timeField: job.data_description.time_field, interval: job.analysis_config.bucket_span, datafeedConfig: job.datafeed_config, diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index 402c922a0034f..799187cc37dfd 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -176,6 +176,11 @@ const POPULATION_DISTRIBUTION_ENABLED = true; // get the chart type based on its configuration export function getChartType(config) { let chartType = CHART_TYPE.SINGLE_METRIC; + + if (config.functionDescription === 'lat_long' || config.mapData !== undefined) { + return CHART_TYPE.GEO_MAP; + } + if ( EVENT_DISTRIBUTION_ENABLED && config.functionDescription === 'rare' && diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index d7ca0203ab69e..9aa16f521554c 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -21,6 +21,7 @@ import type { import type { IndexPatternsContract, DataPublicPluginStart } from 'src/plugins/data/public'; import type { SharePluginStart } from 'src/plugins/share/public'; import type { SecurityPluginSetup } from '../../../../security/public'; +import type { MapsStartApi } from '../../../../maps/public'; export interface DependencyCache { timefilter: DataPublicPluginSetup['query']['timefilter'] | null; @@ -40,6 +41,7 @@ export interface DependencyCache { security: SecurityPluginSetup | undefined | null; i18n: I18nStart | null; urlGenerators: SharePluginStart['urlGenerators'] | null; + maps: MapsStartApi | null; } const cache: DependencyCache = { @@ -60,6 +62,7 @@ const cache: DependencyCache = { security: null, i18n: null, urlGenerators: null, + maps: null, }; export function setDependencyCache(deps: Partial<DependencyCache>) { diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 7c32671be93c4..3ba79e0eb9187 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -25,7 +25,7 @@ import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import type { DataPublicPluginStart } from 'src/plugins/data/public'; import type { HomePublicPluginSetup } from 'src/plugins/home/public'; import type { IndexPatternManagementSetup } from 'src/plugins/index_pattern_management/public'; -import type { EmbeddableSetup } from 'src/plugins/embeddable/public'; +import type { EmbeddableSetup, EmbeddableStart } from 'src/plugins/embeddable/public'; import type { SpacesPluginStart } from '../../spaces/public'; import { AppStatus, AppUpdater, DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; @@ -45,6 +45,7 @@ import { setDependencyCache } from './application/util/dependency_cache'; import { registerFeature } from './register_feature'; // Not importing from `ml_url_generator/index` here to avoid importing unnecessary code import { registerUrlGenerator } from './ml_url_generator/ml_url_generator'; +import type { MapsStartApi } from '../../maps/public'; export interface MlStartDependencies { data: DataPublicPluginStart; @@ -52,6 +53,8 @@ export interface MlStartDependencies { kibanaLegacy: KibanaLegacyStart; uiActions: UiActionsStart; spaces?: SpacesPluginStart; + embeddable: EmbeddableStart; + maps?: MapsStartApi; } export interface MlSetupDependencies { security?: SecurityPluginSetup; @@ -102,7 +105,8 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> { usageCollection: pluginsSetup.usageCollection, licenseManagement: pluginsSetup.licenseManagement, home: pluginsSetup.home, - embeddable: pluginsSetup.embeddable, + embeddable: { ...pluginsSetup.embeddable, ...pluginsStart.embeddable }, + maps: pluginsStart.maps, uiActions: pluginsStart.uiActions, kibanaVersion, }, diff --git a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts index 7b3f97b684edc..22e75ec694733 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts @@ -1189,7 +1189,8 @@ export class DataVisualizer { }); const searchBody = { - _source: field, + fields: [field], + _source: false, query: { bool: { filter: filterCriteria, @@ -1209,16 +1210,16 @@ export class DataVisualizer { if (body.hits.total.value > 0) { const hits = body.hits.hits; for (let i = 0; i < hits.length; i++) { - // Look in the _source for the field value. - // If the field is not in the _source (as will happen if the - // field is populated using copy_to in the index mapping), - // there will be no example to add. // Use lodash get() to support field names containing dots. - const example: any = get(hits[i]._source, field); - if (example !== undefined && stats.examples.indexOf(example) === -1) { - stats.examples.push(example); - if (stats.examples.length === maxExamples) { - break; + const doc: object[] | undefined = get(hits[i].fields, field); + // the results from fields query is always an array + if (Array.isArray(doc) && doc.length > 0) { + const example = doc[0]; + if (example !== undefined && stats.examples.indexOf(example) === -1) { + stats.examples.push(example); + if (stats.examples.length === maxExamples) { + break; + } } } } diff --git a/x-pack/plugins/ml/server/saved_objects/authorization.ts b/x-pack/plugins/ml/server/saved_objects/authorization.ts index 958ee2091f11e..9afc479c32d7d 100644 --- a/x-pack/plugins/ml/server/saved_objects/authorization.ts +++ b/x-pack/plugins/ml/server/saved_objects/authorization.ts @@ -10,6 +10,15 @@ import { ML_SAVED_OBJECT_TYPE } from '../../common/types/saved_objects'; export function authorizationProvider(authorization: SecurityPluginSetup['authz']) { async function authorizationCheck(request: KibanaRequest) { + const shouldAuthorizeRequest = authorization?.mode.useRbacForRequest(request) ?? false; + + if (shouldAuthorizeRequest === false) { + return { + canCreateGlobally: true, + canCreateAtSpace: true, + }; + } + const checkPrivilegesWithRequest = authorization.checkPrivilegesWithRequest(request); // Checking privileges "dynamically" will check against the current space, if spaces are enabled. // If spaces are disabled, then this will check privileges globally instead. diff --git a/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts b/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts index 734caa7374686..3336e65da2b11 100644 --- a/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts +++ b/x-pack/plugins/monitoring/server/es_client/instantiate_client.ts @@ -30,7 +30,6 @@ export function instantiateClient( const cluster = createClient('monitoring', { ...(isMonitoringCluster ? elasticsearchConfig : {}), plugins: [monitoringBulk, monitoringEndpointDisableWatches], - logQueries: Boolean(elasticsearchConfig.logQueries), } as ESClusterConfig); const configSource = isMonitoringCluster ? 'monitoring' : 'production'; diff --git a/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx b/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx index 4819a0760d88a..af61f618a89b2 100644 --- a/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/action_menu/index.tsx @@ -26,7 +26,7 @@ export function SectionTitle({ children }: { children?: ReactNode }) { <EuiText size={'s'} grow={false}> <h5>{children}</h5> </EuiText> - <EuiSpacer size={'s'} /> + <EuiSpacer size={'xs'} /> </> ); } @@ -55,7 +55,7 @@ export function SectionSpacer() { } export const Section = styled.div` - margin-bottom: 24px; + margin-bottom: 16px; &:last-of-type { margin-bottom: 0; } @@ -63,7 +63,7 @@ export const Section = styled.div` export type SectionLinkProps = EuiListGroupItemProps; export function SectionLink(props: SectionLinkProps) { - return <EuiListGroupItem style={{ padding: 0 }} size={'s'} {...props} />; + return <EuiListGroupItem style={{ padding: 0 }} size={'xs'} {...props} />; } export function ActionMenuDivider() { diff --git a/x-pack/plugins/osquery/README.md b/x-pack/plugins/osquery/README.md new file mode 100755 index 0000000000000..e0861fab2040b --- /dev/null +++ b/x-pack/plugins/osquery/README.md @@ -0,0 +1,9 @@ +# osquery + +This plugin adds extended support to Security Solution Fleet Osquery integration + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/x-pack/plugins/osquery/common/constants.ts b/x-pack/plugins/osquery/common/constants.ts new file mode 100644 index 0000000000000..f6027d416beb1 --- /dev/null +++ b/x-pack/plugins/osquery/common/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_MAX_TABLE_QUERY_SIZE = 10000; +export const DEFAULT_DARK_MODE = 'theme:darkMode'; diff --git a/x-pack/plugins/osquery/common/ecs/agent/index.ts b/x-pack/plugins/osquery/common/ecs/agent/index.ts new file mode 100644 index 0000000000000..6f29a2020c944 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/agent/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface AgentEcs { + type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/auditd/index.ts b/x-pack/plugins/osquery/common/ecs/auditd/index.ts new file mode 100644 index 0000000000000..7611e5424e297 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/auditd/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface AuditdEcs { + result?: string[]; + session?: string[]; + data?: AuditdDataEcs; + summary?: SummaryEcs; + sequence?: string[]; +} + +export interface AuditdDataEcs { + acct?: string[]; + terminal?: string[]; + op?: string[]; +} + +export interface SummaryEcs { + actor?: PrimarySecondaryEcs; + object?: PrimarySecondaryEcs; + how?: string[]; + message_type?: string[]; + sequence?: string[]; +} + +export interface PrimarySecondaryEcs { + primary?: string[]; + secondary?: string[]; + type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/cloud/index.ts b/x-pack/plugins/osquery/common/ecs/cloud/index.ts new file mode 100644 index 0000000000000..812b30bcc13f1 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/cloud/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface CloudEcs { + instance?: CloudInstanceEcs; + machine?: CloudMachineEcs; + provider?: string[]; + region?: string[]; +} + +export interface CloudMachineEcs { + type?: string[]; +} + +export interface CloudInstanceEcs { + id?: string[]; +} diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/index.ts b/x-pack/plugins/osquery/common/ecs/destination/index.ts similarity index 50% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/index.ts rename to x-pack/plugins/osquery/common/ecs/destination/index.ts index d2665ec1e2813..be12e829108a9 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/index.ts +++ b/x-pack/plugins/osquery/common/ecs/destination/index.ts @@ -4,8 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'src/core/server'; -import { VisTypeTimeseriesEnhanced } from './plugin'; +import { GeoEcs } from '../geo'; -export const plugin = (initializerContext: PluginInitializerContext) => - new VisTypeTimeseriesEnhanced(initializerContext); +export interface DestinationEcs { + bytes?: number[]; + ip?: string[]; + port?: number[]; + domain?: string[]; + geo?: GeoEcs; + packets?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/dns/index.ts b/x-pack/plugins/osquery/common/ecs/dns/index.ts new file mode 100644 index 0000000000000..45192d03a10b6 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/dns/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface DnsEcs { + question?: DnsQuestionEcs; + resolved_ip?: string[]; + response_code?: string[]; +} + +export interface DnsQuestionEcs { + name?: string[]; + type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.test.ts b/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.test.ts new file mode 100644 index 0000000000000..9ba22e83b4b4d --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extendMap } from './extend_map'; + +describe('ecs_fields test', () => { + describe('extendMap', () => { + test('it should extend a record', () => { + const osFieldsMap: Readonly<Record<string, string>> = { + 'os.platform': 'os.platform', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', + }; + const expected: Record<string, string> = { + 'host.os.family': 'host.os.family', + 'host.os.full': 'host.os.full', + 'host.os.kernel': 'host.os.kernel', + 'host.os.platform': 'host.os.platform', + 'host.os.version': 'host.os.version', + }; + expect(extendMap('host', osFieldsMap)).toEqual(expected); + }); + + test('it should extend a sample hosts record', () => { + const hostMap: Record<string, string> = { + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.name': 'host.name', + }; + const osFieldsMap: Readonly<Record<string, string>> = { + 'os.platform': 'os.platform', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', + }; + const expected: Record<string, string> = { + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.name': 'host.name', + 'host.os.family': 'host.os.family', + 'host.os.full': 'host.os.full', + 'host.os.kernel': 'host.os.kernel', + 'host.os.platform': 'host.os.platform', + 'host.os.version': 'host.os.version', + }; + const output = { ...hostMap, ...extendMap('host', osFieldsMap) }; + expect(output).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.ts b/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.ts new file mode 100644 index 0000000000000..c25979cbcdcee --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const extendMap = ( + path: string, + map: Readonly<Record<string, string>> +): Readonly<Record<string, string>> => + Object.entries(map).reduce<Record<string, string>>((accum, [key, value]) => { + accum[`${path}.${key}`] = `${path}.${value}`; + return accum; + }, {}); diff --git a/x-pack/plugins/osquery/common/ecs/ecs_fields/index.ts b/x-pack/plugins/osquery/common/ecs/ecs_fields/index.ts new file mode 100644 index 0000000000000..19b16bd4bc6d2 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/ecs_fields/index.ts @@ -0,0 +1,358 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extendMap } from './extend_map'; + +export const auditdMap: Readonly<Record<string, string>> = { + 'auditd.result': 'auditd.result', + 'auditd.session': 'auditd.session', + 'auditd.data.acct': 'auditd.data.acct', + 'auditd.data.terminal': 'auditd.data.terminal', + 'auditd.data.op': 'auditd.data.op', + 'auditd.summary.actor.primary': 'auditd.summary.actor.primary', + 'auditd.summary.actor.secondary': 'auditd.summary.actor.secondary', + 'auditd.summary.object.primary': 'auditd.summary.object.primary', + 'auditd.summary.object.secondary': 'auditd.summary.object.secondary', + 'auditd.summary.object.type': 'auditd.summary.object.type', + 'auditd.summary.how': 'auditd.summary.how', + 'auditd.summary.message_type': 'auditd.summary.message_type', + 'auditd.summary.sequence': 'auditd.summary.sequence', +}; + +export const cloudFieldsMap: Readonly<Record<string, string>> = { + 'cloud.account.id': 'cloud.account.id', + 'cloud.availability_zone': 'cloud.availability_zone', + 'cloud.instance.id': 'cloud.instance.id', + 'cloud.instance.name': 'cloud.instance.name', + 'cloud.machine.type': 'cloud.machine.type', + 'cloud.provider': 'cloud.provider', + 'cloud.region': 'cloud.region', +}; + +export const fileMap: Readonly<Record<string, string>> = { + 'file.name': 'file.name', + 'file.path': 'file.path', + 'file.target_path': 'file.target_path', + 'file.extension': 'file.extension', + 'file.type': 'file.type', + 'file.device': 'file.device', + 'file.inode': 'file.inode', + 'file.uid': 'file.uid', + 'file.owner': 'file.owner', + 'file.gid': 'file.gid', + 'file.group': 'file.group', + 'file.mode': 'file.mode', + 'file.size': 'file.size', + 'file.mtime': 'file.mtime', + 'file.ctime': 'file.ctime', +}; + +export const osFieldsMap: Readonly<Record<string, string>> = { + 'os.platform': 'os.platform', + 'os.name': 'os.name', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', +}; + +export const hostFieldsMap: Readonly<Record<string, string>> = { + 'host.architecture': 'host.architecture', + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.mac': 'host.mac', + 'host.name': 'host.name', + ...extendMap('host', osFieldsMap), +}; + +export const processFieldsMap: Readonly<Record<string, string>> = { + 'process.hash.md5': 'process.hash.md5', + 'process.hash.sha1': 'process.hash.sha1', + 'process.hash.sha256': 'process.hash.sha256', + 'process.pid': 'process.pid', + 'process.name': 'process.name', + 'process.ppid': 'process.ppid', + 'process.args': 'process.args', + 'process.entity_id': 'process.entity_id', + 'process.executable': 'process.executable', + 'process.title': 'process.title', + 'process.thread': 'process.thread', + 'process.working_directory': 'process.working_directory', +}; + +export const agentFieldsMap: Readonly<Record<string, string>> = { + 'agent.type': 'agent.type', +}; + +export const userFieldsMap: Readonly<Record<string, string>> = { + 'user.domain': 'user.domain', + 'user.id': 'user.id', + 'user.name': 'user.name', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.full_name': 'user.full_name', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.email': 'user.email', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.hash': 'user.hash', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.group': 'user.group', +}; + +export const winlogFieldsMap: Readonly<Record<string, string>> = { + 'winlog.event_id': 'winlog.event_id', +}; + +export const suricataFieldsMap: Readonly<Record<string, string>> = { + 'suricata.eve.flow_id': 'suricata.eve.flow_id', + 'suricata.eve.proto': 'suricata.eve.proto', + 'suricata.eve.alert.signature': 'suricata.eve.alert.signature', + 'suricata.eve.alert.signature_id': 'suricata.eve.alert.signature_id', +}; + +export const tlsFieldsMap: Readonly<Record<string, string>> = { + 'tls.client_certificate.fingerprint.sha1': 'tls.client_certificate.fingerprint.sha1', + 'tls.fingerprints.ja3.hash': 'tls.fingerprints.ja3.hash', + 'tls.server_certificate.fingerprint.sha1': 'tls.server_certificate.fingerprint.sha1', +}; + +export const urlFieldsMap: Readonly<Record<string, string>> = { + 'url.original': 'url.original', + 'url.domain': 'url.domain', + 'user.username': 'user.username', + 'user.password': 'user.password', +}; + +export const httpFieldsMap: Readonly<Record<string, string>> = { + 'http.version': 'http.version', + 'http.request': 'http.request', + 'http.request.method': 'http.request.method', + 'http.request.body.bytes': 'http.request.body.bytes', + 'http.request.body.content': 'http.request.body.content', + 'http.request.referrer': 'http.request.referrer', + 'http.response.status_code': 'http.response.status_code', + 'http.response.body': 'http.response.body', + 'http.response.body.bytes': 'http.response.body.bytes', + 'http.response.body.content': 'http.response.body.content', +}; + +export const zeekFieldsMap: Readonly<Record<string, string>> = { + 'zeek.session_id': 'zeek.session_id', + 'zeek.connection.local_resp': 'zeek.connection.local_resp', + 'zeek.connection.local_orig': 'zeek.connection.local_orig', + 'zeek.connection.missed_bytes': 'zeek.connection.missed_bytes', + 'zeek.connection.state': 'zeek.connection.state', + 'zeek.connection.history': 'zeek.connection.history', + 'zeek.notice.suppress_for': 'zeek.notice.suppress_for', + 'zeek.notice.msg': 'zeek.notice.msg', + 'zeek.notice.note': 'zeek.notice.note', + 'zeek.notice.sub': 'zeek.notice.sub', + 'zeek.notice.dst': 'zeek.notice.dst', + 'zeek.notice.dropped': 'zeek.notice.dropped', + 'zeek.notice.peer_descr': 'zeek.notice.peer_descr', + 'zeek.dns.AA': 'zeek.dns.AA', + 'zeek.dns.qclass_name': 'zeek.dns.qclass_name', + 'zeek.dns.RD': 'zeek.dns.RD', + 'zeek.dns.qtype_name': 'zeek.dns.qtype_name', + 'zeek.dns.qtype': 'zeek.dns.qtype', + 'zeek.dns.query': 'zeek.dns.query', + 'zeek.dns.trans_id': 'zeek.dns.trans_id', + 'zeek.dns.qclass': 'zeek.dns.qclass', + 'zeek.dns.RA': 'zeek.dns.RA', + 'zeek.dns.TC': 'zeek.dns.TC', + 'zeek.http.resp_mime_types': 'zeek.http.resp_mime_types', + 'zeek.http.trans_depth': 'zeek.http.trans_depth', + 'zeek.http.status_msg': 'zeek.http.status_msg', + 'zeek.http.resp_fuids': 'zeek.http.resp_fuids', + 'zeek.http.tags': 'zeek.http.tags', + 'zeek.files.session_ids': 'zeek.files.session_ids', + 'zeek.files.timedout': 'zeek.files.timedout', + 'zeek.files.local_orig': 'zeek.files.local_orig', + 'zeek.files.tx_host': 'zeek.files.tx_host', + 'zeek.files.source': 'zeek.files.source', + 'zeek.files.is_orig': 'zeek.files.is_orig', + 'zeek.files.overflow_bytes': 'zeek.files.overflow_bytes', + 'zeek.files.sha1': 'zeek.files.sha1', + 'zeek.files.duration': 'zeek.files.duration', + 'zeek.files.depth': 'zeek.files.depth', + 'zeek.files.analyzers': 'zeek.files.analyzers', + 'zeek.files.mime_type': 'zeek.files.mime_type', + 'zeek.files.rx_host': 'zeek.files.rx_host', + 'zeek.files.total_bytes': 'zeek.files.total_bytes', + 'zeek.files.fuid': 'zeek.files.fuid', + 'zeek.files.seen_bytes': 'zeek.files.seen_bytes', + 'zeek.files.missing_bytes': 'zeek.files.missing_bytes', + 'zeek.files.md5': 'zeek.files.md5', + 'zeek.ssl.cipher': 'zeek.ssl.cipher', + 'zeek.ssl.established': 'zeek.ssl.established', + 'zeek.ssl.resumed': 'zeek.ssl.resumed', + 'zeek.ssl.version': 'zeek.ssl.version', +}; + +export const sourceFieldsMap: Readonly<Record<string, string>> = { + 'source.bytes': 'source.bytes', + 'source.ip': 'source.ip', + 'source.packets': 'source.packets', + 'source.port': 'source.port', + 'source.domain': 'source.domain', + 'source.geo.continent_name': 'source.geo.continent_name', + 'source.geo.country_name': 'source.geo.country_name', + 'source.geo.country_iso_code': 'source.geo.country_iso_code', + 'source.geo.city_name': 'source.geo.city_name', + 'source.geo.region_iso_code': 'source.geo.region_iso_code', + 'source.geo.region_name': 'source.geo.region_name', +}; + +export const destinationFieldsMap: Readonly<Record<string, string>> = { + 'destination.bytes': 'destination.bytes', + 'destination.ip': 'destination.ip', + 'destination.packets': 'destination.packets', + 'destination.port': 'destination.port', + 'destination.domain': 'destination.domain', + 'destination.geo.continent_name': 'destination.geo.continent_name', + 'destination.geo.country_name': 'destination.geo.country_name', + 'destination.geo.country_iso_code': 'destination.geo.country_iso_code', + 'destination.geo.city_name': 'destination.geo.city_name', + 'destination.geo.region_iso_code': 'destination.geo.region_iso_code', + 'destination.geo.region_name': 'destination.geo.region_name', +}; + +export const networkFieldsMap: Readonly<Record<string, string>> = { + 'network.bytes': 'network.bytes', + 'network.community_id': 'network.community_id', + 'network.direction': 'network.direction', + 'network.packets': 'network.packets', + 'network.protocol': 'network.protocol', + 'network.transport': 'network.transport', +}; + +export const geoFieldsMap: Readonly<Record<string, string>> = { + 'geo.region_name': 'destination.geo.region_name', + 'geo.country_iso_code': 'destination.geo.country_iso_code', +}; + +export const dnsFieldsMap: Readonly<Record<string, string>> = { + 'dns.question.name': 'dns.question.name', + 'dns.question.type': 'dns.question.type', + 'dns.resolved_ip': 'dns.resolved_ip', + 'dns.response_code': 'dns.response_code', +}; + +export const endgameFieldsMap: Readonly<Record<string, string>> = { + 'endgame.exit_code': 'endgame.exit_code', + 'endgame.file_name': 'endgame.file_name', + 'endgame.file_path': 'endgame.file_path', + 'endgame.logon_type': 'endgame.logon_type', + 'endgame.parent_process_name': 'endgame.parent_process_name', + 'endgame.pid': 'endgame.pid', + 'endgame.process_name': 'endgame.process_name', + 'endgame.subject_domain_name': 'endgame.subject_domain_name', + 'endgame.subject_logon_id': 'endgame.subject_logon_id', + 'endgame.subject_user_name': 'endgame.subject_user_name', + 'endgame.target_domain_name': 'endgame.target_domain_name', + 'endgame.target_logon_id': 'endgame.target_logon_id', + 'endgame.target_user_name': 'endgame.target_user_name', +}; + +export const eventBaseFieldsMap: Readonly<Record<string, string>> = { + 'event.action': 'event.action', + 'event.category': 'event.category', + 'event.code': 'event.code', + 'event.created': 'event.created', + 'event.dataset': 'event.dataset', + 'event.duration': 'event.duration', + 'event.end': 'event.end', + 'event.hash': 'event.hash', + 'event.id': 'event.id', + 'event.kind': 'event.kind', + 'event.module': 'event.module', + 'event.original': 'event.original', + 'event.outcome': 'event.outcome', + 'event.risk_score': 'event.risk_score', + 'event.risk_score_norm': 'event.risk_score_norm', + 'event.severity': 'event.severity', + 'event.start': 'event.start', + 'event.timezone': 'event.timezone', + 'event.type': 'event.type', +}; + +export const systemFieldsMap: Readonly<Record<string, string>> = { + 'system.audit.package.arch': 'system.audit.package.arch', + 'system.audit.package.entity_id': 'system.audit.package.entity_id', + 'system.audit.package.name': 'system.audit.package.name', + 'system.audit.package.size': 'system.audit.package.size', + 'system.audit.package.summary': 'system.audit.package.summary', + 'system.audit.package.version': 'system.audit.package.version', + 'system.auth.ssh.signature': 'system.auth.ssh.signature', + 'system.auth.ssh.method': 'system.auth.ssh.method', +}; + +export const signalFieldsMap: Readonly<Record<string, string>> = { + 'signal.original_time': 'signal.original_time', + 'signal.rule.id': 'signal.rule.id', + 'signal.rule.saved_id': 'signal.rule.saved_id', + 'signal.rule.timeline_id': 'signal.rule.timeline_id', + 'signal.rule.timeline_title': 'signal.rule.timeline_title', + 'signal.rule.output_index': 'signal.rule.output_index', + 'signal.rule.from': 'signal.rule.from', + 'signal.rule.index': 'signal.rule.index', + 'signal.rule.language': 'signal.rule.language', + 'signal.rule.query': 'signal.rule.query', + 'signal.rule.to': 'signal.rule.to', + 'signal.rule.filters': 'signal.rule.filters', + 'signal.rule.rule_id': 'signal.rule.rule_id', + 'signal.rule.false_positives': 'signal.rule.false_positives', + 'signal.rule.max_signals': 'signal.rule.max_signals', + 'signal.rule.risk_score': 'signal.rule.risk_score', + 'signal.rule.description': 'signal.rule.description', + 'signal.rule.name': 'signal.rule.name', + 'signal.rule.immutable': 'signal.rule.immutable', + 'signal.rule.references': 'signal.rule.references', + 'signal.rule.severity': 'signal.rule.severity', + 'signal.rule.tags': 'signal.rule.tags', + 'signal.rule.threat': 'signal.rule.threat', + 'signal.rule.type': 'signal.rule.type', + 'signal.rule.size': 'signal.rule.size', + 'signal.rule.enabled': 'signal.rule.enabled', + 'signal.rule.created_at': 'signal.rule.created_at', + 'signal.rule.updated_at': 'signal.rule.updated_at', + 'signal.rule.created_by': 'signal.rule.created_by', + 'signal.rule.updated_by': 'signal.rule.updated_by', + 'signal.rule.version': 'signal.rule.version', + 'signal.rule.note': 'signal.rule.note', + 'signal.rule.threshold': 'signal.rule.threshold', + 'signal.rule.exceptions_list': 'signal.rule.exceptions_list', +}; + +export const ruleFieldsMap: Readonly<Record<string, string>> = { + 'rule.reference': 'rule.reference', +}; + +export const eventFieldsMap: Readonly<Record<string, string>> = { + timestamp: '@timestamp', + '@timestamp': '@timestamp', + message: 'message', + ...{ ...agentFieldsMap }, + ...{ ...auditdMap }, + ...{ ...destinationFieldsMap }, + ...{ ...dnsFieldsMap }, + ...{ ...endgameFieldsMap }, + ...{ ...eventBaseFieldsMap }, + ...{ ...fileMap }, + ...{ ...geoFieldsMap }, + ...{ ...hostFieldsMap }, + ...{ ...networkFieldsMap }, + ...{ ...ruleFieldsMap }, + ...{ ...signalFieldsMap }, + ...{ ...sourceFieldsMap }, + ...{ ...suricataFieldsMap }, + ...{ ...systemFieldsMap }, + ...{ ...tlsFieldsMap }, + ...{ ...zeekFieldsMap }, + ...{ ...httpFieldsMap }, + ...{ ...userFieldsMap }, + ...{ ...winlogFieldsMap }, + ...{ ...processFieldsMap }, +}; diff --git a/x-pack/plugins/osquery/common/ecs/endgame/index.ts b/x-pack/plugins/osquery/common/ecs/endgame/index.ts new file mode 100644 index 0000000000000..d2fc5d61527a5 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/endgame/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface EndgameEcs { + exit_code?: number[]; + file_name?: string[]; + file_path?: string[]; + logon_type?: number[]; + parent_process_name?: string[]; + pid?: number[]; + process_name?: string[]; + subject_domain_name?: string[]; + subject_logon_id?: string[]; + subject_user_name?: string[]; + target_domain_name?: string[]; + target_logon_id?: string[]; + target_user_name?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/event/index.ts b/x-pack/plugins/osquery/common/ecs/event/index.ts new file mode 100644 index 0000000000000..c3b7b1d0b8436 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/event/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface EventEcs { + action?: string[]; + category?: string[]; + code?: string[]; + created?: string[]; + dataset?: string[]; + duration?: number[]; + end?: string[]; + hash?: string[]; + id?: string[]; + kind?: string[]; + module?: string[]; + original?: string[]; + outcome?: string[]; + risk_score?: number[]; + risk_score_norm?: number[]; + severity?: number[]; + start?: string[]; + timezone?: string[]; + type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/file/index.ts b/x-pack/plugins/osquery/common/ecs/file/index.ts new file mode 100644 index 0000000000000..b01e9514bf425 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/file/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface CodeSignature { + subject_name: string[]; + trusted: string[]; +} +export interface Ext { + code_signature: CodeSignature[] | CodeSignature; +} +export interface Hash { + sha256: string[]; +} + +export interface FileEcs { + name?: string[]; + path?: string[]; + target_path?: string[]; + extension?: string[]; + Ext?: Ext; + type?: string[]; + device?: string[]; + inode?: string[]; + uid?: string[]; + owner?: string[]; + gid?: string[]; + group?: string[]; + mode?: string[]; + size?: number[]; + mtime?: string[]; + ctime?: string[]; + hash?: Hash; +} diff --git a/x-pack/plugins/osquery/common/ecs/geo/index.ts b/x-pack/plugins/osquery/common/ecs/geo/index.ts new file mode 100644 index 0000000000000..4a4c76adb097b --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/geo/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface GeoEcs { + city_name?: string[]; + continent_name?: string[]; + country_iso_code?: string[]; + country_name?: string[]; + location?: Location; + region_iso_code?: string[]; + region_name?: string[]; +} + +export interface Location { + lon?: number[]; + lat?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/host/index.ts b/x-pack/plugins/osquery/common/ecs/host/index.ts new file mode 100644 index 0000000000000..27cbe433f9bf7 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/host/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface HostEcs { + architecture?: string[]; + id?: string[]; + ip?: string[]; + mac?: string[]; + name?: string[]; + os?: OsEcs; + type?: string[]; +} + +export interface OsEcs { + platform?: string[]; + name?: string[]; + full?: string[]; + family?: string[]; + version?: string[]; + kernel?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/http/index.ts b/x-pack/plugins/osquery/common/ecs/http/index.ts new file mode 100644 index 0000000000000..c5c5d1e140d0a --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/http/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface HttpEcs { + version?: string[]; + request?: HttpRequestData; + response?: HttpResponseData; +} + +export interface HttpRequestData { + method?: string[]; + body?: HttpBodyData; + referrer?: string[]; + bytes?: number[]; +} + +export interface HttpBodyData { + content?: string[]; + bytes?: number[]; +} + +export interface HttpResponseData { + status_code?: number[]; + body?: HttpBodyData; + bytes?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/index.ts b/x-pack/plugins/osquery/common/ecs/index.ts new file mode 100644 index 0000000000000..b8190463f5da5 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AgentEcs } from './agent'; +import { AuditdEcs } from './auditd'; +import { DestinationEcs } from './destination'; +import { DnsEcs } from './dns'; +import { EndgameEcs } from './endgame'; +import { EventEcs } from './event'; +import { FileEcs } from './file'; +import { GeoEcs } from './geo'; +import { HostEcs } from './host'; +import { NetworkEcs } from './network'; +import { RuleEcs } from './rule'; +import { SignalEcs } from './signal'; +import { SourceEcs } from './source'; +import { SuricataEcs } from './suricata'; +import { TlsEcs } from './tls'; +import { ZeekEcs } from './zeek'; +import { HttpEcs } from './http'; +import { UrlEcs } from './url'; +import { UserEcs } from './user'; +import { WinlogEcs } from './winlog'; +import { ProcessEcs } from './process'; +import { SystemEcs } from './system'; + +export interface Ecs { + _id: string; + _index?: string; + agent?: AgentEcs; + auditd?: AuditdEcs; + destination?: DestinationEcs; + dns?: DnsEcs; + endgame?: EndgameEcs; + event?: EventEcs; + geo?: GeoEcs; + host?: HostEcs; + network?: NetworkEcs; + rule?: RuleEcs; + signal?: SignalEcs; + source?: SourceEcs; + suricata?: SuricataEcs; + tls?: TlsEcs; + zeek?: ZeekEcs; + http?: HttpEcs; + url?: UrlEcs; + timestamp?: string; + message?: string[]; + user?: UserEcs; + winlog?: WinlogEcs; + process?: ProcessEcs; + file?: FileEcs; + system?: SystemEcs; +} diff --git a/x-pack/plugins/osquery/common/ecs/network/index.ts b/x-pack/plugins/osquery/common/ecs/network/index.ts new file mode 100644 index 0000000000000..18f7583d12231 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/network/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface NetworkEcs { + bytes?: number[]; + community_id?: string[]; + direction?: string[]; + packets?: number[]; + protocol?: string[]; + transport?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/process/index.ts b/x-pack/plugins/osquery/common/ecs/process/index.ts new file mode 100644 index 0000000000000..451f1455f55d4 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/process/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ProcessEcs { + entity_id?: string[]; + hash?: ProcessHashData; + pid?: number[]; + name?: string[]; + ppid?: number[]; + args?: string[]; + executable?: string[]; + title?: string[]; + thread?: Thread; + working_directory?: string[]; +} + +export interface ProcessHashData { + md5?: string[]; + sha1?: string[]; + sha256?: string[]; +} + +export interface Thread { + id?: number[]; + start?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/rule/index.ts b/x-pack/plugins/osquery/common/ecs/rule/index.ts new file mode 100644 index 0000000000000..47d1323371941 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/rule/index.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface RuleEcs { + id?: string[]; + rule_id?: string[]; + name?: string[]; + false_positives: string[]; + saved_id?: string[]; + timeline_id?: string[]; + timeline_title?: string[]; + max_signals?: number[]; + risk_score?: string[]; + output_index?: string[]; + description?: string[]; + from?: string[]; + immutable?: boolean[]; + index?: string[]; + interval?: string[]; + language?: string[]; + query?: string[]; + references?: string[]; + severity?: string[]; + tags?: string[]; + threat?: unknown; + threshold?: { + field: string; + value: number; + }; + type?: string[]; + size?: string[]; + to?: string[]; + enabled?: boolean[]; + filters?: unknown; + created_at?: string[]; + updated_at?: string[]; + created_by?: string[]; + updated_by?: string[]; + version?: string[]; + note?: string[]; + building_block_type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/signal/index.ts b/x-pack/plugins/osquery/common/ecs/signal/index.ts new file mode 100644 index 0000000000000..6482b892bc18d --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/signal/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RuleEcs } from '../rule'; + +export interface SignalEcs { + rule?: RuleEcs; + original_time?: string[]; + status?: string[]; + group?: { + id?: string[]; + }; +} diff --git a/x-pack/plugins/osquery/common/ecs/source/index.ts b/x-pack/plugins/osquery/common/ecs/source/index.ts new file mode 100644 index 0000000000000..2c8618f4edcd0 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/source/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GeoEcs } from '../geo'; + +export interface SourceEcs { + bytes?: number[]; + ip?: string[]; + port?: number[]; + domain?: string[]; + geo?: GeoEcs; + packets?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/suricata/index.ts b/x-pack/plugins/osquery/common/ecs/suricata/index.ts new file mode 100644 index 0000000000000..0ef253ada2620 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/suricata/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface SuricataEcs { + eve?: SuricataEveData; +} + +export interface SuricataEveData { + alert?: SuricataAlertData; + flow_id?: number[]; + proto?: string[]; +} + +export interface SuricataAlertData { + signature?: string[]; + signature_id?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/system/index.ts b/x-pack/plugins/osquery/common/ecs/system/index.ts new file mode 100644 index 0000000000000..641a10209c150 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/system/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface SystemEcs { + audit?: AuditEcs; + auth?: AuthEcs; +} + +export interface AuditEcs { + package?: PackageEcs; +} + +export interface PackageEcs { + arch?: string[]; + entity_id?: string[]; + name?: string[]; + size?: number[]; + summary?: string[]; + version?: string[]; +} + +export interface AuthEcs { + ssh?: SshEcs; +} + +export interface SshEcs { + method?: string[]; + signature?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/tls/index.ts b/x-pack/plugins/osquery/common/ecs/tls/index.ts new file mode 100644 index 0000000000000..1533d46417d0a --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/tls/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface TlsEcs { + client_certificate?: TlsClientCertificateData; + fingerprints?: TlsFingerprintsData; + server_certificate?: TlsServerCertificateData; +} + +export interface TlsClientCertificateData { + fingerprint?: FingerprintData; +} + +export interface FingerprintData { + sha1?: string[]; +} + +export interface TlsFingerprintsData { + ja3?: TlsJa3Data; +} + +export interface TlsJa3Data { + hash?: string[]; +} + +export interface TlsServerCertificateData { + fingerprint?: FingerprintData; +} diff --git a/x-pack/plugins/osquery/common/ecs/url/index.ts b/x-pack/plugins/osquery/common/ecs/url/index.ts new file mode 100644 index 0000000000000..91d7958c813a3 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/url/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface UrlEcs { + domain?: string[]; + original?: string[]; + username?: string[]; + password?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/user/index.ts b/x-pack/plugins/osquery/common/ecs/user/index.ts new file mode 100644 index 0000000000000..35de2e0459ceb --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/user/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface UserEcs { + domain?: string[]; + id?: string[]; + name?: string[]; + full_name?: string[]; + email?: string[]; + hash?: string[]; + group?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/winlog/index.ts b/x-pack/plugins/osquery/common/ecs/winlog/index.ts new file mode 100644 index 0000000000000..a449fb9130e6f --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/winlog/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface WinlogEcs { + event_id?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/zeek/index.ts b/x-pack/plugins/osquery/common/ecs/zeek/index.ts new file mode 100644 index 0000000000000..2563612f09bfb --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/zeek/index.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ZeekEcs { + session_id?: string[]; + connection?: ZeekConnectionData; + notice?: ZeekNoticeData; + dns?: ZeekDnsData; + http?: ZeekHttpData; + files?: ZeekFileData; + ssl?: ZeekSslData; +} + +export interface ZeekConnectionData { + local_resp?: boolean[]; + local_orig?: boolean[]; + missed_bytes?: number[]; + state?: string[]; + history?: string[]; +} + +export interface ZeekNoticeData { + suppress_for?: number[]; + msg?: string[]; + note?: string[]; + sub?: string[]; + dst?: string[]; + dropped?: boolean[]; + peer_descr?: string[]; +} + +export interface ZeekDnsData { + AA?: boolean[]; + qclass_name?: string[]; + RD?: boolean[]; + qtype_name?: string[]; + rejected?: boolean[]; + qtype?: string[]; + query?: string[]; + trans_id?: number[]; + qclass?: string[]; + RA?: boolean[]; + TC?: boolean[]; +} + +export interface ZeekHttpData { + resp_mime_types?: string[]; + trans_depth?: string[]; + status_msg?: string[]; + resp_fuids?: string[]; + tags?: string[]; +} + +export interface ZeekFileData { + session_ids?: string[]; + timedout?: boolean[]; + local_orig?: boolean[]; + tx_host?: string[]; + source?: string[]; + is_orig?: boolean[]; + overflow_bytes?: number[]; + sha1?: string[]; + duration?: number[]; + depth?: number[]; + analyzers?: string[]; + mime_type?: string[]; + rx_host?: string[]; + total_bytes?: number[]; + fuid?: string[]; + seen_bytes?: number[]; + missing_bytes?: number[]; + md5?: string[]; +} + +export interface ZeekSslData { + cipher?: string[]; + established?: boolean[]; + resumed?: boolean[]; + version?: string[]; +} diff --git a/x-pack/plugins/osquery/common/index.ts b/x-pack/plugins/osquery/common/index.ts new file mode 100644 index 0000000000000..e4bbf4781e881 --- /dev/null +++ b/x-pack/plugins/osquery/common/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './constants'; + +export const PLUGIN_ID = 'osquery'; +export const PLUGIN_NAME = 'osquery'; diff --git a/x-pack/plugins/osquery/common/search_strategy/common/index.ts b/x-pack/plugins/osquery/common/search_strategy/common/index.ts new file mode 100644 index 0000000000000..0c1f13dac2e69 --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/common/index.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../src/plugins/data/common'; + +export type Maybe<T> = T | null; + +export type SearchHit = IEsSearchResponse<object>['rawResponse']['hits']['hits'][0]; + +export interface TotalValue { + value: number; + relation: string; +} + +export interface Inspect { + dsl: string[]; +} + +export interface PageInfoPaginated { + activePage: number; + fakeTotalCount: number; + showMorePagesIndicator: boolean; +} + +export interface CursorType { + value?: Maybe<string>; + tiebreaker?: Maybe<string>; +} + +export enum Direction { + asc = 'asc', + desc = 'desc', +} + +export interface SortField<Field = string> { + field: Field; + direction: Direction; +} + +export interface TimerangeInput { + /** The interval string to use for last bucket. The format is '{value}{unit}'. For example '5m' would return the metrics for the last 5 minutes of the timespan. */ + interval: string; + /** The end of the timerange */ + to: string; + /** The beginning of the timerange */ + from: string; +} + +export interface PaginationInput { + /** The limit parameter allows you to configure the maximum amount of items to be returned */ + limit: number; + /** The cursor parameter defines the next result you want to fetch */ + cursor?: Maybe<string>; + /** The tiebreaker parameter allow to be more precise to fetch the next item */ + tiebreaker?: Maybe<string>; +} + +export interface PaginationInputPaginated { + /** The activePage parameter defines the page of results you want to fetch */ + activePage: number; + /** The cursorStart parameter defines the start of the results to be displayed */ + cursorStart: number; + /** The fakePossibleCount parameter determines the total count in order to show 5 additional pages */ + fakePossibleCount: number; + /** The querySize parameter is the number of items to be returned */ + querySize: number; +} + +export interface DocValueFields { + field: string; + format?: string | null; +} + +export interface Explanation { + value: number; + description: string; + details: Explanation[]; +} + +export interface ShardsResponse { + total: number; + successful: number; + failed: number; + skipped: number; +} + +export interface TotalHit { + value: number; + relation: string; +} + +export interface Hit { + _index: string; + _type: string; + _id: string; + _score: number | null; +} + +export interface Hits<T, U> { + hits: { + total: T; + max_score: number | null; + hits: U[]; + }; +} + +export interface GenericBuckets { + key: string; + doc_count: number; +} + +export type StringOrNumber = string | number; + +export interface TimerangeFilter { + range: { + [timestamp: string]: { + gte: string; + lte: string; + format: string; + }; + }; +} diff --git a/x-pack/plugins/osquery/common/search_strategy/index.ts b/x-pack/plugins/osquery/common/search_strategy/index.ts new file mode 100644 index 0000000000000..ff9a8d1aa64c9 --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './common'; +export * from './osquery'; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts new file mode 100644 index 0000000000000..076fa02747573 --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; +import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; + +import { Inspect, Maybe, PageInfoPaginated } from '../../common'; +import { RequestOptions, RequestOptionsPaginated } from '../..'; + +export type ActionEdges = SearchResponse<object>['hits']['hits']; + +export type ActionResultEdges = SearchResponse<object>['hits']['hits']; +export interface ActionsStrategyResponse extends IEsSearchResponse { + edges: ActionEdges; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export type ActionsRequestOptions = RequestOptionsPaginated<{}>; + +export interface ActionDetailsStrategyResponse extends IEsSearchResponse { + actionDetails: Record<string, any>; + inspect?: Maybe<Inspect>; +} + +export interface ActionDetailsRequestOptions extends RequestOptions { + actionId: string; +} + +export interface ActionResultsStrategyResponse extends IEsSearchResponse { + edges: ActionResultEdges; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export interface ActionResultsRequestOptions extends RequestOptionsPaginated { + actionId: string; +} diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts new file mode 100644 index 0000000000000..64a570ef5525b --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; + +import { Inspect, Maybe, PageInfoPaginated } from '../../common'; +import { RequestOptionsPaginated } from '../..'; +import { Agent } from '../../../shared_imports'; + +export interface AgentsStrategyResponse extends IEsSearchResponse { + edges: Agent[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export type AgentsRequestOptions = RequestOptionsPaginated<{}>; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/common/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/common/index.ts new file mode 100644 index 0000000000000..fc58184f40afe --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/common/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CloudEcs } from '../../../ecs/cloud'; +import { HostEcs, OsEcs } from '../../../ecs/host'; +import { Hit, Hits, Maybe, SearchHit, StringOrNumber, TotalValue } from '../../common'; + +export enum HostPolicyResponseActionStatus { + success = 'success', + failure = 'failure', + warning = 'warning', +} + +export enum HostsFields { + lastSeen = 'lastSeen', + hostName = 'hostName', +} + +export interface EndpointFields { + endpointPolicy?: Maybe<string>; + sensorVersion?: Maybe<string>; + policyStatus?: Maybe<HostPolicyResponseActionStatus>; +} + +export interface HostItem { + _id?: Maybe<string>; + cloud?: Maybe<CloudEcs>; + endpoint?: Maybe<EndpointFields>; + host?: Maybe<HostEcs>; + lastSeen?: Maybe<string>; +} + +export interface HostValue { + value: number; + value_as_string: string; +} + +export interface HostBucketItem { + key: string; + doc_count: number; + timestamp: HostValue; +} + +export interface HostBuckets { + buckets: HostBucketItem[]; +} + +export interface HostOsHitsItem { + hits: { + total: TotalValue | number; + max_score: number | null; + hits: Array<{ + _source: { host: { os: Maybe<OsEcs> } }; + sort?: [number]; + _index?: string; + _type?: string; + _id?: string; + _score?: number | null; + }>; + }; +} + +export interface HostAggEsItem { + cloud_instance_id?: HostBuckets; + cloud_machine_type?: HostBuckets; + cloud_provider?: HostBuckets; + cloud_region?: HostBuckets; + firstSeen?: HostValue; + host_architecture?: HostBuckets; + host_id?: HostBuckets; + host_ip?: HostBuckets; + host_mac?: HostBuckets; + host_name?: HostBuckets; + host_os_name?: HostBuckets; + host_os_version?: HostBuckets; + host_type?: HostBuckets; + key?: string; + lastSeen?: HostValue; + os?: HostOsHitsItem; +} + +export interface HostEsData extends SearchHit { + sort: string[]; + aggregations: { + host_count: { + value: number; + }; + host_data: { + buckets: HostAggEsItem[]; + }; + }; +} + +export interface HostAggEsData extends SearchHit { + sort: string[]; + aggregations: HostAggEsItem; +} + +export interface HostHit extends Hit { + _source: { + '@timestamp'?: string; + host: HostEcs; + }; + cursor?: string; + firstSeen?: string; + sort?: StringOrNumber[]; +} + +export type HostHits = Hits<number, HostHit>; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts new file mode 100644 index 0000000000000..70882ffcc2e5c --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; +import { ESQuery } from '../../typed_json'; +import { + ActionsStrategyResponse, + ActionsRequestOptions, + ActionDetailsStrategyResponse, + ActionDetailsRequestOptions, + ActionResultsStrategyResponse, + ActionResultsRequestOptions, +} from './actions'; +import { AgentsStrategyResponse, AgentsRequestOptions } from './agents'; +import { ResultsStrategyResponse, ResultsRequestOptions } from './results'; + +import { DocValueFields, SortField, PaginationInputPaginated } from '../common'; + +export * from './actions'; +export * from './agents'; +export * from './results'; + +export enum OsqueryQueries { + actions = 'actions', + actionDetails = 'actionDetails', + actionResults = 'actionResults', + agents = 'agents', + results = 'results', +} + +export type FactoryQueryTypes = OsqueryQueries; + +export interface RequestBasicOptions extends IEsSearchRequest { + filterQuery: ESQuery | string | undefined; + docValueFields?: DocValueFields[]; + factoryQueryType?: FactoryQueryTypes; +} + +/** A mapping of semantic fields to their document counterparts */ + +export type RequestOptions = RequestBasicOptions; + +export interface RequestOptionsPaginated<Field = string> extends RequestBasicOptions { + pagination: PaginationInputPaginated; + sort: SortField<Field>; +} + +export type StrategyResponseType<T extends FactoryQueryTypes> = T extends OsqueryQueries.actions + ? ActionsStrategyResponse + : T extends OsqueryQueries.actionDetails + ? ActionDetailsStrategyResponse + : T extends OsqueryQueries.actionResults + ? ActionResultsStrategyResponse + : T extends OsqueryQueries.agents + ? AgentsStrategyResponse + : T extends OsqueryQueries.results + ? ResultsStrategyResponse + : never; + +export type StrategyRequestType<T extends FactoryQueryTypes> = T extends OsqueryQueries.actions + ? ActionsRequestOptions + : T extends OsqueryQueries.actionDetails + ? ActionDetailsRequestOptions + : T extends OsqueryQueries.actionResults + ? ActionResultsRequestOptions + : T extends OsqueryQueries.agents + ? AgentsRequestOptions + : T extends OsqueryQueries.results + ? ResultsRequestOptions + : never; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts new file mode 100644 index 0000000000000..65df2591338e4 --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; +import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; + +import { Inspect, Maybe, PageInfoPaginated } from '../../common'; +import { RequestOptionsPaginated } from '../..'; + +export type ResultEdges = SearchResponse<unknown>['hits']['hits']; + +export interface ResultsStrategyResponse extends IEsSearchResponse { + edges: ResultEdges; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export interface ResultsRequestOptions extends RequestOptionsPaginated<{}> { + actionId: string; +} diff --git a/x-pack/plugins/osquery/common/shared_imports.ts b/x-pack/plugins/osquery/common/shared_imports.ts new file mode 100644 index 0000000000000..58133db6aa1b0 --- /dev/null +++ b/x-pack/plugins/osquery/common/shared_imports.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Agent } from '../../fleet/common'; diff --git a/x-pack/plugins/osquery/common/typed_json.ts b/x-pack/plugins/osquery/common/typed_json.ts new file mode 100644 index 0000000000000..0d6e3877eae55 --- /dev/null +++ b/x-pack/plugins/osquery/common/typed_json.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DslQuery, Filter } from 'src/plugins/data/common'; + +import { JsonObject } from '../../../../src/plugins/kibana_utils/common'; + +export type ESQuery = + | ESRangeQuery + | ESQueryStringQuery + | ESMatchQuery + | ESTermQuery + | ESBoolQuery + | JsonObject; + +export interface ESRangeQuery { + range: { + [name: string]: { + gte: number; + lte: number; + format: string; + }; + }; +} + +export interface ESMatchQuery { + match: { + [name: string]: { + query: string; + operator: string; + zero_terms_query: string; + }; + }; +} + +export interface ESQueryStringQuery { + query_string: { + query: string; + analyze_wildcard: boolean; + }; +} + +export interface ESTermQuery { + term: Record<string, string>; +} + +export interface ESBoolQuery { + bool: { + must: DslQuery[]; + filter: Filter[]; + should: never[]; + must_not: Filter[]; + }; +} diff --git a/x-pack/plugins/osquery/common/utility_types.ts b/x-pack/plugins/osquery/common/utility_types.ts new file mode 100644 index 0000000000000..4a7bd02d0442b --- /dev/null +++ b/x-pack/plugins/osquery/common/utility_types.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as runtimeTypes from 'io-ts'; +import { ReactNode } from 'react'; + +// This type is for typing EuiDescriptionList +export interface DescriptionList { + title: NonNullable<ReactNode>; + description: NonNullable<ReactNode>; +} + +export const unionWithNullType = <T extends runtimeTypes.Mixed>(type: T) => + runtimeTypes.union([type, runtimeTypes.null]); + +export const stringEnum = <T>(enumObj: T, enumName = 'enum') => + new runtimeTypes.Type<T[keyof T], string>( + enumName, + (u): u is T[keyof T] => Object.values(enumObj).includes(u), + (u, c) => + Object.values(enumObj).includes(u) + ? runtimeTypes.success(u as T[keyof T]) + : runtimeTypes.failure(u, c), + (a) => (a as unknown) as string + ); + +/** + * Unreachable Assertion helper for scenarios like exhaustive switches. + * For references see: https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript + * This "x" should _always_ be a type of "never" and not change to "unknown" or any other type. See above link or the generic + * concept of exhaustive checks in switch blocks. + * + * Optionally you can avoid the use of this by using early returns and TypeScript will clear your type checking without complaints + * but there are situations and times where this function might still be needed. + * @param x Unreachable field + * @param message Message of error thrown + */ +export const assertUnreachable = ( + x: never, // This should always be a type of "never" + message = 'Unknown Field in switch statement' +): never => { + throw new Error(`${message}: ${x}`); +}; diff --git a/x-pack/plugins/osquery/common/utils/build_query/filters.ts b/x-pack/plugins/osquery/common/utils/build_query/filters.ts new file mode 100644 index 0000000000000..bde03be3f5edc --- /dev/null +++ b/x-pack/plugins/osquery/common/utils/build_query/filters.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, isString } from 'lodash/fp'; + +import { ESQuery } from '../../../common/typed_json'; + +export const createQueryFilterClauses = (filterQuery: ESQuery | string | undefined) => + !isEmpty(filterQuery) ? [isString(filterQuery) ? JSON.parse(filterQuery) : filterQuery] : []; diff --git a/x-pack/plugins/osquery/common/utils/build_query/index.ts b/x-pack/plugins/osquery/common/utils/build_query/index.ts new file mode 100644 index 0000000000000..05606d556528c --- /dev/null +++ b/x-pack/plugins/osquery/common/utils/build_query/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './filters'; + +export const inspectStringifyObject = (obj: unknown) => { + try { + return JSON.stringify(obj, null, 2); + } catch { + return 'Sorry about that, something went wrong.'; + } +}; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/jest.config.js b/x-pack/plugins/osquery/jest.config.js similarity index 82% rename from x-pack/plugins/vis_type_timeseries_enhanced/jest.config.js rename to x-pack/plugins/osquery/jest.config.js index 17c5c87e3ccc2..8132491df8534 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/jest.config.js +++ b/x-pack/plugins/osquery/jest.config.js @@ -7,5 +7,5 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', - roots: ['<rootDir>/x-pack/plugins/vis_type_timeseries_enhanced'], + roots: ['<rootDir>/x-pack/plugins/osquery'], }; diff --git a/x-pack/plugins/osquery/kibana.json b/x-pack/plugins/osquery/kibana.json new file mode 100644 index 0000000000000..f6e90b9460506 --- /dev/null +++ b/x-pack/plugins/osquery/kibana.json @@ -0,0 +1,29 @@ +{ + "configPath": [ + "xpack", + "osquery" + ], + "extraPublicDirs": [ + "common" + ], + "id": "osquery", + "kibanaVersion": "kibana", + "optionalPlugins": [ + "home" + ], + "requiredBundles": [ + "esUiShared", + "kibanaUtils", + "kibanaReact", + "kibanaUtils" + ], + "requiredPlugins": [ + "data", + "dataEnhanced", + "fleet", + "navigation" + ], + "server": true, + "ui": true, + "version": "8.0.0" +} diff --git a/x-pack/plugins/osquery/public/action_results/action_results_table.tsx b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx new file mode 100644 index 0000000000000..68424d848a9c7 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, isEqual, keys, map } from 'lodash/fp'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiDataGridSorting } from '@elastic/eui'; +import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; + +import { useAllResults } from './use_action_results'; +import { Direction, ResultEdges } from '../../common/search_strategy'; + +const DataContext = createContext<ResultEdges>([]); + +interface ActionResultsTableProps { + actionId: string; +} + +const ActionResultsTableComponent: React.FC<ActionResultsTableProps> = ({ actionId }) => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + const onChangeItemsPerPage = useCallback( + (pageSize) => + setPagination((currentPagination) => ({ + ...currentPagination, + pageSize, + pageIndex: 0, + })), + [setPagination] + ); + const onChangePage = useCallback( + (pageIndex) => setPagination((currentPagination) => ({ ...currentPagination, pageIndex })), + [setPagination] + ); + + const [columns, setColumns] = useState<EuiDataGridColumn[]>([]); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); + + const [, { results, totalCount }] = useAllResults({ + actionId, + activePage: pagination.pageIndex, + limit: pagination.pageSize, + direction: Direction.asc, + sortField: '@timestamp', + }); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState<string[]>([]); // initialize to the full set of columns + + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns }), [ + visibleColumns, + setVisibleColumns, + ]); + + const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo( + () => ({ rowIndex, columnId, setCellProps }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const data = useContext(DataContext); + const value = data[rowIndex].fields[columnId]; + + return !isEmpty(value) ? value : '-'; + }, + [] + ); + + const tableSorting: EuiDataGridSorting = useMemo( + () => ({ columns: sortingColumns, onSort: setSortingColumns }), + [sortingColumns] + ); + + const tablePagination = useMemo( + () => ({ + ...pagination, + pageSizeOptions: [10, 50, 100], + onChangeItemsPerPage, + onChangePage, + }), + [onChangeItemsPerPage, onChangePage, pagination] + ); + + useEffect(() => { + const newColumns = keys(results[0]?.fields) + .sort() + .map((fieldName) => ({ + id: fieldName, + displayAsText: fieldName.split('.')[1], + defaultSortDirection: Direction.asc, + })); + + if (!isEqual(columns, newColumns)) { + setColumns(newColumns); + setVisibleColumns(map('id', newColumns)); + } + }, [columns, results]); + + return ( + <DataContext.Provider value={results}> + <EuiDataGrid + aria-label="Osquery results" + columns={columns} + columnVisibility={columnVisibility} + rowCount={totalCount} + renderCellValue={renderCellValue} + sorting={tableSorting} + pagination={tablePagination} + /> + </DataContext.Provider> + ); +}; + +export const ActionResultsTable = React.memo(ActionResultsTableComponent); diff --git a/x-pack/plugins/osquery/public/action_results/helpers.ts b/x-pack/plugins/osquery/public/action_results/helpers.ts new file mode 100644 index 0000000000000..9f908e16c2eb2 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PaginationInputPaginated, + FactoryQueryTypes, + StrategyResponseType, + Inspect, +} from '../../common/search_strategy'; + +export type InspectResponse = Inspect & { response: string[] }; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number, + isBucketSort?: boolean +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: isBucketSort ? limit : limit + cursorStart, + }; +}; + +export const getInspectResponse = <T extends FactoryQueryTypes>( + response: StrategyResponseType<T>, + prevResponse: InspectResponse +): InspectResponse => ({ + dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, +}); diff --git a/x-pack/plugins/osquery/public/action_results/translations.ts b/x-pack/plugins/osquery/public/action_results/translations.ts new file mode 100644 index 0000000000000..54c8ecebc60c0 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', { + defaultMessage: `An error has occurred on all results search`, +}); + +export const FAIL_ALL_RESULTS = i18n.translate('xpack.osquery.results.failSearchDescription', { + defaultMessage: `Failed to fetch results`, +}); diff --git a/x-pack/plugins/osquery/public/action_results/use_action_results.ts b/x-pack/plugins/osquery/public/action_results/use_action_results.ts new file mode 100644 index 0000000000000..2c54606bf3fbb --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/use_action_results.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + ResultEdges, + PageInfoPaginated, + DocValueFields, + OsqueryQueries, + ResultsRequestOptions, + ResultsStrategyResponse, + Direction, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'resultsAllQuery'; + +export interface ResultsArgs { + results: ResultEdges; + id: string; + inspect: InspectResponse; + isInspected: boolean; + pageInfo: PageInfoPaginated; + totalCount: number; +} + +interface UseAllResults { + actionId: string; + activePage: number; + direction: Direction; + limit: number; + sortField: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useAllResults = ({ + actionId, + activePage, + direction, + limit, + sortField, + docValueFields, + filterQuery, + skip = false, +}: UseAllResults): [boolean, ResultsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [resultsRequest, setHostRequest] = useState<ResultsRequestOptions | null>(null); + + const [resultsResponse, setResultsResponse] = useState<ResultsArgs>({ + results: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + totalCount: -1, + }); + + const resultsSearch = useCallback( + (request: ResultsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<ResultsRequestOptions, ResultsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setResultsResponse((prevResponse) => ({ + ...prevResponse, + results: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ALL_RESULTS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_ALL_RESULTS, text: msg.message }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + actionId, + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.actionResults, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: { + direction, + field: sortField, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [actionId, activePage, direction, docValueFields, filterQuery, limit, sortField]); + + useEffect(() => { + resultsSearch(resultsRequest); + }, [resultsRequest, resultsSearch]); + + return [loading, resultsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx new file mode 100644 index 0000000000000..917e915d9d9dc --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, isEqual, keys, map } from 'lodash/fp'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiDataGridSorting } from '@elastic/eui'; +import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; + +import { useAllActions } from './use_all_actions'; +import { ActionEdges, Direction } from '../../common/search_strategy'; + +const DataContext = createContext<ActionEdges>([]); + +const ActionsTableComponent = () => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + const onChangeItemsPerPage = useCallback( + (pageSize) => + setPagination((currentPagination) => ({ + ...currentPagination, + pageSize, + pageIndex: 0, + })), + [setPagination] + ); + const onChangePage = useCallback( + (pageIndex) => setPagination((currentPagination) => ({ ...currentPagination, pageIndex })), + [setPagination] + ); + + const [columns, setColumns] = useState<EuiDataGridColumn[]>([]); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); + + const [, { actions, totalCount }] = useAllActions({ + activePage: pagination.pageIndex, + limit: pagination.pageSize, + direction: Direction.asc, + sortField: '@timestamp', + }); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState<string[]>([]); // initialize to the full set of columns + + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns }), [ + visibleColumns, + setVisibleColumns, + ]); + + const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo(() => { + return ({ rowIndex, columnId, setCellProps }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const data = useContext(DataContext); + const value = data[rowIndex].fields[columnId]; + + return !isEmpty(value) ? value : '-'; + }; + }, []); + + const tableSorting: EuiDataGridSorting = useMemo( + () => ({ columns: sortingColumns, onSort: setSortingColumns }), + [setSortingColumns, sortingColumns] + ); + + const tablePagination = useMemo( + () => ({ + ...pagination, + pageSizeOptions: [10, 50, 100], + onChangeItemsPerPage, + onChangePage, + }), + [onChangeItemsPerPage, onChangePage, pagination] + ); + + useEffect(() => { + const newColumns = keys(actions[0]?.fields) + .sort() + .map((fieldName) => ({ + id: fieldName, + displayAsText: fieldName.split('.')[1], + defaultSortDirection: Direction.asc, + })); + + if (!isEqual(columns, newColumns)) { + setColumns(newColumns); + setVisibleColumns(map('id', newColumns)); + } + }, [columns, actions]); + + return ( + <DataContext.Provider value={actions}> + <EuiDataGrid + aria-label="Osquery actions" + columns={columns} + columnVisibility={columnVisibility} + rowCount={totalCount} + renderCellValue={renderCellValue} + sorting={tableSorting} + pagination={tablePagination} + /> + </DataContext.Provider> + ); +}; + +export const ActionsTable = React.memo(ActionsTableComponent); diff --git a/x-pack/plugins/osquery/public/actions/helpers.ts b/x-pack/plugins/osquery/public/actions/helpers.ts new file mode 100644 index 0000000000000..9f908e16c2eb2 --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PaginationInputPaginated, + FactoryQueryTypes, + StrategyResponseType, + Inspect, +} from '../../common/search_strategy'; + +export type InspectResponse = Inspect & { response: string[] }; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number, + isBucketSort?: boolean +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: isBucketSort ? limit : limit + cursorStart, + }; +}; + +export const getInspectResponse = <T extends FactoryQueryTypes>( + response: StrategyResponseType<T>, + prevResponse: InspectResponse +): InspectResponse => ({ + dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, +}); diff --git a/x-pack/plugins/osquery/public/actions/translations.ts b/x-pack/plugins/osquery/public/actions/translations.ts new file mode 100644 index 0000000000000..3bf2d81e5e092 --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/translations.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_ALL_ACTIONS = i18n.translate('xpack.osquery.actions.errorSearchDescription', { + defaultMessage: `An error has occurred on all actions search`, +}); + +export const FAIL_ALL_ACTIONS = i18n.translate('xpack.osquery.actions.failSearchDescription', { + defaultMessage: `Failed to fetch actions`, +}); + +export const ERROR_ACTION_DETAILS = i18n.translate( + 'xpack.osquery.actionDetails.errorSearchDescription', + { + defaultMessage: `An error has occurred on action details search`, + } +); + +export const FAIL_ACTION_DETAILS = i18n.translate( + 'xpack.osquery.actionDetails.failSearchDescription', + { + defaultMessage: `Failed to fetch action details`, + } +); + +export const ERROR_ACTION_RESULTS = i18n.translate( + 'xpack.osquery.actionResults.errorSearchDescription', + { + defaultMessage: `An error has occurred on action results search`, + } +); + +export const FAIL_ACTION_RESULTS = i18n.translate( + 'xpack.osquery.actionResults.failSearchDescription', + { + defaultMessage: `Failed to fetch action results`, + } +); diff --git a/x-pack/plugins/osquery/public/actions/use_action_details.ts b/x-pack/plugins/osquery/public/actions/use_action_details.ts new file mode 100644 index 0000000000000..3112d7cbf221e --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/use_action_details.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + DocValueFields, + OsqueryQueries, + ActionDetailsRequestOptions, + ActionDetailsStrategyResponse, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'actionDetailsQuery'; + +export interface ActionDetailsArgs { + actionDetails: Record<string, string>; + id: string; + inspect: InspectResponse; + isInspected: boolean; +} + +interface UseActionDetails { + actionId: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useActionDetails = ({ + actionId, + docValueFields, + filterQuery, + skip = false, +}: UseActionDetails): [boolean, ActionDetailsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [actionDetailsRequest, setHostRequest] = useState<ActionDetailsRequestOptions | null>(null); + + const [actionDetailsResponse, setActionDetailsResponse] = useState<ActionDetailsArgs>({ + actionDetails: {}, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + }); + + const actionDetailsSearch = useCallback( + (request: ActionDetailsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<ActionDetailsRequestOptions, ActionDetailsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setActionDetailsResponse((prevResponse) => ({ + ...prevResponse, + actionDetails: response.actionDetails, + inspect: getInspectResponse(response, prevResponse.inspect), + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ACTION_DETAILS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ + title: i18n.FAIL_ACTION_DETAILS, + text: msg.message, + }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + actionId, + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.actionDetails, + filterQuery: createFilter(filterQuery), + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [actionId, docValueFields, filterQuery]); + + useEffect(() => { + actionDetailsSearch(actionDetailsRequest); + }, [actionDetailsRequest, actionDetailsSearch]); + + return [loading, actionDetailsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/actions/use_all_actions.ts b/x-pack/plugins/osquery/public/actions/use_all_actions.ts new file mode 100644 index 0000000000000..192f5b1eb410c --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/use_all_actions.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + ActionEdges, + PageInfoPaginated, + DocValueFields, + OsqueryQueries, + ActionsRequestOptions, + ActionsStrategyResponse, + Direction, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'actionsAllQuery'; + +export interface ActionsArgs { + actions: ActionEdges; + id: string; + inspect: InspectResponse; + isInspected: boolean; + pageInfo: PageInfoPaginated; + totalCount: number; +} + +interface UseAllActions { + activePage: number; + direction: Direction; + limit: number; + sortField: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useAllActions = ({ + activePage, + direction, + limit, + sortField, + docValueFields, + filterQuery, + skip = false, +}: UseAllActions): [boolean, ActionsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [actionsRequest, setHostRequest] = useState<ActionsRequestOptions | null>(null); + + const [actionsResponse, setActionsResponse] = useState<ActionsArgs>({ + actions: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + totalCount: -1, + }); + + const actionsSearch = useCallback( + (request: ActionsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<ActionsRequestOptions, ActionsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setActionsResponse((prevResponse) => ({ + ...prevResponse, + actions: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ALL_ACTIONS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_ALL_ACTIONS, text: msg.message }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.actions, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: { + direction, + field: sortField, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, direction, docValueFields, filterQuery, limit, sortField]); + + useEffect(() => { + actionsSearch(actionsRequest); + }, [actionsRequest, actionsSearch]); + + return [loading, actionsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx new file mode 100644 index 0000000000000..1c0083b8252e8 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { find } from 'lodash/fp'; +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiBasicTableProps, + EuiTableSelectionType, + EuiHealth, +} from '@elastic/eui'; + +import { useAllAgents } from './use_all_agents'; +import { Direction } from '../../common/search_strategy'; +import { Agent } from '../../common/shared_imports'; + +interface AgentsTableProps { + selectedAgents: string[]; + onChange: (payload: string[]) => void; +} + +const AgentsTableComponent: React.FC<AgentsTableProps> = ({ selectedAgents, onChange }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState<keyof Agent>('id'); + const [sortDirection, setSortDirection] = useState<Direction>(Direction.asc); + const [selectedItems, setSelectedItems] = useState([]); + const tableRef = useRef<EuiBasicTable<Agent>>(null); + + const onTableChange: EuiBasicTableProps<Agent>['onChange'] = useCallback( + ({ page = {}, sort = {} }) => { + const { index: newPageIndex, size: newPageSize } = page; + + const { field: newSortField, direction: newSortDirection } = sort; + + setPageIndex(newPageIndex); + setPageSize(newPageSize); + setSortField(newSortField); + setSortDirection(newSortDirection); + }, + [] + ); + + const onSelectionChange: EuiTableSelectionType<{}>['onSelectionChange'] = useCallback( + (newSelectedItems) => { + setSelectedItems(newSelectedItems); + // @ts-expect-error + onChange(newSelectedItems.map((item) => item._id)); + }, + [onChange] + ); + + const renderStatus = (online: string) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return <EuiHealth color={color}>{label}</EuiHealth>; + }; + + const [, { agents, totalCount }] = useAllAgents({ + activePage: pageIndex, + limit: pageSize, + direction: sortDirection, + sortField, + }); + + const columns: Array<EuiBasicTableColumn<{}>> = useMemo( + () => [ + { + field: 'local_metadata.elastic.agent.id', + name: 'id', + sortable: true, + truncateText: true, + }, + { + field: 'local_metadata.host.name', + name: 'hostname', + truncateText: true, + }, + + { + field: 'active', + name: 'Online', + dataType: 'boolean', + render: (active: string) => renderStatus(active), + }, + ], + [] + ); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: totalCount, + pageSizeOptions: [3, 5, 8], + }), + [pageIndex, pageSize, totalCount] + ); + + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const selection: EuiBasicTableProps<Agent>['selection'] = useMemo( + () => ({ + selectable: (agent: Agent) => agent.active, + selectableMessage: (selectable: boolean) => (!selectable ? 'User is currently offline' : ''), + onSelectionChange, + initialSelected: selectedItems, + }), + [onSelectionChange, selectedItems] + ); + + useEffect(() => { + if (selectedAgents?.length && agents.length && selectedItems.length !== selectedAgents.length) { + tableRef?.current?.setSelection( + // @ts-expect-error + selectedAgents.map((agentId) => find({ _id: agentId }, agents)) + ); + } + }, [selectedAgents, agents, selectedItems.length]); + + return ( + <EuiBasicTable<Agent> + ref={tableRef} + items={agents} + itemId="_id" + columns={columns} + pagination={pagination} + sorting={sorting} + isSelectable={true} + selection={selection} + onChange={onTableChange} + rowHeader="firstName" + /> + ); +}; + +export const AgentsTable = React.memo(AgentsTableComponent); diff --git a/x-pack/plugins/osquery/public/agents/helpers.ts b/x-pack/plugins/osquery/public/agents/helpers.ts new file mode 100644 index 0000000000000..9f908e16c2eb2 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PaginationInputPaginated, + FactoryQueryTypes, + StrategyResponseType, + Inspect, +} from '../../common/search_strategy'; + +export type InspectResponse = Inspect & { response: string[] }; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number, + isBucketSort?: boolean +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: isBucketSort ? limit : limit + cursorStart, + }; +}; + +export const getInspectResponse = <T extends FactoryQueryTypes>( + response: StrategyResponseType<T>, + prevResponse: InspectResponse +): InspectResponse => ({ + dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, +}); diff --git a/x-pack/plugins/osquery/public/agents/translations.ts b/x-pack/plugins/osquery/public/agents/translations.ts new file mode 100644 index 0000000000000..a95ad5e4ce163 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearchDescription', { + defaultMessage: `An error has occurred on all agents search`, +}); + +export const FAIL_ALL_AGENTS = i18n.translate('xpack.osquery.agents.failSearchDescription', { + defaultMessage: `Failed to fetch agents`, +}); diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts new file mode 100644 index 0000000000000..ad1a09486961a --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + PageInfoPaginated, + DocValueFields, + OsqueryQueries, + AgentsRequestOptions, + AgentsStrategyResponse, + Direction, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; +import { Agent } from '../../common/shared_imports'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'agentsAllQuery'; + +export interface AgentsArgs { + agents: Agent[]; + id: string; + inspect: InspectResponse; + isInspected: boolean; + pageInfo: PageInfoPaginated; + totalCount: number; +} + +interface UseAllAgents { + activePage: number; + direction: Direction; + limit: number; + sortField: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useAllAgents = ({ + activePage, + direction, + limit, + sortField, + docValueFields, + filterQuery, + skip = false, +}: UseAllAgents): [boolean, AgentsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [agentsRequest, setHostRequest] = useState<AgentsRequestOptions | null>(null); + + const [agentsResponse, setAgentsResponse] = useState<AgentsArgs>({ + agents: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + totalCount: -1, + }); + + const agentsSearch = useCallback( + (request: AgentsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<AgentsRequestOptions, AgentsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setAgentsResponse((prevResponse) => ({ + ...prevResponse, + agents: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ALL_AGENTS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_ALL_AGENTS, text: msg.message }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.agents, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: { + direction, + field: sortField, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, direction, docValueFields, filterQuery, limit, sortField]); + + useEffect(() => { + agentsSearch(agentsRequest); + }, [agentsRequest, agentsSearch]); + + return [loading, agentsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/application.tsx b/x-pack/plugins/osquery/public/application.tsx new file mode 100644 index 0000000000000..1a5c826df3310 --- /dev/null +++ b/x-pack/plugins/osquery/public/application.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiErrorBoundary } from '@elastic/eui'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React, { useMemo } from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ThemeProvider } from 'styled-components'; + +import { useUiSetting$ } from '../../../../src/plugins/kibana_react/public'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; +import { AppMountParameters, CoreStart } from '../../../../src/core/public'; +import { AppPluginStartDependencies } from './types'; +import { OsqueryApp } from './components/app'; +import { DEFAULT_DARK_MODE, PLUGIN_NAME } from '../common'; +import { KibanaContextProvider } from './common/lib/kibana'; + +const OsqueryAppContext = () => { + const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE); + const theme = useMemo( + () => ({ + eui: darkMode ? euiDarkVars : euiLightVars, + darkMode, + }), + [darkMode] + ); + + return ( + <ThemeProvider theme={theme}> + <OsqueryApp /> + </ThemeProvider> + ); +}; + +export const renderApp = ( + core: CoreStart, + services: AppPluginStartDependencies, + { element, history }: AppMountParameters, + storage: Storage, + kibanaVersion: string +) => { + ReactDOM.render( + <KibanaContextProvider + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + services={{ + appName: PLUGIN_NAME, + ...core, + ...services, + storage, + }} + > + <EuiErrorBoundary> + <Router history={history}> + <I18nProvider> + <OsqueryAppContext /> + </I18nProvider> + </Router> + </EuiErrorBoundary> + </KibanaContextProvider>, + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/x-pack/plugins/osquery/public/common/helpers.test.ts b/x-pack/plugins/osquery/public/common/helpers.test.ts new file mode 100644 index 0000000000000..5d378d79acc7a --- /dev/null +++ b/x-pack/plugins/osquery/public/common/helpers.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESQuery } from '../../common/typed_json'; + +import { createFilter } from './helpers'; + +describe('Helpers', () => { + describe('#createFilter', () => { + test('if it is a string it returns untouched', () => { + const filter = createFilter('even invalid strings return the same'); + expect(filter).toBe('even invalid strings return the same'); + }); + + test('if it is an ESQuery object it will be returned as a string', () => { + const query: ESQuery = { term: { 'host.id': 'host-value' } }; + const filter = createFilter(query); + expect(filter).toBe(JSON.stringify(query)); + }); + + test('if it is undefined, then undefined is returned', () => { + const filter = createFilter(undefined); + expect(filter).toBe(undefined); + }); + }); +}); diff --git a/x-pack/plugins/osquery/public/common/helpers.ts b/x-pack/plugins/osquery/public/common/helpers.ts new file mode 100644 index 0000000000000..e922e030c9330 --- /dev/null +++ b/x-pack/plugins/osquery/public/common/helpers.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isString } from 'lodash/fp'; + +import { ESQuery } from '../../common/typed_json'; + +export const createFilter = (filterQuery: ESQuery | string | undefined) => + isString(filterQuery) ? filterQuery : JSON.stringify(filterQuery); diff --git a/x-pack/plugins/osquery/public/common/index.ts b/x-pack/plugins/osquery/public/common/index.ts new file mode 100644 index 0000000000000..d805555791e2a --- /dev/null +++ b/x-pack/plugins/osquery/public/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './helpers'; diff --git a/x-pack/plugins/osquery/public/common/lib/kibana/index.ts b/x-pack/plugins/osquery/public/common/lib/kibana/index.ts new file mode 100644 index 0000000000000..b9cb71d4adb47 --- /dev/null +++ b/x-pack/plugins/osquery/public/common/lib/kibana/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './kibana_react'; diff --git a/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts new file mode 100644 index 0000000000000..b4fb307a62b6c --- /dev/null +++ b/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + KibanaContextProvider, + KibanaReactContextValue, + useKibana, + useUiSetting, + useUiSetting$, + withKibana, +} from '../../../../../../../src/plugins/kibana_react/public'; +import { StartServices } from '../../../types'; + +export type KibanaContext = KibanaReactContextValue<StartServices>; +export interface WithKibanaProps { + kibana: KibanaContext; +} + +const useTypedKibana = () => useKibana<StartServices>(); + +export { + KibanaContextProvider, + useTypedKibana as useKibana, + useUiSetting, + useUiSetting$, + withKibana, +}; diff --git a/x-pack/plugins/osquery/public/components/app.tsx b/x-pack/plugins/osquery/public/components/app.tsx new file mode 100644 index 0000000000000..49ff7e2bfb4da --- /dev/null +++ b/x-pack/plugins/osquery/public/components/app.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Switch, Route } from 'react-router-dom'; + +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; + +import { PLUGIN_NAME } from '../../common'; +import { LiveQuery } from '../live_query'; + +export const OsqueryAppComponent = () => { + return ( + <EuiPage restrictWidth="1000px"> + <EuiPageBody> + <EuiPageHeader> + <EuiTitle size="l"> + <h1> + <FormattedMessage + id="xpack.osquery.helloWorldText" + defaultMessage="{name}" + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + values={{ name: PLUGIN_NAME }} + /> + </h1> + </EuiTitle> + </EuiPageHeader> + <EuiPageContent> + <EuiPageContentBody> + <EuiSpacer /> + + <Switch> + <Route path={`/live_query`}> + <LiveQuery /> + </Route> + </Switch> + + <EuiSpacer /> + </EuiPageContentBody> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + ); +}; + +export const OsqueryApp = React.memo(OsqueryAppComponent); diff --git a/x-pack/plugins/osquery/public/editor/index.tsx b/x-pack/plugins/osquery/public/editor/index.tsx new file mode 100644 index 0000000000000..a0e549e77467b --- /dev/null +++ b/x-pack/plugins/osquery/public/editor/index.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiCodeEditor } from '@elastic/eui'; +import 'brace/mode/sql'; +import 'brace/theme/tomorrow'; +import 'brace/ext/language_tools'; + +const EDITOR_SET_OPTIONS = { + enableBasicAutocompletion: true, + enableLiveAutocompletion: true, +}; + +const EDITOR_PROPS = { + $blockScrolling: true, +}; + +interface OsqueryEditorProps { + defaultValue: string; + onChange: (newValue: string) => void; +} + +const OsqueryEditorComponent: React.FC<OsqueryEditorProps> = ({ defaultValue, onChange }) => { + const handleChange = useCallback( + (newValue) => { + onChange(newValue); + }, + [onChange] + ); + + return ( + <EuiCodeEditor + value={defaultValue} + mode="sql" + theme="tomorrow" + onChange={handleChange} + name="osquery_editor" + setOptions={EDITOR_SET_OPTIONS} + editorProps={EDITOR_PROPS} + height="200px" + /> + ); +}; + +export const OsqueryEditor = React.memo(OsqueryEditorComponent); diff --git a/x-pack/plugins/osquery/public/index.ts b/x-pack/plugins/osquery/public/index.ts new file mode 100644 index 0000000000000..32b0a30c24e7a --- /dev/null +++ b/x-pack/plugins/osquery/public/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { OsqueryPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin(initializerContext: PluginInitializerContext) { + return new OsqueryPlugin(initializerContext); +} +export { OsqueryPluginSetup, OsqueryPluginStart } from './types'; diff --git a/x-pack/plugins/osquery/public/live_query/edit/index.tsx b/x-pack/plugins/osquery/public/live_query/edit/index.tsx new file mode 100644 index 0000000000000..5626e78069d01 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/edit/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; +import { EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { useParams } from 'react-router-dom'; + +import { useActionDetails } from '../../actions/use_action_details'; +import { ResultTabs } from './tabs'; +import { LiveQueryForm } from '../form'; + +const EditLiveQueryPageComponent = () => { + const { actionId } = useParams<{ actionId: string }>(); + const [loading, { actionDetails }] = useActionDetails({ actionId }); + + const handleSubmit = useCallback(() => Promise.resolve(), []); + + if (loading) { + return <>{'Loading...'}</>; + } + + return ( + <> + {!isEmpty(actionDetails) && ( + <LiveQueryForm actionDetails={actionDetails} onSubmit={handleSubmit} /> + )} + <EuiSpacer /> + <ResultTabs /> + </> + ); +}; + +export const EditLiveQueryPage = React.memo(EditLiveQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/live_query/edit/tabs.tsx b/x-pack/plugins/osquery/public/live_query/edit/tabs.tsx new file mode 100644 index 0000000000000..564b91873e11d --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/edit/tabs.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiTabbedContent, EuiSpacer } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import { ResultsTable } from '../../results/results_table'; +import { ActionResultsTable } from '../../action_results/action_results_table'; + +const ResultTabsComponent = () => { + const { actionId } = useParams<{ actionId: string }>(); + const tabs = useMemo( + () => [ + { + id: 'status', + name: 'Status', + content: ( + <> + <EuiSpacer /> + <ActionResultsTable actionId={actionId} /> + </> + ), + }, + { + id: 'results', + name: 'Results', + content: ( + <> + <EuiSpacer /> + <ResultsTable actionId={actionId} /> + </> + ), + }, + ], + [actionId] + ); + + const handleTabClick = useCallback((tab) => { + // eslint-disable-next-line no-console + console.log('clicked tab', tab); + }, []); + + return ( + <EuiTabbedContent + tabs={tabs} + initialSelectedTab={tabs[0]} + autoFocus="selected" + onTabClick={handleTabClick} + /> + ); +}; + +export const ResultTabs = React.memo(ResultTabsComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx b/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx new file mode 100644 index 0000000000000..a6d7fbd404321 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { FieldHook } from '../../shared_imports'; +import { AgentsTable } from '../../agents/agents_table'; + +interface AgentsTableFieldProps { + field: FieldHook<string[]>; +} + +const AgentsTableFieldComponent: React.FC<AgentsTableFieldProps> = ({ field }) => { + const { value, setValue } = field; + const handleChange = useCallback( + (props) => { + if (props !== value) { + return setValue(props); + } + }, + [value, setValue] + ); + + return <AgentsTable selectedAgents={value} onChange={handleChange} />; +}; + +export const AgentsTableField = React.memo(AgentsTableFieldComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx b/x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx new file mode 100644 index 0000000000000..458d2263fe9c2 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; + +import { OsqueryEditor } from '../../editor'; +import { FieldHook } from '../../shared_imports'; + +interface CodeEditorFieldProps { + field: FieldHook<{ query: string }>; +} + +const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({ field }) => { + const { value, setValue } = field; + const handleChange = useCallback( + (newQuery) => { + setValue({ + ...value, + query: newQuery, + }); + }, + [value, setValue] + ); + + return <OsqueryEditor defaultValue={value.query} onChange={handleChange} />; +}; + +export const CodeEditorField = React.memo(CodeEditorFieldComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/index.tsx b/x-pack/plugins/osquery/public/live_query/form/index.tsx new file mode 100644 index 0000000000000..23aa94b46a569 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/index.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { UseField, Form, useForm } from '../../shared_imports'; +import { AgentsTableField } from './agents_table_field'; +import { CodeEditorField } from './code_editor_field'; + +const FORM_ID = 'liveQueryForm'; + +interface LiveQueryFormProps { + actionDetails?: Record<string, string>; + onSubmit: (payload: Record<string, string>) => Promise<void>; +} + +const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ actionDetails, onSubmit }) => { + const handleSubmit = useCallback( + (payload) => { + onSubmit(payload); + return Promise.resolve(); + }, + [onSubmit] + ); + + const { form } = useForm({ + id: FORM_ID, + // schema: formSchema, + onSubmit: handleSubmit, + options: { + stripEmptyFields: false, + }, + defaultValue: actionDetails, + deserializer: ({ fields, _source }) => ({ + agents: fields?.agents, + command: _source?.data?.commands[0], + }), + }); + + const { submit } = form; + + return ( + <Form form={form}> + <UseField path="agents" component={AgentsTableField} /> + <EuiSpacer /> + <UseField path="command" component={CodeEditorField} /> + <EuiButton onClick={submit}>Send query</EuiButton> + </Form> + ); +}; + +export const LiveQueryForm = React.memo(LiveQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/schema.ts b/x-pack/plugins/osquery/public/live_query/form/schema.ts new file mode 100644 index 0000000000000..e55b3d6cd6a5b --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/schema.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FIELD_TYPES, FormSchema } from '../../shared_imports'; + +export const formSchema: FormSchema = { + agents: { + type: FIELD_TYPES.MULTI_SELECT, + }, + command: { + type: FIELD_TYPES.TEXTAREA, + validations: [], + }, +}; diff --git a/x-pack/plugins/osquery/public/live_query/index.tsx b/x-pack/plugins/osquery/public/live_query/index.tsx new file mode 100644 index 0000000000000..646d2637a4c40 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Switch, Route, useRouteMatch } from 'react-router-dom'; + +import { QueriesPage } from './queries'; +import { NewLiveQueryPage } from './new'; +import { EditLiveQueryPage } from './edit'; + +const LiveQueryComponent = () => { + const match = useRouteMatch(); + + return ( + <Switch> + <Route path={`${match.url}/queries/new`}> + <NewLiveQueryPage /> + </Route> + <Route path={`${match.url}/queries/:actionId`}> + <EditLiveQueryPage /> + </Route> + <Route path={`${match.url}/queries`}> + <QueriesPage /> + </Route> + </Switch> + ); +}; + +export const LiveQuery = React.memo(LiveQueryComponent); diff --git a/x-pack/plugins/osquery/public/live_query/new/index.tsx b/x-pack/plugins/osquery/public/live_query/new/index.tsx new file mode 100644 index 0000000000000..40f934b4690f9 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/new/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { useKibana } from '../../common/lib/kibana'; +import { LiveQueryForm } from '../form'; + +const NewLiveQueryPageComponent = () => { + const { http } = useKibana().services; + const history = useHistory(); + + const handleSubmit = useCallback( + async (props) => { + const response = await http.post('/api/osquery/queries', { body: JSON.stringify(props) }); + const requestParamsActionId = JSON.parse(response.meta.request.params.body).action_id; + history.push(`/live_query/queries/${requestParamsActionId}`); + }, + [history, http] + ); + + return <LiveQueryForm onSubmit={handleSubmit} />; +}; + +export const NewLiveQueryPage = React.memo(NewLiveQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/live_query/queries/index.tsx b/x-pack/plugins/osquery/public/live_query/queries/index.tsx new file mode 100644 index 0000000000000..5600284b8c147 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/queries/index.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import React from 'react'; + +import { ActionsTable } from '../../actions/actions_table'; + +const QueriesPageComponent = () => { + return ( + <> + <EuiTitle> + <h1>{'Queries'}</h1> + </EuiTitle> + <EuiSpacer /> + <ActionsTable /> + </> + ); +}; + +export const QueriesPage = React.memo(QueriesPageComponent); diff --git a/x-pack/plugins/osquery/public/plugin.ts b/x-pack/plugins/osquery/public/plugin.ts new file mode 100644 index 0000000000000..41698d3a1740d --- /dev/null +++ b/x-pack/plugins/osquery/public/plugin.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AppMountParameters, + CoreSetup, + Plugin, + PluginInitializerContext, + CoreStart, +} from 'src/core/public'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; +import { OsqueryPluginSetup, OsqueryPluginStart, AppPluginStartDependencies } from './types'; +import { PLUGIN_NAME } from '../common'; + +export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginStart> { + private kibanaVersion: string; + private storage = new Storage(localStorage); + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.kibanaVersion = this.initializerContext.env.packageInfo.version; + } + + public setup(core: CoreSetup): OsqueryPluginSetup { + const config = this.initializerContext.config.get<{ enabled: boolean }>(); + + if (!config.enabled) { + return {}; + } + + const storage = this.storage; + const kibanaVersion = this.kibanaVersion; + // Register an application into the side navigation menu + core.application.register({ + id: 'osquery', + title: PLUGIN_NAME, + async mount(params: AppMountParameters) { + // Get start services as specified in kibana.json + const [coreStart, depsStart] = await core.getStartServices(); + // Load application bundle + const { renderApp } = await import('./application'); + // Render the application + return renderApp( + coreStart, + depsStart as AppPluginStartDependencies, + params, + storage, + kibanaVersion + ); + }, + }); + + // Return methods that should be available to other plugins + return {}; + } + + public start(core: CoreStart): OsqueryPluginStart { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/osquery/public/results/helpers.ts b/x-pack/plugins/osquery/public/results/helpers.ts new file mode 100644 index 0000000000000..9f908e16c2eb2 --- /dev/null +++ b/x-pack/plugins/osquery/public/results/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PaginationInputPaginated, + FactoryQueryTypes, + StrategyResponseType, + Inspect, +} from '../../common/search_strategy'; + +export type InspectResponse = Inspect & { response: string[] }; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number, + isBucketSort?: boolean +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: isBucketSort ? limit : limit + cursorStart, + }; +}; + +export const getInspectResponse = <T extends FactoryQueryTypes>( + response: StrategyResponseType<T>, + prevResponse: InspectResponse +): InspectResponse => ({ + dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, +}); diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx new file mode 100644 index 0000000000000..69b350e461a5c --- /dev/null +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, isEqual, keys, map } from 'lodash/fp'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn } from '@elastic/eui'; +import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; + +import { EuiDataGridSorting } from '@elastic/eui'; +import { useAllResults } from './use_all_results'; +import { Direction, ResultEdges } from '../../common/search_strategy'; + +const DataContext = createContext<ResultEdges>([]); + +interface ResultsTableComponentProps { + actionId: string; +} + +const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId }) => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + const onChangeItemsPerPage = useCallback( + (pageSize) => + setPagination((currentPagination) => ({ + ...currentPagination, + pageSize, + pageIndex: 0, + })), + [setPagination] + ); + const onChangePage = useCallback( + (pageIndex) => setPagination((currentPagination) => ({ ...currentPagination, pageIndex })), + [setPagination] + ); + + const [columns, setColumns] = useState<EuiDataGridColumn[]>([]); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); + const onSort = useCallback( + (newSortingColumns) => { + setSortingColumns(newSortingColumns); + }, + [setSortingColumns] + ); + + const [, { results, totalCount }] = useAllResults({ + actionId, + activePage: pagination.pageIndex, + limit: pagination.pageSize, + direction: Direction.asc, + sortField: '@timestamp', + }); + + const [visibleColumns, setVisibleColumns] = useState<string[]>([]); + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns }), [ + visibleColumns, + setVisibleColumns, + ]); + + const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo( + () => ({ rowIndex, columnId, setCellProps }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const data = useContext(DataContext); + + const value = data[rowIndex].fields[columnId]; + + return !isEmpty(value) ? value : '-'; + }, + [] + ); + + const tableSorting = useMemo(() => ({ columns: sortingColumns, onSort }), [ + onSort, + sortingColumns, + ]); + + const tablePagination = useMemo( + () => ({ + ...pagination, + pageSizeOptions: [10, 50, 100], + onChangeItemsPerPage, + onChangePage, + }), + [onChangeItemsPerPage, onChangePage, pagination] + ); + + useEffect(() => { + const newColumns: EuiDataGridColumn[] = keys(results[0]?.fields) + .sort() + .map((fieldName) => ({ + id: fieldName, + displayAsText: fieldName.split('.')[1], + defaultSortDirection: 'asc', + })); + + if (!isEqual(columns, newColumns)) { + setColumns(newColumns); + setVisibleColumns(map('id', newColumns)); + } + }, [columns, results]); + + return ( + <DataContext.Provider value={results}> + <EuiDataGrid + aria-label="Osquery results" + columns={columns} + columnVisibility={columnVisibility} + rowCount={totalCount} + renderCellValue={renderCellValue} + sorting={tableSorting} + pagination={tablePagination} + /> + </DataContext.Provider> + ); +}; + +export const ResultsTable = React.memo(ResultsTableComponent); diff --git a/x-pack/plugins/osquery/public/results/translations.ts b/x-pack/plugins/osquery/public/results/translations.ts new file mode 100644 index 0000000000000..54c8ecebc60c0 --- /dev/null +++ b/x-pack/plugins/osquery/public/results/translations.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', { + defaultMessage: `An error has occurred on all results search`, +}); + +export const FAIL_ALL_RESULTS = i18n.translate('xpack.osquery.results.failSearchDescription', { + defaultMessage: `Failed to fetch results`, +}); diff --git a/x-pack/plugins/osquery/public/results/use_all_results.ts b/x-pack/plugins/osquery/public/results/use_all_results.ts new file mode 100644 index 0000000000000..2fc5f9ae869a7 --- /dev/null +++ b/x-pack/plugins/osquery/public/results/use_all_results.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + ResultEdges, + PageInfoPaginated, + DocValueFields, + OsqueryQueries, + ResultsRequestOptions, + ResultsStrategyResponse, + Direction, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'resultsAllQuery'; + +export interface ResultsArgs { + results: ResultEdges; + id: string; + inspect: InspectResponse; + isInspected: boolean; + pageInfo: PageInfoPaginated; + totalCount: number; +} + +interface UseAllResults { + actionId: string; + activePage: number; + direction: Direction; + limit: number; + sortField: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useAllResults = ({ + actionId, + activePage, + direction, + limit, + sortField, + docValueFields, + filterQuery, + skip = false, +}: UseAllResults): [boolean, ResultsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [resultsRequest, setHostRequest] = useState<ResultsRequestOptions | null>(null); + + const [resultsResponse, setResultsResponse] = useState<ResultsArgs>({ + results: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + totalCount: -1, + }); + + const resultsSearch = useCallback( + (request: ResultsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<ResultsRequestOptions, ResultsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setResultsResponse((prevResponse) => ({ + ...prevResponse, + results: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ALL_RESULTS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_ALL_RESULTS, text: msg.message }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + actionId, + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.results, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: { + direction, + field: sortField, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [actionId, activePage, direction, docValueFields, filterQuery, limit, sortField]); + + useEffect(() => { + resultsSearch(resultsRequest); + }, [resultsRequest, resultsSearch]); + + return [loading, resultsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/shared_imports.ts b/x-pack/plugins/osquery/public/shared_imports.ts new file mode 100644 index 0000000000000..5f7503a00702c --- /dev/null +++ b/x-pack/plugins/osquery/public/shared_imports.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + getUseField, + getFieldValidityAndErrorMessage, + FieldHook, + FieldValidateResponse, + FIELD_TYPES, + Form, + FormData, + FormDataProvider, + FormHook, + FormSchema, + UseField, + UseMultiFields, + useForm, + useFormContext, + useFormData, + ValidationError, + ValidationFunc, + VALIDATION_TYPES, +} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; +export { Field, SelectField } from '../../../../src/plugins/es_ui_shared/static/forms/components'; +export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; +export { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/plugins/osquery/public/types.ts b/x-pack/plugins/osquery/public/types.ts new file mode 100644 index 0000000000000..faaccfc29d5f1 --- /dev/null +++ b/x-pack/plugins/osquery/public/types.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { FleetStart } from '../../fleet/public'; +import { CoreStart } from '../../../../src/core/public'; +import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OsqueryPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OsqueryPluginStart {} + +export interface AppPluginStartDependencies { + navigation: NavigationPublicPluginStart; +} + +export interface StartPlugins { + data: DataPublicPluginStart; + fleet?: FleetStart; +} + +export type StartServices = CoreStart & StartPlugins; diff --git a/x-pack/plugins/osquery/server/config.ts b/x-pack/plugins/osquery/server/config.ts new file mode 100644 index 0000000000000..633a95b8f91a7 --- /dev/null +++ b/x-pack/plugins/osquery/server/config.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf, schema } from '@kbn/config-schema'; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +export type ConfigType = TypeOf<typeof ConfigSchema>; diff --git a/x-pack/plugins/osquery/server/create_config.ts b/x-pack/plugins/osquery/server/create_config.ts new file mode 100644 index 0000000000000..e46c71798eb9f --- /dev/null +++ b/x-pack/plugins/osquery/server/create_config.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map } from 'rxjs/operators'; +import { PluginInitializerContext } from 'kibana/server'; +import { Observable } from 'rxjs'; + +import { ConfigType } from './config'; + +export const createConfig$ = ( + context: PluginInitializerContext +): Observable<Readonly<ConfigType>> => { + return context.config.create<ConfigType>().pipe(map((config) => config)); +}; diff --git a/x-pack/plugins/osquery/server/index.ts b/x-pack/plugins/osquery/server/index.ts new file mode 100644 index 0000000000000..c74ef6c95a2e7 --- /dev/null +++ b/x-pack/plugins/osquery/server/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { OsqueryPlugin } from './plugin'; +import { ConfigSchema } from './config'; + +export const config = { + schema: ConfigSchema, + exposeToBrowser: { + enabled: true, + }, +}; +export function plugin(initializerContext: PluginInitializerContext) { + return new OsqueryPlugin(initializerContext); +} + +export { OsqueryPluginSetup, OsqueryPluginStart } from './types'; diff --git a/x-pack/plugins/osquery/server/plugin.ts b/x-pack/plugins/osquery/server/plugin.ts new file mode 100644 index 0000000000000..3e59faa55d057 --- /dev/null +++ b/x-pack/plugins/osquery/server/plugin.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first } from 'rxjs/operators'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../../src/core/server'; + +import { createConfig$ } from './create_config'; +import { OsqueryPluginSetup, OsqueryPluginStart, SetupPlugins, StartPlugins } from './types'; +import { defineRoutes } from './routes'; +import { osquerySearchStrategyProvider } from './search_strategy/osquery'; + +export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginStart> { + private readonly logger: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup<StartPlugins, OsqueryPluginStart>, plugins: SetupPlugins) { + this.logger.debug('osquery: Setup'); + const config = await createConfig$(this.initializerContext).pipe(first()).toPromise(); + + if (!config.enabled) { + return {}; + } + + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router); + + core.getStartServices().then(([_, depsStart]) => { + const osquerySearchStrategy = osquerySearchStrategyProvider(depsStart.data); + + plugins.data.search.registerSearchStrategy('osquerySearchStrategy', osquerySearchStrategy); + }); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('osquery: Started'); + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/osquery/server/routes/index.ts b/x-pack/plugins/osquery/server/routes/index.ts new file mode 100644 index 0000000000000..f975865950a4d --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/index.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { schema } from '@kbn/config-schema'; +import moment from 'moment'; + +import { IRouter } from '../../../../../src/core/server'; + +export function defineRoutes(router: IRouter) { + router.post( + { + path: '/api/osquery/queries', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const esClient = context.core.elasticsearch.client.asInternalUser; + const query = await esClient.index<{}, {}>({ + index: '.fleet-actions-new', + body: { + action_id: uuid.v4(), + '@timestamp': moment().toISOString(), + expiration: moment().add(2, 'days').toISOString(), + type: 'APP_ACTION', + input_id: 'osquery', + // @ts-expect-error + agents: request.body.agents, + data: { + commands: [ + { + id: uuid.v4(), + // @ts-expect-error + query: request.body.command.query, + }, + ], + }, + }, + }); + return response.ok({ + body: query, + }); + } + ); +} diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts new file mode 100644 index 0000000000000..75cdb67deed4d --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + ActionsStrategyResponse, + ActionsRequestOptions, + OsqueryQueries, +} from '../../../../../../common/search_strategy/osquery'; +import { inspectStringifyObject } from '../../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../../types'; +import { buildActionsQuery } from './query.all_actions.dsl'; + +export const allActions: OsqueryFactory<OsqueryQueries.actions> = { + buildDsl: (options: ActionsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildActionsQuery(options); + }, + parse: async ( + options: ActionsRequestOptions, + response: IEsSearchResponse<object> + ): Promise<ActionsStrategyResponse> => { + const { activePage } = options.pagination; + const inspect = { + dsl: [inspectStringifyObject(buildActionsQuery(options))], + }; + + return { + ...response, + inspect, + edges: response.rawResponse.hits.hits, + totalCount: response.rawResponse.hits.total, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts new file mode 100644 index 0000000000000..29af1df3a9e0c --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; +import { AgentsRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../../common/utils/build_query'; + +export const buildActionsQuery = ({ + docValueFields, + filterQuery, + pagination: { activePage, querySize }, + sort, +}: AgentsRequestOptions): ISearchRequestParams => { + const filter = [...createQueryFilterClauses(filterQuery)]; + + const dslQuery = { + allowNoIndices: true, + index: '.fleet-actions', + ignoreUnavailable: true, + body: { + query: { bool: { filter } }, + from: activePage * querySize, + size: querySize, + track_total_hits: true, + fields: ['*'], + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/index.ts new file mode 100644 index 0000000000000..09e317786e20f --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + ActionDetailsStrategyResponse, + ActionDetailsRequestOptions, + OsqueryQueries, +} from '../../../../../../common/search_strategy/osquery'; + +import { inspectStringifyObject } from '../../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../../types'; +import { buildActionDetailsQuery } from './query.action_details.dsl'; + +export const actionDetails: OsqueryFactory<OsqueryQueries.actionDetails> = { + buildDsl: (options: ActionDetailsRequestOptions) => { + return buildActionDetailsQuery(options); + }, + parse: async ( + options: ActionDetailsRequestOptions, + response: IEsSearchResponse<unknown> + ): Promise<ActionDetailsStrategyResponse> => { + const inspect = { + dsl: [inspectStringifyObject(buildActionDetailsQuery(options))], + }; + + return { + ...response, + inspect, + actionDetails: response.rawResponse.hits.hits[0], + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts new file mode 100644 index 0000000000000..f22066134cbca --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; +import { ActionDetailsRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../../common/utils/build_query'; + +export const buildActionDetailsQuery = ({ + actionId, + docValueFields, + filterQuery, +}: ActionDetailsRequestOptions): ISearchRequestParams => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + match_phrase: { + action_id: actionId, + }, + }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: '.fleet-actions', + ignoreUnavailable: true, + body: { + query: { bool: { filter } }, + size: 1, + fields: ['*'], + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/index.ts new file mode 100644 index 0000000000000..c5a6342c180ed --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/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; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './all'; +export * from './details'; +export * from './results'; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts new file mode 100644 index 0000000000000..4a049ca670cc6 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + ActionResultsStrategyResponse, + ActionResultsRequestOptions, + OsqueryQueries, +} from '../../../../../../common/search_strategy/osquery'; + +import { inspectStringifyObject } from '../../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../../types'; +import { buildActionResultsQuery } from './query.action_results.dsl'; + +export const actionResults: OsqueryFactory<OsqueryQueries.actionResults> = { + buildDsl: (options: ActionResultsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildActionResultsQuery(options); + }, + parse: async ( + options: ActionResultsRequestOptions, + response: IEsSearchResponse<object> + ): Promise<ActionResultsStrategyResponse> => { + const { activePage } = options.pagination; + const inspect = { + dsl: [inspectStringifyObject(buildActionResultsQuery(options))], + }; + + return { + ...response, + inspect, + edges: response.rawResponse.hits.hits, + totalCount: response.rawResponse.hits.total, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts new file mode 100644 index 0000000000000..8b80427d4d0e0 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; +import { ActionResultsRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../../common/utils/build_query'; + +export const buildActionResultsQuery = ({ + actionId, + docValueFields, + filterQuery, + pagination: { activePage, querySize }, + sort, +}: ActionResultsRequestOptions): ISearchRequestParams => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + match_phrase: { + action_id: actionId, + }, + }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: '.fleet-actions-results', + ignoreUnavailable: true, + body: { + query: { bool: { filter } }, + from: activePage * querySize, + size: querySize, + track_total_hits: true, + fields: ['*'], + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts new file mode 100644 index 0000000000000..615343c738d78 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; +import { + AgentsStrategyResponse, + AgentsRequestOptions, + OsqueryQueries, +} from '../../../../../common/search_strategy/osquery'; + +import { Agent } from '../../../../../common/shared_imports'; +import { inspectStringifyObject } from '../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../types'; +import { buildAgentsQuery } from './query.all_agents.dsl'; + +export const allAgents: OsqueryFactory<OsqueryQueries.agents> = { + buildDsl: (options: AgentsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildAgentsQuery(options); + }, + parse: async ( + options: AgentsRequestOptions, + response: IEsSearchResponse<Agent> + ): Promise<AgentsStrategyResponse> => { + const { activePage } = options.pagination; + const inspect = { + dsl: [inspectStringifyObject(buildAgentsQuery(options))], + }; + + return { + ...response, + inspect, + edges: response.rawResponse.hits.hits.map((hit) => ({ _id: hit._id, ...hit._source })), + totalCount: response.rawResponse.hits.total, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts new file mode 100644 index 0000000000000..935a6cd7b215e --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; +import { ISearchRequestParams } from '../../../../../../../../src/plugins/data/common'; +import { AgentsRequestOptions } from '../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../common/utils/build_query'; + +export const buildAgentsQuery = ({ + docValueFields, + filterQuery, + pagination: { querySize }, + sort, +}: AgentsRequestOptions): ISearchRequestParams => { + const filter = [...createQueryFilterClauses(filterQuery)]; + + const dslQuery = { + allowNoIndices: true, + index: '.fleet-agents', + ignoreUnavailable: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + query: { bool: { filter } }, + track_total_hits: true, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/index.ts new file mode 100644 index 0000000000000..dc2e741dbe3e4 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FactoryQueryTypes, OsqueryQueries } from '../../../../common/search_strategy/osquery'; + +import { allActions, actionDetails, actionResults } from './actions'; +import { allAgents } from './agents'; +import { allResults } from './results'; + +import { OsqueryFactory } from './types'; + +export const osqueryFactory: Record<FactoryQueryTypes, OsqueryFactory<FactoryQueryTypes>> = { + [OsqueryQueries.actions]: allActions, + [OsqueryQueries.actionDetails]: actionDetails, + [OsqueryQueries.actionResults]: actionResults, + [OsqueryQueries.agents]: allAgents, + [OsqueryQueries.results]: allResults, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts new file mode 100644 index 0000000000000..1460a0e5d331e --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; +import { + ResultsStrategyResponse, + ResultsRequestOptions, + OsqueryQueries, +} from '../../../../../common/search_strategy/osquery'; +import { inspectStringifyObject } from '../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../types'; +import { buildResultsQuery } from './query.all_results.dsl'; + +export const allResults: OsqueryFactory<OsqueryQueries.results> = { + buildDsl: (options: ResultsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildResultsQuery(options); + }, + parse: async ( + options: ResultsRequestOptions, + response: IEsSearchResponse<unknown> + ): Promise<ResultsStrategyResponse> => { + const { activePage } = options.pagination; + const inspect = { + dsl: [inspectStringifyObject(buildResultsQuery(options))], + }; + + return { + ...response, + inspect, + edges: response.rawResponse.hits.hits, + totalCount: response.rawResponse.hits.total, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts new file mode 100644 index 0000000000000..c099e2762d741 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISearchRequestParams } from '../../../../../../../../src/plugins/data/common'; +import { ResultsRequestOptions } from '../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../common/utils/build_query'; + +export const buildResultsQuery = ({ + actionId, + filterQuery, + pagination: { activePage, querySize }, + sort, +}: ResultsRequestOptions): ISearchRequestParams => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + match_phrase: { + action_id: actionId, + }, + }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: 'logs-elastic_agent.osquery*', + ignoreUnavailable: true, + body: { + query: { bool: { filter } }, + from: activePage * querySize, + size: querySize, + track_total_hits: true, + fields: ['agent.*', 'osquery.*'], + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/types.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/types.ts new file mode 100644 index 0000000000000..bc2bf63958a09 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IEsSearchResponse, + ISearchRequestParams, +} from '../../../../../../../src/plugins/data/common'; +import { + FactoryQueryTypes, + StrategyRequestType, + StrategyResponseType, +} from '../../../../common/search_strategy/osquery'; + +export interface OsqueryFactory<T extends FactoryQueryTypes> { + buildDsl: (options: StrategyRequestType<T>) => ISearchRequestParams; + parse: ( + options: StrategyRequestType<T>, + response: IEsSearchResponse + ) => Promise<StrategyResponseType<T>>; +} diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/index.ts new file mode 100644 index 0000000000000..8d8a255f2fcdd --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map, mergeMap } from 'rxjs/operators'; +import { + ISearchStrategy, + PluginStart, + shimHitsTotal, +} from '../../../../../../src/plugins/data/server'; +import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../data_enhanced/common'; +import { + FactoryQueryTypes, + StrategyResponseType, + StrategyRequestType, +} from '../../../common/search_strategy/osquery'; +import { osqueryFactory } from './factory'; +import { OsqueryFactory } from './factory/types'; + +export const osquerySearchStrategyProvider = <T extends FactoryQueryTypes>( + data: PluginStart +): ISearchStrategy<StrategyRequestType<T>, StrategyResponseType<T>> => { + const es = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); + + return { + search: (request, options, deps) => { + if (request.factoryQueryType == null) { + throw new Error('factoryQueryType is required'); + } + const queryFactory: OsqueryFactory<T> = osqueryFactory[request.factoryQueryType]; + const dsl = queryFactory.buildDsl(request); + return es.search({ ...request, params: dsl }, options, deps).pipe( + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + mergeMap((esSearchRes) => queryFactory.parse(request, esSearchRes)) + ); + }, + cancel: async (id, options, deps) => { + if (es.cancel) { + return es.cancel(id, options, deps); + } + }, + }; +}; diff --git a/x-pack/plugins/osquery/server/types.ts b/x-pack/plugins/osquery/server/types.ts new file mode 100644 index 0000000000000..51ef28b4c3478 --- /dev/null +++ b/x-pack/plugins/osquery/server/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PluginSetup as DataPluginSetup, + PluginStart as DataPluginStart, +} from '../../../../src/plugins/data/server'; +import { FleetStartContract } from '../../fleet/server'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OsqueryPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OsqueryPluginStart {} + +export interface SetupPlugins { + data: DataPluginSetup; +} + +export interface StartPlugins { + data: DataPluginStart; + fleet?: FleetStartContract; +} diff --git a/x-pack/plugins/painless_lab/tsconfig.json b/x-pack/plugins/painless_lab/tsconfig.json new file mode 100644 index 0000000000000..a869b21e06d4d --- /dev/null +++ b/x-pack/plugins/painless_lab/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/dev_tools/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" } + ] +} diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 16e40bab65a46..882387184ba9c 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -56,14 +56,19 @@ export const LAYOUT_TYPES = { }; // Export Type Definitions -export const CSV_REPORT_TYPE = 'CSV'; export const PDF_REPORT_TYPE = 'printablePdf'; -export const PNG_REPORT_TYPE = 'PNG'; - export const PDF_JOB_TYPE = 'printable_pdf'; + +export const PNG_REPORT_TYPE = 'PNG'; export const PNG_JOB_TYPE = 'PNG'; -export const CSV_JOB_TYPE = 'csv'; + export const CSV_FROM_SAVEDOBJECT_JOB_TYPE = 'csv_from_savedobject'; + +// This is deprecated because it lacks support for runtime fields +// but the extension points are still needed for pre-existing scripted automation, until 8.0 +export const CSV_REPORT_TYPE_DEPRECATED = 'CSV'; +export const CSV_JOB_TYPE_DEPRECATED = 'csv'; + export const USES_HEADLESS_JOB_TYPES = [PDF_JOB_TYPE, PNG_JOB_TYPE]; // Licenses diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx index bbdc2e1aebe77..bafb5d7a68630 100644 --- a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx @@ -10,7 +10,11 @@ import React, { Component, ReactElement } from 'react'; import { ToastsSetup } from 'src/core/public'; import url from 'url'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { CSV_REPORT_TYPE, PDF_REPORT_TYPE, PNG_REPORT_TYPE } from '../../common/constants'; +import { + CSV_REPORT_TYPE_DEPRECATED, + PDF_REPORT_TYPE, + PNG_REPORT_TYPE, +} from '../../common/constants'; import { BaseParams } from '../../common/types'; import { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -173,7 +177,7 @@ class ReportingPanelContentUi extends Component<Props, State> { case PDF_REPORT_TYPE: return 'PDF'; case 'csv': - return CSV_REPORT_TYPE; + return CSV_REPORT_TYPE_DEPRECATED; case 'png': return PNG_REPORT_TYPE; default: diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index 9a4832b114e40..49c0eaaa2960d 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -103,7 +103,6 @@ export class GetCsvReportPanelAction implements ActionDefinition<ActionContext> const kibanaTimezone = this.core.uiSettings.get('dateFormat:tz'); const id = `search:${embeddable.getSavedSearch().id}`; - const filename = embeddable.getSavedSearch().title; const timezone = kibanaTimezone === 'Browser' ? moment.tz.guess() : kibanaTimezone; const fromTime = dateMath.parse(from); const toTime = dateMath.parse(to, { roundUp: true }); @@ -140,7 +139,7 @@ export class GetCsvReportPanelAction implements ActionDefinition<ActionContext> .then((rawResponse: string) => { this.isDownloading = false; - const download = `${filename}.csv`; + const download = `${embeddable.getSavedSearch().title}.csv`; const blob = new Blob([rawResponse], { type: 'text/csv;charset=utf-8;' }); // Hack for IE11 Support diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 7126762c0f4ee..4659952eef720 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -10,7 +10,10 @@ import React from 'react'; import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; import { ShareContext } from '../../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../../licensing/public'; -import { JobParamsCSV, SearchRequest } from '../../server/export_types/csv/types'; +import { + JobParamsDeprecatedCSV, + SearchRequestDeprecatedCSV, +} from '../../server/export_types/csv/types'; import { ReportingPanelContent } from '../components/reporting_panel_content_lazy'; import { checkLicense } from '../lib/license_check'; import { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -59,12 +62,12 @@ export const csvReportingProvider = ({ return []; } - const jobParams: JobParamsCSV = { + const jobParams: JobParamsDeprecatedCSV = { browserTimezone, objectType, title: sharingData.title as string, indexPatternId: sharingData.indexPatternId as string, - searchRequest: sharingData.searchRequest as SearchRequest, + searchRequest: sharingData.searchRequest as SearchRequestDeprecatedCSV, fields: sharingData.fields as string[], metaFields: sharingData.metaFields as string[], conflictedTypesFields: sharingData.conflictedTypesFields as string[], diff --git a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts index f0f72a0bc9965..e704f9650b7a8 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CSV_JOB_TYPE } from '../../../common/constants'; +import { CSV_JOB_TYPE_DEPRECATED } from '../../../common/constants'; import { cryptoFactory } from '../../lib'; import { CreateJobFn, CreateJobFnFactory } from '../../types'; -import { IndexPatternSavedObject, JobParamsCSV, TaskPayloadCSV } from './types'; +import { + IndexPatternSavedObjectDeprecatedCSV, + JobParamsDeprecatedCSV, + TaskPayloadDeprecatedCSV, +} from './types'; export const createJobFnFactory: CreateJobFnFactory< - CreateJobFn<JobParamsCSV, TaskPayloadCSV> + CreateJobFn<JobParamsDeprecatedCSV, TaskPayloadDeprecatedCSV> > = function createJobFactoryFn(reporting, parentLogger) { - const logger = parentLogger.clone([CSV_JOB_TYPE, 'create-job']); + const logger = parentLogger.clone([CSV_JOB_TYPE_DEPRECATED, 'create-job']); const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); @@ -24,7 +28,7 @@ export const createJobFnFactory: CreateJobFnFactory< const indexPatternSavedObject = ((await savedObjectsClient.get( 'index-pattern', jobParams.indexPatternId - )) as unknown) as IndexPatternSavedObject; // FIXME + )) as unknown) as IndexPatternSavedObjectDeprecatedCSV; return { headers: serializedEncryptedHeaders, diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts index ea65262c090ee..098a90959f8a7 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts @@ -22,7 +22,7 @@ import { LevelLogger } from '../../lib'; import { setFieldFormats } from '../../services'; import { createMockReportingCore } from '../../test_helpers'; import { runTaskFnFactory } from './execute_job'; -import { TaskPayloadCSV } from './types'; +import { TaskPayloadDeprecatedCSV } from './types'; const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(() => resolve(), ms)); @@ -31,7 +31,7 @@ const getRandomScrollId = () => { return puid.generate(); }; -const getBasePayload = (baseObj: any) => baseObj as TaskPayloadCSV; +const getBasePayload = (baseObj: any) => baseObj as TaskPayloadDeprecatedCSV; describe('CSV Execute Job', function () { const encryptionKey = 'testEncryptionKey'; diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts index 6b4dd48583efe..cb321b7573701 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts @@ -4,20 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CONTENT_TYPE_CSV, CSV_JOB_TYPE } from '../../../common/constants'; +import { CONTENT_TYPE_CSV, CSV_JOB_TYPE_DEPRECATED } from '../../../common/constants'; import { RunTaskFn, RunTaskFnFactory } from '../../types'; import { decryptJobHeaders } from '../common'; import { createGenerateCsv } from './generate_csv'; -import { TaskPayloadCSV } from './types'; +import { TaskPayloadDeprecatedCSV } from './types'; export const runTaskFnFactory: RunTaskFnFactory< - RunTaskFn<TaskPayloadCSV> + RunTaskFn<TaskPayloadDeprecatedCSV> > = function executeJobFactoryFn(reporting, parentLogger) { const config = reporting.getConfig(); return async function runTask(jobId, job, cancellationToken) { const elasticsearch = reporting.getElasticsearchService(); - const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job', jobId]); + const logger = parentLogger.clone([CSV_JOB_TYPE_DEPRECATED, 'execute-job', jobId]); const generateCsv = createGenerateCsv(logger); const encryptionKey = config.get('encryptionKey'); diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/field_format_map.test.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/field_format_map.test.ts index 4cb8de5810584..0c74e3aa54b0e 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/field_format_map.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/field_format_map.test.ts @@ -6,13 +6,13 @@ import expect from '@kbn/expect'; import { fieldFormats, FieldFormatsGetConfigFn, UI_SETTINGS } from 'src/plugins/data/server'; -import { IndexPatternSavedObject } from '../types'; +import { IndexPatternSavedObjectDeprecatedCSV } from '../types'; import { fieldFormatMapFactory } from './field_format_map'; type ConfigValue = { number: { id: string; params: {} } } | string; describe('field format map', function () { - const indexPatternSavedObject: IndexPatternSavedObject = { + const indexPatternSavedObject: IndexPatternSavedObjectDeprecatedCSV = { timeFieldName: '@timestamp', title: 'logstash-*', attributes: { diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/field_format_map.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/field_format_map.ts index e01fee530fc65..c05dc7d3fd75f 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/field_format_map.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/field_format_map.ts @@ -7,7 +7,7 @@ import _ from 'lodash'; import { FieldFormat } from 'src/plugins/data/common'; import { FieldFormatConfig, IFieldFormatsRegistry } from 'src/plugins/data/server'; -import { IndexPatternSavedObject } from '../types'; +import { IndexPatternSavedObjectDeprecatedCSV } from '../types'; /** * Create a map of FieldFormat instances for index pattern fields @@ -17,7 +17,7 @@ import { IndexPatternSavedObject } from '../types'; * @return {Map} key: field name, value: FieldFormat instance */ export function fieldFormatMapFactory( - indexPatternSavedObject: IndexPatternSavedObject, + indexPatternSavedObject: IndexPatternSavedObjectDeprecatedCSV, fieldFormatsRegistry: IFieldFormatsRegistry, timezone: string | undefined ) { diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts index 2f6df9cd67a75..ee09f3904678c 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts @@ -12,7 +12,7 @@ import { CSV_BOM_CHARS } from '../../../../common/constants'; import { byteSizeValueToNumber } from '../../../../common/schema_utils'; import { LevelLogger } from '../../../lib'; import { getFieldFormats } from '../../../services'; -import { IndexPatternSavedObject, SavedSearchGeneratorResult } from '../types'; +import { IndexPatternSavedObjectDeprecatedCSV, SavedSearchGeneratorResult } from '../types'; import { checkIfRowsHaveFormulas } from './check_cells_for_formulas'; import { createEscapeValue } from './escape_value'; import { fieldFormatMapFactory } from './field_format_map'; @@ -39,7 +39,7 @@ interface SearchRequest { export interface GenerateCsvParams { browserTimezone?: string; searchRequest: SearchRequest; - indexPatternSavedObject: IndexPatternSavedObject; + indexPatternSavedObject: IndexPatternSavedObjectDeprecatedCSV; fields: string[]; metaFields: string[]; conflictedTypesFields: string[]; diff --git a/x-pack/plugins/reporting/server/export_types/csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/index.ts index f7b7ff5709fe6..23f4b879eb140 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/index.ts @@ -5,7 +5,7 @@ */ import { - CSV_JOB_TYPE as jobType, + CSV_JOB_TYPE_DEPRECATED as jobType, LICENSE_TYPE_BASIC, LICENSE_TYPE_ENTERPRISE, LICENSE_TYPE_GOLD, @@ -17,11 +17,11 @@ import { CreateJobFn, ExportTypeDefinition, RunTaskFn } from '../../types'; import { createJobFnFactory } from './create_job'; import { runTaskFnFactory } from './execute_job'; import { metadata } from './metadata'; -import { JobParamsCSV, TaskPayloadCSV } from './types'; +import { JobParamsDeprecatedCSV, TaskPayloadDeprecatedCSV } from './types'; export const getExportType = (): ExportTypeDefinition< - CreateJobFn<JobParamsCSV>, - RunTaskFn<TaskPayloadCSV> + CreateJobFn<JobParamsDeprecatedCSV>, + RunTaskFn<TaskPayloadDeprecatedCSV> > => ({ ...metadata, jobType, diff --git a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts index 78615a0e7b72c..dd0b37a17a2ff 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts @@ -8,7 +8,7 @@ import { BaseParams, BasePayload } from '../../types'; export type RawValue = string | object | null | undefined; -export interface IndexPatternSavedObject { +export interface IndexPatternSavedObjectDeprecatedCSV { title: string; timeFieldName: string; fields?: any[]; @@ -18,25 +18,25 @@ export interface IndexPatternSavedObject { }; } -interface BaseParamsCSV { - searchRequest: SearchRequest; +interface BaseParamsDeprecatedCSV { + searchRequest: SearchRequestDeprecatedCSV; fields: string[]; metaFields: string[]; conflictedTypesFields: string[]; } -export type JobParamsCSV = BaseParamsCSV & +export type JobParamsDeprecatedCSV = BaseParamsDeprecatedCSV & BaseParams & { indexPatternId: string; }; // CSV create job method converts indexPatternID to indexPatternSavedObject -export type TaskPayloadCSV = BaseParamsCSV & +export type TaskPayloadDeprecatedCSV = BaseParamsDeprecatedCSV & BasePayload & { - indexPatternSavedObject: IndexPatternSavedObject; + indexPatternSavedObject: IndexPatternSavedObjectDeprecatedCSV; }; -export interface SearchRequest { +export interface SearchRequestDeprecatedCSV { index: string; body: | { @@ -66,7 +66,7 @@ export interface SearchRequest { | any; } -type FormatsMap = Map< +type FormatsMapDeprecatedCSV = Map< string, { id: string; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_data_source.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_data_source.ts index e3631b9c89724..fa983c5af639c 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_data_source.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_data_source.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IndexPatternSavedObject } from '../../csv/types'; +import { IndexPatternSavedObjectDeprecatedCSV } from '../../csv/types'; import { SavedObjectReference, SavedSearchObjectAttributesJSON, SearchSource } from '../types'; export async function getDataSource( @@ -12,10 +12,10 @@ export async function getDataSource( indexPatternId?: string, savedSearchObjectId?: string ): Promise<{ - indexPatternSavedObject: IndexPatternSavedObject; + indexPatternSavedObject: IndexPatternSavedObjectDeprecatedCSV; searchSource: SearchSource | null; }> { - let indexPatternSavedObject: IndexPatternSavedObject; + let indexPatternSavedObject: IndexPatternSavedObjectDeprecatedCSV; let searchSource: SearchSource | null = null; if (savedSearchObjectId) { diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index b154978d041f4..641ce6e48a1f3 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -7,13 +7,13 @@ // @ts-ignore import contentDisposition from 'content-disposition'; import { get } from 'lodash'; -import { CSV_JOB_TYPE } from '../../../common/constants'; +import { CSV_JOB_TYPE_DEPRECATED } from '../../../common/constants'; import { ExportTypesRegistry, statuses } from '../../lib'; import { ReportDocument } from '../../lib/store'; import { TaskRunResult } from '../../lib/tasks'; import { ExportTypeDefinition } from '../../types'; -interface ErrorFromPayload { +export interface ErrorFromPayload { message: string; } @@ -33,7 +33,7 @@ const getTitle = (exportType: ExportTypeDefinition, title?: string): string => const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeDefinition) => { const metaDataHeaders: Record<string, boolean> = {}; - if (exportType.jobType === CSV_JOB_TYPE) { + if (exportType.jobType === CSV_JOB_TYPE_DEPRECATED) { const csvContainsFormulas = get(output, 'csv_contains_formulas', false); const maxSizedReach = get(output, 'max_size_reached', false); diff --git a/x-pack/plugins/reporting/server/usage/decorate_range_stats.ts b/x-pack/plugins/reporting/server/usage/decorate_range_stats.ts index 30befcf291a54..8d69d75f66212 100644 --- a/x-pack/plugins/reporting/server/usage/decorate_range_stats.ts +++ b/x-pack/plugins/reporting/server/usage/decorate_range_stats.ts @@ -5,7 +5,7 @@ */ import { uniq } from 'lodash'; -import { CSV_JOB_TYPE, PDF_JOB_TYPE, PNG_JOB_TYPE } from '../../common/constants'; +import { CSV_JOB_TYPE_DEPRECATED, PDF_JOB_TYPE, PNG_JOB_TYPE } from '../../common/constants'; import { AvailableTotal, ExportType, FeatureAvailabilityMap, RangeStats } from './types'; function getForFeature( @@ -54,7 +54,7 @@ export const decorateRangeStats = ( // combine the known types with any unknown type found in reporting data const keysBasic = uniq([ - CSV_JOB_TYPE, + CSV_JOB_TYPE_DEPRECATED, PNG_JOB_TYPE, ...Object.keys(rangeStatsBasic), ]) as ExportType[]; diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json new file mode 100644 index 0000000000000..88e8d343f4700 --- /dev/null +++ b/x-pack/plugins/reporting/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "../../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json"}, + { "path": "../../../src/plugins/discover/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/share/tsconfig.json" }, + { "path": "../../../src/plugins/ui_actions/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + { "path": "../spaces/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index fafd0c2772842..7e2c634b2f1cf 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -17,7 +17,7 @@ import { PolicyData, SafeEndpointEvent, } from './types'; -import { factory as policyFactory } from './models/policy_config'; +import { policyFactory } from './models/policy_config'; import { ancestryArray, entityIDSafeVersion, diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index b3259b19cf2c0..b9d7f6dfe7a5a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -30,7 +30,7 @@ import { PostAgentAcksResponse, PostAgentAcksRequest, } from '../../../fleet/common'; -import { factory as policyConfigFactory } from './models/policy_config'; +import { policyFactory as policyConfigFactory } from './models/policy_config'; import { HostMetadata } from './types'; import { KbnClientWithApiKeySupport } from '../../scripts/endpoint/kbn_client_with_api_key_support'; diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts index 14941b019421b..614aac6ea8041 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts @@ -7,9 +7,9 @@ import { PolicyConfig, ProtectionModes } from '../types'; /** - * Return a new default `PolicyConfig`. + * Return a new default `PolicyConfig` for platinum and above licenses */ -export const factory = (): PolicyConfig => { +export const policyFactory = (): PolicyConfig => { return { windows: { events: { @@ -24,11 +24,18 @@ export const factory = (): PolicyConfig => { malware: { mode: ProtectionModes.prevent, }, + ransomware: { + mode: ProtectionModes.prevent, + }, popup: { malware: { message: '', enabled: true, }, + ransomware: { + message: '', + enabled: true, + }, }, logging: { file: 'info', @@ -46,11 +53,18 @@ export const factory = (): PolicyConfig => { malware: { mode: ProtectionModes.prevent, }, + ransomware: { + mode: ProtectionModes.prevent, + }, popup: { malware: { message: '', enabled: true, }, + ransomware: { + message: '', + enabled: true, + }, }, logging: { file: 'info', @@ -69,6 +83,51 @@ export const factory = (): PolicyConfig => { }; }; +/** + * Strips paid features from an existing or new `PolicyConfig` for gold and below license + */ +export const policyFactoryWithoutPaidFeatures = ( + policy: PolicyConfig = policyFactory() +): PolicyConfig => { + return { + ...policy, + windows: { + ...policy.windows, + ransomware: { + mode: ProtectionModes.off, + }, + popup: { + ...policy.windows.popup, + malware: { + message: '', + enabled: true, + }, + ransomware: { + message: '', + enabled: false, + }, + }, + }, + mac: { + ...policy.mac, + ransomware: { + mode: ProtectionModes.off, + }, + popup: { + ...policy.mac.popup, + malware: { + message: '', + enabled: true, + }, + ransomware: { + message: '', + enabled: false, + }, + }, + }, + }; +}; + /** * Reflects what string the Endpoint will use when message field is default/empty */ diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.test.ts similarity index 98% rename from x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts rename to x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.test.ts index 1b70a13935b7d..932afc6af3f16 100644 --- a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.test.ts @@ -6,7 +6,7 @@ import { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from 'kibana/server'; import { PackagePolicy } from '../../../../../fleet/common'; -import { migratePackagePolicyToV7110 } from './to_v7_11.0'; +import { migratePackagePolicyToV7110 } from './to_v7_11_0'; describe('7.11.0 Endpoint Package Policy migration', () => { const migration = migratePackagePolicyToV7110; diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.ts similarity index 100% rename from x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.ts rename to x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.test.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.test.ts new file mode 100644 index 0000000000000..2666b477921fc --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { PackagePolicy } from '../../../../../fleet/common'; +import { PolicyData, ProtectionModes } from '../../types'; +import { migratePackagePolicyToV7120 } from './to_v7_12_0'; + +describe('7.12.0 Endpoint Package Policy migration', () => { + const migration = migratePackagePolicyToV7120; + it('adds ransomware option and notification customization', () => { + const doc: SavedObjectUnsanitizedDoc<PolicyData> = { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + windows: { + // @ts-expect-error + popup: { + malware: { + message: '', + enabled: false, + }, + }, + }, + mac: { + // @ts-expect-error + popup: { + malware: { + message: '', + enabled: false, + }, + }, + }, + }, + }, + }, + }, + ], + }, + type: ' nested', + }; + + expect( + migration(doc, {} as SavedObjectMigrationContext) as SavedObjectUnsanitizedDoc<PolicyData> + ).toEqual({ + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + windows: { + ransomware: ProtectionModes.off, + popup: { + malware: { + message: '', + enabled: false, + }, + ransomware: { + message: '', + enabled: false, + }, + }, + }, + mac: { + ransomware: ProtectionModes.off, + popup: { + malware: { + message: '', + enabled: false, + }, + ransomware: { + message: '', + enabled: false, + }, + }, + }, + }, + }, + }, + }, + ], + }, + type: ' nested', + id: 'mock-saved-object-id', + }); + }); + + it('does not modify non-endpoint package policies', () => { + const doc: SavedObjectUnsanitizedDoc<PackagePolicy> = { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + }; + + expect( + migration(doc, {} as SavedObjectMigrationContext) as SavedObjectUnsanitizedDoc<PackagePolicy> + ).toEqual({ + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + id: 'mock-saved-object-id', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.ts new file mode 100644 index 0000000000000..6004ef533d5ad --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { cloneDeep } from 'lodash'; +import { PackagePolicy } from '../../../../../fleet/common'; +import { ProtectionModes } from '../../types'; + +export const migratePackagePolicyToV7120: SavedObjectMigrationFn<PackagePolicy, PackagePolicy> = ( + packagePolicyDoc +) => { + const updatedPackagePolicyDoc: SavedObjectUnsanitizedDoc<PackagePolicy> = cloneDeep( + packagePolicyDoc + ); + if (packagePolicyDoc.attributes.package?.name === 'endpoint') { + const input = updatedPackagePolicyDoc.attributes.inputs[0]; + const ransomware = { + message: '', + enabled: false, + }; + if (input && input.config) { + const policy = input.config.policy.value; + + policy.windows.ransomware = ProtectionModes.off; + policy.mac.ransomware = ProtectionModes.off; + policy.windows.popup.ransomware = ransomware; + policy.mac.popup.ransomware = ransomware; + } + } + + return updatedPackagePolicyDoc; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index fab5bd9daae00..f72373a6544a0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -816,7 +816,8 @@ export interface PolicyConfig { registry: boolean; security: boolean; }; - malware: MalwareFields; + malware: ProtectionFields; + ransomware: ProtectionFields; logging: { file: string; }; @@ -825,6 +826,10 @@ export interface PolicyConfig { message: string; enabled: boolean; }; + ransomware: { + message: string; + enabled: boolean; + }; }; antivirus_registration: { enabled: boolean; @@ -837,12 +842,17 @@ export interface PolicyConfig { process: boolean; network: boolean; }; - malware: MalwareFields; + malware: ProtectionFields; + ransomware: ProtectionFields; popup: { malware: { message: string; enabled: boolean; }; + ransomware: { + message: string; + enabled: boolean; + }; }; logging: { file: string; @@ -870,20 +880,20 @@ export interface UIPolicyConfig { */ windows: Pick< PolicyConfig['windows'], - 'events' | 'malware' | 'popup' | 'antivirus_registration' | 'advanced' + 'events' | 'malware' | 'ransomware' | 'popup' | 'antivirus_registration' | 'advanced' >; /** * Mac-specific policy configuration that is supported via the UI */ - mac: Pick<PolicyConfig['mac'], 'malware' | 'events' | 'popup' | 'advanced'>; + mac: Pick<PolicyConfig['mac'], 'malware' | 'ransomware' | 'events' | 'popup' | 'advanced'>; /** * Linux-specific policy configuration that is supported via the UI */ linux: Pick<PolicyConfig['linux'], 'events' | 'advanced'>; } -/** Policy: Malware protection fields */ -export interface MalwareFields { +/** Policy: Protection fields */ +export interface ProtectionFields { mode: ProtectionModes; } diff --git a/x-pack/plugins/security_solution/common/license/policy_config.test.ts b/x-pack/plugins/security_solution/common/license/policy_config.test.ts index 6923bf00055f6..ef10bba7428ee 100644 --- a/x-pack/plugins/security_solution/common/license/policy_config.test.ts +++ b/x-pack/plugins/security_solution/common/license/policy_config.test.ts @@ -8,8 +8,13 @@ import { isEndpointPolicyValidForLicense, unsetPolicyFeaturesAboveLicenseLevel, } from './policy_config'; -import { DefaultMalwareMessage, factory } from '../endpoint/models/policy_config'; +import { + DefaultMalwareMessage, + policyFactory, + policyFactoryWithoutPaidFeatures, +} from '../endpoint/models/policy_config'; import { licenseMock } from '../../../licensing/common/licensing.mock'; +import { ProtectionModes } from '../endpoint/types'; describe('policy_config and licenses', () => { const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } }); @@ -18,13 +23,13 @@ describe('policy_config and licenses', () => { describe('isEndpointPolicyValidForLicense', () => { it('allows malware notification to be disabled with a Platinum license', () => { - const policy = factory(); + const policy = policyFactory(); policy.windows.popup.malware.enabled = false; // make policy change const valid = isEndpointPolicyValidForLicense(policy, Platinum); expect(valid).toBeTruthy(); }); it('blocks windows malware notification changes below Platinum licenses', () => { - const policy = factory(); + const policy = policyFactory(); policy.windows.popup.malware.enabled = false; // make policy change let valid = isEndpointPolicyValidForLicense(policy, Gold); expect(valid).toBeFalsy(); @@ -34,7 +39,7 @@ describe('policy_config and licenses', () => { }); it('blocks mac malware notification changes below Platinum licenses', () => { - const policy = factory(); + const policy = policyFactory(); policy.mac.popup.malware.enabled = false; // make policy change let valid = isEndpointPolicyValidForLicense(policy, Gold); expect(valid).toBeFalsy(); @@ -44,13 +49,13 @@ describe('policy_config and licenses', () => { }); it('allows malware notification message changes with a Platinum license', () => { - const policy = factory(); + const policy = policyFactory(); policy.windows.popup.malware.message = 'BOOM'; // make policy change const valid = isEndpointPolicyValidForLicense(policy, Platinum); expect(valid).toBeTruthy(); }); it('blocks windows malware notification message changes below Platinum licenses', () => { - const policy = factory(); + const policy = policyFactory(); policy.windows.popup.malware.message = 'BOOM'; // make policy change let valid = isEndpointPolicyValidForLicense(policy, Gold); expect(valid).toBeFalsy(); @@ -59,7 +64,7 @@ describe('policy_config and licenses', () => { expect(valid).toBeFalsy(); }); it('blocks mac malware notification message changes below Platinum licenses', () => { - const policy = factory(); + const policy = policyFactory(); policy.mac.popup.malware.message = 'BOOM'; // make policy change let valid = isEndpointPolicyValidForLicense(policy, Gold); expect(valid).toBeFalsy(); @@ -68,16 +73,71 @@ describe('policy_config and licenses', () => { expect(valid).toBeFalsy(); }); + it('allows ransomware to be turned on for Platinum licenses', () => { + const policy = policyFactoryWithoutPaidFeatures(); + policy.windows.ransomware.mode = ProtectionModes.prevent; + policy.mac.ransomware.mode = ProtectionModes.prevent; + + const valid = isEndpointPolicyValidForLicense(policy, Platinum); + expect(valid).toBeTruthy(); + }); + it('blocks ransomware to be turned on for Gold and below licenses', () => { + const policy = policyFactoryWithoutPaidFeatures(); + policy.windows.ransomware.mode = ProtectionModes.prevent; + policy.mac.ransomware.mode = ProtectionModes.prevent; + + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + + it('allows ransomware notification to be turned on with a Platinum license', () => { + const policy = policyFactoryWithoutPaidFeatures(); + policy.windows.popup.ransomware.enabled = true; + policy.mac.popup.ransomware.enabled = true; + const valid = isEndpointPolicyValidForLicense(policy, Platinum); + expect(valid).toBeTruthy(); + }); + it('blocks ransomware notification to be turned on for Gold and below licenses', () => { + const policy = policyFactoryWithoutPaidFeatures(); + policy.windows.popup.ransomware.enabled = true; + policy.mac.popup.ransomware.enabled = true; + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + + it('allows ransomware notification message changes with a Platinum license', () => { + const policy = policyFactory(); + policy.windows.popup.ransomware.message = 'BOOM'; + policy.mac.popup.ransomware.message = 'BOOM'; + const valid = isEndpointPolicyValidForLicense(policy, Platinum); + expect(valid).toBeTruthy(); + }); + it('blocks ransomware notification message changes for Gold and below licenses', () => { + const policy = policyFactory(); + policy.windows.popup.ransomware.message = 'BOOM'; + policy.mac.popup.ransomware.message = 'BOOM'; + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + it('allows default policyConfig with Basic', () => { - const policy = factory(); + const policy = policyFactoryWithoutPaidFeatures(); const valid = isEndpointPolicyValidForLicense(policy, Basic); expect(valid).toBeTruthy(); }); }); describe('unsetPolicyFeaturesAboveLicenseLevel', () => { - it('does not change any fields with a Platinum license', () => { - const policy = factory(); + it('does not change any malware fields with a Platinum license', () => { + const policy = policyFactory(); const popupMessage = 'WOOP WOOP'; policy.windows.popup.malware.message = popupMessage; policy.mac.popup.malware.message = popupMessage; @@ -88,14 +148,37 @@ describe('policy_config and licenses', () => { expect(retPolicy.windows.popup.malware.message).toEqual(popupMessage); expect(retPolicy.mac.popup.malware.message).toEqual(popupMessage); }); - it('resets Platinum-paid fields for lower license tiers', () => { - const defaults = factory(); // reference - const policy = factory(); // what we will modify, and should be reset + + it('does not change any ransomware fields with a Platinum license', () => { + const policy = policyFactory(); + const popupMessage = 'WOOP WOOP'; + policy.windows.ransomware.mode = ProtectionModes.detect; + policy.mac.ransomware.mode = ProtectionModes.detect; + policy.windows.popup.ransomware.enabled = false; + policy.mac.popup.ransomware.enabled = false; + policy.windows.popup.ransomware.message = popupMessage; + policy.mac.popup.ransomware.message = popupMessage; + + const retPolicy = unsetPolicyFeaturesAboveLicenseLevel(policy, Platinum); + expect(retPolicy.windows.ransomware.mode).toEqual(ProtectionModes.detect); + expect(retPolicy.mac.ransomware.mode).toEqual(ProtectionModes.detect); + expect(retPolicy.windows.popup.ransomware.enabled).toBeFalsy(); + expect(retPolicy.mac.popup.ransomware.enabled).toBeFalsy(); + expect(retPolicy.windows.popup.ransomware.message).toEqual(popupMessage); + expect(retPolicy.mac.popup.ransomware.message).toEqual(popupMessage); + }); + + it('resets Platinum-paid malware fields for lower license tiers', () => { + const defaults = policyFactory(); // reference + const policy = policyFactory(); // what we will modify, and should be reset const popupMessage = 'WOOP WOOP'; policy.windows.popup.malware.message = popupMessage; policy.mac.popup.malware.message = popupMessage; policy.windows.popup.malware.enabled = false; + policy.windows.popup.ransomware.message = popupMessage; + policy.mac.popup.ransomware.message = popupMessage; + policy.windows.popup.ransomware.enabled = false; const retPolicy = unsetPolicyFeaturesAboveLicenseLevel(policy, Gold); expect(retPolicy.windows.popup.malware.enabled).toEqual( defaults.windows.popup.malware.enabled @@ -106,5 +189,37 @@ describe('policy_config and licenses', () => { // need to invert the test, since it could be either value expect(['', DefaultMalwareMessage]).toContain(retPolicy.windows.popup.malware.message); }); + + it('resets Platinum-paid ransomware fields for lower license tiers', () => { + const defaults = policyFactoryWithoutPaidFeatures(); // reference + const policy = policyFactory(); // what we will modify, and should be reset + const popupMessage = 'WOOP WOOP'; + policy.windows.popup.ransomware.message = popupMessage; + policy.mac.popup.ransomware.message = popupMessage; + + const retPolicy = unsetPolicyFeaturesAboveLicenseLevel(policy, Gold); + + expect(retPolicy.windows.ransomware.mode).toEqual(defaults.windows.ransomware.mode); + expect(retPolicy.mac.ransomware.mode).toEqual(defaults.mac.ransomware.mode); + expect(retPolicy.windows.popup.ransomware.enabled).toEqual( + defaults.windows.popup.ransomware.enabled + ); + expect(retPolicy.mac.popup.ransomware.enabled).toEqual(defaults.mac.popup.ransomware.enabled); + expect(retPolicy.windows.popup.ransomware.message).not.toEqual(popupMessage); + expect(retPolicy.mac.popup.ransomware.message).not.toEqual(popupMessage); + + // need to invert the test, since it could be either value + expect(['', DefaultMalwareMessage]).toContain(retPolicy.windows.popup.ransomware.message); + expect(['', DefaultMalwareMessage]).toContain(retPolicy.mac.popup.ransomware.message); + }); + }); + + describe('policyFactoryWithoutPaidFeatures for gold and below license', () => { + it('preserves non license-gated features', () => { + const policy = policyFactory(); // what we will modify, and should be reset + policy.windows.events.file = false; + const retPolicy = policyFactoryWithoutPaidFeatures(policy); + expect(retPolicy.windows.events.file).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/license/policy_config.ts b/x-pack/plugins/security_solution/common/license/policy_config.ts index da2260ad55e8b..e791b68f12f40 100644 --- a/x-pack/plugins/security_solution/common/license/policy_config.ts +++ b/x-pack/plugins/security_solution/common/license/policy_config.ts @@ -7,7 +7,10 @@ import { ILicense } from '../../../licensing/common/types'; import { isAtLeast } from './license'; import { PolicyConfig } from '../endpoint/types'; -import { DefaultMalwareMessage, factory } from '../endpoint/models/policy_config'; +import { + DefaultMalwareMessage, + policyFactoryWithoutPaidFeatures, +} from '../endpoint/models/policy_config'; /** * Given an endpoint package policy, verifies that all enabled features that @@ -21,7 +24,7 @@ export const isEndpointPolicyValidForLicense = ( return true; // currently, platinum allows all features } - const defaults = factory(); + const defaults = policyFactoryWithoutPaidFeatures(); // only platinum or higher may disable malware notification if ( @@ -40,6 +43,32 @@ export const isEndpointPolicyValidForLicense = ( return false; } + // only platinum or higher may enable ransomware + if ( + policy.windows.ransomware.mode !== defaults.windows.ransomware.mode || + policy.mac.ransomware.mode !== defaults.mac.ransomware.mode + ) { + return false; + } + + // only platinum or higher may enable ransomware notification + if ( + policy.windows.popup.ransomware.enabled !== defaults.windows.popup.ransomware.enabled || + policy.mac.popup.ransomware.enabled !== defaults.mac.popup.ransomware.enabled + ) { + return false; + } + + // Only Platinum or higher may change the ransomware message (which can be blank or what Endpoint defaults) + if ( + [policy.windows, policy.mac].some( + (p) => + p.popup.ransomware.message !== '' && p.popup.ransomware.message !== DefaultMalwareMessage + ) + ) { + return false; + } + return true; }; @@ -55,12 +84,6 @@ export const unsetPolicyFeaturesAboveLicenseLevel = ( return policy; } - const defaults = factory(); // set any license-gated features back to the defaults - policy.windows.popup.malware.enabled = defaults.windows.popup.malware.enabled; - policy.mac.popup.malware.enabled = defaults.mac.popup.malware.enabled; - policy.windows.popup.malware.message = defaults.windows.popup.malware.message; - policy.mac.popup.malware.message = defaults.mac.popup.malware.message; - - return policy; + return policyFactoryWithoutPaidFeatures(policy); }; diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts index fb457933f4b54..92a736ae601df 100644 --- a/x-pack/plugins/security_solution/common/shared_exports.ts +++ b/x-pack/plugins/security_solution/common/shared_exports.ts @@ -16,4 +16,5 @@ export { exactCheck } from './exact_check'; export { getPaths, foldLeftRight, removeExternalLinkText } from './test_utils'; export { validate, validateEither } from './validate'; export { formatErrors } from './format_errors'; -export { migratePackagePolicyToV7110 } from './endpoint/policy/migrations/to_v7_11.0'; +export { migratePackagePolicyToV7110 } from './endpoint/policy/migrations/to_v7_11_0'; +export { migratePackagePolicyToV7120 } from './endpoint/policy/migrations/to_v7_12_0'; diff --git a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts index 8b5871a6a67db..857582aac7638 100644 --- a/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/data_sources/sourcerer.spec.ts @@ -28,7 +28,9 @@ import { populateTimeline } from '../../tasks/timeline'; import { SERVER_SIDE_EVENT_COUNT } from '../../screens/timeline'; import { cleanKibana } from '../../tasks/common'; -describe('Sourcerer', () => { +// Skipped at the moment as this has flake due to click handler issues. This has been raised with team members +// and the code is being re-worked and then these tests will be unskipped +describe.skip('Sourcerer', () => { before(() => { cleanKibana(); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index 37123dedfd661..2c9dc14aa05b2 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -5,7 +5,7 @@ */ import { formatMitreAttackDescription } from '../../helpers/rules'; -import { newThreatIndicatorRule } from '../../objects/rule'; +import { indexPatterns, newThreatIndicatorRule } from '../../objects/rule'; import { ALERT_RULE_METHOD, @@ -70,7 +70,24 @@ import { createAndActivateRule, fillAboutRuleAndContinue, fillDefineIndicatorMatchRuleAndContinue, + fillIndexAndIndicatorIndexPattern, + fillIndicatorMatchRow, fillScheduleRuleAndContinue, + getCustomIndicatorQueryInput, + getCustomQueryInput, + getCustomQueryInvalidationText, + getDefineContinueButton, + getIndexPatternClearButton, + getIndexPatternInvalidationText, + getIndicatorAndButton, + getIndicatorAtLeastOneInvalidationText, + getIndicatorDeleteButton, + getIndicatorIndex, + getIndicatorIndexComboField, + getIndicatorIndicatorIndex, + getIndicatorInvalidationText, + getIndicatorMappingComboField, + getIndicatorOrButton, selectIndicatorMatchType, waitForAlertsToPopulate, waitForTheRuleToBeExecuted, @@ -92,14 +109,6 @@ describe('Detection rules, Indicator Match', () => { cleanKibana(); esArchiverLoad('threat_indicator'); esArchiverLoad('threat_data'); - }); - - afterEach(() => { - esArchiverUnload('threat_indicator'); - esArchiverUnload('threat_data'); - }); - - it('Creates and activates a new Indicator Match rule', () => { loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); @@ -107,89 +116,330 @@ describe('Detection rules, Indicator Match', () => { waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); goToCreateNewRule(); selectIndicatorMatchType(); - fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule); - fillAboutRuleAndContinue(newThreatIndicatorRule); - fillScheduleRuleAndContinue(newThreatIndicatorRule); - createAndActivateRule(); + }); + + afterEach(() => { + esArchiverUnload('threat_indicator'); + esArchiverUnload('threat_data'); + }); - cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); + describe('Creating new indicator match rules', () => { + describe('Index patterns', () => { + it('Contains a predefined index pattern', () => { + getIndicatorIndex().should('have.text', indexPatterns.join('')); + }); - changeToThreeHundredRowsPerPage(); - waitForRulesToBeLoaded(); + it('Does NOT show invalidation text on initial page load if indicator index pattern is filled out', () => { + getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`); + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('not.exist'); + }); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + it('Shows invalidation text when you try to continue without filling it out', () => { + getIndexPatternClearButton().click(); + getIndicatorIndicatorIndex().type(`${newThreatIndicatorRule.indicatorIndexPattern}{enter}`); + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('exist'); + }); }); - filterByCustomRules(); + describe('Indicator index patterns', () => { + it('Contains empty index pattern', () => { + getIndicatorIndicatorIndex().should('have.text', ''); + }); + + it('Does NOT show invalidation text on initial page load', () => { + getIndexPatternInvalidationText().should('not.exist'); + }); - cy.get(RULES_TABLE).then(($table) => { - cy.wrap($table.find(RULES_ROW).length).should('eql', 1); + it('Shows invalidation text if you try to continue without filling it out', () => { + getDefineContinueButton().click(); + getIndexPatternInvalidationText().should('exist'); + }); }); - cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name); - cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore); - cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity); - cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); - - goToRuleDetails(); - - cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`); - cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description); - cy.get(ABOUT_DETAILS).within(() => { - getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity); - getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore); - getDetails(REFERENCE_URLS_DETAILS).should((details) => { - expect(removeExternalLinkText(details.text())).equal(expectedUrls); - }); - getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); - getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { - expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); - }); - getDetails(TAGS_DETAILS).should('have.text', expectedTags); + + describe('custom query input', () => { + it('Has a default set of *:*', () => { + getCustomQueryInput().should('have.text', '*:*'); + }); + + it('Shows invalidation text if text is removed', () => { + getCustomQueryInput().type('{selectall}{del}'); + getCustomQueryInvalidationText().should('exist'); + }); }); - cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); - cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN); - - cy.get(DEFINITION_DETAILS).within(() => { - getDetails(INDEX_PATTERNS_DETAILS).should( - 'have.text', - newThreatIndicatorRule.index!.join('') - ); - getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*'); - getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match'); - getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None'); - getDetails(INDICATOR_INDEX_PATTERNS).should( - 'have.text', - newThreatIndicatorRule.indicatorIndexPattern.join('') - ); - getDetails(INDICATOR_MAPPING).should( - 'have.text', - `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` - ); - getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*'); + + describe('custom indicator query input', () => { + it('Has a default set of *:*', () => { + getCustomIndicatorQueryInput().should('have.text', '*:*'); + }); + + it('Shows invalidation text if text is removed', () => { + getCustomIndicatorQueryInput().type('{selectall}{del}'); + getCustomQueryInvalidationText().should('exist'); + }); }); - cy.get(SCHEDULE_DETAILS).within(() => { - getDetails(RUNS_EVERY_DETAILS).should( - 'have.text', - `${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}` - ); - getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should( - 'have.text', - `${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}` - ); + describe('Indicator mapping', () => { + beforeEach(() => { + fillIndexAndIndicatorIndexPattern( + newThreatIndicatorRule.index, + newThreatIndicatorRule.indicatorIndexPattern + ); + }); + + it('Does NOT show invalidation text on initial page load', () => { + getIndicatorInvalidationText().should('not.exist'); + }); + + it('Shows invalidation text when you try to press continue without filling anything out', () => { + getDefineContinueButton().click(); + getIndicatorAtLeastOneInvalidationText().should('exist'); + }); + + it('Shows invalidation text when the "AND" button is pressed and both the mappings are blank', () => { + getIndicatorAndButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Shows invalidation text when the "OR" button is pressed and both the mappings are blank', () => { + getIndicatorOrButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Does NOT show invalidation text when there is a valid "index field" and a valid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('not.exist'); + }); + + it('Shows invalidation text when there is an invalid "index field" and a valid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Shows invalidation text when there is a valid "index field" and an invalid "indicator index field"', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'non-existent-value', + validColumns: 'indexField', + }); + getDefineContinueButton().click(); + getIndicatorInvalidationText().should('exist'); + }); + + it('Deletes the first row when you have two rows. Both rows valid rows of "index fields" and valid "indicator index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'agent.name', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('have.text', 'agent.name'); + getIndicatorMappingComboField().should( + 'have.text', + newThreatIndicatorRule.indicatorIndexField + ); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row when you have two rows. Both rows have valid "index fields" and invalid "indicator index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'non-existent-value', + validColumns: 'indexField', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: 'second-non-existent-value', + validColumns: 'indexField', + }); + getIndicatorDeleteButton().click(); + getIndicatorMappingComboField().should('have.text', 'second-non-existent-value'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row when you have two rows. Both rows have valid "indicator index fields" and invalid "index fields". The second row should become the first row', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'second-non-existent-value', + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + validColumns: 'indicatorField', + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('have.text', 'second-non-existent-value'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the first row of data but not the UI elements and the text defaults back to the placeholder of Search', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('text', 'Search'); + getIndicatorMappingComboField().should('text', 'Search'); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); + + it('Deletes the second row when you have three rows. The first row is valid data, the second row is invalid data, and the third row is valid data. Third row should shift up correctly', () => { + fillIndicatorMatchRow({ + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: 'non-existent-value', + indicatorIndexField: 'non-existent-value', + validColumns: 'none', + }); + getIndicatorAndButton().click(); + fillIndicatorMatchRow({ + rowNumber: 3, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton(2).click(); + getIndicatorIndexComboField(1).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField(1).should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(2).should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField(2).should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(3).should('not.exist'); + getIndicatorMappingComboField(3).should('not.exist'); + }); + + it('Can add two OR rows and delete the second row. The first row has invalid data and the second row has valid data. The first row is deleted and the second row shifts up correctly.', () => { + fillIndicatorMatchRow({ + indexField: 'non-existent-value-one', + indicatorIndexField: 'non-existent-value-two', + validColumns: 'none', + }); + getIndicatorOrButton().click(); + fillIndicatorMatchRow({ + rowNumber: 2, + indexField: newThreatIndicatorRule.indicatorMapping, + indicatorIndexField: newThreatIndicatorRule.indicatorIndexField, + }); + getIndicatorDeleteButton().click(); + getIndicatorIndexComboField().should('text', newThreatIndicatorRule.indicatorMapping); + getIndicatorMappingComboField().should('text', newThreatIndicatorRule.indicatorIndexField); + getIndicatorIndexComboField(2).should('not.exist'); + getIndicatorMappingComboField(2).should('not.exist'); + }); }); - waitForTheRuleToBeExecuted(); - waitForAlertsToPopulate(); - - cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); - cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name); - cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); - cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match'); - cy.get(ALERT_RULE_SEVERITY) - .first() - .should('have.text', newThreatIndicatorRule.severity.toLowerCase()); - cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore); + it('Creates and activates a new Indicator Match rule', () => { + fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule); + fillAboutRuleAndContinue(newThreatIndicatorRule); + fillScheduleRuleAndContinue(newThreatIndicatorRule); + createAndActivateRule(); + + cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)'); + + changeToThreeHundredRowsPerPage(); + waitForRulesToBeLoaded(); + + cy.get(RULES_TABLE).then(($table) => { + cy.wrap($table.find(RULES_ROW).length).should('eql', expectedNumberOfRules); + }); + + filterByCustomRules(); + + cy.get(RULES_TABLE).then(($table) => { + cy.wrap($table.find(RULES_ROW).length).should('eql', 1); + }); + cy.get(RULE_NAME).should('have.text', newThreatIndicatorRule.name); + cy.get(RISK_SCORE).should('have.text', newThreatIndicatorRule.riskScore); + cy.get(SEVERITY).should('have.text', newThreatIndicatorRule.severity); + cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true'); + + goToRuleDetails(); + + cy.get(RULE_NAME_HEADER).should('have.text', `${newThreatIndicatorRule.name}`); + cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', newThreatIndicatorRule.description); + cy.get(ABOUT_DETAILS).within(() => { + getDetails(SEVERITY_DETAILS).should('have.text', newThreatIndicatorRule.severity); + getDetails(RISK_SCORE_DETAILS).should('have.text', newThreatIndicatorRule.riskScore); + getDetails(REFERENCE_URLS_DETAILS).should((details) => { + expect(removeExternalLinkText(details.text())).equal(expectedUrls); + }); + getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives); + getDetails(MITRE_ATTACK_DETAILS).should((mitre) => { + expect(removeExternalLinkText(mitre.text())).equal(expectedMitre); + }); + getDetails(TAGS_DETAILS).should('have.text', expectedTags); + }); + cy.get(INVESTIGATION_NOTES_TOGGLE).click({ force: true }); + cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN); + + cy.get(DEFINITION_DETAILS).within(() => { + getDetails(INDEX_PATTERNS_DETAILS).should( + 'have.text', + newThreatIndicatorRule.index!.join('') + ); + getDetails(CUSTOM_QUERY_DETAILS).should('have.text', '*:*'); + getDetails(RULE_TYPE_DETAILS).should('have.text', 'Indicator Match'); + getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None'); + getDetails(INDICATOR_INDEX_PATTERNS).should( + 'have.text', + newThreatIndicatorRule.indicatorIndexPattern.join('') + ); + getDetails(INDICATOR_MAPPING).should( + 'have.text', + `${newThreatIndicatorRule.indicatorMapping} MATCHES ${newThreatIndicatorRule.indicatorIndexField}` + ); + getDetails(INDICATOR_INDEX_QUERY).should('have.text', '*:*'); + }); + + cy.get(SCHEDULE_DETAILS).within(() => { + getDetails(RUNS_EVERY_DETAILS).should( + 'have.text', + `${newThreatIndicatorRule.runsEvery.interval}${newThreatIndicatorRule.runsEvery.type}` + ); + getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should( + 'have.text', + `${newThreatIndicatorRule.lookBack.interval}${newThreatIndicatorRule.lookBack.type}` + ); + }); + + waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); + + cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); + cy.get(ALERT_RULE_NAME).first().should('have.text', newThreatIndicatorRule.name); + cy.get(ALERT_RULE_VERSION).first().should('have.text', '1'); + cy.get(ALERT_RULE_METHOD).first().should('have.text', 'threat_match'); + cy.get(ALERT_RULE_SEVERITY) + .first() + .should('have.text', newThreatIndicatorRule.severity.toLowerCase()); + cy.get(ALERT_RULE_RISK_SCORE).first().should('have.text', newThreatIndicatorRule.riskScore); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts index 2bfd2fbf0054c..ac70a1cae148e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timelines/creation.spec.ts @@ -47,7 +47,8 @@ import { openTimeline } from '../../tasks/timelines'; import { OVERVIEW_URL } from '../../urls/navigation'; -describe('Timelines', () => { +// Skipped at the moment as there looks to be in-deterministic bugs with the open timeline dialog. +describe.skip('Timelines', () => { beforeEach(() => { cleanKibana(); }); @@ -89,7 +90,7 @@ describe('Timelines', () => { cy.get(FAVORITE_TIMELINE).should('exist'); cy.get(TIMELINE_TITLE).should('have.text', timeline.title); - cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); + cy.get(TIMELINE_DESCRIPTION).should('have.text', timeline.description); // This is the flake part where it sometimes does not show/load the timelines correctly cy.get(TIMELINE_QUERY).should('have.text', `${timeline.query} `); cy.get(TIMELINE_FILTER(timeline.filter)).should('exist'); cy.get(PIN_EVENT) diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 66681e77b7eb9..2a59dd33399c5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -38,6 +38,22 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const THREAT_MATCH_QUERY_INPUT = '[data-test-subj="detectionEngineStepDefineThreatRuleQueryBar"] [data-test-subj="queryInput"]'; +export const THREAT_MATCH_AND_BUTTON = '[data-test-subj="andButton"]'; + +export const THREAT_ITEM_ENTRY_DELETE_BUTTON = '[data-test-subj="itemEntryDeleteButton"]'; + +export const THREAT_MATCH_OR_BUTTON = '[data-test-subj="orButton"]'; + +export const THREAT_COMBO_BOX_INPUT = '[data-test-subj="fieldAutocompleteComboBox"]'; + +export const INVALID_MATCH_CONTENT = 'All matches require both a field and threat index field.'; + +export const AT_LEAST_ONE_VALID_MATCH = 'At least one indicator match is required.'; + +export const AT_LEAST_ONE_INDEX_PATTERN = 'A minimum of one index pattern is required.'; + +export const CUSTOM_QUERY_REQUIRED = 'A custom query is required.'; + export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; export const DEFINE_EDIT_BUTTON = '[data-test-subj="edit-define-rule"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 7836960b1a694..5143dc27e7d7a 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -63,13 +63,20 @@ import { EQL_QUERY_PREVIEW_HISTOGRAM, EQL_QUERY_VALIDATION_SPINNER, COMBO_BOX_CLEAR_BTN, - COMBO_BOX_RESULT, MITRE_ATTACK_TACTIC_DROPDOWN, MITRE_ATTACK_TECHNIQUE_DROPDOWN, MITRE_ATTACK_SUBTECHNIQUE_DROPDOWN, MITRE_ATTACK_ADD_TACTIC_BUTTON, MITRE_ATTACK_ADD_SUBTECHNIQUE_BUTTON, MITRE_ATTACK_ADD_TECHNIQUE_BUTTON, + THREAT_COMBO_BOX_INPUT, + THREAT_ITEM_ENTRY_DELETE_BUTTON, + THREAT_MATCH_AND_BUTTON, + INVALID_MATCH_CONTENT, + THREAT_MATCH_OR_BUTTON, + AT_LEAST_ONE_VALID_MATCH, + AT_LEAST_ONE_INDEX_PATTERN, + CUSTOM_QUERY_REQUIRED, } from '../screens/create_new_rule'; import { TOAST_ERROR } from '../screens/shared'; import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; @@ -144,7 +151,7 @@ export const fillAboutRuleAndContinue = ( rule: CustomRule | MachineLearningRule | ThresholdRule | ThreatIndicatorRule ) => { fillAboutRule(rule); - cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + getAboutContinueButton().should('exist').click({ force: true }); }; export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => { @@ -222,7 +229,7 @@ export const fillAboutRuleWithOverrideAndContinue = (rule: OverrideRule) => { cy.get(COMBO_BOX_INPUT).type(`${rule.timestampOverride}{enter}`); }); - cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + getAboutContinueButton().should('exist').click({ force: true }); }; export const fillDefineCustomRuleWithImportedQueryAndContinue = ( @@ -282,19 +289,132 @@ export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { cy.get(EQL_QUERY_INPUT).should('not.exist'); }; +/** + * Fills in the indicator match rows for tests by giving it an optional rowNumber, + * a indexField, a indicatorIndexField, and an optional validRows which indicates + * which row is valid or not. + * + * There are special tricks below with Eui combo box: + * cy.get(`button[title="${indexField}"]`) + * .should('be.visible') + * .then(([e]) => e.click()); + * + * To first ensure the button is there before clicking on the button. There are + * race conditions where if the Eui drop down button from the combo box is not + * visible then the click handler is not there either, and when we click on it + * that will cause the item to _not_ be selected. Using a {enter} with the combo + * box also does not select things from EuiCombo boxes either, so I have to click + * the actual contents of the EuiCombo box to select things. + */ +export const fillIndicatorMatchRow = ({ + rowNumber, + indexField, + indicatorIndexField, + validColumns, +}: { + rowNumber?: number; // default is 1 + indexField: string; + indicatorIndexField: string; + validColumns?: 'indexField' | 'indicatorField' | 'both' | 'none'; // default is both are valid entries +}) => { + const computedRowNumber = rowNumber == null ? 1 : rowNumber; + const computedValueRows = validColumns == null ? 'both' : validColumns; + const OFFSET = 2; + cy.get(COMBO_BOX_INPUT) + .eq(computedRowNumber * OFFSET + 1) + .type(indexField); + if (computedValueRows === 'indexField' || computedValueRows === 'both') { + cy.get(`button[title="${indexField}"]`) + .should('be.visible') + .then(([e]) => e.click()); + } + cy.get(COMBO_BOX_INPUT) + .eq(computedRowNumber * OFFSET + 2) + .type(indicatorIndexField); + + if (computedValueRows === 'indicatorField' || computedValueRows === 'both') { + cy.get(`button[title="${indicatorIndexField}"]`) + .should('be.visible') + .then(([e]) => e.click()); + } +}; + +/** + * Fills in both the index pattern and the indicator match index pattern. + * @param indexPattern The index pattern. + * @param indicatorIndex The indicator index pattern. + */ +export const fillIndexAndIndicatorIndexPattern = ( + indexPattern?: string[], + indicatorIndex?: string[] +) => { + getIndexPatternClearButton().click(); + getIndicatorIndex().type(`${indexPattern}{enter}`); + getIndicatorIndicatorIndex().type(`${indicatorIndex}{enter}`); +}; + +/** Returns the indicator index drop down field. Pass in row number, default is 1 */ +export const getIndicatorIndexComboField = (row = 1) => + cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 2); + +/** Returns the indicator mapping drop down field. Pass in row number, default is 1 */ +export const getIndicatorMappingComboField = (row = 1) => + cy.get(THREAT_COMBO_BOX_INPUT).eq(row * 2 - 1); + +/** Returns the indicator matches DELETE button for the mapping. Pass in row number, default is 1 */ +export const getIndicatorDeleteButton = (row = 1) => + cy.get(THREAT_ITEM_ENTRY_DELETE_BUTTON).eq(row - 1); + +/** Returns the indicator matches AND button for the mapping */ +export const getIndicatorAndButton = () => cy.get(THREAT_MATCH_AND_BUTTON); + +/** Returns the indicator matches OR button for the mapping */ +export const getIndicatorOrButton = () => cy.get(THREAT_MATCH_OR_BUTTON); + +/** Returns the invalid match content. */ +export const getIndicatorInvalidationText = () => cy.contains(INVALID_MATCH_CONTENT); + +/** Returns that at least one valid match is required content */ +export const getIndicatorAtLeastOneInvalidationText = () => cy.contains(AT_LEAST_ONE_VALID_MATCH); + +/** Returns that at least one index pattern is required content */ +export const getIndexPatternInvalidationText = () => cy.contains(AT_LEAST_ONE_INDEX_PATTERN); + +/** Returns the continue button on the step of about */ +export const getAboutContinueButton = () => cy.get(ABOUT_CONTINUE_BTN); + +/** Returns the continue button on the step of define */ +export const getDefineContinueButton = () => cy.get(DEFINE_CONTINUE_BUTTON); + +/** Returns the indicator index pattern */ +export const getIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(0); + +/** Returns the indicator's indicator index */ +export const getIndicatorIndicatorIndex = () => cy.get(COMBO_BOX_INPUT).eq(2); + +/** Returns the index pattern's clear button */ +export const getIndexPatternClearButton = () => cy.get(COMBO_BOX_CLEAR_BTN); + +/** Returns the custom query input */ +export const getCustomQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(0); + +/** Returns the custom query input */ +export const getCustomIndicatorQueryInput = () => cy.get(CUSTOM_QUERY_INPUT).eq(1); + +/** Returns custom query required content */ +export const getCustomQueryInvalidationText = () => cy.contains(CUSTOM_QUERY_REQUIRED); + +/** + * Fills in the define indicator match rules and then presses the continue button + * @param rule The rule to use to fill in everything + */ export const fillDefineIndicatorMatchRuleAndContinue = (rule: ThreatIndicatorRule) => { - const INDEX_PATTERNS = 0; - const INDICATOR_INDEX_PATTERN = 2; - const INDICATOR_MAPPING = 3; - const INDICATOR_INDEX_FIELD = 4; - - cy.get(COMBO_BOX_CLEAR_BTN).click(); - cy.get(COMBO_BOX_INPUT).eq(INDEX_PATTERNS).type(`${rule.index}{enter}`); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_PATTERN).type(`${rule.indicatorIndexPattern}{enter}`); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_MAPPING).type(`${rule.indicatorMapping}{enter}`); - cy.get(COMBO_BOX_RESULT).first().click(); - cy.get(COMBO_BOX_INPUT).eq(INDICATOR_INDEX_FIELD).type(`${rule.indicatorIndexField}{enter}`); - cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + fillIndexAndIndicatorIndexPattern(rule.index, rule.indicatorIndexPattern); + fillIndicatorMatchRow({ + indexField: rule.indicatorMapping, + indicatorIndexField: rule.indicatorIndexField, + }); + getDefineContinueButton().should('exist').click({ force: true }); cy.get(CUSTOM_QUERY_INPUT).should('not.exist'); }; @@ -304,7 +424,7 @@ export const fillDefineMachineLearningRuleAndContinue = (rule: MachineLearningRu cy.get(ANOMALY_THRESHOLD_INPUT).type(`{selectall}${machineLearningRule.anomalyScoreThreshold}`, { force: true, }); - cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + getDefineContinueButton().should('exist').click({ force: true }); cy.get(MACHINE_LEARNING_DROPDOWN).should('not.exist'); }; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts index a04ecb1f9ccaa..c2b5790b1ae12 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts @@ -19,7 +19,10 @@ export const exportTimeline = (timelineId: string) => { }; export const openTimeline = (id: string) => { - cy.get(TIMELINE(id), { timeout: 500 }).click(); + // This temporary wait here is to reduce flakeyness until we integrate cypress-pipe. Then please let us use cypress pipe. + // Ref: https://www.cypress.io/blog/2019/01/22/when-can-the-test-click/ + // Ref: https://github.com/NicholasBoll/cypress-pipe#readme + cy.get(TIMELINE(id)).should('be.visible').wait(1500).click(); }; export const waitForTimelinesPanelToBeLoaded = () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 71fd74570c16a..009053067064a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -20,6 +20,7 @@ import { useDeleteCases } from '../../containers/use_delete_cases'; import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useUpdateCases } from '../../containers/use_bulk_update_case'; +import { useGetActionLicense } from '../../containers/use_get_action_license'; import { getCasesColumns } from './columns'; import { AllCases } from '.'; @@ -27,12 +28,14 @@ jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); +jest.mock('../../containers/use_get_action_license'); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useUpdateCasesMock = useUpdateCases as jest.Mock; +const useGetActionLicenseMock = useGetActionLicense as jest.Mock; jest.mock('../../../common/components/link_to'); @@ -86,6 +89,12 @@ describe('AllCases', () => { updateBulkStatus, }; + const defaultActionLicense = { + actionLicense: null, + isLoading: false, + isError: false, + }; + let navigateToApp: jest.Mock; beforeEach(() => { @@ -96,6 +105,7 @@ describe('AllCases', () => { useGetCasesMock.mockReturnValue(defaultGetCases); useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); + useGetActionLicenseMock.mockReturnValue(defaultActionLicense); moment.tz.setDefault('UTC'); }); @@ -398,6 +408,7 @@ describe('AllCases', () => { expect(dispatchResetIsDeleted).toBeCalled(); }); }); + it('isUpdated is true, refetch', async () => { useUpdateCasesMock.mockReturnValue({ ...defaultUpdateCases, @@ -627,4 +638,56 @@ describe('AllCases', () => { ); }); }); + + it('should not allow the user to enter configuration page with basic license', async () => { + useGetActionLicenseMock.mockReturnValue({ + ...defaultActionLicense, + actionLicense: { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: false, + }, + }); + + const wrapper = mount( + <TestProviders> + <AllCases userCanCrud={true} /> + </TestProviders> + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="configure-case-button"]').first().prop('isDisabled') + ).toBeTruthy(); + }); + }); + + it('should allow the user to enter configuration page with gold license and above', async () => { + useGetActionLicenseMock.mockReturnValue({ + ...defaultActionLicense, + actionLicense: { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + }); + + const wrapper = mount( + <TestProviders> + <AllCases userCanCrud={true} /> + </TestProviders> + ); + + await waitFor(() => { + expect( + wrapper.find('[data-test-subj="configure-case-button"]').first().prop('isDisabled') + ).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx index b1b5f2b087eee..93890656b4a7f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/__mock__/index.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ConnectorTypes } from '../../../../../../case/common/api'; import { ActionConnector } from '../../../containers/configure/types'; import { UseConnectorsResponse } from '../../../containers/configure/use_connectors'; -import { connectorsMock } from '../../../containers/configure/mock'; import { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; -import { ConnectorTypes } from '../../../../../../case/common/api'; +import { UseActionTypesResponse } from '../../../containers/configure/use_action_types'; +import { connectorsMock, actionTypesMock } from '../../../containers/configure/mock'; export { mappings } from '../../../containers/configure/mock'; export const connectors: ActionConnector[] = connectorsMock; @@ -51,3 +52,9 @@ export const useConnectorsResponse: UseConnectorsResponse = { connectors, refetchConnectors: jest.fn(), }; + +export const useActionTypesResponse: UseActionTypesResponse = { + loading: false, + actionTypes: actionTypesMock, + refetchActionTypes: jest.fn(), +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx index 44767471dd9e7..9f3dcd168ba5f 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.tsx @@ -38,6 +38,7 @@ const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({ }, [history, urlSearch] ); + const configureCaseButton = useMemo( () => ( <LinkButton @@ -53,6 +54,7 @@ const ConfigureCaseButtonComponent: React.FC<ConfigureCaseButtonProps> = ({ ), [label, isDisabled, formatUrl, goToCaseConfigure] ); + return showToolTip ? ( <EuiToolTip position="top" diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 2656e2496c2fc..dbdf3d914efab 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -22,20 +22,29 @@ import { actionTypeRegistryMock } from '../../../../../triggers_actions_ui/publi import { useKibana } from '../../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useActionTypes } from '../../containers/configure/use_action_types'; import { useGetUrlSearch } from '../../../common/components/navigation/use_get_url_search'; -import { connectors, searchURL, useCaseConfigureResponse, useConnectorsResponse } from './__mock__'; +import { + connectors, + searchURL, + useCaseConfigureResponse, + useConnectorsResponse, + useActionTypesResponse, +} from './__mock__'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; jest.mock('../../../common/lib/kibana'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); +jest.mock('../../containers/configure/use_action_types'); jest.mock('../../../common/components/navigation/use_get_url_search'); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; const useGetUrlSearchMock = useGetUrlSearch as jest.Mock; +const useActionTypesMock = useActionTypes as jest.Mock; describe('ConfigureCases', () => { beforeEach(() => { @@ -83,6 +92,8 @@ describe('ConfigureCases', () => { /> )), } as unknown) as TriggersAndActionsUIPublicPluginStart; + + useActionTypesMock.mockImplementation(() => useActionTypesResponse); }); describe('rendering', () => { @@ -265,10 +276,12 @@ describe('ConfigureCases', () => { closureType: 'close-by-user', }, })); + useConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, loading: true, })); + useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(<ConfigureCases userCanCrud />, { wrappingComponent: TestProviders }); }); @@ -294,6 +307,18 @@ describe('ConfigureCases', () => { .prop('disabled') ).toBe(true); }); + + test('it shows isLoading when loading action types', () => { + useConnectorsMock.mockImplementation(() => ({ + ...useConnectorsResponse, + loading: false, + })); + + useActionTypesMock.mockImplementation(() => ({ ...useActionTypesResponse, loading: true })); + + wrapper = mount(<ConfigureCases userCanCrud />, { wrappingComponent: TestProviders }); + expect(wrapper.find(Connectors).prop('isLoading')).toBe(true); + }); }); describe('saving configuration', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx index d34dc168ba7a2..bc56e404c891d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.tsx @@ -9,16 +9,16 @@ import styled, { css } from 'styled-components'; import { EuiCallOut } from '@elastic/eui'; +import { SUPPORTED_CONNECTORS } from '../../../../../case/common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { useConnectors } from '../../containers/configure/use_connectors'; +import { useActionTypes } from '../../containers/configure/use_action_types'; import { useCaseConfigure } from '../../containers/configure/use_configure'; -import { ActionType } from '../../../../../triggers_actions_ui/public'; import { ClosureType } from '../../containers/configure/types'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ActionConnectorTableItem } from '../../../../../triggers_actions_ui/public/types'; -import { connectorsConfiguration } from '../connectors'; import { SectionWrapper } from '../wrappers'; import { Connectors } from './connectors'; @@ -49,8 +49,6 @@ const FormWrapper = styled.div` `} `; -const actionTypes: ActionType[] = Object.values(connectorsConfiguration); - interface ConfigureCasesComponentProps { userCanCrud: boolean; } @@ -78,12 +76,20 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC } = useCaseConfigure(); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); + const { loading: isLoadingActionTypes, actionTypes, refetchActionTypes } = useActionTypes(); + const supportedActionTypes = useMemo( + () => actionTypes.filter((actionType) => SUPPORTED_CONNECTORS.includes(actionType.id)), + [actionTypes] + ); const onConnectorUpdate = useCallback(async () => { refetchConnectors(); + refetchActionTypes(); refetchCaseConfigure(); - }, [refetchCaseConfigure, refetchConnectors]); - const isLoadingAny = isLoadingConnectors || persistLoading || loadingCaseConfigure; + }, [refetchActionTypes, refetchCaseConfigure, refetchConnectors]); + + const isLoadingAny = + isLoadingConnectors || persistLoading || loadingCaseConfigure || isLoadingActionTypes; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { setEditFlyoutVisibility(true); @@ -154,11 +160,11 @@ const ConfigureCasesComponent: React.FC<ConfigureCasesComponentProps> = ({ userC triggersActionsUi.getAddConnectorFlyout({ consumer: 'case', onClose: onCloseAddFlyout, - actionTypes, + actionTypes: supportedActionTypes, reloadConnectors: onConnectorUpdate, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [supportedActionTypes] ); const ConnectorEditFlyout = useMemo( diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx index f3b47f756bce9..1b4df7730cc8b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.test.tsx @@ -33,8 +33,13 @@ import { import { FormContext } from './form_context'; import { CreateCaseForm } from './form'; import { SubmitCaseButton } from './submit_button'; +import { usePostPushToService } from '../../containers/use_post_push_to_service'; +import { noop } from 'lodash/fp'; + +const sampleId = 'case-id'; jest.mock('../../containers/use_post_case'); +jest.mock('../../containers/use_post_push_to_service'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); @@ -48,19 +53,28 @@ jest.mock('../settings/jira/use_get_issues'); const useConnectorsMock = useConnectors as jest.Mock; const useCaseConfigureMock = useCaseConfigure as jest.Mock; const usePostCaseMock = usePostCase as jest.Mock; +const usePostPushToServiceMock = usePostPushToService as jest.Mock; const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetIssueTypesMock = useGetIssueTypes as jest.Mock; const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const postCase = jest.fn(); +const postPushToService = jest.fn(); const defaultPostCase = { isLoading: false, isError: false, - caseData: null, postCase, }; +const defaultPostPushToService = { + serviceData: null, + pushedCaseData: null, + isLoading: false, + isError: false, + postPushToService, +}; + const fillForm = (wrapper: ReactWrapper) => { wrapper .find(`[data-test-subj="caseTitle"] input`) @@ -85,7 +99,12 @@ describe('Create case', () => { beforeEach(() => { jest.resetAllMocks(); + postCase.mockResolvedValue({ + id: sampleId, + ...sampleData, + }); usePostCaseMock.mockImplementation(() => defaultPostCase); + usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); useConnectorsMock.mockReturnValue(sampleConnectorData); useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); @@ -163,25 +182,6 @@ describe('Create case', () => { ); }); - it('should redirect to new case when caseData is there', async () => { - const sampleId = 'case-id'; - usePostCaseMock.mockImplementation(() => ({ - ...defaultPostCase, - caseData: { id: sampleId }, - })); - - mount( - <TestProviders> - <FormContext onSuccess={onFormSubmitSuccess}> - <CreateCaseForm /> - <SubmitCaseButton /> - </FormContext> - </TestProviders> - ); - - await waitFor(() => expect(onFormSubmitSuccess).toHaveBeenCalledWith({ id: 'case-id' })); - }); - it('it should select the default connector set in the configuration', async () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, @@ -258,12 +258,15 @@ describe('Create case', () => { fillForm(wrapper); wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => expect(postCase).toBeCalledWith(sampleData)); + await waitFor(() => { + expect(postCase).toBeCalledWith(sampleData); + expect(postPushToService).not.toHaveBeenCalled(); + }); }); }); describe('Step 2 - Connector Fields', () => { - it(`it should submit a Jira connector`, async () => { + it(`it should submit and push to Jira connector`, async () => { useConnectorsMock.mockReturnValue({ ...sampleConnectorData, connectors: connectorsMock, @@ -304,7 +307,7 @@ describe('Create case', () => { wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => + await waitFor(() => { expect(postCase).toBeCalledWith({ ...sampleData, connector: { @@ -313,11 +316,27 @@ describe('Create case', () => { type: '.jira', fields: { issueType: '10007', parent: null, priority: '2' }, }, - }) - ); + }); + expect(postPushToService).toHaveBeenCalledWith({ + caseId: sampleId, + caseServices: {}, + connector: { + id: 'jira-1', + name: 'Jira', + type: '.jira', + fields: { issueType: '10007', parent: null, priority: '2' }, + }, + alerts: {}, + updateCase: noop, + }); + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); }); - it(`it should submit a resilient connector`, async () => { + it(`it should submit and push to resilient connector`, async () => { useConnectorsMock.mockReturnValue({ ...sampleConnectorData, connectors: connectorsMock, @@ -359,7 +378,7 @@ describe('Create case', () => { wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => + await waitFor(() => { expect(postCase).toBeCalledWith({ ...sampleData, connector: { @@ -368,11 +387,29 @@ describe('Create case', () => { type: '.resilient', fields: { incidentTypes: ['19'], severityCode: '4' }, }, - }) - ); + }); + + expect(postPushToService).toHaveBeenCalledWith({ + caseId: sampleId, + caseServices: {}, + connector: { + id: 'resilient-2', + name: 'My Connector 2', + type: '.resilient', + fields: { incidentTypes: ['19'], severityCode: '4' }, + }, + alerts: {}, + updateCase: noop, + }); + + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); }); - it(`it should submit a servicenow connector`, async () => { + it(`it should submit and push to servicenow connector`, async () => { useConnectorsMock.mockReturnValue({ ...sampleConnectorData, connectors: connectorsMock, @@ -404,7 +441,7 @@ describe('Create case', () => { wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); - await waitFor(() => + await waitFor(() => { expect(postCase).toBeCalledWith({ ...sampleData, connector: { @@ -413,8 +450,26 @@ describe('Create case', () => { type: '.servicenow', fields: { impact: '2', severity: '2', urgency: '2' }, }, - }) - ); + }); + + expect(postPushToService).toHaveBeenCalledWith({ + caseId: sampleId, + caseServices: {}, + connector: { + id: 'servicenow-1', + name: 'My Connector', + type: '.servicenow', + fields: { impact: '2', severity: '2', urgency: '2' }, + }, + alerts: {}, + updateCase: noop, + }); + + expect(onFormSubmitSuccess).toHaveBeenCalledWith({ + id: sampleId, + ...sampleData, + }); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index 4315011ac8df1..03e03d853878c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -3,9 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import React, { useCallback, useEffect, useMemo } from 'react'; - +import { noop } from 'lodash/fp'; import { schema, FormProps } from './schema'; import { Form, useForm } from '../../../shared_imports'; import { @@ -14,6 +13,8 @@ import { normalizeActionConnector, } from '../configure_cases/utils'; import { usePostCase } from '../../containers/use_post_case'; +import { usePostPushToService } from '../../containers/use_post_push_to_service'; + import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Case } from '../../containers/types'; @@ -34,7 +35,9 @@ interface Props { export const FormContext: React.FC<Props> = ({ children, onSuccess }) => { const { connectors } = useConnectors(); const { connector: configurationConnector } = useCaseConfigure(); - const { caseData, postCase } = usePostCase(); + const { postCase } = usePostCase(); + const { postPushToService } = usePostPushToService(); + const connectorId = useMemo( () => connectors.some((connector) => connector.id === configurationConnector.id) @@ -50,18 +53,33 @@ export const FormContext: React.FC<Props> = ({ children, onSuccess }) => { ) => { if (isValid) { const caseConnector = getConnectorById(dataConnectorId, connectors); + const connectorToUpdate = caseConnector ? normalizeActionConnector(caseConnector, fields) : getNoneConnector(); - await postCase({ + const updatedCase = await postCase({ ...dataWithoutConnectorId, connector: connectorToUpdate, settings: { syncAlerts }, }); + + if (updatedCase?.id && dataConnectorId !== 'none') { + await postPushToService({ + caseId: updatedCase.id, + caseServices: {}, + connector: connectorToUpdate, + alerts: {}, + updateCase: noop, + }); + } + + if (onSuccess && updatedCase) { + onSuccess(updatedCase); + } } }, - [postCase, connectors] + [connectors, postCase, onSuccess, postPushToService] ); const { form } = useForm<FormProps>({ @@ -70,18 +88,10 @@ export const FormContext: React.FC<Props> = ({ children, onSuccess }) => { schema, onSubmit: submitCase, }); - const { setFieldValue } = form; - // Set the selected connector to the configuration connector useEffect(() => setFieldValue('connectorId', connectorId), [connectorId, setFieldValue]); - useEffect(() => { - if (caseData && onSuccess) { - onSuccess(caseData); - } - }, [caseData, onSuccess]); - return <Form form={form}>{children}</Form>; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx index 43f2a2a6e12f1..396ce0725eb3a 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx @@ -17,11 +17,16 @@ export const getLicenseError = () => ({ title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, description: ( <FormattedMessage - defaultMessage="To open cases in external systems, you must update your license to Platinum, start a free 30-day trial, or spin up a {link} on AWS, GCP, or Azure." + defaultMessage="Opening cases in external systems is available when you have the {appropriateLicense}, are using a {cloud}, or are testing out a Free Trial." id="xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription" values={{ - link: ( - <EuiLink href="https://www.elastic.co/cloud/" target="_blank"> + appropriateLicense: ( + <EuiLink href="https://www.elastic.co/subscriptions" target="_blank"> + {i18n.LINK_APPROPRIATE_LICENSE} + </EuiLink> + ), + cloud: ( + <EuiLink href="https://www.elastic.co/cloud/elasticsearch-service/signup" target="_blank"> {i18n.LINK_CLOUD_DEPLOYMENT} </EuiLink> ), diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts index f4539b8019d43..16f1b8965bb0b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/translations.ts @@ -69,7 +69,7 @@ export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( 'xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle', { - defaultMessage: 'Upgrade to Elastic Platinum', + defaultMessage: 'Upgrade to an appropriate license', } ); @@ -80,6 +80,13 @@ export const LINK_CLOUD_DEPLOYMENT = i18n.translate( } ); +export const LINK_APPROPRIATE_LICENSE = i18n.translate( + 'xpack.securitySolution.case.caseView.appropiateLicense', + { + defaultMessage: 'appropriate license', + } +); + export const LINK_CONNECTOR_CONFIGURE = i18n.translate( 'xpack.securitySolution.case.caseView.connectorConfigureLink', { diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts index 257cb171a4a9a..ed2f77657fb5e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/__mocks__/api.ts @@ -8,11 +8,12 @@ import { CasesConfigurePatch, CasesConfigureRequest, ActionConnector, + ActionTypeConnector, } from '../../../../../../case/common/api'; import { ApiProps } from '../../types'; import { CaseConfigure } from '../types'; -import { connectorsMock, caseConfigurationCamelCaseResponseMock } from '../mock'; +import { connectorsMock, caseConfigurationCamelCaseResponseMock, actionTypesMock } from '../mock'; export const fetchConnectors = async ({ signal }: ApiProps): Promise<ActionConnector[]> => Promise.resolve(connectorsMock); @@ -29,3 +30,6 @@ export const patchCaseConfigure = async ( caseConfiguration: CasesConfigurePatch, signal: AbortSignal ): Promise<CaseConfigure> => Promise.resolve(caseConfigurationCamelCaseResponseMock); + +export const fetchActionTypes = async ({ signal }: ApiProps): Promise<ActionTypeConnector[]> => + Promise.resolve(actionTypesMock); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts index f9115963c745d..70576482fbe89 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.test.ts @@ -5,9 +5,16 @@ */ import { KibanaServices } from '../../../common/lib/kibana'; -import { fetchConnectors, getCaseConfigure, postCaseConfigure, patchCaseConfigure } from './api'; +import { + fetchConnectors, + getCaseConfigure, + postCaseConfigure, + patchCaseConfigure, + fetchActionTypes, +} from './api'; import { connectorsMock, + actionTypesMock, caseConfigurationMock, caseConfigurationResposeMock, caseConfigurationCamelCaseResponseMock, @@ -123,4 +130,24 @@ describe('Case Configuration API', () => { expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); }); }); + + describe('fetch actionTypes', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(actionTypesMock); + }); + + test('check url, method, signal', async () => { + await fetchActionTypes({ signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/actions/list_action_types', { + method: 'GET', + signal: abortCtrl.signal, + }); + }); + + test('happy path', async () => { + const resp = await fetchActionTypes({ signal: abortCtrl.signal }); + expect(resp).toEqual(actionTypesMock); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts index 8652e48fd834d..2b2bd1a782f75 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/api.ts @@ -7,6 +7,7 @@ import { isEmpty } from 'lodash/fp'; import { ActionConnector, + ActionTypeConnector, CasesConfigurePatch, CasesConfigureResponse, CasesConfigureRequest, @@ -16,6 +17,7 @@ import { KibanaServices } from '../../../common/lib/kibana'; import { CASE_CONFIGURE_CONNECTORS_URL, CASE_CONFIGURE_URL, + ACTION_TYPES_URL, } from '../../../../../case/common/constants'; import { ApiProps } from '../types'; @@ -89,3 +91,12 @@ export const patchCaseConfigure = async ( decodeCaseConfigureResponse(response) ); }; + +export const fetchActionTypes = async ({ signal }: ApiProps): Promise<ActionTypeConnector[]> => { + const response = await KibanaServices.get().http.fetch(ACTION_TYPES_URL, { + method: 'GET', + signal, + }); + + return response; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts index fabd1187698a7..79aaaab61324e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/mock.ts @@ -6,6 +6,7 @@ import { ActionConnector, + ActionTypeConnector, CasesConfigureResponse, CasesConfigureRequest, ConnectorTypes, @@ -29,6 +30,7 @@ export const mappings: CaseConnectorMapping[] = [ actionType: 'append', }, ]; + export const connectorsMock: ActionConnector[] = [ { id: 'servicenow-1', @@ -60,6 +62,49 @@ export const connectorsMock: ActionConnector[] = [ }, ]; +export const actionTypesMock: ActionTypeConnector[] = [ + { + id: '.email', + name: 'Email', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.index', + name: 'Index', + minimumLicenseRequired: 'basic', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.servicenow', + name: 'ServiceNow', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.jira', + name: 'Jira', + minimumLicenseRequired: 'gold', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, + { + id: '.resilient', + name: 'IBM Resilient', + minimumLicenseRequired: 'platinum', + enabled: false, + enabledInConfig: true, + enabledInLicense: true, + }, +]; + export const caseConfigurationResposeMock: CasesConfigureResponse = { created_at: '2020-04-06T13:03:18.657Z', created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts index 41acb91f2ae96..ff2441d361c2c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/types.ts @@ -7,6 +7,7 @@ import { ElasticUser } from '../types'; import { ActionConnector, + ActionTypeConnector, ActionType, CaseConnector, CaseField, @@ -15,7 +16,15 @@ import { ThirdPartyField, } from '../../../../../case/common/api'; -export { ActionConnector, ActionType, CaseConnector, CaseField, ClosureType, ThirdPartyField }; +export { + ActionConnector, + ActionTypeConnector, + ActionType, + CaseConnector, + CaseField, + ClosureType, + ThirdPartyField, +}; export interface CaseConnectorMapping { actionType: ActionType; diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx new file mode 100644 index 0000000000000..b2213fb8fc8c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { useActionTypes, UseActionTypesResponse } from './use_action_types'; +import { actionTypesMock } from './mock'; +import * as api from './api'; + +jest.mock('./api'); + +describe('useActionTypes', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + await waitForNextUpdate(); + expect(result.current).toEqual({ + loading: true, + actionTypes: [], + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); + + test('fetch action types', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + actionTypes: actionTypesMock, + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); + + test('refetch actionTypes', async () => { + const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.refetchActionTypes(); + expect(spyOnfetchActionTypes).toHaveBeenCalledTimes(2); + }); + }); + + test('set isLoading to true when refetching actionTypes', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + + await waitForNextUpdate(); + await waitForNextUpdate(); + + result.current.refetchActionTypes(); + + expect(result.current.loading).toBe(true); + }); + }); + + test('unhappy path', async () => { + const spyOnfetchActionTypes = jest.spyOn(api, 'fetchActionTypes'); + spyOnfetchActionTypes.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UseActionTypesResponse>(() => + useActionTypes() + ); + await waitForNextUpdate(); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + loading: false, + actionTypes: [], + refetchActionTypes: result.current.refetchActionTypes, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx new file mode 100644 index 0000000000000..980db8ed61f8f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/containers/configure/use_action_types.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; + +import { useStateToaster, errorToToaster } from '../../../common/components/toasters'; +import * as i18n from '../translations'; +import { fetchActionTypes } from './api'; +import { ActionTypeConnector } from './types'; + +export interface UseActionTypesResponse { + loading: boolean; + actionTypes: ActionTypeConnector[]; + refetchActionTypes: () => void; +} + +export const useActionTypes = (): UseActionTypesResponse => { + const [, dispatchToaster] = useStateToaster(); + const [loading, setLoading] = useState(true); + const [actionTypes, setActionTypes] = useState<ActionTypeConnector[]>([]); + const didCancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + const queryFirstTime = useRef(true); + + const refetchActionTypes = useCallback(async () => { + try { + setLoading(true); + didCancel.current = false; + abortCtrl.current.abort(); + abortCtrl.current = new AbortController(); + + const res = await fetchActionTypes({ signal: abortCtrl.current.signal }); + + if (!didCancel.current) { + setLoading(false); + setActionTypes(res); + } + } catch (error) { + if (!didCancel.current) { + setLoading(false); + setActionTypes([]); + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + } + } + }, [dispatchToaster]); + + useEffect(() => { + if (queryFirstTime.current) { + refetchActionTypes(); + queryFirstTime.current = false; + } + + return () => { + didCancel.current = true; + abortCtrl.current.abort(); + queryFirstTime.current = true; + }; + }, [refetchActionTypes]); + + return { + loading, + actionTypes, + refetchActionTypes, + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index fd24a8451fcbe..3fb962df232bc 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -199,6 +199,13 @@ export const actionLicenses: ActionLicense[] = [ enabledInConfig: true, enabledInLicense: true, }, + { + id: '.jira', + name: 'Jira', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + }, ]; // Snake case for mock api responses diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx index 23c9ff5e49586..3e501a5276d5b 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.test.tsx @@ -51,7 +51,7 @@ describe('useGetActionLicense', () => { expect(result.current).toEqual({ isLoading: false, isError: false, - actionLicense: actionLicenses[0], + actionLicense: actionLicenses[1], }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx index e289a1973cf6e..8ce5c4aeef4b6 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_action_license.tsx @@ -23,6 +23,8 @@ export const initialData: ActionLicenseState = { isError: false, }; +const MINIMUM_LICENSE_REQUIRED_CONNECTOR = '.jira'; + export const useGetActionLicense = (): ActionLicenseState => { const [actionLicenseState, setActionLicensesState] = useState<ActionLicenseState>(initialData); @@ -40,7 +42,8 @@ export const useGetActionLicense = (): ActionLicenseState => { const response = await getActionLicense(abortCtrl.signal); if (!didCancel) { setActionLicensesState({ - actionLicense: response.find((l) => l.id === '.servicenow') ?? null, + actionLicense: + response.find((l) => l.id === MINIMUM_LICENSE_REQUIRED_CONNECTOR) ?? null, isLoading: false, isError: false, }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx index 8e8432d0d190c..bd57f57713e08 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx @@ -6,9 +6,9 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { usePostCase, UsePostCase } from './use_post_case'; -import { basicCasePost } from './mock'; import * as api from './api'; import { ConnectorTypes } from '../../../../case/common/api/connectors'; +import { basicCasePost } from './mock'; jest.mock('./api'); @@ -40,7 +40,6 @@ describe('usePostCase', () => { expect(result.current).toEqual({ isLoading: false, isError: false, - caseData: null, postCase: result.current.postCase, }); }); @@ -59,6 +58,16 @@ describe('usePostCase', () => { }); }); + it('calls postCase with correct result', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); + await waitForNextUpdate(); + + const postData = await result.current.postCase(samplePost); + expect(postData).toEqual(basicCasePost); + }); + }); + it('post case', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook<string, UsePostCase>(() => usePostCase()); @@ -66,7 +75,6 @@ describe('usePostCase', () => { result.current.postCase(samplePost); await waitForNextUpdate(); expect(result.current).toEqual({ - caseData: basicCasePost, isLoading: false, isError: false, postCase: result.current.postCase, @@ -96,7 +104,6 @@ describe('usePostCase', () => { result.current.postCase(samplePost); expect(result.current).toEqual({ - caseData: null, isLoading: false, isError: true, postCase: result.current.postCase, diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx index 3ca78dfe75c80..c98446effe47d 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.tsx @@ -3,25 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { useReducer, useCallback } from 'react'; - +import { useReducer, useCallback, useRef, useEffect } from 'react'; import { CasePostRequest } from '../../../../case/common/api'; import { errorToToaster, useStateToaster } from '../../common/components/toasters'; import { postCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; - interface NewCaseState { - caseData: Case | null; isLoading: boolean; isError: boolean; } -type Action = - | { type: 'FETCH_INIT' } - | { type: 'FETCH_SUCCESS'; payload: Case } - | { type: 'FETCH_FAILURE' }; - +type Action = { type: 'FETCH_INIT' } | { type: 'FETCH_SUCCESS' } | { type: 'FETCH_FAILURE' }; const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => { switch (action.type) { case 'FETCH_INIT': @@ -35,7 +27,6 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => ...state, isLoading: false, isError: false, - caseData: action.payload ?? null, }; case 'FETCH_FAILURE': return { @@ -47,47 +38,47 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => return state; } }; - export interface UsePostCase extends NewCaseState { - postCase: (data: CasePostRequest) => Promise<() => void>; + postCase: (data: CasePostRequest) => Promise<Case | undefined>; } export const usePostCase = (): UsePostCase => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, - caseData: null, }); const [, dispatchToaster] = useStateToaster(); - - const postMyCase = useCallback(async (data: CasePostRequest) => { - let cancel = false; - const abortCtrl = new AbortController(); - - try { - dispatch({ type: 'FETCH_INIT' }); - const response = await postCase(data, abortCtrl.signal); - if (!cancel) { - dispatch({ - type: 'FETCH_SUCCESS', - payload: response, - }); + const cancel = useRef(false); + const abortCtrl = useRef(new AbortController()); + const postMyCase = useCallback( + async (data: CasePostRequest) => { + try { + dispatch({ type: 'FETCH_INIT' }); + abortCtrl.current.abort(); + cancel.current = false; + abortCtrl.current = new AbortController(); + const response = await postCase(data, abortCtrl.current.signal); + if (!cancel.current) { + dispatch({ type: 'FETCH_SUCCESS' }); + } + return response; + } catch (error) { + if (!cancel.current) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } } - } catch (error) { - if (!cancel) { - errorToToaster({ - title: i18n.ERROR_TITLE, - error: error.body && error.body.message ? new Error(error.body.message) : error, - dispatchToaster, - }); - dispatch({ type: 'FETCH_FAILURE' }); - } - } + }, + [dispatchToaster] + ); + useEffect(() => { return () => { - abortCtrl.abort(); - cancel = true; + abortCtrl.current.abort(); + cancel.current = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - return { ...state, postCase: postMyCase }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx index 36033c358766d..ce6ca7ebc22dd 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx @@ -22,6 +22,7 @@ describe('EntryItem', () => { const wrapper = mount( <EntryItem entry={{ + id: '123', field: undefined, value: undefined, type: 'mapping', @@ -54,6 +55,7 @@ describe('EntryItem', () => { const wrapper = mount( <EntryItem entry={{ + id: '123', field: getField('ip'), type: 'mapping', value: getField('ip'), @@ -84,6 +86,7 @@ describe('EntryItem', () => { expect(mockOnChange).toHaveBeenCalledWith( { + id: '123', field: 'machine.os', type: 'mapping', value: 'ip', @@ -97,6 +100,7 @@ describe('EntryItem', () => { const wrapper = mount( <EntryItem entry={{ + id: '123', field: getField('ip'), type: 'mapping', value: getField('ip'), @@ -125,6 +129,9 @@ describe('EntryItem', () => { onChange: (a: EuiComboBoxOptionOption[]) => void; }).onChange([{ label: 'is not' }]); - expect(mockOnChange).toHaveBeenCalledWith({ field: 'ip', type: 'mapping', value: '' }, 0); + expect(mockOnChange).toHaveBeenCalledWith( + { id: '123', field: 'ip', type: 'mapping', value: '' }, + 0 + ); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx index c99e63ff4eda0..51b724bff2e5d 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx @@ -75,7 +75,11 @@ export const EntryItem: React.FC<EntryItemProps> = ({ </EuiFormRow> ); } else { - return comboBox; + return ( + <EuiFormRow label={''} data-test-subj="entryItemFieldInputFormRow"> + {comboBox} + </EuiFormRow> + ); } }, [handleFieldChange, indexPattern, entry, showLabel]); @@ -101,7 +105,11 @@ export const EntryItem: React.FC<EntryItemProps> = ({ </EuiFormRow> ); } else { - return comboBox; + return ( + <EuiFormRow label={''} data-test-subj="threatFieldInputFormRow"> + {comboBox} + </EuiFormRow> + ); } }, [handleThreatFieldChange, threatIndexPatterns, entry, showLabel]); diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx index b4f97808b54c4..b3a74c7697715 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.test.tsx @@ -21,6 +21,10 @@ import { } from './helpers'; import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const getMockIndexPattern = (): IndexPattern => ({ id: '1234', @@ -29,6 +33,7 @@ const getMockIndexPattern = (): IndexPattern => } as IndexPattern); const getMockEntry = (): FormattedEntry => ({ + id: '123', field: getField('ip'), value: getField('ip'), type: 'mapping', @@ -42,6 +47,7 @@ describe('Helpers', () => { afterEach(() => { moment.tz.setDefault('Browser'); + jest.clearAllMocks(); }); describe('#getFormattedEntry', () => { @@ -70,6 +76,7 @@ describe('Helpers', () => { const output = getFormattedEntry(payloadIndexPattern, payloadIndexPattern, payloadItem, 0); const expected: FormattedEntry = { entryIndex: 0, + id: '123', field: { name: 'machine.os.raw.text', type: 'string', @@ -94,6 +101,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: undefined, value: undefined, @@ -109,6 +117,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: { name: 'machine.os', @@ -134,6 +143,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, threatIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', entryIndex: 0, field: { name: 'machine.os', @@ -170,6 +180,7 @@ describe('Helpers', () => { const output = getFormattedEntries(payloadIndexPattern, payloadIndexPattern, payloadItems); const expected: FormattedEntry[] = [ { + id: '123', field: { name: 'machine.os', type: 'string', @@ -194,6 +205,7 @@ describe('Helpers', () => { entryIndex: 0, }, { + id: '123', field: { name: 'ip', type: 'ip', @@ -249,9 +261,10 @@ describe('Helpers', () => { const payloadItem = getMockEntry(); const payloadIFieldType = getField('ip'); const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); - const expected: { updatedEntry: Entry; index: number } = { + const expected: { updatedEntry: Entry & { id: string }; index: number } = { index: 0, updatedEntry: { + id: '123', field: 'ip', type: 'mapping', value: 'ip', diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx index 349dae76301d4..90a996c06e492 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/helpers.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import uuid from 'uuid'; import { ThreatMap, threatMap, @@ -12,6 +13,7 @@ import { import { IndexPattern, IFieldType } from '../../../../../../../src/plugins/data/common'; import { Entry, FormattedEntry, ThreatMapEntries, EmptyEntry } from './types'; +import { addIdToItem } from '../../utils/add_remove_id_to_item'; /** * Formats the entry into one that is easily usable for the UI. @@ -24,7 +26,8 @@ export const getFormattedEntry = ( indexPattern: IndexPattern, threatIndexPatterns: IndexPattern, item: Entry, - itemIndex: number + itemIndex: number, + uuidGen: () => string = uuid.v4 ): FormattedEntry => { const { fields } = indexPattern; const { fields: threatFields } = threatIndexPatterns; @@ -34,7 +37,9 @@ export const getFormattedEntry = ( const [threatFoundField] = threatFields.filter( ({ name }) => threatField != null && threatField === name ); + const maybeId: typeof item & { id?: string } = item; return { + id: maybeId.id ?? uuidGen(), field: foundField, type: 'mapping', value: threatFoundField, @@ -90,10 +95,11 @@ export const getEntryOnFieldChange = ( const { entryIndex } = item; return { updatedEntry: { + id: item.id, field: newField != null ? newField.name : '', type: 'mapping', value: item.value != null ? item.value.name : '', - }, + } as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere index: entryIndex, }; }; @@ -112,30 +118,33 @@ export const getEntryOnThreatFieldChange = ( const { entryIndex } = item; return { updatedEntry: { + id: item.id, field: item.field != null ? item.field.name : '', type: 'mapping', value: newField != null ? newField.name : '', - }, + } as Entry, // Cast to Entry since id is only used as a react key prop and can be ignored elsewhere index: entryIndex, }; }; -export const getDefaultEmptyEntry = (): EmptyEntry => ({ - field: '', - type: 'mapping', - value: '', -}); +export const getDefaultEmptyEntry = (): EmptyEntry => { + return addIdToItem({ + field: '', + type: 'mapping', + value: '', + }); +}; export const getNewItem = (): ThreatMap => { - return { + return addIdToItem({ entries: [ - { + addIdToItem({ field: '', type: 'mapping', value: '', - }, + }), ], - }; + }); }; export const filterItems = (items: ThreatMapEntries[]): ThreatMapping => { diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx index d3936e10bd877..8aa4af21b03cc 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/index.tsx @@ -158,43 +158,45 @@ export const ThreatMatchComponent = ({ }, []); return ( <EuiFlexGroup gutterSize="s" direction="column"> - {entries.map((entryListItem, index) => ( - <EuiFlexItem grow={1} key={`${index}`}> - <EuiFlexGroup gutterSize="s" direction="column"> - {index !== 0 && - (andLogicIncluded ? ( - <EuiFlexItem grow={false}> - <EuiFlexGroup gutterSize="none" direction="row"> - <MyInvisibleAndBadge grow={false}> - <MyAndBadge includeAntennas type="and" /> - </MyInvisibleAndBadge> - <EuiFlexItem grow={false}> - <MyAndBadge type="or" /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - ) : ( - <EuiFlexItem grow={false}> - <MyAndBadge type="or" /> - </EuiFlexItem> - ))} - <EuiFlexItem grow={false}> - <ListItemComponent - key={`${index}`} - listItem={entryListItem} - listId={`${index}`} - indexPattern={indexPatterns} - threatIndexPatterns={threatIndexPatterns} - listItemIndex={index} - andLogicIncluded={andLogicIncluded} - isOnlyItem={entries.length === 1} - onDeleteEntryItem={handleDeleteEntryItem} - onChangeEntryItem={handleEntryItemChange} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - ))} + {entries.map((entryListItem, index) => { + const key = (entryListItem as typeof entryListItem & { id?: string }).id ?? `${index}`; + return ( + <EuiFlexItem grow={1} key={key}> + <EuiFlexGroup gutterSize="s" direction="column"> + {index !== 0 && + (andLogicIncluded ? ( + <EuiFlexItem grow={false}> + <EuiFlexGroup gutterSize="none" direction="row"> + <MyInvisibleAndBadge grow={false}> + <MyAndBadge includeAntennas type="and" /> + </MyInvisibleAndBadge> + <EuiFlexItem grow={false}> + <MyAndBadge type="or" /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + ) : ( + <EuiFlexItem grow={false}> + <MyAndBadge type="or" /> + </EuiFlexItem> + ))} + <EuiFlexItem grow={false}> + <ListItemComponent + key={key} + listItem={entryListItem} + indexPattern={indexPatterns} + threatIndexPatterns={threatIndexPatterns} + listItemIndex={index} + andLogicIncluded={andLogicIncluded} + isOnlyItem={entries.length === 1} + onDeleteEntryItem={handleDeleteEntryItem} + onChangeEntryItem={handleEntryItemChange} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + ); + })} <MyButtonsContainer data-test-subj={'andOrOperatorButtons'}> <EuiFlexGroup gutterSize="s"> diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx index 90492bc46e2b0..66af24025656e 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.test.tsx @@ -68,7 +68,6 @@ describe('ListItemComponent', () => { <ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}> <ListItemComponent listItem={doublePayload()} - listId={'123'} listItemIndex={0} indexPattern={ { @@ -102,7 +101,6 @@ describe('ListItemComponent', () => { <ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}> <ListItemComponent listItem={doublePayload()} - listId={'123'} listItemIndex={1} indexPattern={ { @@ -134,7 +132,6 @@ describe('ListItemComponent', () => { <ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}> <ListItemComponent listItem={singlePayload()} - listId={'123'} listItemIndex={1} indexPattern={ { @@ -168,7 +165,6 @@ describe('ListItemComponent', () => { <ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}> <ListItemComponent listItem={singlePayload()} - listId={'123'} listItemIndex={1} indexPattern={ { @@ -210,7 +206,6 @@ describe('ListItemComponent', () => { const wrapper = mount( <ListItemComponent listItem={item} - listId={'123'} listItemIndex={0} indexPattern={ { @@ -242,7 +237,6 @@ describe('ListItemComponent', () => { const wrapper = mount( <ListItemComponent listItem={singlePayload()} - listId={'123'} listItemIndex={0} indexPattern={ { @@ -274,7 +268,6 @@ describe('ListItemComponent', () => { const wrapper = mount( <ListItemComponent listItem={singlePayload()} - listId={'123'} listItemIndex={1} indexPattern={ { @@ -308,7 +301,6 @@ describe('ListItemComponent', () => { const wrapper = mount( <ListItemComponent listItem={doublePayload()} - listId={'123'} listItemIndex={0} indexPattern={ { @@ -341,7 +333,6 @@ describe('ListItemComponent', () => { const wrapper = mount( <ListItemComponent listItem={doublePayload()} - listId={'123'} listItemIndex={0} indexPattern={ { diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.tsx index 5fa2997193bd9..d1ec40c627cb8 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/list_item.tsx @@ -22,7 +22,6 @@ const MyOverflowContainer = styled(EuiFlexItem)` interface ListItemProps { listItem: ThreatMapEntries; - listId: string; listItemIndex: number; indexPattern: IndexPattern; threatIndexPatterns: IndexPattern; @@ -35,7 +34,6 @@ interface ListItemProps { export const ListItemComponent = React.memo<ListItemProps>( ({ listItem, - listId, listItemIndex, indexPattern, threatIndexPatterns, @@ -88,7 +86,7 @@ export const ListItemComponent = React.memo<ListItemProps>( <MyOverflowContainer grow={6}> <EuiFlexGroup gutterSize="s" direction="column"> {entries.map((item, index) => ( - <EuiFlexItem key={`${listId}-${index}`} grow={1}> + <EuiFlexItem key={item.id} grow={1}> <EuiFlexGroup gutterSize="xs" alignItems="center" direction="row"> <MyOverflowContainer grow={1}> <EntryItem diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.test.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.test.ts index 6b2a443ec45a5..db56d1e34b641 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/reducer.test.ts @@ -9,6 +9,10 @@ import { State, reducer } from './reducer'; import { getDefaultEmptyEntry } from './helpers'; import { ThreatMapEntry } from '../../../../common/detection_engine/schemas/types'; +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + const initialState: State = { andLogicIncluded: false, entries: [], @@ -22,6 +26,10 @@ const getEntry = (): ThreatMapEntry => ({ }); describe('reducer', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + describe('#setEntries', () => { test('should return "andLogicIncluded" ', () => { const update = reducer()(initialState, { diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts index 0cbd885db2d54..f3af5faaed25c 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/types.ts @@ -7,6 +7,7 @@ import { ThreatMap, ThreatMapEntry } from '../../../../common/detection_engine/s import { IFieldType } from '../../../../../../../src/plugins/data/common'; export interface FormattedEntry { + id: string; field: IFieldType | undefined; type: 'mapping'; value: IFieldType | undefined; diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts new file mode 100644 index 0000000000000..fa067a53f2573 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { addIdToItem, removeIdFromItem } from './add_remove_id_to_item'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +describe('add_remove_id_to_item', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('addIdToItem', () => { + test('it adds an id to an empty item', () => { + expect(addIdToItem({})).toEqual({ id: '123' }); + }); + + test('it adds a complex object', () => { + expect( + addIdToItem({ + field: '', + type: 'mapping', + value: '', + }) + ).toEqual({ + id: '123', + field: '', + type: 'mapping', + value: '', + }); + }); + + test('it adds an id to an existing item', () => { + expect(addIdToItem({ test: '456' })).toEqual({ id: '123', test: '456' }); + }); + + test('it does not change the id if it already exists', () => { + expect(addIdToItem({ id: '456' })).toEqual({ id: '456' }); + }); + + test('it returns the same reference if it has an id already', () => { + const obj = { id: '456' }; + expect(addIdToItem(obj)).toBe(obj); + }); + + test('it returns a new reference if it adds an id to an item', () => { + const obj = { test: '456' }; + expect(addIdToItem(obj)).not.toBe(obj); + }); + }); + + describe('removeIdFromItem', () => { + test('it removes an id from an item', () => { + expect(removeIdFromItem({ id: '456' })).toEqual({}); + }); + + test('it returns a new reference if it removes an id from an item', () => { + const obj = { id: '123', test: '456' }; + expect(removeIdFromItem(obj)).not.toBe(obj); + }); + + test('it does not effect an item without an id', () => { + expect(removeIdFromItem({ test: '456' })).toEqual({ test: '456' }); + }); + + test('it returns the same reference if it does not have an id already', () => { + const obj = { test: '456' }; + expect(removeIdFromItem(obj)).toBe(obj); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts new file mode 100644 index 0000000000000..a74cf8680fa48 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/add_remove_id_to_item.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; + +/** + * This is useful for when you have arrays without an ID and need to add one for + * ReactJS keys. I break the types slightly by introducing an id to an arbitrary item + * but then cast it back to the regular type T. + * Usage of this could be considered tech debt as I am adding an ID when the backend + * could be doing the same thing but it depends on how you want to model your data and + * if you view modeling your data with id's to please ReactJS a good or bad thing. + * @param item The item to add an id to. + */ +type NotArray<T> = T extends unknown[] ? never : T; +export const addIdToItem = <T>(item: NotArray<T>): T => { + const maybeId: typeof item & { id?: string } = item; + if (maybeId.id != null) { + return item; + } else { + return { ...item, id: uuid.v4() }; + } +}; + +/** + * This is to reverse the id you added to your arrays for ReactJS keys. + * @param item The item to remove the id from. + */ +export const removeIdFromItem = <T>( + item: NotArray<T> +): + | T + | Pick< + T & { + id?: string | undefined; + }, + Exclude<keyof T, 'id'> + > => { + const maybeId: typeof item & { id?: string } = item; + if (maybeId.id != null) { + const { id, ...noId } = maybeId; + return noId; + } else { + return item; + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx index b72dd3b2f84dd..191c3955caa9b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_privilege_user.tsx @@ -50,7 +50,7 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => { const abortCtrl = new AbortController(); setLoading(true); - async function fetchData() { + const fetchData = async () => { try { const privilege = await getUserPrivilege({ signal: abortCtrl.signal, @@ -89,15 +89,14 @@ export const usePrivilegeUser = (): ReturnPrivilegeUser => { if (isSubscribed) { setLoading(false); } - } + }; fetchData(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return { loading, ...privilegeUser }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx index 3bef1d8edd048..9022e3a32163c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_query.tsx @@ -46,7 +46,7 @@ export const useQueryAlerts = <Hit, Aggs>( let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData() { + const fetchData = async () => { try { setLoading(true); const alertResponse = await fetchQueryAlerts<Hit, Aggs>({ @@ -77,7 +77,7 @@ export const useQueryAlerts = <Hit, Aggs>( if (isSubscribed) { setLoading(false); } - } + }; fetchData(); return () => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx index 5ebdb38b8dd5c..bfdc1d1ceee21 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.tsx @@ -106,8 +106,7 @@ export const useSignalIndex = (): ReturnSignalIndex => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return { loading, ...signalIndex }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts new file mode 100644 index 0000000000000..7821bb23a7ca3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/transforms.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { flow } from 'fp-ts/lib/function'; +import { addIdToItem, removeIdFromItem } from '../../../../common/utils/add_remove_id_to_item'; +import { + CreateRulesSchema, + UpdateRulesSchema, +} from '../../../../../common/detection_engine/schemas/request'; +import { Rule } from './types'; + +// These are a collection of transforms that are UI specific and useful for UI concerns +// that are inserted between the API and the actual user interface. In some ways these +// might be viewed as technical debt or to compensate for the differences and preferences +// of how ReactJS might prefer data vs. how we want to model data. Each function should have +// a description giving context around the transform. + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the output called "myNewTransform" do it + * in the form of: + * flow(removeIdFromThreatMatchArray, myNewTransform)(rule) + * + * @param rule The rule to transform the output of + * @returns The rule transformed from the output + */ +export const transformOutput = ( + rule: CreateRulesSchema | UpdateRulesSchema +): CreateRulesSchema | UpdateRulesSchema => flow(removeIdFromThreatMatchArray)(rule); + +/** + * Transforms the output of rules to compensate for technical debt or UI concerns such as + * ReactJS preferences for having ids within arrays if the data is not modeled that way. + * + * If you add a new transform of the input called "myNewTransform" do it + * in the form of: + * flow(addIdToThreatMatchArray, myNewTransform)(rule) + * + * @param rule The rule to transform the output of + * @returns The rule transformed from the output + */ +export const transformInput = (rule: Rule): Rule => flow(addIdToThreatMatchArray)(rule); + +/** + * This adds an id to the incoming threat match arrays as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * This does break the type system slightly as we are lying a bit to the type system as we return + * the same rule as we have previously but are augmenting the arrays with an id which TypeScript + * doesn't mind us doing here. However, downstream you will notice that you have an id when the type + * does not indicate it. In that case just cast this temporarily if you're using the id. If you're not, + * you can ignore the id and just use the normal TypeScript with ReactJS. + * + * @param rule The rule to add an id to the threat matches. + * @returns rule The rule but with id added to the threat array and entries + */ +export const addIdToThreatMatchArray = (rule: Rule): Rule => { + if (rule.type === 'threat_match' && rule.threat_mapping != null) { + const threatMapWithId = rule.threat_mapping.map((mapping) => { + const newEntries = mapping.entries.map((entry) => addIdToItem(entry)); + return addIdToItem({ entries: newEntries }); + }); + return { ...rule, threat_mapping: threatMapWithId }; + } else { + return rule; + } +}; + +/** + * This removes an id from the threat match arrays as ReactJS prefers to have + * an id added to them for use as a stable id. Later if we decide to change the data + * model to have id's within the array then this code should be removed. If not, then + * this code should stay as an adapter for ReactJS. + * + * @param rule The rule to remove an id from the threat matches. + * @returns rule The rule but with id removed from the threat array and entries + */ +export const removeIdFromThreatMatchArray = ( + rule: CreateRulesSchema | UpdateRulesSchema +): CreateRulesSchema | UpdateRulesSchema => { + if (rule.type === 'threat_match' && rule.threat_mapping != null) { + const threatMapWithoutId = rule.threat_mapping.map((mapping) => { + const newEntries = mapping.entries.map((entry) => removeIdFromItem(entry)); + const newMapping = removeIdFromItem(mapping); + return { ...newMapping, entries: newEntries }; + }); + return { ...rule, threat_mapping: threatMapWithoutId }; + } else { + return rule; + } +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx index 2bbd27994fc77..fe8e0fd8ceb97 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx @@ -11,6 +11,7 @@ import { CreateRulesSchema } from '../../../../../common/detection_engine/schema import { createRule } from './api'; import * as i18n from './translations'; +import { transformOutput } from './transforms'; interface CreateRuleReturn { isLoading: boolean; @@ -29,11 +30,11 @@ export const useCreateRule = (): ReturnCreateRule => { let isSubscribed = true; const abortCtrl = new AbortController(); setIsSaved(false); - async function saveRule() { + const saveRule = async () => { if (rule != null) { try { setIsLoading(true); - await createRule({ rule, signal: abortCtrl.signal }); + await createRule({ rule: transformOutput(rule), signal: abortCtrl.signal }); if (isSubscribed) { setIsSaved(true); } @@ -46,15 +47,14 @@ export const useCreateRule = (): ReturnCreateRule => { setIsLoading(false); } } - } + }; saveRule(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); + }, [rule, dispatchToaster]); return [{ isLoading, isSaved }, setRule]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx index d83d4e0caa977..bdbe13af40151 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx @@ -262,8 +262,14 @@ export const usePrePackagedRules = ({ isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [canUserCRUD, hasIndexWrite, isAuthenticated, hasEncryptionKey, isSignalIndexExists]); + }, [ + canUserCRUD, + hasIndexWrite, + isAuthenticated, + hasEncryptionKey, + isSignalIndexExists, + dispatchToaster, + ]); const prePackagedRuleStatus = useMemo( () => diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx index 706c2645a4ddd..3b84558d344e7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; import { fetchRuleById } from './api'; +import { transformInput } from './transforms'; import * as i18n from './translations'; import { Rule } from './types'; @@ -28,13 +29,15 @@ export const useRule = (id: string | undefined): ReturnRule => { let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData(idToFetch: string) { + const fetchData = async (idToFetch: string) => { try { setLoading(true); - const ruleResponse = await fetchRuleById({ - id: idToFetch, - signal: abortCtrl.signal, - }); + const ruleResponse = transformInput( + await fetchRuleById({ + id: idToFetch, + signal: abortCtrl.signal, + }) + ); if (isSubscribed) { setRule(ruleResponse); } @@ -47,7 +50,7 @@ export const useRule = (id: string | undefined): ReturnRule => { if (isSubscribed) { setLoading(false); } - } + }; if (id != null) { fetchData(id); } @@ -55,8 +58,7 @@ export const useRule = (id: string | undefined): ReturnRule => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); + }, [id, dispatchToaster]); return [loading, rule]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx index fbca46097dcd9..48bfe71b4722b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx @@ -6,12 +6,14 @@ import { useEffect, useCallback } from 'react'; +import { flow } from 'fp-ts/lib/function'; import { useAsync, withOptionalSignal } from '../../../../shared_imports'; import { useHttp } from '../../../../common/lib/kibana'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { pureFetchRuleById } from './api'; import { Rule } from './types'; import * as i18n from './translations'; +import { transformInput } from './transforms'; export interface UseRuleAsync { error: unknown; @@ -20,11 +22,15 @@ export interface UseRuleAsync { rule: Rule | null; } -const _fetchRule = withOptionalSignal(pureFetchRuleById); -const _useRuleAsync = () => useAsync(_fetchRule); +const _fetchRule = flow(withOptionalSignal(pureFetchRuleById), async (rule: Promise<Rule>) => + transformInput(await rule) +); + +/** This does not use "_useRuleAsyncInternal" as that would deactivate the useHooks linter rule, so instead it has the word "Internal" post-pended */ +const useRuleAsyncInternal = () => useAsync(_fetchRule); export const useRuleAsync = (ruleId: string): UseRuleAsync => { - const { start, loading, result, error } = _useRuleAsync(); + const { start, loading, result, error } = useRuleAsyncInternal(); const http = useHttp(); const { addError } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx index ddf50e9edae51..2bec8f9a2d0a2 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx @@ -64,8 +64,7 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); + }, [id, dispatchToaster]); return [loading, ruleStatus, fetchRuleStatus.current]; }; @@ -122,8 +121,7 @@ export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rules]); + }, [rules, dispatchToaster]); return { loading, rulesStatuses }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx index 038f974e1394e..bab419813e1aa 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_tags.tsx @@ -26,7 +26,7 @@ export const useTags = (): ReturnTags => { let isSubscribed = true; const abortCtrl = new AbortController(); - async function fetchData() { + const fetchData = async () => { setLoading(true); try { const fetchTagsResult = await fetchTags({ @@ -44,7 +44,7 @@ export const useTags = (): ReturnTags => { if (isSubscribed) { setLoading(false); } - } + }; fetchData(); reFetchTags.current = fetchData; @@ -53,8 +53,7 @@ export const useTags = (): ReturnTags => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [dispatchToaster]); return [loading, tags, reFetchTags.current]; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx index a437974e93ba3..729336b697e4d 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx @@ -9,6 +9,8 @@ import { useEffect, useState, Dispatch } from 'react'; import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; import { UpdateRulesSchema } from '../../../../../common/detection_engine/schemas/request'; +import { transformOutput } from './transforms'; + import { updateRule } from './api'; import * as i18n from './translations'; @@ -29,11 +31,11 @@ export const useUpdateRule = (): ReturnUpdateRule => { let isSubscribed = true; const abortCtrl = new AbortController(); setIsSaved(false); - async function saveRule() { + const saveRule = async () => { if (rule != null) { try { setIsLoading(true); - await updateRule({ rule, signal: abortCtrl.signal }); + await updateRule({ rule: transformOutput(rule), signal: abortCtrl.signal }); if (isSubscribed) { setIsSaved(true); } @@ -46,15 +48,14 @@ export const useUpdateRule = (): ReturnUpdateRule => { setIsLoading(false); } } - } + }; saveRule(); return () => { isSubscribed = false; abortCtrl.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); + }, [rule, dispatchToaster]); return [{ isLoading, isSaved }, setRule]; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 0d585b4463815..86f24594fc57e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -356,19 +356,20 @@ export const getMonitoringColumns = ( truncateText: true, width: '14%', }, - { - field: 'current_status.last_look_back_date', - name: i18n.COLUMN_LAST_LOOKBACK_DATE, - render: (value: RuleStatus['current_status']['last_look_back_date']) => { - return value == null ? ( - getEmptyTagValue() - ) : ( - <FormattedDate value={value} fieldName={'last look back date'} /> - ); - }, - truncateText: true, - width: '16%', - }, + // hiding this field until after 7.11 release + // { + // field: 'current_status.last_look_back_date', + // name: i18n.COLUMN_LAST_LOOKBACK_DATE, + // render: (value: RuleStatus['current_status']['last_look_back_date']) => { + // return value == null ? ( + // getEmptyTagValue() + // ) : ( + // <FormattedDate value={value} fieldName={'last look back date'} /> + // ); + // }, + // truncateText: true, + // width: '16%', + // }, { field: 'current_status.status_date', name: i18n.COLUMN_LAST_COMPLETE_RUN, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts index 8451eb0dfbe6c..716585071c3f6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts @@ -99,7 +99,7 @@ describe('helpers', () => { { ...mockThreat, tactic: { ...mockThreat.tactic, name: 'none' } }, ]; const result = filterEmptyThreats(threat); - const expected = [mockThreat]; + const expected: Threats = [mockThreat]; expect(result).toEqual(expected); }); }); @@ -112,8 +112,8 @@ describe('helpers', () => { }); test('returns formatted object as DefineStepRuleJson', () => { - const result: DefineStepRuleJson = formatDefineStepData(mockData); - const expected = { + const result = formatDefineStepData(mockData); + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -128,15 +128,15 @@ describe('helpers', () => { }); test('returns formatted object with no saved_id if no savedId provided', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, queryBar: { ...mockData.queryBar, saved_id: '', }, }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); - const expected = { + const result = formatDefineStepData(mockStepData); + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -151,15 +151,15 @@ describe('helpers', () => { }); test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, }; // @ts-expect-error delete mockStepData.timeline.id; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -172,16 +172,16 @@ describe('helpers', () => { }); test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, timeline: { ...mockData.timeline, id: '', }, }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -196,7 +196,7 @@ describe('helpers', () => { }); test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, timeline: { ...mockData.timeline, @@ -205,9 +205,9 @@ describe('helpers', () => { }; // @ts-expect-error delete mockStepData.timeline.title; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -220,16 +220,16 @@ describe('helpers', () => { }); test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, timeline: { ...mockData.timeline, title: '', }, }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -250,9 +250,9 @@ describe('helpers', () => { anomalyThreshold: 44, machineLearningJobId: 'some_jobert_id', }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { type: 'machine_learning', anomaly_threshold: 44, machine_learning_job_id: 'some_jobert_id', @@ -276,9 +276,9 @@ describe('helpers', () => { }, }, }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { filters: mockStepData.queryBar.filters, index: mockStepData.index, language: 'eql', @@ -288,6 +288,70 @@ describe('helpers', () => { expect(result).toEqual(expect.objectContaining(expected)); }); + + test('returns expected indicator matching rule type if all fields are filled out', () => { + const threatFilters: DefineStepRule['threatQueryBar']['filters'] = [ + { + meta: { alias: '', disabled: false, negate: false }, + query: { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [{ exists: { field: 'host.name' } }], + }, + }, + {}, + ], + must: [], + must_not: [], + should: [], + }, + }, + }, + ]; + const threatMapping: DefineStepRule['threatMapping'] = [ + { + entries: [ + { + field: 'host.name', + type: 'mapping', + value: 'host.name', + }, + ], + }, + ]; + const mockStepData: DefineStepRule = { + ...mockData, + ruleType: 'threat_match', + threatIndex: ['index_1', 'index_2'], + threatQueryBar: { + query: { language: 'kql', query: 'threat_host: *' }, + filters: threatFilters, + }, + threatMapping, + }; + const result = formatDefineStepData(mockStepData); + + const expected: DefineStepRuleJson = { + language: 'kuery', + query: 'test query', + saved_id: 'test123', + type: 'threat_match', + threat_query: 'threat_host: *', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', + threat_mapping: threatMapping, + threat_language: mockStepData.threatQueryBar.query.language, + filters: mockStepData.queryBar.filters, + threat_index: mockStepData.threatIndex, + index: mockStepData.index, + threat_filters: threatFilters, + }; + + expect(result).toEqual(expected); + }); }); describe('formatScheduleStepData', () => { @@ -298,8 +362,8 @@ describe('helpers', () => { }); test('returns formatted object as ScheduleStepRuleJson', () => { - const result: ScheduleStepRuleJson = formatScheduleStepData(mockData); - const expected = { + const result = formatScheduleStepData(mockData); + const expected: ScheduleStepRuleJson = { from: 'now-660s', to: 'now', interval: '5m', @@ -312,12 +376,12 @@ describe('helpers', () => { }); test('returns formatted object with "to" as "now" if "to" not supplied', () => { - const mockStepData = { + const mockStepData: ScheduleStepRule = { ...mockData, }; delete mockStepData.to; - const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); - const expected = { + const result = formatScheduleStepData(mockStepData); + const expected: ScheduleStepRuleJson = { from: 'now-660s', to: 'now', interval: '5m', @@ -330,12 +394,12 @@ describe('helpers', () => { }); test('returns formatted object with "to" as "now" if "to" random string', () => { - const mockStepData = { + const mockStepData: ScheduleStepRule = { ...mockData, to: 'random', }; - const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); - const expected = { + const result = formatScheduleStepData(mockStepData); + const expected: ScheduleStepRuleJson = { from: 'now-660s', to: 'now', interval: '5m', @@ -348,12 +412,12 @@ describe('helpers', () => { }); test('returns formatted object if "from" random string', () => { - const mockStepData = { + const mockStepData: ScheduleStepRule = { ...mockData, from: 'random', }; - const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); - const expected = { + const result = formatScheduleStepData(mockStepData); + const expected: ScheduleStepRuleJson = { from: 'now-300s', to: 'now', interval: '5m', @@ -366,12 +430,12 @@ describe('helpers', () => { }); test('returns formatted object if "interval" random string', () => { - const mockStepData = { + const mockStepData: ScheduleStepRule = { ...mockData, interval: 'random', }; - const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); - const expected = { + const result = formatScheduleStepData(mockStepData); + const expected: ScheduleStepRuleJson = { from: 'now-360s', to: 'now', interval: 'random', @@ -392,8 +456,8 @@ describe('helpers', () => { }); test('returns formatted object as AboutStepRuleJson', () => { - const result: AboutStepRuleJson = formatAboutStepData(mockData); - const expected = { + const result = formatAboutStepData(mockData); + const expected: AboutStepRuleJson = { author: ['Elastic'], description: '24/7', false_positives: ['test'], @@ -413,7 +477,7 @@ describe('helpers', () => { }); test('returns formatted object with endpoint exceptions_list', () => { - const result: AboutStepRuleJson = formatAboutStepData( + const result = formatAboutStepData( { ...mockData, isAssociatedToEndpointList: true, @@ -424,12 +488,12 @@ describe('helpers', () => { }); test('returns formatted object with detections exceptions_list', () => { - const result: AboutStepRuleJson = formatAboutStepData(mockData, [getListMock()]); + const result = formatAboutStepData(mockData, [getListMock()]); expect(result.exceptions_list).toEqual([getListMock()]); }); test('returns formatted object with both exceptions_lists', () => { - const result: AboutStepRuleJson = formatAboutStepData( + const result = formatAboutStepData( { ...mockData, isAssociatedToEndpointList: true, @@ -441,7 +505,7 @@ describe('helpers', () => { test('returns formatted object with pre-existing exceptions lists', () => { const exceptionsLists: List[] = [getEndpointListMock(), getListMock()]; - const result: AboutStepRuleJson = formatAboutStepData( + const result = formatAboutStepData( { ...mockData, isAssociatedToEndpointList: true, @@ -453,18 +517,18 @@ describe('helpers', () => { test('returns formatted object with pre-existing endpoint exceptions list disabled', () => { const exceptionsLists: List[] = [getEndpointListMock(), getListMock()]; - const result: AboutStepRuleJson = formatAboutStepData(mockData, exceptionsLists); + const result = formatAboutStepData(mockData, exceptionsLists); expect(result.exceptions_list).toEqual([getListMock()]); }); test('returns formatted object with empty falsePositive and references filtered out', () => { - const mockStepData = { + const mockStepData: AboutStepRule = { ...mockData, falsePositives: ['', 'test', ''], references: ['www.test.co', ''], }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { + const result = formatAboutStepData(mockStepData); + const expected: AboutStepRuleJson = { author: ['Elastic'], description: '24/7', false_positives: ['test'], @@ -484,12 +548,12 @@ describe('helpers', () => { }); test('returns formatted object without note if note is empty string', () => { - const mockStepData = { + const mockStepData: AboutStepRule = { ...mockData, note: '', }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { + const result = formatAboutStepData(mockStepData); + const expected: AboutStepRuleJson = { author: ['Elastic'], description: '24/7', false_positives: ['test'], @@ -508,7 +572,7 @@ describe('helpers', () => { }); test('returns formatted object with threats filtered out where tactic.name is "none"', () => { - const mockStepData = { + const mockStepData: AboutStepRule = { ...mockData, threat: [ ...getThreatMock(), @@ -530,8 +594,8 @@ describe('helpers', () => { }, ], }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { + const result = formatAboutStepData(mockStepData); + const expected: AboutStepRuleJson = { author: ['Elastic'], license: 'Elastic License', description: '24/7', @@ -551,7 +615,7 @@ describe('helpers', () => { }); test('returns formatted object with threats that contains no subtechniques', () => { - const mockStepData = { + const mockStepData: AboutStepRule = { ...mockData, threat: [ ...getThreatMock(), @@ -573,8 +637,8 @@ describe('helpers', () => { }, ], }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { + const result = formatAboutStepData(mockStepData); + const expected: AboutStepRuleJson = { author: ['Elastic'], license: 'Elastic License', description: '24/7', @@ -611,8 +675,8 @@ describe('helpers', () => { }); test('returns formatted object as ActionsStepRuleJson', () => { - const result: ActionsStepRuleJson = formatActionsStepData(mockData); - const expected = { + const result = formatActionsStepData(mockData); + const expected: ActionsStepRuleJson = { actions: [], enabled: false, meta: { @@ -625,12 +689,12 @@ describe('helpers', () => { }); test('returns proper throttle value for no_actions', () => { - const mockStepData = { + const mockStepData: ActionsStepRule = { ...mockData, throttle: 'no_actions', }; - const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); - const expected = { + const result = formatActionsStepData(mockStepData); + const expected: ActionsStepRuleJson = { actions: [], enabled: false, meta: { @@ -643,7 +707,7 @@ describe('helpers', () => { }); test('returns proper throttle value for rule', () => { - const mockStepData = { + const mockStepData: ActionsStepRule = { ...mockData, throttle: 'rule', actions: [ @@ -655,8 +719,8 @@ describe('helpers', () => { }, ], }; - const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); - const expected = { + const result = formatActionsStepData(mockStepData); + const expected: ActionsStepRuleJson = { actions: [ { group: mockStepData.actions[0].group, @@ -676,7 +740,7 @@ describe('helpers', () => { }); test('returns proper throttle value for interval', () => { - const mockStepData = { + const mockStepData: ActionsStepRule = { ...mockData, throttle: '1d', actions: [ @@ -688,8 +752,8 @@ describe('helpers', () => { }, ], }; - const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); - const expected = { + const result = formatActionsStepData(mockStepData); + const expected: ActionsStepRuleJson = { actions: [ { group: mockStepData.actions[0].group, @@ -716,12 +780,12 @@ describe('helpers', () => { actionTypeId: '.slack', }; - const mockStepData = { + const mockStepData: ActionsStepRule = { ...mockData, actions: [mockAction], }; - const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); - const expected = { + const result = formatActionsStepData(mockStepData); + const expected: ActionsStepRuleJson = { actions: [ { group: mockAction.group, @@ -755,20 +819,20 @@ describe('helpers', () => { }); test('returns rule with type of saved_query when saved_id exists', () => { - const result: Rule = formatRule<Rule>(mockDefine, mockAbout, mockSchedule, mockActions); + const result = formatRule<Rule>(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.type).toEqual('saved_query'); }); test('returns rule with type of query when saved_id does not exist', () => { - const mockDefineStepRuleWithoutSavedId = { + const mockDefineStepRuleWithoutSavedId: DefineStepRule = { ...mockDefine, queryBar: { ...mockDefine.queryBar, saved_id: '', }, }; - const result: CreateRulesSchema = formatRule<CreateRulesSchema>( + const result = formatRule<CreateRulesSchema>( mockDefineStepRuleWithoutSavedId, mockAbout, mockSchedule, @@ -779,7 +843,7 @@ describe('helpers', () => { }); test('returns rule without id if ruleId does not exist', () => { - const result: CreateRulesSchema = formatRule<CreateRulesSchema>( + const result = formatRule<CreateRulesSchema>( mockDefine, mockAbout, mockSchedule, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 7952bd396b72a..34818e7f0e4e2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -232,6 +232,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep saved_id: ruleFields.queryBar?.saved_id, threat_index: ruleFields.threatIndex, threat_query: ruleFields.threatQueryBar?.query?.query as string, + threat_filters: ruleFields.threatQueryBar?.filters, threat_mapping: ruleFields.threatMapping, threat_language: ruleFields.threatQueryBar?.query?.language, } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 2d993c7be08b0..f7066cd42e4c1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -353,7 +353,7 @@ export const COLUMN_QUERY_TIMES = i18n.translate( export const COLUMN_GAP = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.columns.gap', { - defaultMessage: 'Gap (if any)', + defaultMessage: 'Last Gap (if any)', } ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index 59cc7fba017e2..00206279be229 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -159,6 +159,11 @@ export interface DefineStepRuleJson { field: string; value: number; }; + threat_query?: string; + threat_mapping?: ThreatMapping; + threat_language?: string; + threat_index?: string[]; + threat_filters?: Filter[]; timeline_id?: string; timeline_title?: string; type: Type; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts index 70ffc1f8a9fc4..bda268c1fad00 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts @@ -8,7 +8,7 @@ import { PolicyDetailsState } from '../../types'; import { applyMiddleware, createStore, Dispatch, Store } from 'redux'; import { policyDetailsReducer, PolicyDetailsAction, policyDetailsMiddlewareFactory } from './index'; import { policyConfig } from './selectors'; -import { factory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; +import { policyFactory } from '../../../../../../common/endpoint/models/policy_config'; import { PolicyData } from '../../../../../../common/endpoint/types'; import { createSpyMiddleware, @@ -54,7 +54,7 @@ describe('policy details: ', () => { }, }, policy: { - value: policyConfigFactory(), + value: policyFactory(), }, }, }, @@ -254,6 +254,7 @@ describe('policy details: ', () => { http.put.mock.calls.length - 1 ] as unknown) as [string, HttpFetchOptions])[1]; + // license is below platinum in this test, paid features are off expect(JSON.parse(lastPutCallPayload.body as string)).toEqual({ name: '', description: '', @@ -282,11 +283,16 @@ describe('policy details: ', () => { security: true, }, malware: { mode: 'prevent' }, + ransomware: { mode: 'off' }, popup: { malware: { enabled: true, message: '', }, + ransomware: { + enabled: false, + message: '', + }, }, logging: { file: 'info' }, antivirus_registration: { @@ -296,11 +302,16 @@ describe('policy details: ', () => { mac: { events: { process: true, file: true, network: true }, malware: { mode: 'prevent' }, + ransomware: { mode: 'off' }, popup: { malware: { enabled: true, message: '', }, + ransomware: { + enabled: false, + message: '', + }, }, logging: { file: 'info' }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts index cc286b4c478d3..4d54acb5eae13 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts @@ -41,6 +41,10 @@ export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory<PolicyDe policyItem.inputs[0].config.policy.value.windows.popup.malware.message = DefaultMalwareMessage; policyItem.inputs[0].config.policy.value.mac.popup.malware.message = DefaultMalwareMessage; } + if (policyItem.inputs[0].config.policy.value.windows.popup.ransomware.message === '') { + policyItem.inputs[0].config.policy.value.windows.popup.ransomware.message = DefaultMalwareMessage; + policyItem.inputs[0].config.policy.value.mac.popup.ransomware.message = DefaultMalwareMessage; + } } catch (error) { dispatch({ type: 'serverFailedToReturnPolicyDetailsData', diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts index 7fe9987e3b724..0e0b891629d5e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts @@ -16,7 +16,7 @@ import { PolicyData, UIPolicyConfig, } from '../../../../../../common/endpoint/types'; -import { factory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; +import { policyFactory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; import { MANAGEMENT_ROUTING_POLICY_DETAILS_PATH } from '../../../../common/constants'; import { ManagementRoutePolicyDetailsParams } from '../../../../types'; @@ -32,10 +32,26 @@ export const licensedPolicy: ( licenseState, (policyData, license) => { if (policyData) { - unsetPolicyFeaturesAboveLicenseLevel( - policyData?.inputs[0]?.config.policy.value, + const policyValue = unsetPolicyFeaturesAboveLicenseLevel( + policyData.inputs[0].config.policy.value, license as ILicense ); + const newPolicyData: Immutable<PolicyData> = { + ...policyData, + inputs: [ + { + ...policyData.inputs[0], + config: { + ...policyData.inputs[0].config, + policy: { + ...policyData.inputs[0].config.policy, + value: policyValue, + }, + }, + }, + ], + }; + return newPolicyData; } return policyData; } @@ -167,6 +183,7 @@ export const policyConfig: (s: PolicyDetailsState) => UIPolicyConfig = createSel advanced: windows.advanced, events: windows.events, malware: windows.malware, + ransomware: windows.ransomware, popup: windows.popup, antivirus_registration: windows.antivirus_registration, }, @@ -174,6 +191,7 @@ export const policyConfig: (s: PolicyDetailsState) => UIPolicyConfig = createSel advanced: mac.advanced, events: mac.events, malware: mac.malware, + ransomware: mac.ransomware, popup: mac.popup, }, linux: { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts index 228e8cc1c4385..a37e404cdc522 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts @@ -8,7 +8,7 @@ import { ILicense } from '../../../../../licensing/common/types'; import { AppLocation, Immutable, - MalwareFields, + ProtectionFields, PolicyData, UIPolicyConfig, } from '../../../../common/endpoint/types'; @@ -108,7 +108,16 @@ export type KeysByValueCriteria<O, Criteria> = { }[keyof O]; /** Returns an array of the policy OSes that have a malware protection field */ -export type MalwareProtectionOSes = KeysByValueCriteria<UIPolicyConfig, { malware: MalwareFields }>; +export type MalwareProtectionOSes = KeysByValueCriteria< + UIPolicyConfig, + { malware: ProtectionFields } +>; + +/** Returns an array of the policy OSes that have a ransomware protection field */ +export type RansomwareProtectionOSes = KeysByValueCriteria< + UIPolicyConfig, + { ransomware: ProtectionFields } +>; export interface GetPolicyListResponse extends GetPackagePoliciesResponse { items: PolicyData[]; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx index ce5eb03d60cd0..aea4df5b2a6fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx @@ -58,7 +58,7 @@ export const ConfigForm: FC<ConfigFormProps> = memo( <ConfigFormHeading>{TITLES.type}</ConfigFormHeading> <EuiText size="m">{type}</EuiText> </EuiFlexItem> - <EuiFlexItem> + <EuiFlexItem grow={2}> <ConfigFormHeading>{TITLES.os}</ConfigFormHeading> <EuiText>{supportedOss.map((os) => OS_TITLES[os]).join(', ')}</EuiText> </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 1280f1c351c2b..55fc7703de44b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -301,11 +301,16 @@ describe('Policy Details', () => { const userNotificationCustomMessageTextArea = policyView.find( 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' ); - const tooltip = policyView.find('EuiIconTip'); + const tooltip = policyView.find('EuiIconTip[data-test-subj="malwareTooltip"]'); expect(userNotificationCheckbox).toHaveLength(1); expect(userNotificationCustomMessageTextArea).toHaveLength(1); expect(tooltip).toHaveLength(1); }); + + it('ransomware card is shown', () => { + const ransomware = policyView.find('EuiPanel[data-test-subj="ransomwareProtectionsForm"]'); + expect(ransomware).toHaveLength(1); + }); }); describe('when the subscription tier is gold or lower', () => { beforeEach(() => { @@ -325,6 +330,11 @@ describe('Policy Details', () => { expect(userNotificationCustomMessageTextArea).toHaveLength(0); expect(tooltip).toHaveLength(0); }); + + it('ransomware card is hidden', () => { + const ransomware = policyView.find('EuiPanel[data-test-subj="ransomwareProtectionsForm"]'); + expect(ransomware).toHaveLength(0); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx index a0bf2b37e8a12..8710f696fad41 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx @@ -11,12 +11,15 @@ import { MalwareProtections } from './policy_forms/protections/malware'; import { LinuxEvents, MacEvents, WindowsEvents } from './policy_forms/events'; import { AdvancedPolicyForms } from './policy_advanced'; import { AntivirusRegistrationForm } from './components/antivirus_registration_form'; +import { Ransomware } from './policy_forms/protections/ransomware'; +import { useLicense } from '../../../../common/hooks/use_license'; export const PolicyDetailsForm = memo(() => { const [showAdvancedPolicy, setShowAdvancedPolicy] = useState<boolean>(false); const handleAdvancedPolicyClick = useCallback(() => { setShowAdvancedPolicy(!showAdvancedPolicy); }, [showAdvancedPolicy]); + const isPlatinumPlus = useLicense().isPlatinumPlus(); return ( <> @@ -31,6 +34,8 @@ export const PolicyDetailsForm = memo(() => { <EuiSpacer size="xs" /> <MalwareProtections /> + <EuiSpacer size="m" /> + {isPlatinumPlus && <Ransomware />} <EuiSpacer size="l" /> <EuiText size="xs" color="subdued"> @@ -44,14 +49,14 @@ export const PolicyDetailsForm = memo(() => { <EuiSpacer size="xs" /> <WindowsEvents /> - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <MacEvents /> - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <LinuxEvents /> - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <AntivirusRegistrationForm /> - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <EuiButtonEmpty data-test-subj="advancedPolicyButton" onClick={handleAdvancedPolicyClick}> <FormattedMessage id="xpack.securitySolution.endpoint.policy.advanced.show" diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 8e631e497e57b..1bc49c716a3c3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -6,7 +6,6 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -23,9 +22,9 @@ import { EuiIconTip, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; +import styled from 'styled-components'; import { APP_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; - import { Immutable, OperatingSystem, @@ -36,91 +35,77 @@ import { ConfigForm, ConfigFormHeading } from '../../components/config_form'; import { policyConfig } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; -import { popupVersionsMap } from './popup_options_to_versions'; import { useLicense } from '../../../../../../common/hooks/use_license'; +import { AppAction } from '../../../../../../common/store/actions'; +import { SupportedVersionNotice } from './supported_version'; -const ProtectionRadioGroup = styled.div` - display: flex; - .policyDetailsProtectionRadio { - margin-right: ${(props) => props.theme.eui.euiSizeXXL}; +export const RadioFlexGroup = styled(EuiFlexGroup)` + .no-right-margin-radio { + margin-right: 0; + } + .no-horizontal-margin-radio { + margin: ${(props) => props.theme.eui.ruleMargins.marginSmall} 0; } `; const OSes: Immutable<MalwareProtectionOSes[]> = [OS.windows, OS.mac]; const protection = 'malware'; -const ProtectionRadio = React.memo(({ id, label }: { id: ProtectionModes; label: string }) => { - const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); - const dispatch = useDispatch(); - const radioButtonId = useMemo(() => htmlIdGenerator()(), []); - // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value - const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; - const isPlatinumPlus = useLicense().isPlatinumPlus(); +const ProtectionRadio = React.memo( + ({ protectionMode, label }: { protectionMode: ProtectionModes; label: string }) => { + const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); + const dispatch = useDispatch<(action: AppAction) => void>(); + const radioButtonId = useMemo(() => htmlIdGenerator()(), []); + // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value + const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; + const isPlatinumPlus = useLicense().isPlatinumPlus(); - const handleRadioChange = useCallback(() => { - if (policyDetailsConfig) { - const newPayload = cloneDeep(policyDetailsConfig); - for (const os of OSes) { - newPayload[os][protection].mode = id; - if (isPlatinumPlus) { - if (id === ProtectionModes.prevent) { - newPayload[os].popup[protection].enabled = true; - } else { - newPayload[os].popup[protection].enabled = false; + const handleRadioChange = useCallback(() => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + for (const os of OSes) { + newPayload[os][protection].mode = protectionMode; + if (isPlatinumPlus) { + if (protectionMode === ProtectionModes.prevent) { + newPayload[os].popup[protection].enabled = true; + } else { + newPayload[os].popup[protection].enabled = false; + } } } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); } - dispatch({ - type: 'userChangedPolicyConfig', - payload: { policyConfig: newPayload }, - }); - } - }, [dispatch, id, policyDetailsConfig, isPlatinumPlus]); + }, [dispatch, protectionMode, policyDetailsConfig, isPlatinumPlus]); - /** - * Passing an arbitrary id because EuiRadio - * requires an id if label is passed - */ + /** + * Passing an arbitrary id because EuiRadio + * requires an id if label is passed + */ - return ( - <EuiRadio - className="policyDetailsProtectionRadio" - label={label} - id={radioButtonId} - checked={selected === id} - onChange={handleRadioChange} - disabled={selected === ProtectionModes.off} - /> - ); -}); - -ProtectionRadio.displayName = 'ProtectionRadio'; - -const SupportedVersionNotice = ({ optionName }: { optionName: string }) => { - const version = popupVersionsMap.get(optionName); - if (!version) { - return null; + return ( + <EuiRadio + className="policyDetailsProtectionRadio" + label={label} + id={radioButtonId} + checked={selected === protectionMode} + onChange={handleRadioChange} + disabled={selected === ProtectionModes.off} + /> + ); } +); - return ( - <EuiText color="subdued" size="xs"> - <i> - <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetails.supportedVersion" - defaultMessage="Agent version {version}" - values={{ version }} - /> - </i> - </EuiText> - ); -}; +ProtectionRadio.displayName = 'ProtectionRadio'; /** The Malware Protections form for policy details * which will configure for all relevant OSes. */ export const MalwareProtections = React.memo(() => { const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); - const dispatch = useDispatch(); + const dispatch = useDispatch<(action: AppAction) => void>(); // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; const userNotificationSelected = @@ -224,19 +209,25 @@ export const MalwareProtections = React.memo(() => { /> </ConfigFormHeading> <EuiSpacer size="xs" /> - <ProtectionRadioGroup> - {radios.map((radio) => { - return ( - <ProtectionRadio - id={radio.id} - key={radio.protection + radio.id} - label={radio.label} - /> - ); - })} - </ProtectionRadioGroup> + <RadioFlexGroup> + <EuiFlexItem className="no-right-margin-radio" grow={1}> + <ProtectionRadio + protectionMode={radios[0].id} + key={radios[0].protection + radios[0].id} + label={radios[0].label} + /> + </EuiFlexItem> + <EuiFlexItem className="no-horizontal-margin-radio" grow={4}> + <ProtectionRadio + protectionMode={radios[1].id} + key={radios[1].protection + radios[1].id} + label={radios[1].label} + /> + </EuiFlexItem> + </RadioFlexGroup> {isPlatinumPlus && ( <> + <EuiSpacer size="m" /> <ConfigFormHeading> <FormattedMessage id="xpack.securitySolution.endpoint.policyDetailsConfig.userNotification" @@ -277,15 +268,16 @@ export const MalwareProtections = React.memo(() => { <EuiFlexItem grow={false}> <EuiIconTip position="right" + data-test-subj="malwareTooltip" content={ <> <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification.tooltip.a" + id="xpack.securitySolution.endpoint.policyDetailsConfig.malware.notifyUserTooltip.a" defaultMessage="Selecting the user notification option will display a notification to the host user when malware is prevented or detected." /> <EuiSpacer size="m" /> <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification.tooltip.b" + id="xpack.securitySolution.endpoint.policyDetailsConfig.malware.notifyUserTooltip.b" defaultMessage=" The user notification can be customized in the text box below. Bracketed tags can be used to dynamically populate the applicable action (such as prevented or detected) and the filename." /> @@ -327,7 +319,7 @@ export const MalwareProtections = React.memo(() => { label={i18n.translate( 'xpack.securitySolution.endpoint.policy.details.malwareProtectionsEnabled', { - defaultMessage: 'Malware Protections {mode, select, true {Enabled} false {Disabled}}', + defaultMessage: 'Malware protections {mode, select, true {enabled} false {disabled}}', values: { mode: selected !== ProtectionModes.off, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/popup_options_to_versions.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/popup_options_to_versions.ts index d4c7d0102ebd4..795f7dda52499 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/popup_options_to_versions.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/popup_options_to_versions.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -const popupVersions: Array<[string, string]> = [['malware', '7.11+']]; +const popupVersions: Array<[string, string]> = [ + ['malware', '7.11+'], + ['ransomware', '7.12+'], +]; export const popupVersionsMap: ReadonlyMap<string, string> = new Map<string, string>(popupVersions); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx new file mode 100644 index 0000000000000..eb2dd4b2fe8d8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx @@ -0,0 +1,346 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiCallOut, + EuiCheckbox, + EuiRadio, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTextArea, + htmlIdGenerator, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiCheckboxProps, + EuiRadioProps, + EuiSwitchProps, +} from '@elastic/eui'; +import { cloneDeep } from 'lodash'; +import { APP_ID } from '../../../../../../../common/constants'; +import { SecurityPageName } from '../../../../../../app/types'; +import { + Immutable, + OperatingSystem, + ProtectionModes, +} from '../../../../../../../common/endpoint/types'; +import { RansomwareProtectionOSes, OS } from '../../../types'; +import { ConfigForm, ConfigFormHeading } from '../../components/config_form'; +import { policyConfig } from '../../../store/policy_details/selectors'; +import { usePolicyDetailsSelector } from '../../policy_hooks'; +import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; +import { AppAction } from '../../../../../../common/store/actions'; +import { SupportedVersionNotice } from './supported_version'; +import { RadioFlexGroup } from './malware'; + +const OSes: Immutable<RansomwareProtectionOSes[]> = [OS.windows, OS.mac]; +const protection = 'ransomware'; + +const ProtectionRadio = React.memo( + ({ protectionMode, label }: { protectionMode: ProtectionModes; label: string }) => { + const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); + const dispatch = useDispatch<(action: AppAction) => void>(); + const radioButtonId = useMemo(() => htmlIdGenerator()(), []); + // currently just taking windows.ransomware, but both windows.ransomware and mac.ransomware should be the same value + const selected = policyDetailsConfig && policyDetailsConfig.windows.ransomware.mode; + + const handleRadioChange: EuiRadioProps['onChange'] = useCallback(() => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + for (const os of OSes) { + newPayload[os][protection].mode = protectionMode; + if (protectionMode === ProtectionModes.prevent) { + newPayload[os].popup[protection].enabled = true; + } else { + newPayload[os].popup[protection].enabled = false; + } + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, [dispatch, protectionMode, policyDetailsConfig]); + + /** + * Passing an arbitrary id because EuiRadio + * requires an id if label is passed + */ + + return ( + <EuiRadio + className="policyDetailsProtectionRadio" + label={label} + id={radioButtonId} + checked={selected === protectionMode} + onChange={handleRadioChange} + disabled={selected === ProtectionModes.off} + /> + ); + } +); + +ProtectionRadio.displayName = 'ProtectionRadio'; + +/** The Ransomware Protections form for policy details + * which will configure for all relevant OSes. + */ +export const Ransomware = React.memo(() => { + const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); + const dispatch = useDispatch<(action: AppAction) => void>(); + // currently just taking windows.ransomware, but both windows.ransomware and mac.ransomware should be the same value + const selected = policyDetailsConfig && policyDetailsConfig.windows.ransomware.mode; + const userNotificationSelected = + policyDetailsConfig && policyDetailsConfig.windows.popup.ransomware.enabled; + const userNotificationMessage = + policyDetailsConfig && policyDetailsConfig.windows.popup.ransomware.message; + + const radios: Immutable< + Array<{ + id: ProtectionModes; + label: string; + protection: 'ransomware'; + }> + > = useMemo(() => { + return [ + { + id: ProtectionModes.detect, + label: i18n.translate('xpack.securitySolution.endpoint.policy.details.detect', { + defaultMessage: 'Detect', + }), + protection: 'ransomware', + }, + { + id: ProtectionModes.prevent, + label: i18n.translate('xpack.securitySolution.endpoint.policy.details.prevent', { + defaultMessage: 'Prevent', + }), + protection: 'ransomware', + }, + ]; + }, []); + + const handleSwitchChange: EuiSwitchProps['onChange'] = useCallback( + (event) => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + if (event.target.checked === false) { + for (const os of OSes) { + newPayload[os][protection].mode = ProtectionModes.off; + newPayload[os].popup[protection].enabled = event.target.checked; + } + } else { + for (const os of OSes) { + newPayload[os][protection].mode = ProtectionModes.prevent; + newPayload[os].popup[protection].enabled = event.target.checked; + } + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, + [dispatch, policyDetailsConfig] + ); + + const handleUserNotificationCheckbox: EuiCheckboxProps['onChange'] = useCallback( + (event) => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + for (const os of OSes) { + newPayload[os].popup[protection].enabled = event.target.checked; + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, + [policyDetailsConfig, dispatch] + ); + + const handleCustomUserNotification = useCallback( + (event) => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + for (const os of OSes) { + newPayload[os].popup[protection].message = event.target.value; + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, + [policyDetailsConfig, dispatch] + ); + + const radioButtons = useMemo(() => { + return ( + <> + <ConfigFormHeading> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.protectionLevel" + defaultMessage="Protection Level" + /> + </ConfigFormHeading> + <EuiSpacer size="xs" /> + <RadioFlexGroup> + <EuiFlexItem className="no-right-margin-radio" grow={1}> + <ProtectionRadio + protectionMode={radios[0].id} + key={radios[0].protection + radios[0].id} + label={radios[0].label} + /> + </EuiFlexItem> + <EuiFlexItem className="no-horizontal-margin-radio" grow={4}> + <ProtectionRadio + protectionMode={radios[1].id} + key={radios[1].protection + radios[1].id} + label={radios[1].label} + /> + </EuiFlexItem> + </RadioFlexGroup> + <EuiSpacer size="m" /> + <ConfigFormHeading> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.userNotification" + defaultMessage="User Notification" + /> + </ConfigFormHeading> + <SupportedVersionNotice optionName="ransomware" /> + <EuiSpacer size="s" /> + <EuiCheckbox + data-test-subj="ransomwareUserNotificationCheckbox" + id="xpack.securitySolution.endpoint.policyDetail.ransomware.userNotification" + onChange={handleUserNotificationCheckbox} + checked={userNotificationSelected} + disabled={selected === ProtectionModes.off} + label={i18n.translate( + 'xpack.securitySolution.endpoint.policyDetail.ransomware.notifyUser', + { + defaultMessage: 'Notify User', + } + )} + /> + {userNotificationSelected && ( + <> + <EuiSpacer size="s" /> + <EuiFlexGroup gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiText size="xs"> + <h4> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification" + defaultMessage="Customize notification message" + /> + </h4> + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiIconTip + position="right" + data-test-subj="ransomwareTooltip" + content={ + <> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.ransomware.notifyUserTooltip.a" + defaultMessage="Selecting the user notification option will display a notification to the host user when ransomware is prevented or detected." + /> + <EuiSpacer size="m" /> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.ransomware.notifyUserTooltip.b" + defaultMessage=" + The user notification can be customized in the text box below. Bracketed tags can be used to dynamically populate the applicable action (such as prevented or detected) and the filename." + /> + </> + } + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="xs" /> + <EuiTextArea + placeholder={i18n.translate( + 'xpack.securitySolution.endpoint.policyDetails.ransomware.userNotification.placeholder', + { + defaultMessage: 'Input your custom notification message', + } + )} + value={userNotificationMessage} + onChange={handleCustomUserNotification} + fullWidth={true} + data-test-subj="ransomwareUserNotificationCustomMessage" + /> + </> + )} + </> + ); + }, [ + radios, + selected, + handleUserNotificationCheckbox, + userNotificationSelected, + userNotificationMessage, + handleCustomUserNotification, + ]); + + const protectionSwitch = useMemo(() => { + return ( + <EuiSwitch + label={i18n.translate( + 'xpack.securitySolution.endpoint.policy.details.ransomwareProtectionsEnabled', + { + defaultMessage: + 'Ransomware protections {mode, select, true {enabled} false {disabled}}', + values: { + mode: selected !== ProtectionModes.off, + }, + } + )} + checked={selected !== ProtectionModes.off} + onChange={handleSwitchChange} + /> + ); + }, [handleSwitchChange, selected]); + + return ( + <ConfigForm + type={i18n.translate('xpack.securitySolution.endpoint.policy.details.ransomware', { + defaultMessage: 'Ransomware', + })} + supportedOss={[OperatingSystem.WINDOWS, OperatingSystem.MAC]} + dataTestSubj="ransomwareProtectionsForm" + rightCorner={protectionSwitch} + > + {radioButtons} + <EuiSpacer size="m" /> + <EuiCallOut iconType="iInCircle"> + <FormattedMessage + id="xpack.securitySolution.endpoint.policy.details.detectionRulesMessage" + defaultMessage="View {detectionRulesLink}. Prebuilt rules are tagged “Elastic” on the Detection Rules page." + values={{ + detectionRulesLink: ( + <LinkToApp appId={`${APP_ID}:${SecurityPageName.detections}`} appPath={`/rules`}> + <FormattedMessage + id="xpack.securitySolution.endpoint.policy.details.detectionRulesLink" + defaultMessage="related detection rules" + /> + </LinkToApp> + ), + }} + /> + </EuiCallOut> + </ConfigForm> + ); +}); + +Ransomware.displayName = 'RansomwareProtections'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/supported_version.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/supported_version.tsx new file mode 100644 index 0000000000000..dee6418b4f3ff --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/supported_version.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText } from '@elastic/eui'; +import { popupVersionsMap } from './popup_options_to_versions'; + +export const SupportedVersionNotice = ({ optionName }: { optionName: string }) => { + const version = popupVersionsMap.get(optionName); + if (!version) { + return null; + } + + return ( + <EuiText color="subdued" size="xs"> + <i> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetails.supportedVersion" + defaultMessage="Agent version {version}" + values={{ version }} + /> + </i> + </EuiText> + ); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index ec3d35cbb6585..7f9e8b42490fa 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -124,6 +124,7 @@ export class EndpointAppContextService { dependencies.config.maxTimelineImportExportSize, dependencies.security, dependencies.alerts, + dependencies.licenseService, dependencies.exceptionListsClient ) ); diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts index d287ada74eebc..2710e4afb5968 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts @@ -6,7 +6,10 @@ import { httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { createNewPackagePolicyMock } from '../../../fleet/common/mocks'; -import { factory as policyConfigFactory } from '../../common/endpoint/models/policy_config'; +import { + policyFactory, + policyFactoryWithoutPaidFeatures, +} from '../../common/endpoint/models/policy_config'; import { getManifestManagerMock, ManifestManagerMockType, @@ -55,6 +58,9 @@ describe('ingest_integration tests ', () => { }); describe('ingest_integration sanity checks', () => { + beforeEach(() => { + licenseEmitter.next(Platinum); // set license level to platinum + }); test('policy is updated with initial manifest', async () => { const logger = loggingSystemMock.create().get('ingest_integration.test'); const manifestManager = getManifestManagerMock({ @@ -68,13 +74,14 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); // policy config without manifest const newPolicyConfig = await callback(policyConfig, ctx, req); // policy config WITH manifest expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual({ artifacts: { 'endpoint-exceptionlist-macos-v1': { @@ -146,13 +153,14 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); const newPolicyConfig = await callback(policyConfig, ctx, req); expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( lastComputed!.toEndpointFormat() ); @@ -174,13 +182,14 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); const newPolicyConfig = await callback(policyConfig, ctx, req); expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); }); test('subsequent policy creations succeed', async () => { @@ -196,13 +205,14 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); const newPolicyConfig = await callback(policyConfig, ctx, req); expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( lastComputed!.toEndpointFormat() ); @@ -221,6 +231,7 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); @@ -228,7 +239,7 @@ describe('ingest_integration tests ', () => { expect(exceptionListClient.createEndpointList).toHaveBeenCalled(); expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( lastComputed!.toEndpointFormat() ); @@ -239,8 +250,7 @@ describe('ingest_integration tests ', () => { licenseEmitter.next(Gold); // set license level to gold }); it('returns an error if paid features are turned on in the policy', async () => { - const mockPolicy = policyConfigFactory(); - mockPolicy.windows.popup.malware.message = 'paid feature'; + const mockPolicy = policyFactory(); // defaults with paid features on const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyUpdateCallback(logger, licenseService); const policyConfig = generator.generatePolicyPackagePolicy(); @@ -250,7 +260,7 @@ describe('ingest_integration tests ', () => { ); }); it('updates successfully if no paid features are turned on in the policy', async () => { - const mockPolicy = policyConfigFactory(); + const mockPolicy = policyFactoryWithoutPaidFeatures(); mockPolicy.windows.malware.mode = ProtectionModes.detect; const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyUpdateCallback(logger, licenseService); @@ -265,7 +275,7 @@ describe('ingest_integration tests ', () => { licenseEmitter.next(Platinum); // set license level to platinum }); it('updates successfully when paid features are turned on', async () => { - const mockPolicy = policyConfigFactory(); + const mockPolicy = policyFactory(); mockPolicy.windows.popup.malware.message = 'paid feature'; const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyUpdateCallback(logger, licenseService); diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts index 1e7f440ed6788..114c6ba969227 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts @@ -10,7 +10,10 @@ import { SecurityPluginSetup } from '../../../security/server'; import { ExternalCallback } from '../../../fleet/server'; import { KibanaRequest, Logger, RequestHandlerContext } from '../../../../../src/core/server'; import { NewPackagePolicy, UpdatePackagePolicy } from '../../../fleet/common/types/models'; -import { factory as policyConfigFactory } from '../../common/endpoint/models/policy_config'; +import { + policyFactory as policyConfigFactory, + policyFactoryWithoutPaidFeatures as policyConfigFactoryWithoutPaidFeatures, +} from '../../common/endpoint/models/policy_config'; import { NewPolicyData } from '../../common/endpoint/types'; import { ManifestManager } from './services/artifacts'; import { Manifest } from './lib/artifacts'; @@ -22,7 +25,7 @@ import { createDetectionIndex } from '../lib/detection_engine/routes/index/creat import { createPrepackagedRules } from '../lib/detection_engine/routes/rules/add_prepackaged_rules_route'; import { buildFrameworkRequest } from '../lib/timeline/routes/utils/common'; import { isEndpointPolicyValidForLicense } from '../../common/license/policy_config'; -import { LicenseService } from '../../common/license/license'; +import { isAtLeast, LicenseService } from '../../common/license/license'; const getManifest = async (logger: Logger, manifestManager: ManifestManager): Promise<Manifest> => { let manifest: Manifest | null = null; @@ -86,6 +89,7 @@ export const getPackagePolicyCreateCallback = ( maxTimelineImportExportSize: number, securitySetup: SecurityPluginSetup, alerts: AlertsStartContract, + licenseService: LicenseService, exceptionsClient: ExceptionListClient | undefined ): ExternalCallback[1] => { const handlePackagePolicyCreate = async ( @@ -151,6 +155,12 @@ export const getPackagePolicyCreateCallback = ( // Until we get the Default Policy Configuration in the Endpoint package, // we will add it here manually at creation time. + + // generate the correct default policy depending on the license + const defaultPolicy = isAtLeast(licenseService.getLicenseInformation(), 'platinum') + ? policyConfigFactory() + : policyConfigFactoryWithoutPaidFeatures(); + updatedPackagePolicy = { ...newPackagePolicy, inputs: [ @@ -163,7 +173,7 @@ export const getPackagePolicyCreateCallback = ( value: serializedManifest, }, policy: { - value: policyConfigFactory(), + value: defaultPolicy, }, }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts index 225592fa8e686..8e7c4d2d4daf5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts @@ -18,7 +18,7 @@ import { licenseMock } from '../../../../../licensing/common/licensing.mock'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { PackagePolicy } from '../../../../../fleet/common'; import { createPackagePolicyMock } from '../../../../../fleet/common/mocks'; -import { factory } from '../../../../common/endpoint/models/policy_config'; +import { policyFactory } from '../../../../common/endpoint/models/policy_config'; import { PolicyConfig } from '../../../../common/endpoint/types'; const MockPPWithEndpointPolicy = (cb?: (p: PolicyConfig) => PolicyConfig): PackagePolicy => { @@ -27,7 +27,7 @@ const MockPPWithEndpointPolicy = (cb?: (p: PolicyConfig) => PolicyConfig): Packa // eslint-disable-next-line no-param-reassign cb = (p) => p; } - const policyConfig = cb(factory()); + const policyConfig = cb(policyFactory()); packagePolicy.inputs[0].config = { policy: { value: policyConfig } }; return packagePolicy; }; diff --git a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts new file mode 100644 index 0000000000000..d74edef896c65 --- /dev/null +++ b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts @@ -0,0 +1,398 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildSortedEventsQuery, BuildSortedEventsQuery } from './build_sorted_events_query'; +import type { Writable } from '@kbn/utility-types'; + +const DefaultQuery: Writable<Partial<BuildSortedEventsQuery>> = { + index: ['index-name'], + from: '2021-01-01T00:00:10.123Z', + to: '2021-01-23T12:00:50.321Z', + filter: {}, + size: 100, + timeField: 'timefield', +}; + +describe('buildSortedEventsQuery', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let query: any; + beforeEach(() => { + query = { ...DefaultQuery }; + }); + + test('it builds a filter with given date range', () => { + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + }, + }); + }); + + test('it does not include searchAfterSortId if it is an empty string', () => { + query.searchAfterSortId = ''; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + }, + }); + }); + + test('it includes searchAfterSortId if it is a valid string', () => { + const sortId = '123456789012'; + query.searchAfterSortId = sortId; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + search_after: [sortId], + }, + }); + }); + + test('it includes searchAfterSortId if it is a valid number', () => { + const sortId = 123456789012; + query.searchAfterSortId = sortId; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + search_after: [sortId], + }, + }); + }); + + test('it includes aggregations if provided', () => { + query.aggs = { + tags: { + terms: { + field: 'tag', + }, + }, + }; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + aggs: { + tags: { + terms: { + field: 'tag', + }, + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + }, + }); + }); + + test('it uses sortOrder if specified', () => { + query.sortOrder = 'desc'; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'desc', + }, + }, + ], + }, + }); + }); + + test('it uses track_total_hits if specified', () => { + query.track_total_hits = true; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: true, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts new file mode 100644 index 0000000000000..b9a65cf1a7489 --- /dev/null +++ b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESSearchBody, ESSearchRequest } from '../../../typings/elasticsearch'; +import { SortOrder } from '../../../typings/elasticsearch/aggregations'; + +type BuildSortedEventsQueryOpts = Pick<ESSearchBody, 'aggs' | 'track_total_hits'> & + Pick<Required<ESSearchRequest>, 'index' | 'size'>; + +export interface BuildSortedEventsQuery extends BuildSortedEventsQueryOpts { + filter: unknown; + from: string; + to: string; + sortOrder?: SortOrder | undefined; + searchAfterSortId: string | number | undefined; + timeField: string; +} + +export const buildSortedEventsQuery = ({ + aggs, + index, + from, + to, + filter, + size, + searchAfterSortId, + sortOrder, + timeField, + // eslint-disable-next-line @typescript-eslint/naming-convention + track_total_hits, +}: BuildSortedEventsQuery): ESSearchRequest => { + const sortField = timeField; + const docFields = [timeField].map((tstamp) => ({ + field: tstamp, + format: 'strict_date_optional_time', + })); + + const rangeFilter: unknown[] = [ + { + range: { + [timeField]: { + lte: to, + gte: from, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + const filterWithTime = [filter, { bool: { filter: rangeFilter } }]; + + const searchQuery = { + allowNoIndices: true, + index, + size, + ignoreUnavailable: true, + track_total_hits: track_total_hits ?? false, + body: { + docvalue_fields: docFields, + query: { + bool: { + filter: [ + ...filterWithTime, + { + match_all: {}, + }, + ], + }, + }, + ...(aggs ? { aggs } : {}), + sort: [ + { + [sortField]: { + order: sortOrder ?? 'asc', + }, + }, + ], + }, + }; + + if (searchAfterSortId) { + return { + ...searchQuery, + body: { + ...searchQuery.body, + search_after: [searchAfterSortId], + }, + } as ESSearchRequest; + } + return searchQuery as ESSearchRequest; +}; diff --git a/x-pack/plugins/stack_alerts/kibana.json b/x-pack/plugins/stack_alerts/kibana.json index 884d33ef669e5..80eb177f92024 100644 --- a/x-pack/plugins/stack_alerts/kibana.json +++ b/x-pack/plugins/stack_alerts/kibana.json @@ -5,5 +5,6 @@ "kibanaVersion": "kibana", "requiredPlugins": ["alerts", "features", "triggersActionsUi", "kibanaReact", "savedObjects", "data"], "configPath": ["xpack", "stack_alerts"], + "requiredBundles": ["esUiShared"], "ui": true } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx new file mode 100644 index 0000000000000..5dc7c9248135c --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { IndexSelectPopover } from './index_select_popover'; + +jest.mock('../../../../triggers_actions_ui/public', () => ({ + getIndexPatterns: () => { + return ['index1', 'index2']; + }, + firstFieldOption: () => { + return { text: 'Select a field', value: '' }; + }, + getTimeFieldOptions: () => { + return [ + { + text: '@timestamp', + value: '@timestamp', + }, + ]; + }, + getFields: () => { + return Promise.resolve([ + { + name: '@timestamp', + type: 'date', + }, + { + name: 'field', + type: 'text', + }, + ]); + }, + getIndexOptions: () => { + return Promise.resolve([ + { + label: 'indexOption', + options: [ + { + label: 'index1', + value: 'index1', + }, + { + label: 'index2', + value: 'index2', + }, + ], + }, + ]); + }, +})); + +describe('IndexSelectPopover', () => { + const props = { + index: [], + esFields: [], + timeField: undefined, + errors: { + index: [], + timeField: [], + }, + onIndexChange: jest.fn(), + onTimeFieldChange: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('renders closed popover initially and opens on click', async () => { + const wrapper = mountWithIntl(<IndexSelectPopover {...props} />); + + expect(wrapper.find('[data-test-subj="selectIndexExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="thresholdAlertTimeFieldSelect"]').exists()).toBeFalsy(); + + wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="thresholdAlertTimeFieldSelect"]').exists()).toBeTruthy(); + }); + + test('renders search input', async () => { + const wrapper = mountWithIntl(<IndexSelectPopover {...props} />); + + expect(wrapper.find('[data-test-subj="selectIndexExpression"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeTruthy(); + const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); + expect(indexSearchBoxValue.first().props().value).toEqual(''); + + const indexComboBox = wrapper.find('#indexSelectSearchBox'); + indexComboBox.first().simulate('click'); + const event = { target: { value: 'indexPattern1' } }; + indexComboBox.find('input').first().simulate('change', event); + + const updatedIndexSearchValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); + expect(updatedIndexSearchValue.first().props().value).toEqual('indexPattern1'); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx new file mode 100644 index 0000000000000..6fe61be024042 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx @@ -0,0 +1,239 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { isString } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonIcon, + EuiComboBox, + EuiComboBoxOptionOption, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverTitle, + EuiSelect, +} from '@elastic/eui'; +import { HttpSetup } from 'kibana/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { + firstFieldOption, + getFields, + getIndexOptions, + getIndexPatterns, + getTimeFieldOptions, + IErrorObject, +} from '../../../../triggers_actions_ui/public'; + +interface KibanaDeps { + http: HttpSetup; +} +interface Props { + index: string[]; + esFields: Array<{ + name: string; + type: string; + normalizedType: string; + searchable: boolean; + aggregatable: boolean; + }>; + timeField: string | undefined; + errors: IErrorObject; + onIndexChange: (indices: string[]) => void; + onTimeFieldChange: (timeField: string) => void; +} + +export const IndexSelectPopover: React.FunctionComponent<Props> = ({ + index, + esFields, + timeField, + errors, + onIndexChange, + onTimeFieldChange, +}) => { + const { http } = useKibana<KibanaDeps>().services; + + const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); + const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]); + const [indexPatterns, setIndexPatterns] = useState([]); + const [areIndicesLoading, setAreIndicesLoading] = useState<boolean>(false); + const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); + + useEffect(() => { + const indexPatternsFunction = async () => { + setIndexPatterns(await getIndexPatterns()); + }; + indexPatternsFunction(); + }, []); + + useEffect(() => { + const timeFields = getTimeFieldOptions(esFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + }, [esFields]); + + const renderIndices = (indices: string[]) => { + const rows = indices.map((indexName: string, idx: number) => { + return ( + <p key={idx}> + {indexName} + {idx < indices.length - 1 ? ',' : null} + </p> + ); + }); + return <div>{rows}</div>; + }; + + const closeIndexPopover = () => { + setIndexPopoverOpen(false); + if (timeField === undefined) { + onTimeFieldChange(''); + } + }; + + return ( + <EuiPopover + id="indexPopover" + button={ + <EuiExpression + display="columns" + data-test-subj="selectIndexExpression" + description={i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexLabel', { + defaultMessage: 'index', + })} + value={index && index.length > 0 ? renderIndices(index) : firstFieldOption.text} + isActive={indexPopoverOpen} + onClick={() => { + setIndexPopoverOpen(true); + }} + isInvalid={!(index && index.length > 0 && timeField !== '')} + /> + } + isOpen={indexPopoverOpen} + closePopover={closeIndexPopover} + ownFocus + anchorPosition="downLeft" + zIndex={8000} + display="block" + > + <div style={{ width: '450px' }}> + <EuiPopoverTitle> + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem> + {i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexButtonLabel', { + defaultMessage: 'index', + })} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj="closePopover" + iconType="cross" + color="danger" + aria-label={i18n.translate( + 'xpack.stackAlerts.components.ui.alertParams.closeIndexPopoverLabel', + { + defaultMessage: 'Close', + } + )} + onClick={closeIndexPopover} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPopoverTitle> + <EuiFormRow + id="indexSelectSearchBox" + fullWidth + label={ + <FormattedMessage + id="xpack.stackAlerts.components.ui.alertParams.indicesToQueryLabel" + defaultMessage="Indices to query" + /> + } + isInvalid={errors.index.length > 0 && index != null && index.length > 0} + error={errors.index} + helpText={ + <FormattedMessage + id="xpack.stackAlerts.components.ui.alertParams.howToBroadenSearchQueryDescription" + defaultMessage="Use * to broaden your query." + /> + } + > + <EuiComboBox + fullWidth + async + isLoading={areIndicesLoading} + isInvalid={errors.index.length > 0 && index != null && index.length > 0} + noSuggestions={!indexOptions.length} + options={indexOptions} + data-test-subj="thresholdIndexesComboBox" + selectedOptions={(index || []).map((anIndex: string) => { + return { + label: anIndex, + value: anIndex, + }; + })} + onChange={async (selected: EuiComboBoxOptionOption[]) => { + const selectedIndices = selected + .map((aSelected) => aSelected.value) + .filter<string>(isString); + onIndexChange(selectedIndices); + + // reset time field if indices have been reset + if (selectedIndices.length === 0) { + setTimeFieldOptions([firstFieldOption]); + } else { + const currentEsFields = await getFields(http!, selectedIndices); + const timeFields = getTimeFieldOptions(currentEsFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + } + }} + onSearchChange={async (search) => { + setAreIndicesLoading(true); + setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); + setAreIndicesLoading(false); + }} + onBlur={() => { + if (!index) { + onIndexChange([]); + } + }} + /> + </EuiFormRow> + <EuiFormRow + id="thresholdTimeField" + fullWidth + label={ + <FormattedMessage + id="xpack.stackAlerts.components.ui.alertParams.timeFieldLabel" + defaultMessage="Time field" + /> + } + isInvalid={errors.timeField.length > 0 && timeField !== undefined} + error={errors.timeField} + > + <EuiSelect + options={timeFieldOptions} + isInvalid={errors.timeField.length > 0 && timeField !== undefined} + fullWidth + name="thresholdTimeField" + data-test-subj="thresholdAlertTimeFieldSelect" + value={timeField || ''} + onChange={(e) => { + onTimeFieldChange(e.target.value); + }} + onBlur={() => { + if (timeField === undefined) { + onTimeFieldChange(''); + } + }} + /> + </EuiFormRow> + </div> + </EuiPopover> + ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx new file mode 100644 index 0000000000000..96a45da3d0808 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import 'brace'; +import { of } from 'rxjs'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; +import EsQueryAlertTypeExpression from './expression'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { + DataPublicPluginStart, + IKibanaSearchResponse, + ISearchStart, +} from 'src/plugins/data/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { EsQueryAlertParams } from './types'; + +jest.mock('../../../../../../src/plugins/kibana_react/public'); +jest.mock('../../../../../../src/plugins/es_ui_shared/public'); +jest.mock('../../../../../../src/plugins/es_ui_shared/public', () => ({ + XJson: { + useXJsonMode: jest.fn().mockReturnValue({ + convertToJson: jest.fn(), + setXJson: jest.fn(), + xJson: jest.fn(), + }), + }, +})); +jest.mock(''); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + // Mocking EuiCodeEditor, which uses React Ace under the hood + // eslint-disable-next-line @typescript-eslint/no-explicit-any + EuiCodeEditor: (props: any) => ( + <input + data-test-subj="mockCodeEditor" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange={(syntheticEvent: any) => { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), + }; +}); +jest.mock('../../../../triggers_actions_ui/public', () => { + const original = jest.requireActual('../../../../triggers_actions_ui/public'); + return { + ...original, + getIndexPatterns: () => { + return ['index1', 'index2']; + }, + firstFieldOption: () => { + return { text: 'Select a field', value: '' }; + }, + getTimeFieldOptions: () => { + return [ + { + text: '@timestamp', + value: '@timestamp', + }, + ]; + }, + getFields: () => { + return Promise.resolve([ + { + name: '@timestamp', + type: 'date', + }, + { + name: 'field', + type: 'text', + }, + ]); + }, + getIndexOptions: () => { + return Promise.resolve([ + { + label: 'indexOption', + options: [ + { + label: 'index1', + value: 'index1', + }, + { + label: 'index2', + value: 'index2', + }, + ], + }, + ]); + }, + }; +}); + +const createDataPluginMock = () => { + const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + search: ISearchStart & { search: jest.MockedFunction<any> }; + }; + return dataMock; +}; + +const dataMock = createDataPluginMock(); +const chartsStartMock = chartPluginMock.createStartContract(); + +describe('EsQueryAlertTypeExpression', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + docLinks: { + ELASTIC_WEBSITE_URL: '', + DOC_LINK_VERSION: '', + }, + }, + }); + }); + + function getAlertParams(overrides = {}) { + return { + index: ['test-index'], + timeField: '@timestamp', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + thresholdComparator: '>', + threshold: [0], + timeWindowSize: 15, + timeWindowUnit: 's', + ...overrides, + }; + } + async function setup(alertParams: EsQueryAlertParams) { + const errors = { + index: [], + esQuery: [], + timeField: [], + timeWindowSize: [], + }; + + const wrapper = mountWithIntl( + <EsQueryAlertTypeExpression + alertInterval="1m" + alertThrottle="1m" + alertParams={alertParams} + setAlertParams={() => {}} + setAlertProperty={() => {}} + errors={errors} + data={dataMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + /> + ); + + const update = async () => + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + await update(); + return wrapper; + } + + test('should render EsQueryAlertTypeExpression with expected components', async () => { + const wrapper = await setup(getAlertParams()); + expect(wrapper.find('[data-test-subj="indexSelectPopover"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="queryJsonEditor"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy(); + + const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]'); + expect(testQueryButton.exists()).toBeTruthy(); + expect(testQueryButton.prop('disabled')).toBe(false); + }); + + test('should render Test Query button disabled if alert params are invalid', async () => { + const wrapper = await setup(getAlertParams({ timeField: null })); + const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]'); + expect(testQueryButton.exists()).toBeTruthy(); + expect(testQueryButton.prop('disabled')).toBe(true); + }); + + test('should show success message if Test Query is successful', async () => { + const searchResponseMock$ = of<IKibanaSearchResponse>({ + rawResponse: { + hits: { + total: 1234, + }, + }, + }); + dataMock.search.search.mockImplementation(() => searchResponseMock$); + const wrapper = await setup(getAlertParams()); + const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]'); + + testQueryButton.simulate('click'); + expect(dataMock.search.search).toHaveBeenCalled(); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy(); + expect(wrapper.find('EuiText[data-test-subj="testQuerySuccess"]').text()).toEqual( + `Query matched 1234 documents in the last 15s.` + ); + }); + + test('should show error message if Test Query is throws error', async () => { + dataMock.search.search.mockImplementation(() => { + throw new Error('What is this query'); + }); + const wrapper = await setup(getAlertParams()); + const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]'); + + testQueryButton.simulate('click'); + expect(dataMock.search.search).toHaveBeenCalled(); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx new file mode 100644 index 0000000000000..bba0e30978305 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx @@ -0,0 +1,371 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, Fragment, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import 'brace/theme/github'; +import { XJsonMode } from '@kbn/ace'; + +import { + EuiButtonEmpty, + EuiCodeEditor, + EuiSpacer, + EuiFormRow, + EuiCallOut, + EuiText, + EuiTitle, + EuiLink, +} from '@elastic/eui'; +import { DocLinksStart, HttpSetup } from 'kibana/public'; +import { XJson } from '../../../../../../src/plugins/es_ui_shared/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { + getFields, + COMPARATORS, + ThresholdExpression, + ForLastExpression, + AlertTypeParamsExpressionProps, +} from '../../../../triggers_actions_ui/public'; +import { validateExpression } from './validation'; +import { parseDuration } from '../../../../alerts/common'; +import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query'; +import { EsQueryAlertParams } from './types'; +import { IndexSelectPopover } from '../components/index_select_popover'; + +const DEFAULT_VALUES = { + THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, + QUERY: `{ + "query":{ + "match_all" : {} + } +}`, + TIME_WINDOW_SIZE: 5, + TIME_WINDOW_UNIT: 'm', + THRESHOLD: [1000], +}; + +const expressionFieldsWithValidation = [ + 'index', + 'esQuery', + 'timeField', + 'threshold0', + 'threshold1', + 'timeWindowSize', +]; + +const { useXJsonMode } = XJson; +const xJsonMode = new XJsonMode(); + +interface KibanaDeps { + http: HttpSetup; + docLinks: DocLinksStart; +} + +export const EsQueryAlertTypeExpression: React.FunctionComponent< + AlertTypeParamsExpressionProps<EsQueryAlertParams> +> = ({ alertParams, setAlertParams, setAlertProperty, errors, data }) => { + const { + index, + timeField, + esQuery, + thresholdComparator, + threshold, + timeWindowSize, + timeWindowUnit, + } = alertParams; + + const getDefaultParams = () => ({ + ...alertParams, + esQuery: esQuery ?? DEFAULT_VALUES.QUERY, + timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, + threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, + thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, + }); + + const { http, docLinks } = useKibana<KibanaDeps>().services; + + const [esFields, setEsFields] = useState< + Array<{ + name: string; + type: string; + normalizedType: string; + searchable: boolean; + aggregatable: boolean; + }> + >([]); + const { convertToJson, setXJson, xJson } = useXJsonMode(DEFAULT_VALUES.QUERY); + const [currentAlertParams, setCurrentAlertParams] = useState<EsQueryAlertParams>( + getDefaultParams() + ); + const [testQueryResult, setTestQueryResult] = useState<string | null>(null); + const [testQueryError, setTestQueryError] = useState<string | null>(null); + + const hasExpressionErrors = !!Object.keys(errors).find( + (errorKey) => + expressionFieldsWithValidation.includes(errorKey) && + errors[errorKey].length >= 1 && + alertParams[errorKey as keyof EsQueryAlertParams] !== undefined + ); + + const expressionErrorMessage = i18n.translate( + 'xpack.stackAlerts.esQuery.ui.alertParams.fixErrorInExpressionBelowValidationMessage', + { + defaultMessage: 'Expression contains errors.', + } + ); + + const setDefaultExpressionValues = async () => { + setAlertProperty('params', getDefaultParams()); + + setXJson(esQuery ?? DEFAULT_VALUES.QUERY); + + if (index && index.length > 0) { + await refreshEsFields(); + } + }; + + const setParam = (paramField: string, paramValue: unknown) => { + setCurrentAlertParams({ + ...currentAlertParams, + [paramField]: paramValue, + }); + setAlertParams(paramField, paramValue); + }; + + useEffect(() => { + setDefaultExpressionValues(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const refreshEsFields = async () => { + if (index) { + const currentEsFields = await getFields(http, index); + setEsFields(currentEsFields); + } + }; + + const hasValidationErrors = () => { + const { errors: validationErrors } = validateExpression(currentAlertParams); + return Object.keys(validationErrors).some( + (key) => validationErrors[key] && validationErrors[key].length + ); + }; + + const onTestQuery = async () => { + if (!hasValidationErrors()) { + setTestQueryError(null); + setTestQueryResult(null); + try { + const window = `${timeWindowSize}${timeWindowUnit}`; + const timeWindow = parseDuration(window); + const parsedQuery = JSON.parse(esQuery); + const now = Date.now(); + const { rawResponse } = await data.search + .search({ + params: buildSortedEventsQuery({ + index, + from: new Date(now - timeWindow).toISOString(), + to: new Date(now).toISOString(), + filter: parsedQuery.query, + size: 0, + searchAfterSortId: undefined, + timeField: timeField ? timeField : '', + track_total_hits: true, + }), + }) + .toPromise(); + + const hits = rawResponse.hits; + setTestQueryResult( + i18n.translate('xpack.stackAlerts.esQuery.ui.numQueryMatchesText', { + defaultMessage: 'Query matched {count} documents in the last {window}.', + values: { count: hits.total, window }, + }) + ); + } catch (err) { + const message = err?.body?.attributes?.error?.root_cause[0]?.reason || err?.body?.message; + setTestQueryError( + i18n.translate('xpack.stackAlerts.esQuery.ui.queryError', { + defaultMessage: 'Error testing query: {message}', + values: { message: message ? `${err.message}: ${message}` : err.message }, + }) + ); + } + } + }; + + return ( + <Fragment> + {hasExpressionErrors ? ( + <Fragment> + <EuiSpacer /> + <EuiCallOut color="danger" size="s" title={expressionErrorMessage} /> + <EuiSpacer /> + </Fragment> + ) : null} + <EuiTitle size="xs"> + <h5> + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.selectIndex" + defaultMessage="Select an index" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + <IndexSelectPopover + index={index} + data-test-subj="indexSelectPopover" + esFields={esFields} + timeField={timeField} + errors={errors} + onIndexChange={async (indices: string[]) => { + setParam('index', indices); + + // reset expression fields if indices are deleted + if (indices.length === 0) { + setAlertProperty('params', { + ...alertParams, + index: indices, + esQuery: DEFAULT_VALUES.QUERY, + thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, + timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, + threshold: DEFAULT_VALUES.THRESHOLD, + timeField: '', + }); + } else { + await refreshEsFields(); + } + }} + onTimeFieldChange={(updatedTimeField: string) => setParam('timeField', updatedTimeField)} + /> + <EuiSpacer /> + <EuiTitle size="xs"> + <h5> + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.queryPrompt" + defaultMessage="Define the ES query" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + <EuiFormRow + id="queryEditor" + fullWidth + label={ + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.queryPrompt.label" + defaultMessage="ES query" + /> + } + isInvalid={errors.esQuery.length > 0} + error={errors.esQuery} + helpText={ + <EuiLink + href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${docLinks.DOC_LINK_VERSION}/query-dsl.html`} + target="_blank" + > + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.queryPrompt.help" + defaultMessage="ES Query DSL documentation" + /> + </EuiLink> + } + > + <EuiCodeEditor + mode={xJsonMode} + width="100%" + height="200px" + theme="github" + data-test-subj="queryJsonEditor" + aria-label={i18n.translate('xpack.stackAlerts.esQuery.ui.queryEditor', { + defaultMessage: 'ES query editor', + })} + value={xJson} + onChange={(xjson: string) => { + setXJson(xjson); + setParam('esQuery', convertToJson(xjson)); + }} + /> + </EuiFormRow> + <EuiFormRow> + <EuiButtonEmpty + data-test-subj="testQuery" + color={'primary'} + iconSide={'left'} + flush={'left'} + iconType={'play'} + disabled={hasValidationErrors()} + onClick={onTestQuery} + > + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.testQuery" + defaultMessage="Test query" + /> + </EuiButtonEmpty> + </EuiFormRow> + {testQueryResult && ( + <EuiFormRow> + <EuiText data-test-subj="testQuerySuccess" color="subdued" size="s"> + <p>{testQueryResult}</p> + </EuiText> + </EuiFormRow> + )} + {testQueryError && ( + <EuiFormRow> + <EuiText data-test-subj="testQueryError" color="danger" size="s"> + <p>{testQueryError}</p> + </EuiText> + </EuiFormRow> + )} + <EuiSpacer /> + <EuiTitle size="xs"> + <h5> + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.conditionPrompt" + defaultMessage="When number of matches" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + <ThresholdExpression + data-test-subj="thresholdExpression" + thresholdComparator={thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR} + threshold={threshold ?? DEFAULT_VALUES.THRESHOLD} + errors={errors} + display="fullWidth" + popupPosition={'upLeft'} + onChangeSelectedThreshold={(selectedThresholds) => + setParam('threshold', selectedThresholds) + } + onChangeSelectedThresholdComparator={(selectedThresholdComparator) => + setParam('thresholdComparator', selectedThresholdComparator) + } + /> + <ForLastExpression + data-test-subj="forLastExpression" + popupPosition={'upLeft'} + timeWindowSize={timeWindowSize} + timeWindowUnit={timeWindowUnit} + display="fullWidth" + errors={errors} + onChangeWindowSize={(selectedWindowSize: number | undefined) => + setParam('timeWindowSize', selectedWindowSize) + } + onChangeWindowUnit={(selectedWindowUnit: string) => + setParam('timeWindowUnit', selectedWindowUnit) + } + /> + <EuiSpacer /> + </Fragment> + ); +}; + +// eslint-disable-next-line import/no-default-export +export { EsQueryAlertTypeExpression as default }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts new file mode 100644 index 0000000000000..62b343ffd6d2f --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { validateExpression } from './validation'; +import { EsQueryAlertParams } from './types'; +import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; + +export function getAlertType(): AlertTypeModel<EsQueryAlertParams> { + return { + id: '.es-query', + description: i18n.translate('xpack.stackAlerts.esQuery.ui.alertType.descriptionText', { + defaultMessage: 'Alert on matches against an ES query.', + }), + iconClass: 'logoElastic', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/alert-types.html#alert-type-es-query`; + }, + alertParamsExpression: lazy(() => import('./expression')), + validate: validateExpression, + defaultActionMessage: i18n.translate( + 'xpack.stackAlerts.esQuery.ui.alertType.defaultActionMessage', + { + defaultMessage: `ES query alert '\\{\\{alertName\\}\\}' is active: + +- Value: \\{\\{context.value\\}\\} +- Conditions Met: \\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\} +- Timestamp: \\{\\{context.date\\}\\}`, + } + ), + requiresAppContext: false, + }; +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts new file mode 100644 index 0000000000000..803c4bde873b4 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertTypeParams } from '../../../../alerts/common'; + +export interface Comparator { + text: string; + value: string; + requiredValues: number; +} + +export interface EsQueryAlertParams extends AlertTypeParams { + index: string[]; + timeField?: string; + esQuery: string; + thresholdComparator?: string; + threshold: number[]; + timeWindowSize: number; + timeWindowUnit: string; +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts new file mode 100644 index 0000000000000..15aff9c9a6495 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EsQueryAlertParams } from './types'; +import { validateExpression } from './validation'; + +describe('expression params validation', () => { + test('if index property is invalid should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: [], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + }; + expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.index[0]).toBe('Index is required.'); + }); + + test('if timeField property is not defined should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + }; + expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.'); + }); + + test('if esQuery property is invalid JSON should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + }; + expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.esQuery[0]).toBe('Query must be valid JSON.'); + }); + + test('if esQuery property is invalid should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"aggs\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + }; + expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.esQuery[0]).toBe(`Query field is required.`); + }); + + test('if threshold0 property is not set should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + threshold: [], + timeWindowSize: 1, + timeWindowUnit: 's', + thresholdComparator: '<', + }; + expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold0[0]).toBe('Threshold 0 is required.'); + }); + + test('if threshold1 property is needed by thresholdComparator but not set should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + threshold: [1], + timeWindowSize: 1, + timeWindowUnit: 's', + thresholdComparator: 'between', + }; + expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold1[0]).toBe('Threshold 1 is required.'); + }); + + test('if threshold0 property greater than threshold1 property should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + threshold: [10, 1], + timeWindowSize: 1, + timeWindowUnit: 's', + thresholdComparator: 'between', + }; + expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold1[0]).toBe( + 'Threshold 1 must be > Threshold 0.' + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts new file mode 100644 index 0000000000000..d54e24e21d61e --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { EsQueryAlertParams } from './types'; +import { ValidationResult, builtInComparators } from '../../../../triggers_actions_ui/public'; + +export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => { + const { index, timeField, esQuery, threshold, timeWindowSize, thresholdComparator } = alertParams; + const validationResult = { errors: {} }; + const errors = { + index: new Array<string>(), + timeField: new Array<string>(), + esQuery: new Array<string>(), + threshold0: new Array<string>(), + threshold1: new Array<string>(), + thresholdComparator: new Array<string>(), + timeWindowSize: new Array<string>(), + }; + validationResult.errors = errors; + if (!index || index.length === 0) { + errors.index.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredIndexText', { + defaultMessage: 'Index is required.', + }) + ); + } + if (!timeField) { + errors.timeField.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeFieldText', { + defaultMessage: 'Time field is required.', + }) + ); + } + if (!esQuery) { + errors.esQuery.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredQueryText', { + defaultMessage: 'ES query is required.', + }) + ); + } else { + try { + const parsedQuery = JSON.parse(esQuery); + if (!parsedQuery.query) { + errors.esQuery.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredEsQueryText', { + defaultMessage: `Query field is required.`, + }) + ); + } + } catch (err) { + errors.esQuery.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.jsonQueryText', { + defaultMessage: 'Query must be valid JSON.', + }) + ); + } + } + if (!threshold || threshold.length === 0 || threshold[0] === undefined) { + errors.threshold0.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold0Text', { + defaultMessage: 'Threshold 0 is required.', + }) + ); + } + if ( + thresholdComparator && + builtInComparators[thresholdComparator].requiredValues > 1 && + (!threshold || + threshold[1] === undefined || + (threshold && threshold.length < builtInComparators[thresholdComparator!].requiredValues)) + ) { + errors.threshold1.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold1Text', { + defaultMessage: 'Threshold 1 is required.', + }) + ); + } + if (threshold && threshold.length === 2 && threshold[0] > threshold[1]) { + errors.threshold1.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.greaterThenThreshold0Text', { + defaultMessage: 'Threshold 1 must be > Threshold 0.', + }) + ); + } + if (!timeWindowSize) { + errors.timeWindowSize.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeWindowSizeText', { + defaultMessage: 'Time window size is required.', + }) + ); + } + return validationResult; +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts index d1f64c9298f15..c67043106c0d1 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts @@ -18,7 +18,6 @@ export interface GeoContainmentAlertParams extends AlertTypeParams { boundaryIndexId: string; boundaryGeoField: string; boundaryNameField?: string; - delayOffsetWithUnits?: string; indexQuery?: Query; boundaryIndexQuery?: Query; } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts deleted file mode 100644 index 8ba632633a3af..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { lazy } from 'react'; -import { i18n } from '@kbn/i18n'; -import { validateExpression } from './validation'; -import { GeoThresholdAlertParams } from './types'; -import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; - -export function getAlertType(): AlertTypeModel<GeoThresholdAlertParams> { - return { - id: '.geo-threshold', - description: i18n.translate('xpack.stackAlerts.geoThreshold.descriptionText', { - defaultMessage: 'Alert when an entity enters or leaves a geo boundary.', - }), - iconClass: 'globe', - // TODO: Add documentation for geo threshold alert - documentationUrl: null, - alertParamsExpression: lazy(() => import('./query_builder')), - validate: validateExpression, - requiresAppContext: false, - }; -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap deleted file mode 100644 index ce59adc688c36..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/__snapshots__/geo_threshold_alert_type_expression.test.tsx.snap +++ /dev/null @@ -1,240 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render BoundaryIndexExpression 1`] = ` -<ExpressionWithPopover - defaultValue="Select an index pattern and geo shape field" - expressionDescription="index" - popoverContent={ - <React.Fragment> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="geoIndexPatternSelect" - labelType="label" - > - <GeoIndexPatternSelect - IndexPatternSelectComponent={[MockFunction]} - includedGeoTypes={ - Array [ - "geo_shape", - ] - } - indexPatternService={ - Object { - "clearCache": [MockFunction], - "createField": [MockFunction], - "createFieldList": [MockFunction], - "ensureDefaultIndexPattern": [MockFunction], - "find": [MockFunction], - "get": [MockFunction], - "make": [Function], - } - } - onChange={[Function]} - /> - </EuiFormRow> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="geoField" - label="Geospatial field" - labelType="label" - > - <SingleFieldSelect - fields={Array []} - onChange={[Function]} - placeholder="Select geo field" - value="" - /> - </EuiFormRow> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="boundaryNameFieldSelect" - label="Human-readable boundary name (optional)" - labelType="label" - > - <SingleFieldSelect - fields={Array []} - onChange={[Function]} - placeholder="Select boundary name" - value="testNameField" - /> - </EuiFormRow> - </React.Fragment> - } -/> -`; - -exports[`should render EntityIndexExpression 1`] = ` -<ExpressionWithPopover - defaultValue="Select an index pattern and geo point field" - expressionDescription="index" - isInvalid={false} - popoverContent={ - <React.Fragment> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="geoIndexPatternSelect" - labelType="label" - > - <GeoIndexPatternSelect - IndexPatternSelectComponent={[MockFunction]} - includedGeoTypes={ - Array [ - "geo_point", - ] - } - indexPatternService={ - Object { - "clearCache": [MockFunction], - "createField": [MockFunction], - "createFieldList": [MockFunction], - "ensureDefaultIndexPattern": [MockFunction], - "find": [MockFunction], - "get": [MockFunction], - "make": [Function], - } - } - onChange={[Function]} - /> - </EuiFormRow> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="thresholdTimeField" - label={ - <FormattedMessage - defaultMessage="Time field" - id="xpack.stackAlerts.geoThreshold.timeFieldLabel" - values={Object {}} - /> - } - labelType="label" - > - <SingleFieldSelect - fields={Array []} - onChange={[Function]} - placeholder="Select time field" - value="testDateField" - /> - </EuiFormRow> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="geoField" - label="Geospatial field" - labelType="label" - > - <SingleFieldSelect - fields={Array []} - onChange={[Function]} - placeholder="Select geo field" - value="testGeoField" - /> - </EuiFormRow> - </React.Fragment> - } -/> -`; - -exports[`should render EntityIndexExpression w/ invalid flag if invalid 1`] = ` -<ExpressionWithPopover - defaultValue="Select an index pattern and geo point field" - expressionDescription="index" - isInvalid={true} - popoverContent={ - <React.Fragment> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="geoIndexPatternSelect" - labelType="label" - > - <GeoIndexPatternSelect - IndexPatternSelectComponent={[MockFunction]} - includedGeoTypes={ - Array [ - "geo_point", - ] - } - indexPatternService={ - Object { - "clearCache": [MockFunction], - "createField": [MockFunction], - "createFieldList": [MockFunction], - "ensureDefaultIndexPattern": [MockFunction], - "find": [MockFunction], - "get": [MockFunction], - "make": [Function], - } - } - onChange={[Function]} - /> - </EuiFormRow> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="thresholdTimeField" - label={ - <FormattedMessage - defaultMessage="Time field" - id="xpack.stackAlerts.geoThreshold.timeFieldLabel" - values={Object {}} - /> - } - labelType="label" - > - <SingleFieldSelect - fields={Array []} - onChange={[Function]} - placeholder="Select time field" - value="testDateField" - /> - </EuiFormRow> - <EuiFormRow - describedByIds={Array []} - display="row" - fullWidth={true} - hasChildLabel={true} - hasEmptyLabelSpace={false} - id="geoField" - label="Geospatial field" - labelType="label" - > - <SingleFieldSelect - fields={Array []} - onChange={[Function]} - placeholder="Select geo field" - value="testGeoField" - /> - </EuiFormRow> - </React.Fragment> - } -/> -`; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx deleted file mode 100644 index 93918c82d664c..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/boundary_index_expression.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { HttpSetup } from 'kibana/public'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { IErrorObject } from '../../../../../../triggers_actions_ui/public'; -import { ES_GEO_SHAPE_TYPES, GeoThresholdAlertParams } from '../../types'; -import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; - -interface Props { - alertParams: GeoThresholdAlertParams; - errors: IErrorObject; - boundaryIndexPattern: IIndexPattern; - boundaryNameField?: string; - setBoundaryIndexPattern: (boundaryIndexPattern?: IIndexPattern) => void; - setBoundaryGeoField: (boundaryGeoField?: string) => void; - setBoundaryNameField: (boundaryNameField?: string) => void; - data: DataPublicPluginStart; -} - -interface KibanaDeps { - http: HttpSetup; -} - -export const BoundaryIndexExpression: FunctionComponent<Props> = ({ - alertParams, - errors, - boundaryIndexPattern, - boundaryNameField, - setBoundaryIndexPattern, - setBoundaryGeoField, - setBoundaryNameField, - data, -}) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const BOUNDARY_NAME_ENTITY_TYPES = ['string', 'number', 'ip']; - const { http } = useKibana<KibanaDeps>().services; - const IndexPatternSelect = (data.ui && data.ui.IndexPatternSelect) || null; - const { boundaryGeoField } = alertParams; - // eslint-disable-next-line react-hooks/exhaustive-deps - const nothingSelected: IFieldType = { - name: '<nothing selected>', - type: 'string', - }; - - const usePrevious = <T extends unknown>(value: T): T | undefined => { - const ref = useRef<T>(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexPattern = usePrevious(boundaryIndexPattern); - const fields = useRef<{ - geoFields: IFieldType[]; - boundaryNameFields: IFieldType[]; - }>({ - geoFields: [], - boundaryNameFields: [], - }); - useEffect(() => { - if (oldIndexPattern !== boundaryIndexPattern) { - fields.current.geoFields = - (boundaryIndexPattern.fields.length && - boundaryIndexPattern.fields.filter((field: IFieldType) => - ES_GEO_SHAPE_TYPES.includes(field.type) - )) || - []; - if (fields.current.geoFields.length) { - setBoundaryGeoField(fields.current.geoFields[0].name); - } - - fields.current.boundaryNameFields = [ - ...boundaryIndexPattern.fields.filter((field: IFieldType) => { - return ( - BOUNDARY_NAME_ENTITY_TYPES.includes(field.type) && - !field.name.startsWith('_') && - !field.name.endsWith('keyword') - ); - }), - nothingSelected, - ]; - if (fields.current.boundaryNameFields.length) { - setBoundaryNameField(fields.current.boundaryNameFields[0].name); - } - } - }, [ - BOUNDARY_NAME_ENTITY_TYPES, - boundaryIndexPattern, - nothingSelected, - oldIndexPattern, - setBoundaryGeoField, - setBoundaryNameField, - ]); - - const indexPopover = ( - <Fragment> - <EuiFormRow id="geoIndexPatternSelect" fullWidth error={errors.index}> - <GeoIndexPatternSelect - onChange={(_indexPattern) => { - if (!_indexPattern) { - return; - } - setBoundaryIndexPattern(_indexPattern); - }} - value={boundaryIndexPattern.id} - IndexPatternSelectComponent={IndexPatternSelect} - indexPatternService={data.indexPatterns} - http={http} - includedGeoTypes={ES_GEO_SHAPE_TYPES} - /> - </EuiFormRow> - <EuiFormRow - id="geoField" - fullWidth - label={i18n.translate('xpack.stackAlerts.geoThreshold.geofieldLabel', { - defaultMessage: 'Geospatial field', - })} - > - <SingleFieldSelect - placeholder={i18n.translate('xpack.stackAlerts.geoThreshold.selectLabel', { - defaultMessage: 'Select geo field', - })} - value={boundaryGeoField} - onChange={setBoundaryGeoField} - fields={fields.current.geoFields} - /> - </EuiFormRow> - <EuiFormRow - id="boundaryNameFieldSelect" - fullWidth - label={i18n.translate('xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel', { - defaultMessage: 'Human-readable boundary name (optional)', - })} - > - <SingleFieldSelect - placeholder={i18n.translate('xpack.stackAlerts.geoThreshold.boundaryNameSelect', { - defaultMessage: 'Select boundary name', - })} - value={boundaryNameField || null} - onChange={(name) => { - setBoundaryNameField(name === nothingSelected.name ? undefined : name); - }} - fields={fields.current.boundaryNameFields} - /> - </EuiFormRow> - </Fragment> - ); - - return ( - <ExpressionWithPopover - defaultValue={'Select an index pattern and geo shape field'} - value={boundaryIndexPattern.title} - popoverContent={indexPopover} - expressionDescription={i18n.translate('xpack.stackAlerts.geoThreshold.indexLabel', { - defaultMessage: 'index', - })} - /> - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx deleted file mode 100644 index 0cff207e674e5..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_by_expression.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import _ from 'lodash'; -import { IErrorObject } from '../../../../../../triggers_actions_ui/public'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; - -interface Props { - errors: IErrorObject; - entity: string; - setAlertParamsEntity: (entity: string) => void; - indexFields: IFieldType[]; - isInvalid: boolean; -} - -export const EntityByExpression: FunctionComponent<Props> = ({ - errors, - entity, - setAlertParamsEntity, - indexFields, - isInvalid, -}) => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const ENTITY_TYPES = ['string', 'number', 'ip']; - - const usePrevious = <T extends unknown>(value: T): T | undefined => { - const ref = useRef<T>(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexFields = usePrevious(indexFields); - const fields = useRef<{ - indexFields: IFieldType[]; - }>({ - indexFields: [], - }); - useEffect(() => { - if (!_.isEqual(oldIndexFields, indexFields)) { - fields.current.indexFields = indexFields.filter( - (field: IFieldType) => ENTITY_TYPES.includes(field.type) && !field.name.startsWith('_') - ); - if (!entity && fields.current.indexFields.length) { - setAlertParamsEntity(fields.current.indexFields[0].name); - } - } - }, [ENTITY_TYPES, indexFields, oldIndexFields, setAlertParamsEntity, entity]); - - const indexPopover = ( - <EuiFormRow id="entitySelect" fullWidth error={errors.index}> - <SingleFieldSelect - placeholder={i18n.translate( - 'xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder', - { - defaultMessage: 'Select entity field', - } - )} - value={entity} - onChange={(_entity) => _entity && setAlertParamsEntity(_entity)} - fields={fields.current.indexFields} - /> - </EuiFormRow> - ); - - return ( - <ExpressionWithPopover - isInvalid={isInvalid} - value={entity} - defaultValue={'Select entity field'} - popoverContent={indexPopover} - expressionDescription={i18n.translate('xpack.stackAlerts.geoThreshold.entityByLabel', { - defaultMessage: 'by', - })} - /> - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx deleted file mode 100644 index f2d2f7848a4f9..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/expressions/entity_index_expression.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, FunctionComponent, useEffect, useRef } from 'react'; -import { EuiFormRow } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { - IErrorObject, - AlertTypeParamsExpressionProps, -} from '../../../../../../triggers_actions_ui/public'; -import { ES_GEO_FIELD_TYPES } from '../../types'; -import { GeoIndexPatternSelect } from '../util_components/geo_index_pattern_select'; -import { SingleFieldSelect } from '../util_components/single_field_select'; -import { ExpressionWithPopover } from '../util_components/expression_with_popover'; -import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; - -interface Props { - dateField: string; - geoField: string; - errors: IErrorObject; - setAlertParamsDate: (date: string) => void; - setAlertParamsGeoField: (geoField: string) => void; - setAlertProperty: AlertTypeParamsExpressionProps['setAlertProperty']; - setIndexPattern: (indexPattern: IIndexPattern) => void; - indexPattern: IIndexPattern; - isInvalid: boolean; - data: DataPublicPluginStart; -} - -export const EntityIndexExpression: FunctionComponent<Props> = ({ - setAlertParamsDate, - setAlertParamsGeoField, - errors, - setIndexPattern, - indexPattern, - isInvalid, - dateField: timeField, - geoField, - data, -}) => { - const { http } = useKibana().services; - const IndexPatternSelect = (data.ui && data.ui.IndexPatternSelect) || null; - - const usePrevious = <T extends unknown>(value: T): T | undefined => { - const ref = useRef<T>(); - useEffect(() => { - ref.current = value; - }); - return ref.current; - }; - - const oldIndexPattern = usePrevious(indexPattern); - const fields = useRef<{ - dateFields: IFieldType[]; - geoFields: IFieldType[]; - }>({ - dateFields: [], - geoFields: [], - }); - useEffect(() => { - if (oldIndexPattern !== indexPattern) { - fields.current.geoFields = - (indexPattern.fields.length && - indexPattern.fields.filter((field: IFieldType) => - ES_GEO_FIELD_TYPES.includes(field.type) - )) || - []; - if (fields.current.geoFields.length) { - setAlertParamsGeoField(fields.current.geoFields[0].name); - } - - fields.current.dateFields = - (indexPattern.fields.length && - indexPattern.fields.filter((field: IFieldType) => field.type === 'date')) || - []; - if (fields.current.dateFields.length) { - setAlertParamsDate(fields.current.dateFields[0].name); - } - } - }, [indexPattern, oldIndexPattern, setAlertParamsDate, setAlertParamsGeoField]); - - const indexPopover = ( - <Fragment> - <EuiFormRow id="geoIndexPatternSelect" fullWidth error={errors.index}> - <GeoIndexPatternSelect - onChange={(_indexPattern) => { - // reset time field and expression fields if indices are deleted - if (!_indexPattern) { - return; - } - setIndexPattern(_indexPattern); - }} - value={indexPattern.id} - IndexPatternSelectComponent={IndexPatternSelect} - indexPatternService={data.indexPatterns} - http={http!} - includedGeoTypes={ES_GEO_FIELD_TYPES} - /> - </EuiFormRow> - <EuiFormRow - id="thresholdTimeField" - fullWidth - label={ - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.timeFieldLabel" - defaultMessage="Time field" - /> - } - > - <SingleFieldSelect - placeholder={i18n.translate('xpack.stackAlerts.geoThreshold.selectTimeLabel', { - defaultMessage: 'Select time field', - })} - value={timeField} - onChange={(_timeField: string | undefined) => - _timeField && setAlertParamsDate(_timeField) - } - fields={fields.current.dateFields} - /> - </EuiFormRow> - <EuiFormRow - id="geoField" - fullWidth - label={i18n.translate('xpack.stackAlerts.geoThreshold.geofieldLabel', { - defaultMessage: 'Geospatial field', - })} - > - <SingleFieldSelect - placeholder={i18n.translate('xpack.stackAlerts.geoThreshold.selectGeoLabel', { - defaultMessage: 'Select geo field', - })} - value={geoField} - onChange={(_geoField: string | undefined) => - _geoField && setAlertParamsGeoField(_geoField) - } - fields={fields.current.geoFields} - /> - </EuiFormRow> - </Fragment> - ); - - return ( - <ExpressionWithPopover - isInvalid={isInvalid} - value={indexPattern.title} - defaultValue={i18n.translate('xpack.stackAlerts.geoThreshold.entityIndexSelect', { - defaultMessage: 'Select an index pattern and geo point field', - })} - popoverContent={indexPopover} - expressionDescription={i18n.translate('xpack.stackAlerts.geoThreshold.entityIndexLabel', { - defaultMessage: 'index', - })} - /> - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx deleted file mode 100644 index c8158b0a6feaa..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/geo_threshold_alert_type_expression.test.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { shallow } from 'enzyme'; -import { EntityIndexExpression } from './expressions/entity_index_expression'; -import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; -import { IErrorObject } from '../../../../../triggers_actions_ui/public'; -import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; -import { dataPluginMock } from 'src/plugins/data/public/mocks'; - -const alertParams = { - index: '', - indexId: '', - geoField: '', - entity: '', - dateField: '', - trackingEvent: '', - boundaryType: '', - boundaryIndexTitle: '', - boundaryIndexId: '', - boundaryGeoField: '', -}; - -const dataStartMock = dataPluginMock.createStartContract(); - -test('should render EntityIndexExpression', async () => { - const component = shallow( - <EntityIndexExpression - dateField={'testDateField'} - geoField={'testGeoField'} - errors={{} as IErrorObject} - setAlertParamsDate={() => {}} - setAlertParamsGeoField={() => {}} - setAlertProperty={() => {}} - setIndexPattern={() => {}} - indexPattern={('' as unknown) as IIndexPattern} - isInvalid={false} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); - -test('should render EntityIndexExpression w/ invalid flag if invalid', async () => { - const component = shallow( - <EntityIndexExpression - dateField={'testDateField'} - geoField={'testGeoField'} - errors={{} as IErrorObject} - setAlertParamsDate={() => {}} - setAlertParamsGeoField={() => {}} - setAlertProperty={() => {}} - setIndexPattern={() => {}} - indexPattern={('' as unknown) as IIndexPattern} - isInvalid={true} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); - -test('should render BoundaryIndexExpression', async () => { - const component = shallow( - <BoundaryIndexExpression - alertParams={alertParams} - errors={{} as IErrorObject} - boundaryIndexPattern={('' as unknown) as IIndexPattern} - setBoundaryIndexPattern={() => {}} - setBoundaryGeoField={() => {}} - setBoundaryNameField={() => {}} - boundaryNameField={'testNameField'} - data={dataStartMock} - /> - ); - - expect(component).toMatchSnapshot(); -}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx deleted file mode 100644 index 2a08a4b32f076..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/index.tsx +++ /dev/null @@ -1,386 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment, useEffect, useState } from 'react'; -import { - EuiCallOut, - EuiFieldNumber, - EuiFlexGrid, - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiIconTip, - EuiSelect, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; -import { - AlertTypeParamsExpressionProps, - getTimeOptions, -} from '../../../../../triggers_actions_ui/public'; -import { GeoThresholdAlertParams, TrackingEvent } from '../types'; -import { ExpressionWithPopover } from './util_components/expression_with_popover'; -import { EntityIndexExpression } from './expressions/entity_index_expression'; -import { EntityByExpression } from './expressions/entity_by_expression'; -import { BoundaryIndexExpression } from './expressions/boundary_index_expression'; -import { IIndexPattern } from '../../../../../../../src/plugins/data/common/index_patterns'; -import { - esQuery, - esKuery, - Query, - QueryStringInput, -} from '../../../../../../../src/plugins/data/public'; - -const DEFAULT_VALUES = { - TRACKING_EVENT: '', - ENTITY: '', - INDEX: '', - INDEX_ID: '', - DATE_FIELD: '', - BOUNDARY_TYPE: 'entireIndex', // Only one supported currently. Will eventually be more - GEO_FIELD: '', - BOUNDARY_INDEX: '', - BOUNDARY_INDEX_ID: '', - BOUNDARY_GEO_FIELD: '', - BOUNDARY_NAME_FIELD: '', - DELAY_OFFSET_WITH_UNITS: '0m', -}; - -const conditionOptions = Object.keys(TrackingEvent).map((key) => ({ - text: TrackingEvent[key as TrackingEvent], - value: TrackingEvent[key as TrackingEvent], -})); - -const labelForDelayOffset = ( - <> - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.delayOffset" - defaultMessage="Delayed evaluation offset" - />{' '} - <EuiIconTip - position="right" - type="questionInCircle" - content={i18n.translate('xpack.stackAlerts.geoThreshold.delayOffsetTooltip', { - defaultMessage: 'Evaluate alerts on a delayed cycle to adjust for data latency', - })} - /> - </> -); - -function validateQuery(query: Query) { - try { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - query.language === 'kuery' - ? esKuery.fromKueryExpression(query.query) - : esQuery.luceneStringToDsl(query.query); - } catch (err) { - return false; - } - return true; -} - -export const GeoThresholdAlertTypeExpression: React.FunctionComponent< - AlertTypeParamsExpressionProps<GeoThresholdAlertParams> -> = ({ alertParams, alertInterval, setAlertParams, setAlertProperty, errors, data }) => { - const { - index, - indexId, - indexQuery, - geoField, - entity, - dateField, - trackingEvent, - boundaryType, - boundaryIndexTitle, - boundaryIndexId, - boundaryIndexQuery, - boundaryGeoField, - boundaryNameField, - delayOffsetWithUnits, - } = alertParams; - - const [indexPattern, _setIndexPattern] = useState<IIndexPattern>({ - id: '', - fields: [], - title: '', - }); - const setIndexPattern = (_indexPattern?: IIndexPattern) => { - if (_indexPattern) { - _setIndexPattern(_indexPattern); - if (_indexPattern.title) { - setAlertParams('index', _indexPattern.title); - } - if (_indexPattern.id) { - setAlertParams('indexId', _indexPattern.id); - } - } - }; - const [indexQueryInput, setIndexQueryInput] = useState<Query>( - indexQuery || { - query: '', - language: 'kuery', - } - ); - const [boundaryIndexPattern, _setBoundaryIndexPattern] = useState<IIndexPattern>({ - id: '', - fields: [], - title: '', - }); - const setBoundaryIndexPattern = (_indexPattern?: IIndexPattern) => { - if (_indexPattern) { - _setBoundaryIndexPattern(_indexPattern); - if (_indexPattern.title) { - setAlertParams('boundaryIndexTitle', _indexPattern.title); - } - if (_indexPattern.id) { - setAlertParams('boundaryIndexId', _indexPattern.id); - } - } - }; - const [boundaryIndexQueryInput, setBoundaryIndexQueryInput] = useState<Query>( - boundaryIndexQuery || { - query: '', - language: 'kuery', - } - ); - const [delayOffset, _setDelayOffset] = useState<number>(0); - function setDelayOffset(_delayOffset: number) { - setAlertParams('delayOffsetWithUnits', `${_delayOffset}${delayOffsetUnit}`); - _setDelayOffset(_delayOffset); - } - const [delayOffsetUnit, setDelayOffsetUnit] = useState<string>('m'); - - const hasExpressionErrors = false; - const expressionErrorMessage = i18n.translate( - 'xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage', - { - defaultMessage: 'Expression contains errors.', - } - ); - - useEffect(() => { - const initToDefaultParams = async () => { - setAlertProperty('params', { - ...alertParams, - index: index ?? DEFAULT_VALUES.INDEX, - indexId: indexId ?? DEFAULT_VALUES.INDEX_ID, - entity: entity ?? DEFAULT_VALUES.ENTITY, - dateField: dateField ?? DEFAULT_VALUES.DATE_FIELD, - trackingEvent: trackingEvent ?? DEFAULT_VALUES.TRACKING_EVENT, - boundaryType: boundaryType ?? DEFAULT_VALUES.BOUNDARY_TYPE, - geoField: geoField ?? DEFAULT_VALUES.GEO_FIELD, - boundaryIndexTitle: boundaryIndexTitle ?? DEFAULT_VALUES.BOUNDARY_INDEX, - boundaryIndexId: boundaryIndexId ?? DEFAULT_VALUES.BOUNDARY_INDEX_ID, - boundaryGeoField: boundaryGeoField ?? DEFAULT_VALUES.BOUNDARY_GEO_FIELD, - boundaryNameField: boundaryNameField ?? DEFAULT_VALUES.BOUNDARY_NAME_FIELD, - delayOffsetWithUnits: delayOffsetWithUnits ?? DEFAULT_VALUES.DELAY_OFFSET_WITH_UNITS, - }); - if (!data?.indexPatterns) { - return; - } - if (indexId) { - const _indexPattern = await data?.indexPatterns.get(indexId); - setIndexPattern(_indexPattern); - } - if (boundaryIndexId) { - const _boundaryIndexPattern = await data?.indexPatterns.get(boundaryIndexId); - setBoundaryIndexPattern(_boundaryIndexPattern); - } - if (delayOffsetWithUnits) { - setDelayOffset(+delayOffsetWithUnits.replace(/\D/g, '')); - } - }; - initToDefaultParams(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - <Fragment> - {hasExpressionErrors ? ( - <Fragment> - <EuiSpacer /> - <EuiCallOut color="danger" size="s" title={expressionErrorMessage} /> - <EuiSpacer /> - </Fragment> - ) : null} - <EuiSpacer size="l" /> - <EuiTitle size="xs"> - <h5> - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.selectOffset" - defaultMessage="Select offset (optional)" - /> - </h5> - </EuiTitle> - <EuiSpacer size="m" /> - <EuiFlexGrid columns={2}> - <EuiFlexItem> - <EuiFormRow fullWidth display="rowCompressed" label={labelForDelayOffset}> - <EuiFlexGroup gutterSize="s"> - <EuiFlexItem> - <EuiFieldNumber - fullWidth - min={0} - compressed - value={delayOffset || 0} - name="delayOffset" - onChange={(e) => { - setDelayOffset(+e.target.value); - }} - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiSelect - fullWidth - compressed - value={delayOffsetUnit} - options={getTimeOptions(+alertInterval ?? 1)} - onChange={(e) => { - setDelayOffsetUnit(e.target.value); - }} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFormRow> - </EuiFlexItem> - </EuiFlexGrid> - <EuiSpacer size="m" /> - <EuiTitle size="xs"> - <h5> - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.selectEntity" - defaultMessage="Select entity" - /> - </h5> - </EuiTitle> - <EuiSpacer size="s" /> - <EntityIndexExpression - dateField={dateField} - geoField={geoField} - errors={errors} - setAlertParamsDate={(_date) => setAlertParams('dateField', _date)} - setAlertParamsGeoField={(_geoField) => setAlertParams('geoField', _geoField)} - setAlertProperty={setAlertProperty} - setIndexPattern={setIndexPattern} - indexPattern={indexPattern} - isInvalid={!indexId || !dateField || !geoField} - data={data} - /> - <EntityByExpression - errors={errors} - entity={entity} - setAlertParamsEntity={(entityName) => setAlertParams('entity', entityName)} - indexFields={indexPattern.fields} - isInvalid={indexId && dateField && geoField ? !entity : false} - /> - <EuiSpacer size="s" /> - <EuiFlexItem> - <QueryStringInput - disableAutoFocus - bubbleSubmitEvent - indexPatterns={indexPattern ? [indexPattern] : []} - query={indexQueryInput} - onChange={(query) => { - if (query.language) { - if (validateQuery(query)) { - setAlertParams('indexQuery', query); - } - setIndexQueryInput(query); - } - }} - /> - </EuiFlexItem> - - <EuiSpacer size="l" /> - <EuiTitle size="xs"> - <h5> - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.selectIndex" - defaultMessage="Define the condition" - /> - </h5> - </EuiTitle> - <EuiSpacer size="s" /> - <ExpressionWithPopover - isInvalid={entity ? !trackingEvent : false} - defaultValue={'Select crossing option'} - value={trackingEvent} - popoverContent={ - <EuiFormRow id="someSelect" fullWidth error={errors.index}> - <div> - <EuiSelect - data-test-subj="whenExpressionSelect" - value={ - (trackingEvent && trackingEvent) || - (entity && - setAlertParams('trackingEvent', conditionOptions[0].text) && - conditionOptions[0].text) || - undefined - } - fullWidth - onChange={(e) => setAlertParams('trackingEvent', e.target.value)} - options={conditionOptions} - /> - </div> - </EuiFormRow> - } - expressionDescription={i18n.translate('xpack.stackAlerts.geoThreshold.whenEntityLabel', { - defaultMessage: 'when entity', - })} - /> - - <EuiSpacer size="l" /> - <EuiTitle size="xs"> - <h5> - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.selectBoundaryIndex" - defaultMessage="Select boundary:" - /> - </h5> - </EuiTitle> - <EuiSpacer size="s" /> - <BoundaryIndexExpression - alertParams={alertParams} - errors={errors} - boundaryIndexPattern={boundaryIndexPattern} - setBoundaryIndexPattern={setBoundaryIndexPattern} - setBoundaryGeoField={(_geoField: string | undefined) => - _geoField && setAlertParams('boundaryGeoField', _geoField) - } - setBoundaryNameField={(_boundaryNameField: string | undefined) => - _boundaryNameField - ? setAlertParams('boundaryNameField', _boundaryNameField) - : setAlertParams('boundaryNameField', '') - } - boundaryNameField={boundaryNameField} - data={data} - /> - <EuiSpacer size="s" /> - <EuiFlexItem> - <QueryStringInput - disableAutoFocus - bubbleSubmitEvent - indexPatterns={boundaryIndexPattern ? [boundaryIndexPattern] : []} - query={boundaryIndexQueryInput} - onChange={(query) => { - if (query.language) { - if (validateQuery(query)) { - setAlertParams('boundaryIndexQuery', query); - } - setBoundaryIndexQueryInput(query); - } - }} - /> - </EuiFlexItem> - <EuiSpacer size="l" /> - </Fragment> - ); -}; - -// eslint-disable-next-line import/no-default-export -export { GeoThresholdAlertTypeExpression as default }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx deleted file mode 100644 index a83667cfd92c6..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/expression_with_popover.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { ReactNode, useState } from 'react'; -import { - EuiButtonIcon, - EuiExpression, - EuiFlexGroup, - EuiFlexItem, - EuiPopover, - EuiPopoverTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -export const ExpressionWithPopover: ({ - popoverContent, - expressionDescription, - defaultValue, - value, - isInvalid, -}: { - popoverContent: ReactNode; - expressionDescription: ReactNode; - defaultValue?: ReactNode; - value?: ReactNode; - isInvalid?: boolean; -}) => JSX.Element = ({ popoverContent, expressionDescription, defaultValue, value, isInvalid }) => { - const [popoverOpen, setPopoverOpen] = useState(false); - - return ( - <EuiPopover - id="popoverForExpression" - button={ - <EuiExpression - display="columns" - data-test-subj="selectIndexExpression" - description={expressionDescription} - value={value || defaultValue} - isActive={popoverOpen} - onClick={() => setPopoverOpen(true)} - isInvalid={isInvalid} - /> - } - isOpen={popoverOpen} - closePopover={() => setPopoverOpen(false)} - ownFocus - anchorPosition="downLeft" - zIndex={8000} - display="block" - > - <div style={{ width: '450px' }}> - <EuiPopoverTitle> - <EuiFlexGroup alignItems="center" gutterSize="s"> - <EuiFlexItem>{expressionDescription}</EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonIcon - data-test-subj="closePopover" - iconType="cross" - color="danger" - aria-label={i18n.translate( - 'xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel', - { - defaultMessage: 'Close', - } - )} - onClick={() => setPopoverOpen(false)} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiPopoverTitle> - {popoverContent} - </div> - </EuiPopover> - ); -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx deleted file mode 100644 index a552d6d998c7e..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/geo_index_pattern_select.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Component } from 'react'; -import { EuiCallOut, EuiFormRow, EuiLink, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { IndexPattern, IndexPatternsContract } from 'src/plugins/data/public'; -import { HttpSetup } from 'kibana/public'; - -interface Props { - onChange: (indexPattern: IndexPattern) => void; - value: string | undefined; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - IndexPatternSelectComponent: any; - indexPatternService: IndexPatternsContract | undefined; - http: HttpSetup; - includedGeoTypes: string[]; -} - -interface State { - noGeoIndexPatternsExist: boolean; -} - -export class GeoIndexPatternSelect extends Component<Props, State> { - private _isMounted: boolean = false; - - state = { - noGeoIndexPatternsExist: false, - }; - - componentWillUnmount() { - this._isMounted = false; - } - - componentDidMount() { - this._isMounted = true; - } - - _onIndexPatternSelect = async (indexPatternId: string) => { - if (!indexPatternId || indexPatternId.length === 0 || !this.props.indexPatternService) { - return; - } - - let indexPattern; - try { - indexPattern = await this.props.indexPatternService.get(indexPatternId); - } catch (err) { - return; - } - - // method may be called again before 'get' returns - // ignore response when fetched index pattern does not match active index pattern - if (this._isMounted && indexPattern.id === indexPatternId) { - this.props.onChange(indexPattern); - } - }; - - _onNoIndexPatterns = () => { - this.setState({ noGeoIndexPatternsExist: true }); - }; - - _renderNoIndexPatternWarning() { - if (!this.state.noGeoIndexPatternsExist) { - return null; - } - - return ( - <> - <EuiCallOut - title={i18n.translate('xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle', { - defaultMessage: `Couldn't find any index patterns with geospatial fields`, - })} - color="warning" - > - <p> - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription" - defaultMessage="You'll need to " - /> - <EuiLink - href={this.props.http.basePath.prepend(`/app/management/kibana/indexPatterns`)} - > - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription" - defaultMessage="create an index pattern" - /> - </EuiLink> - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription" - defaultMessage=" with geospatial fields." - /> - </p> - <p> - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription" - defaultMessage="Don't have any geospatial data sets? " - /> - <EuiLink - href={this.props.http.basePath.prepend('/app/home#/tutorial_directory/sampleData')} - > - <FormattedMessage - id="xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText" - defaultMessage="Get started with some sample data sets." - /> - </EuiLink> - </p> - </EuiCallOut> - <EuiSpacer size="s" /> - </> - ); - } - - render() { - const IndexPatternSelectComponent = this.props.IndexPatternSelectComponent; - return ( - <> - {this._renderNoIndexPatternWarning()} - - <EuiFormRow - label={i18n.translate('xpack.stackAlerts.geoThreshold.indexPatternSelectLabel', { - defaultMessage: 'Index pattern', - })} - > - {IndexPatternSelectComponent ? ( - <IndexPatternSelectComponent - isDisabled={this.state.noGeoIndexPatternsExist} - indexPatternId={this.props.value} - onChange={this._onIndexPatternSelect} - placeholder={i18n.translate( - 'xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder', - { - defaultMessage: 'Select index pattern', - } - )} - fieldTypes={this.props.includedGeoTypes} - onNoIndexPatterns={this._onNoIndexPatterns} - isClearable={false} - /> - ) : ( - <div /> - )} - </EuiFormRow> - </> - ); - } -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx deleted file mode 100644 index ef6e6f6f5e18f..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/query_builder/util_components/single_field_select.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import React from 'react'; -import { - EuiComboBox, - EuiComboBoxOptionOption, - EuiHighlight, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { IFieldType } from 'src/plugins/data/public'; -import { FieldIcon } from '../../../../../../../../src/plugins/kibana_react/public'; - -function fieldsToOptions(fields?: IFieldType[]): Array<EuiComboBoxOptionOption<IFieldType>> { - if (!fields) { - return []; - } - - return fields - .map((field) => ({ - value: field, - label: field.name, - })) - .sort((a, b) => { - return a.label.toLowerCase().localeCompare(b.label.toLowerCase()); - }); -} - -interface Props { - placeholder: string; - value: string | null; // index pattern field name - onChange: (fieldName?: string) => void; - fields: IFieldType[]; -} - -export function SingleFieldSelect({ placeholder, value, onChange, fields }: Props) { - function renderOption( - option: EuiComboBoxOptionOption<IFieldType>, - searchValue: string, - contentClassName: string - ) { - return ( - <EuiFlexGroup className={contentClassName} gutterSize="s" alignItems="center"> - <EuiFlexItem grow={null}> - <FieldIcon type={option.value!.type} fill="none" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiHighlight search={searchValue}>{option.label}</EuiHighlight> - </EuiFlexItem> - </EuiFlexGroup> - ); - } - - const onSelection = (selectedOptions: Array<EuiComboBoxOptionOption<IFieldType>>) => { - onChange(_.get(selectedOptions, '0.value.name')); - }; - - const selectedOptions: Array<EuiComboBoxOptionOption<IFieldType>> = []; - if (value && fields) { - const selectedField = fields.find((field: IFieldType) => field.name === value); - if (selectedField) { - selectedOptions.push({ value: selectedField, label: value }); - } - } - - return ( - <EuiComboBox - singleSelection={true} - options={fieldsToOptions(fields)} - selectedOptions={selectedOptions} - onChange={onSelection} - isDisabled={!fields} - renderOption={renderOption} - isClearable={false} - placeholder={placeholder} - compressed - /> - ); -} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts deleted file mode 100644 index 3f487135f0474..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { AlertTypeParams } from '../../../../alerts/common'; -import { Query } from '../../../../../../src/plugins/data/common'; - -export enum TrackingEvent { - entered = 'entered', - exited = 'exited', - crossed = 'crossed', -} - -export interface GeoThresholdAlertParams extends AlertTypeParams { - index: string; - indexId: string; - geoField: string; - entity: string; - dateField: string; - trackingEvent: string; - boundaryType: string; - boundaryIndexTitle: string; - boundaryIndexId: string; - boundaryGeoField: string; - boundaryNameField?: string; - delayOffsetWithUnits?: string; - indexQuery?: Query; - boundaryIndexQuery?: Query; -} - -// Will eventually include 'geo_shape' -export const ES_GEO_FIELD_TYPES = ['geo_point']; -export const ES_GEO_SHAPE_TYPES = ['geo_shape']; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts deleted file mode 100644 index 9cc5b1eb069ae..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { GeoThresholdAlertParams } from './types'; -import { validateExpression } from './validation'; - -describe('expression params validation', () => { - test('if index property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: '', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.index[0]).toBe('Index pattern is required.'); - }); - - test('if geoField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: '', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.geoField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.geoField[0]).toBe('Geo field is required.'); - }); - - test('if entity property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: '', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.entity.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.entity[0]).toBe('Entity is required.'); - }); - - test('if dateField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: '', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.dateField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.dateField[0]).toBe('Date field is required.'); - }); - - test('if trackingEvent property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: '', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.trackingEvent.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.trackingEvent[0]).toBe( - 'Tracking event is required.' - ); - }); - - test('if boundaryType property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: '', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.boundaryType.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryType[0]).toBe( - 'Boundary type is required.' - ); - }); - - test('if boundaryIndexTitle property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: '', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - }; - expect(validateExpression(initialParams).errors.boundaryIndexTitle.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryIndexTitle[0]).toBe( - 'Boundary index pattern title is required.' - ); - }); - - test('if boundaryGeoField property is invalid should return proper error message', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: '', - }; - expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBeGreaterThan(0); - expect(validateExpression(initialParams).errors.boundaryGeoField[0]).toBe( - 'Boundary geo field is required.' - ); - }); - - test('if boundaryNameField property is missing should not return error', () => { - const initialParams: GeoThresholdAlertParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndexId', - boundaryGeoField: 'testField', - boundaryNameField: '', - }; - expect(validateExpression(initialParams).errors.boundaryGeoField.length).toBe(0); - }); -}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts deleted file mode 100644 index 7a511f681ecaa..0000000000000 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_threshold/validation.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { i18n } from '@kbn/i18n'; -import { ValidationResult } from '../../../../triggers_actions_ui/public'; -import { GeoThresholdAlertParams } from './types'; - -export const validateExpression = (alertParams: GeoThresholdAlertParams): ValidationResult => { - const { - index, - geoField, - entity, - dateField, - trackingEvent, - boundaryType, - boundaryIndexTitle, - boundaryGeoField, - } = alertParams; - const validationResult = { errors: {} }; - const errors = { - index: new Array<string>(), - indexId: new Array<string>(), - geoField: new Array<string>(), - entity: new Array<string>(), - dateField: new Array<string>(), - trackingEvent: new Array<string>(), - boundaryType: new Array<string>(), - boundaryIndexTitle: new Array<string>(), - boundaryIndexId: new Array<string>(), - boundaryGeoField: new Array<string>(), - }; - validationResult.errors = errors; - - if (!index) { - errors.index.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText', { - defaultMessage: 'Index pattern is required.', - }) - ); - } - - if (!geoField) { - errors.geoField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText', { - defaultMessage: 'Geo field is required.', - }) - ); - } - - if (!entity) { - errors.entity.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredEntityText', { - defaultMessage: 'Entity is required.', - }) - ); - } - - if (!dateField) { - errors.dateField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredDateFieldText', { - defaultMessage: 'Date field is required.', - }) - ); - } - - if (!trackingEvent) { - errors.trackingEvent.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText', { - defaultMessage: 'Tracking event is required.', - }) - ); - } - - if (!boundaryType) { - errors.boundaryType.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText', { - defaultMessage: 'Boundary type is required.', - }) - ); - } - - if (!boundaryIndexTitle) { - errors.boundaryIndexTitle.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText', { - defaultMessage: 'Boundary index pattern title is required.', - }) - ); - } - - if (!boundaryGeoField) { - errors.boundaryGeoField.push( - i18n.translate('xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText', { - defaultMessage: 'Boundary geo field is required.', - }) - ); - } - - return validationResult; -}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/index.ts index 1a9710eb08eb0..026383cd92f20 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/index.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getAlertType as getGeoThresholdAlertType } from './geo_threshold'; import { getAlertType as getGeoContainmentAlertType } from './geo_containment'; import { getAlertType as getThresholdAlertType } from './threshold'; +import { getAlertType as getEsQueryAlertType } from './es_query'; import { Config } from '../../common'; import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; @@ -18,8 +18,8 @@ export function registerAlertTypes({ config: Config; }) { if (config.enableGeoAlerting) { - alertTypeRegistry.register(getGeoThresholdAlertType()); alertTypeRegistry.register(getGeoContainmentAlertType()); } alertTypeRegistry.register(getThresholdAlertType()); + alertTypeRegistry.register(getEsQueryAlertType()); } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx index 8348a797972ae..00c170e291504 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx @@ -7,33 +7,13 @@ import React, { useState, Fragment, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlexItem, - EuiFlexGroup, - EuiExpression, - EuiPopover, - EuiPopoverTitle, - EuiSelect, - EuiSpacer, - EuiComboBox, - EuiComboBoxOptionOption, - EuiFormRow, - EuiCallOut, - EuiEmptyPrompt, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiEmptyPrompt, EuiText, EuiTitle } from '@elastic/eui'; import { HttpSetup } from 'kibana/public'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { - firstFieldOption, - getIndexPatterns, - getIndexOptions, getFields, COMPARATORS, builtInComparators, - getTimeFieldOptions, OfExpression, ThresholdExpression, ForLastExpression, @@ -45,6 +25,7 @@ import { import { ThresholdVisualization } from './visualization'; import { IndexThresholdAlertParams } from './types'; import './expression.scss'; +import { IndexSelectPopover } from '../components/index_select_popover'; const DEFAULT_VALUES = { AGGREGATION_TYPE: 'count', @@ -101,12 +82,15 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< const indexArray = indexParamToArray(index); const { http } = useKibana<KibanaDeps>().services; - const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); - const [indexPatterns, setIndexPatterns] = useState([]); - const [esFields, setEsFields] = useState<unknown[]>([]); - const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]); - const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); - const [isIndiciesLoading, setIsIndiciesLoading] = useState<boolean>(false); + const [esFields, setEsFields] = useState< + Array<{ + name: string; + type: string; + normalizedType: string; + searchable: boolean; + aggregatable: boolean; + }> + >([]); const hasExpressionErrors = !!Object.keys(errors).find( (errorKey) => @@ -139,153 +123,22 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< }); if (indexArray.length > 0) { - const currentEsFields = await getFields(http, indexArray); - const timeFields = getTimeFieldOptions(currentEsFields); - - setEsFields(currentEsFields); - setTimeFieldOptions([firstFieldOption, ...timeFields]); + await refreshEsFields(); } }; - const closeIndexPopover = () => { - setIndexPopoverOpen(false); - if (timeField === undefined) { - setAlertParams('timeField', ''); + const refreshEsFields = async () => { + if (indexArray.length > 0) { + const currentEsFields = await getFields(http, indexArray); + setEsFields(currentEsFields); } }; - useEffect(() => { - const indexPatternsFunction = async () => { - setIndexPatterns(await getIndexPatterns()); - }; - indexPatternsFunction(); - }, []); - useEffect(() => { setDefaultExpressionValues(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const indexPopover = ( - <Fragment> - <EuiFormRow - id="indexSelectSearchBox" - fullWidth - label={ - <FormattedMessage - id="xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel" - defaultMessage="Indices to query" - /> - } - isInvalid={errors.index.length > 0 && indexArray.length > 0} - error={errors.index} - helpText={ - <FormattedMessage - id="xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription" - defaultMessage="Use * to broaden your query." - /> - } - > - <EuiComboBox - fullWidth - async - isLoading={isIndiciesLoading} - isInvalid={errors.index.length > 0 && indexArray.length > 0} - noSuggestions={!indexOptions.length} - options={indexOptions} - data-test-subj="thresholdIndexesComboBox" - selectedOptions={indexArray.map((anIndex: string) => { - return { - label: anIndex, - value: anIndex, - }; - })} - onChange={async (selected: EuiComboBoxOptionOption[]) => { - const indicies: string[] = selected - .map((aSelected) => aSelected.value) - .filter<string>(isString); - setAlertParams('index', indicies); - const indices = selected.map((s) => s.value as string); - - // reset time field and expression fields if indices are deleted - if (indices.length === 0) { - setTimeFieldOptions([firstFieldOption]); - setAlertProperty('params', { - ...alertParams, - index: indices, - aggType: DEFAULT_VALUES.AGGREGATION_TYPE, - termSize: DEFAULT_VALUES.TERM_SIZE, - thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, - timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, - timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, - groupBy: DEFAULT_VALUES.GROUP_BY, - threshold: DEFAULT_VALUES.THRESHOLD, - timeField: '', - }); - return; - } - const currentEsFields = await getFields(http!, indices); - const timeFields = getTimeFieldOptions(currentEsFields); - - setEsFields(currentEsFields); - setTimeFieldOptions([firstFieldOption, ...timeFields]); - }} - onSearchChange={async (search) => { - setIsIndiciesLoading(true); - setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); - setIsIndiciesLoading(false); - }} - onBlur={() => { - if (!index) { - setAlertParams('index', []); - } - }} - /> - </EuiFormRow> - <EuiFormRow - id="thresholdTimeField" - fullWidth - label={ - <FormattedMessage - id="xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel" - defaultMessage="Time field" - /> - } - isInvalid={errors.timeField.length > 0 && timeField !== undefined} - error={errors.timeField} - > - <EuiSelect - options={timeFieldOptions} - isInvalid={errors.timeField.length > 0 && timeField !== undefined} - fullWidth - name="thresholdTimeField" - data-test-subj="thresholdAlertTimeFieldSelect" - value={timeField || ''} - onChange={(e) => { - setAlertParams('timeField', e.target.value); - }} - onBlur={() => { - if (timeField === undefined) { - setAlertParams('timeField', ''); - } - }} - /> - </EuiFormRow> - </Fragment> - ); - - const renderIndices = (indices: string[]) => { - const rows = indices.map((s: string, i: number) => { - return ( - <p key={i}> - {s} - {i < indices.length - 1 ? ',' : null} - </p> - ); - }); - return <div>{rows}</div>; - }; - return ( <Fragment> {hasExpressionErrors ? ( @@ -304,58 +157,36 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< </h5> </EuiTitle> <EuiSpacer size="s" /> - <EuiPopover - id="indexPopover" - button={ - <EuiExpression - display="columns" - data-test-subj="selectIndexExpression" - description={i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexLabel', { - defaultMessage: 'index', - })} - value={indexArray.length > 0 ? renderIndices(indexArray) : firstFieldOption.text} - isActive={indexPopoverOpen} - onClick={() => { - setIndexPopoverOpen(true); - }} - isInvalid={!(indexArray.length > 0 && timeField !== '')} - /> - } - isOpen={indexPopoverOpen} - closePopover={closeIndexPopover} - ownFocus - anchorPosition="downLeft" - zIndex={8000} - display="block" - > - <div style={{ width: '450px' }}> - <EuiPopoverTitle> - <EuiFlexGroup alignItems="center" gutterSize="s"> - <EuiFlexItem> - {i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel', { - defaultMessage: 'index', - })} - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonIcon - data-test-subj="closePopover" - iconType="cross" - color="danger" - aria-label={i18n.translate( - 'xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel', - { - defaultMessage: 'Close', - } - )} - onClick={closeIndexPopover} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiPopoverTitle> + <IndexSelectPopover + index={indexArray} + esFields={esFields} + timeField={timeField} + errors={errors} + onIndexChange={async (indices: string[]) => { + setAlertParams('index', indices); - {indexPopover} - </div> - </EuiPopover> + // reset expression fields if indices are deleted + if (indices.length === 0) { + setAlertProperty('params', { + ...alertParams, + index: indices, + aggType: DEFAULT_VALUES.AGGREGATION_TYPE, + termSize: DEFAULT_VALUES.TERM_SIZE, + thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, + timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, + groupBy: DEFAULT_VALUES.GROUP_BY, + threshold: DEFAULT_VALUES.THRESHOLD, + timeField: '', + }); + } else { + await refreshEsFields(); + } + }} + onTimeFieldChange={(updatedTimeField: string) => + setAlertParams('timeField', updatedTimeField) + } + /> <WhenExpression display="fullWidth" aggType={aggType ?? DEFAULT_VALUES.AGGREGATION_TYPE} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts new file mode 100644 index 0000000000000..882580a00e951 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EsQueryAlertActionContext, addMessages } from './action_context'; +import { EsQueryAlertParamsSchema } from './alert_type_params'; + +describe('ActionContext', () => { + it('generates expected properties', async () => { + const params = EsQueryAlertParamsSchema.validate({ + index: ['[index]'], + timeField: '[timeField]', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', + threshold: [4], + }); + const base: EsQueryAlertActionContext = { + date: '2020-01-01T00:00:00.000Z', + value: 42, + conditions: 'count greater than 4', + hits: [], + }; + const context = addMessages({ name: '[alert-name]' }, base, params); + expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`); + expect(context.message).toEqual( + `alert '[alert-name]' is active: + +- Value: 42 +- Conditions Met: count greater than 4 over 5m +- Timestamp: 2020-01-01T00:00:00.000Z` + ); + }); + + it('generates expected properties if comparator is between', async () => { + const params = EsQueryAlertParamsSchema.validate({ + index: ['[index]'], + timeField: '[timeField]', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: 'between', + threshold: [4, 5], + }); + const base: EsQueryAlertActionContext = { + date: '2020-01-01T00:00:00.000Z', + value: 4, + conditions: 'count between 4 and 5', + hits: [], + }; + const context = addMessages({ name: '[alert-name]' }, base, params); + expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`); + expect(context.message).toEqual( + `alert '[alert-name]' is active: + +- Value: 4 +- Conditions Met: count between 4 and 5 over 5m +- Timestamp: 2020-01-01T00:00:00.000Z` + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts new file mode 100644 index 0000000000000..67d0ac0df8ffe --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { AlertExecutorOptions, AlertInstanceContext } from '../../../../alerts/server'; +import { EsQueryAlertParams } from './alert_type_params'; +import { ESSearchHit } from '../../../../../typings/elasticsearch'; + +// alert type context provided to actions + +type AlertInfo = Pick<AlertExecutorOptions, 'name'>; + +export interface ActionContext extends EsQueryAlertActionContext { + // a short pre-constructed message which may be used in an action field + title: string; + // a longer pre-constructed message which may be used in an action field + message: string; +} + +export interface EsQueryAlertActionContext extends AlertInstanceContext { + // the date the alert was run as an ISO date + date: string; + // the value that met the threshold + value: number; + // threshold conditions + conditions: string; + // query matches + hits: ESSearchHit[]; +} + +export function addMessages( + alertInfo: AlertInfo, + baseContext: EsQueryAlertActionContext, + params: EsQueryAlertParams +): ActionContext { + const title = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle', { + defaultMessage: `alert '{name}' matched query`, + values: { + name: alertInfo.name, + }, + }); + + const window = `${params.timeWindowSize}${params.timeWindowUnit}`; + const message = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextMessageDescription', { + defaultMessage: `alert '{name}' is active: + +- Value: {value} +- Conditions Met: {conditions} over {window} +- Timestamp: {date}`, + values: { + name: alertInfo.name, + value: baseContext.value, + conditions: baseContext.conditions, + window, + date: baseContext.date, + }, + }); + + return { ...baseContext, title, message }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts new file mode 100644 index 0000000000000..c5f57a056b002 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import type { Writable } from '@kbn/utility-types'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { getAlertType } from './alert_type'; +import { EsQueryAlertParams } from './alert_type_params'; + +describe('alertType', () => { + const logger = loggingSystemMock.create().get(); + + const alertType = getAlertType(logger); + + it('alert type creation structure is the expected value', async () => { + expect(alertType.id).toBe('.es-query'); + expect(alertType.name).toBe('ES query'); + expect(alertType.actionGroups).toEqual([{ id: 'query matched', name: 'Query matched' }]); + + expect(alertType.actionVariables).toMatchInlineSnapshot(` + Object { + "context": Array [ + Object { + "description": "A message for the alert.", + "name": "message", + }, + Object { + "description": "A title for the alert.", + "name": "title", + }, + Object { + "description": "The date that the alert met the threshold condition.", + "name": "date", + }, + Object { + "description": "The value that met the threshold condition.", + "name": "value", + }, + Object { + "description": "The documents that met the threshold condition.", + "name": "hits", + }, + Object { + "description": "A string that describes the threshold condition.", + "name": "conditions", + }, + ], + "params": Array [ + Object { + "description": "The index the query was run against.", + "name": "index", + }, + Object { + "description": "The string representation of the ES query.", + "name": "esQuery", + }, + Object { + "description": "An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.", + "name": "threshold", + }, + Object { + "description": "A function to determine if the threshold has been met.", + "name": "thresholdComparator", + }, + ], + } + `); + }); + + it('validator succeeds with valid params', async () => { + const params: Partial<Writable<EsQueryAlertParams>> = { + index: ['index-name'], + timeField: 'time-field', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '<', + threshold: [0], + }; + + expect(alertType.validate?.params?.validate(params)).toBeTruthy(); + }); + + it('validator fails with invalid params - threshold', async () => { + const paramsSchema = alertType.validate?.params; + if (!paramsSchema) throw new Error('params validator not set'); + + const params: Partial<Writable<EsQueryAlertParams>> = { + index: ['index-name'], + timeField: 'time-field', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: 'between', + threshold: [0], + }; + + expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: must have two elements for the \\"between\\" comparator"` + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts new file mode 100644 index 0000000000000..a0da622a73cee --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -0,0 +1,317 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { Logger } from 'src/core/server'; +import { ESSearchResponse } from '../../../../../typings/elasticsearch'; +import { AlertType, AlertExecutorOptions } from '../../types'; +import { ActionContext, EsQueryAlertActionContext, addMessages } from './action_context'; +import { + EsQueryAlertParams, + EsQueryAlertParamsSchema, + EsQueryAlertState, +} from './alert_type_params'; +import { STACK_ALERTS_FEATURE_ID } from '../../../common'; +import { ComparatorFns, getHumanReadableComparator } from '../lib'; +import { parseDuration } from '../../../../alerts/server'; +import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query'; +import { ESSearchHit } from '../../../../../typings/elasticsearch'; + +export const ES_QUERY_ID = '.es-query'; + +const DEFAULT_MAX_HITS_PER_EXECUTION = 1000; + +const ActionGroupId = 'query matched'; +const ConditionMetAlertInstanceId = 'query matched'; + +export function getAlertType( + logger: Logger +): AlertType<EsQueryAlertParams, EsQueryAlertState, {}, ActionContext, typeof ActionGroupId> { + const alertTypeName = i18n.translate('xpack.stackAlerts.esQuery.alertTypeTitle', { + defaultMessage: 'ES query', + }); + + const actionGroupName = i18n.translate('xpack.stackAlerts.esQuery.actionGroupThresholdMetTitle', { + defaultMessage: 'Query matched', + }); + + const actionVariableContextDateLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextDateLabel', + { + defaultMessage: 'The date that the alert met the threshold condition.', + } + ); + + const actionVariableContextValueLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextValueLabel', + { + defaultMessage: 'The value that met the threshold condition.', + } + ); + + const actionVariableContextHitsLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextHitsLabel', + { + defaultMessage: 'The documents that met the threshold condition.', + } + ); + + const actionVariableContextMessageLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextMessageLabel', + { + defaultMessage: 'A message for the alert.', + } + ); + + const actionVariableContextTitleLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextTitleLabel', + { + defaultMessage: 'A title for the alert.', + } + ); + + const actionVariableContextIndexLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextIndexLabel', + { + defaultMessage: 'The index the query was run against.', + } + ); + + const actionVariableContextQueryLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextQueryLabel', + { + defaultMessage: 'The string representation of the ES query.', + } + ); + + const actionVariableContextThresholdLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextThresholdLabel', + { + defaultMessage: + "An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.", + } + ); + + const actionVariableContextThresholdComparatorLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextThresholdComparatorLabel', + { + defaultMessage: 'A function to determine if the threshold has been met.', + } + ); + + const actionVariableContextConditionsLabel = i18n.translate( + 'xpack.stackAlerts.esQuery.actionVariableContextConditionsLabel', + { + defaultMessage: 'A string that describes the threshold condition.', + } + ); + + return { + id: ES_QUERY_ID, + name: alertTypeName, + actionGroups: [{ id: ActionGroupId, name: actionGroupName }], + defaultActionGroupId: ActionGroupId, + validate: { + params: EsQueryAlertParamsSchema, + }, + actionVariables: { + context: [ + { name: 'message', description: actionVariableContextMessageLabel }, + { name: 'title', description: actionVariableContextTitleLabel }, + { name: 'date', description: actionVariableContextDateLabel }, + { name: 'value', description: actionVariableContextValueLabel }, + { name: 'hits', description: actionVariableContextHitsLabel }, + { name: 'conditions', description: actionVariableContextConditionsLabel }, + ], + params: [ + { name: 'index', description: actionVariableContextIndexLabel }, + { name: 'esQuery', description: actionVariableContextQueryLabel }, + { name: 'threshold', description: actionVariableContextThresholdLabel }, + { name: 'thresholdComparator', description: actionVariableContextThresholdComparatorLabel }, + ], + }, + minimumLicenseRequired: 'basic', + executor, + producer: STACK_ALERTS_FEATURE_ID, + }; + + async function executor( + options: AlertExecutorOptions< + EsQueryAlertParams, + EsQueryAlertState, + {}, + ActionContext, + typeof ActionGroupId + > + ) { + const { alertId, name, services, params, state } = options; + const previousTimestamp = state.latestTimestamp; + + const callCluster = services.callCluster; + const { parsedQuery, dateStart, dateEnd } = getSearchParams(params); + + const compareFn = ComparatorFns.get(params.thresholdComparator); + if (compareFn == null) { + throw new Error(getInvalidComparatorError(params.thresholdComparator)); + } + + // During each alert execution, we run the configured query, get a hit count + // (hits.total) and retrieve up to DEFAULT_MAX_HITS_PER_EXECUTION hits. We + // evaluate the threshold condition using the value of hits.total. If the threshold + // condition is met, the hits are counted toward the query match and we update + // the alert state with the timestamp of the latest hit. In the next execution + // of the alert, the latestTimestamp will be used to gate the query in order to + // avoid counting a document multiple times. + + let timestamp: string | undefined = previousTimestamp; + const filter = timestamp + ? { + bool: { + filter: [ + parsedQuery.query, + { + bool: { + must_not: [ + { + bool: { + filter: [ + { + range: { + [params.timeField]: { lte: new Date(timestamp).toISOString() }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + } + : parsedQuery.query; + + const query = buildSortedEventsQuery({ + index: params.index, + from: dateStart, + to: dateEnd, + filter, + size: DEFAULT_MAX_HITS_PER_EXECUTION, + sortOrder: 'desc', + searchAfterSortId: undefined, + timeField: params.timeField, + track_total_hits: true, + }); + + logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`); + + const searchResult: ESSearchResponse<unknown, {}> = await callCluster('search', query); + + if (searchResult.hits.hits.length > 0) { + const numMatches = searchResult.hits.total.value; + logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query has ${numMatches} matches`); + + // apply the alert condition + const conditionMet = compareFn(numMatches, params.threshold); + + if (conditionMet) { + const humanFn = i18n.translate( + 'xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', + { + defaultMessage: `Number of matching documents is {thresholdComparator} {threshold}`, + values: { + thresholdComparator: getHumanReadableComparator(params.thresholdComparator), + threshold: params.threshold.join(' and '), + }, + } + ); + + const baseContext: EsQueryAlertActionContext = { + date: new Date().toISOString(), + value: numMatches, + conditions: humanFn, + hits: searchResult.hits.hits, + }; + + const actionContext = addMessages(options, baseContext, params); + const alertInstance = options.services.alertInstanceFactory(ConditionMetAlertInstanceId); + alertInstance + // store the params we would need to recreate the query that led to this alert instance + .replaceState({ latestTimestamp: timestamp, dateStart, dateEnd }) + .scheduleActions(ActionGroupId, actionContext); + + // update the timestamp based on the current search results + const firstHitWithSort = searchResult.hits.hits.find( + (hit: ESSearchHit) => hit.sort != null + ); + const lastTimestamp = firstHitWithSort?.sort; + if (lastTimestamp != null && lastTimestamp.length > 0) { + timestamp = lastTimestamp[0]; + } + } + } + + return { + latestTimestamp: timestamp, + }; + } +} + +function getInvalidComparatorError(comparator: string) { + return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', { + defaultMessage: 'invalid thresholdComparator specified: {comparator}', + values: { + comparator, + }, + }); +} + +function getInvalidWindowSizeError(windowValue: string) { + return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', { + defaultMessage: 'invalid format for windowSize: "{windowValue}"', + values: { + windowValue, + }, + }); +} + +function getInvalidQueryError(query: string) { + return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', { + defaultMessage: 'invalid query specified: "{query}" - query must be JSON', + values: { + query, + }, + }); +} + +function getSearchParams(queryParams: EsQueryAlertParams) { + const date = Date.now(); + const { esQuery, timeWindowSize, timeWindowUnit } = queryParams; + + let parsedQuery; + try { + parsedQuery = JSON.parse(esQuery); + } catch (err) { + throw new Error(getInvalidQueryError(esQuery)); + } + + if (parsedQuery && !parsedQuery.query) { + throw new Error(getInvalidQueryError(esQuery)); + } + + const window = `${timeWindowSize}${timeWindowUnit}`; + let timeWindow: number; + try { + timeWindow = parseDuration(window); + } catch (err) { + throw new Error(getInvalidWindowSizeError(window)); + } + + const dateStart = new Date(date - timeWindow).toISOString(); + const dateEnd = new Date(date).toISOString(); + + return { parsedQuery, dateStart, dateEnd }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts new file mode 100644 index 0000000000000..09ad66f248fee --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import type { Writable } from '@kbn/utility-types'; +import { EsQueryAlertParamsSchema, EsQueryAlertParams } from './alert_type_params'; + +const DefaultParams: Writable<Partial<EsQueryAlertParams>> = { + index: ['index-name'], + timeField: 'time-field', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', + threshold: [0], +}; + +describe('alertType Params validate()', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let params: any; + beforeEach(() => { + params = { ...DefaultParams }; + }); + + it('passes for valid input', async () => { + expect(validate()).toBeTruthy(); + }); + + it('fails for invalid index', async () => { + delete params.index; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: expected value of type [array] but got [undefined]"` + ); + + params.index = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: expected value of type [array] but got [number]"` + ); + + params.index = 'index-name'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: could not parse array value from json input"` + ); + + params.index = []; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: array size is [0], but cannot be smaller than [1]"` + ); + + params.index = ['', 'a']; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index.0]: value has length [0] but it must have a minimum length of [1]."` + ); + }); + + it('fails for invalid timeField', async () => { + delete params.timeField; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: expected value of type [string] but got [undefined]"` + ); + + params.timeField = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: expected value of type [string] but got [number]"` + ); + + params.timeField = ''; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: value has length [0] but it must have a minimum length of [1]."` + ); + }); + + it('fails for invalid esQuery', async () => { + delete params.esQuery; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: expected value of type [string] but got [undefined]"` + ); + + params.esQuery = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: expected value of type [string] but got [number]"` + ); + + params.esQuery = ''; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: value has length [0] but it must have a minimum length of [1]."` + ); + + params.esQuery = '{\n "query":{\n "match_all" : {}\n }\n'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(`"[esQuery]: must be valid JSON"`); + + params.esQuery = '{\n "aggs":{\n "match_all" : {}\n }\n}'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: must contain \\"query\\""` + ); + }); + + it('fails for invalid timeWindowSize', async () => { + delete params.timeWindowSize; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowSize]: expected value of type [number] but got [undefined]"` + ); + + params.timeWindowSize = 'foo'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowSize]: expected value of type [number] but got [string]"` + ); + + params.timeWindowSize = 0; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowSize]: Value must be equal to or greater than [1]."` + ); + }); + + it('fails for invalid timeWindowUnit', async () => { + delete params.timeWindowUnit; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowUnit]: expected value of type [string] but got [undefined]"` + ); + + params.timeWindowUnit = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowUnit]: expected value of type [string] but got [number]"` + ); + + params.timeWindowUnit = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowUnit]: invalid timeWindowUnit: \\"x\\""` + ); + }); + + it('fails for invalid threshold', async () => { + params.threshold = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: expected value of type [array] but got [number]"` + ); + + params.threshold = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: could not parse array value from json input"` + ); + + params.threshold = []; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: array size is [0], but cannot be smaller than [1]"` + ); + + params.threshold = [1, 2, 3]; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: array size is [3], but cannot be greater than [2]"` + ); + + params.threshold = ['foo']; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold.0]: expected value of type [number] but got [string]"` + ); + }); + + it('fails for invalid thresholdComparator', async () => { + params.thresholdComparator = '[invalid-comparator]'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[thresholdComparator]: invalid thresholdComparator specified: [invalid-comparator]"` + ); + }); + + it('fails for invalid threshold length', async () => { + params.thresholdComparator = '<'; + params.threshold = [0, 1, 2]; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: array size is [3], but cannot be greater than [2]"` + ); + + params.thresholdComparator = 'between'; + params.threshold = [0]; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: must have two elements for the \\"between\\" comparator"` + ); + }); + + function onValidate(): () => void { + return () => validate(); + } + + function validate(): TypeOf<typeof EsQueryAlertParamsSchema> { + return EsQueryAlertParamsSchema.validate(params); + } +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts new file mode 100644 index 0000000000000..2e7cd15d323e7 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { ComparatorFnNames } from '../lib'; +import { validateTimeWindowUnits } from '../../../../triggers_actions_ui/server'; +import { AlertTypeState } from '../../../../alerts/server'; + +// alert type parameters +export type EsQueryAlertParams = TypeOf<typeof EsQueryAlertParamsSchema>; +export interface EsQueryAlertState extends AlertTypeState { + latestTimestamp: string | undefined; +} + +export const EsQueryAlertParamsSchemaProperties = { + index: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + timeField: schema.string({ minLength: 1 }), + esQuery: schema.string({ minLength: 1 }), + timeWindowSize: schema.number({ min: 1 }), + timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }), + threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), + thresholdComparator: schema.string({ validate: validateComparator }), +}; + +export const EsQueryAlertParamsSchema = schema.object(EsQueryAlertParamsSchemaProperties, { + validate: validateParams, +}); + +const betweenComparators = new Set(['between', 'notBetween']); + +// using direct type not allowed, circular reference, so body is typed to any +function validateParams(anyParams: unknown): string | undefined { + const { + esQuery, + thresholdComparator, + threshold, + }: EsQueryAlertParams = anyParams as EsQueryAlertParams; + + if (betweenComparators.has(thresholdComparator) && threshold.length === 1) { + return i18n.translate('xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage', { + defaultMessage: + '[threshold]: must have two elements for the "{thresholdComparator}" comparator', + values: { + thresholdComparator, + }, + }); + } + + try { + const parsedQuery = JSON.parse(esQuery); + + if (parsedQuery && !parsedQuery.query) { + return i18n.translate('xpack.stackAlerts.esQuery.missingEsQueryErrorMessage', { + defaultMessage: '[esQuery]: must contain "query"', + }); + } + } catch (err) { + return i18n.translate('xpack.stackAlerts.esQuery.invalidEsQueryErrorMessage', { + defaultMessage: '[esQuery]: must be valid JSON', + }); + } +} + +export function validateComparator(comparator: string): string | undefined { + if (ComparatorFnNames.has(comparator)) return; + + return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', { + defaultMessage: 'invalid thresholdComparator specified: {comparator}', + values: { + comparator, + }, + }); +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/index.ts similarity index 100% rename from x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/index.ts rename to x-pack/plugins/stack_alerts/server/alert_types/es_query/index.ts diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts index 85e02b21cc78c..6b6f17bf4ba2a 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts @@ -98,7 +98,6 @@ export const ParamsSchema = schema.object({ boundaryIndexId: schema.string({ minLength: 1 }), boundaryGeoField: schema.string({ minLength: 1 }), boundaryNameField: schema.maybe(schema.string({ minLength: 1 })), - delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })), indexQuery: schema.maybe(schema.any({})), boundaryIndexQuery: schema.maybe(schema.any({})), }); @@ -114,7 +113,6 @@ export interface GeoContainmentParams extends AlertTypeParams { boundaryIndexId: string; boundaryGeoField: string; boundaryNameField?: string; - delayOffsetWithUnits?: string; indexQuery?: Query; boundaryIndexQuery?: Query; } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts index 24232e47225f0..1648ad9ad2a62 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts @@ -24,7 +24,7 @@ export function transformResults( results: SearchResponse<unknown> | undefined, dateField: string, geoField: string -): Map<string, LatestEntityLocation> { +): Map<string, LatestEntityLocation[]> { if (!results) { return new Map(); } @@ -64,12 +64,15 @@ export function transformResults( // Get unique .reduce( ( - accu: Map<string, LatestEntityLocation>, + accu: Map<string, LatestEntityLocation[]>, el: LatestEntityLocation & { entityName: string } ) => { const { entityName, ...locationData } = el; - if (!accu.has(entityName)) { - accu.set(entityName, locationData); + if (entityName) { + if (!accu.has(entityName)) { + accu.set(entityName, []); + } + accu.get(entityName)!.push(locationData); } return accu; }, @@ -78,26 +81,9 @@ export function transformResults( return orderedResults; } -function getOffsetTime(delayOffsetWithUnits: string, oldTime: Date): Date { - const timeUnit = delayOffsetWithUnits.slice(-1); - const time: number = +delayOffsetWithUnits.slice(0, -1); - - const adjustedDate = new Date(oldTime.getTime()); - if (timeUnit === 's') { - adjustedDate.setSeconds(adjustedDate.getSeconds() - time); - } else if (timeUnit === 'm') { - adjustedDate.setMinutes(adjustedDate.getMinutes() - time); - } else if (timeUnit === 'h') { - adjustedDate.setHours(adjustedDate.getHours() - time); - } else if (timeUnit === 'd') { - adjustedDate.setDate(adjustedDate.getDate() - time); - } - return adjustedDate; -} - export function getActiveEntriesAndGenerateAlerts( - prevLocationMap: Record<string, LatestEntityLocation>, - currLocationMap: Map<string, LatestEntityLocation>, + prevLocationMap: Map<string, LatestEntityLocation[]>, + currLocationMap: Map<string, LatestEntityLocation[]>, alertInstanceFactory: AlertServices< GeoContainmentInstanceState, GeoContainmentInstanceContext, @@ -106,32 +92,55 @@ export function getActiveEntriesAndGenerateAlerts( shapesIdsNamesMap: Record<string, unknown>, currIntervalEndTime: Date ) { - const allActiveEntriesMap: Map<string, LatestEntityLocation> = new Map([ - ...Object.entries(prevLocationMap || {}), + const allActiveEntriesMap: Map<string, LatestEntityLocation[]> = new Map([ + ...prevLocationMap, ...currLocationMap, ]); - allActiveEntriesMap.forEach(({ location, shapeLocationId, dateInShape, docId }, entityName) => { - const containingBoundaryName = shapesIdsNamesMap[shapeLocationId] || shapeLocationId; - const context = { - entityId: entityName, - entityDateTime: dateInShape ? new Date(dateInShape).toISOString() : null, - entityDocumentId: docId, - detectionDateTime: new Date(currIntervalEndTime).toISOString(), - entityLocation: `POINT (${location[0]} ${location[1]})`, - containingBoundaryId: shapeLocationId, - containingBoundaryName, - }; - const alertInstanceId = `${entityName}-${containingBoundaryName}`; - if (shapeLocationId === OTHER_CATEGORY) { + allActiveEntriesMap.forEach((locationsArr, entityName) => { + // Generate alerts + locationsArr.forEach(({ location, shapeLocationId, dateInShape, docId }) => { + const context = { + entityId: entityName, + entityDateTime: dateInShape ? new Date(dateInShape).toISOString() : null, + entityDocumentId: docId, + detectionDateTime: new Date(currIntervalEndTime).toISOString(), + entityLocation: `POINT (${location[0]} ${location[1]})`, + containingBoundaryId: shapeLocationId, + containingBoundaryName: shapesIdsNamesMap[shapeLocationId] || shapeLocationId, + }; + const alertInstanceId = `${entityName}-${context.containingBoundaryName}`; + if (shapeLocationId !== OTHER_CATEGORY) { + alertInstanceFactory(alertInstanceId).scheduleActions(ActionGroupId, context); + } + }); + + if (locationsArr[0].shapeLocationId === OTHER_CATEGORY) { allActiveEntriesMap.delete(entityName); + return; + } + + const otherCatIndex = locationsArr.findIndex( + ({ shapeLocationId }) => shapeLocationId === OTHER_CATEGORY + ); + if (otherCatIndex >= 0) { + const afterOtherLocationsArr = locationsArr.slice(0, otherCatIndex); + allActiveEntriesMap.set(entityName, afterOtherLocationsArr); } else { - alertInstanceFactory(alertInstanceId).scheduleActions(ActionGroupId, context); + allActiveEntriesMap.set(entityName, locationsArr); } }); return allActiveEntriesMap; } + export const getGeoContainmentExecutor = (log: Logger): GeoContainmentAlertType['executor'] => - async function ({ previousStartedAt, startedAt, services, params, alertId, state }) { + async function ({ + previousStartedAt: currIntervalStartTime, + startedAt: currIntervalEndTime, + services, + params, + alertId, + state, + }) { const { shapesFilters, shapesIdsNamesMap } = state.shapesFilters ? state : await getShapesFilters( @@ -147,15 +156,6 @@ export const getGeoContainmentExecutor = (log: Logger): GeoContainmentAlertType[ const executeEsQuery = await executeEsQueryFactory(params, services, log, shapesFilters); - let currIntervalStartTime = previousStartedAt; - let currIntervalEndTime = startedAt; - if (params.delayOffsetWithUnits) { - if (currIntervalStartTime) { - currIntervalStartTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalStartTime); - } - currIntervalEndTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalEndTime); - } - // Start collecting data only on the first cycle let currentIntervalResults: SearchResponse<unknown> | undefined; if (!currIntervalStartTime) { @@ -169,14 +169,17 @@ export const getGeoContainmentExecutor = (log: Logger): GeoContainmentAlertType[ currentIntervalResults = await executeEsQuery(currIntervalStartTime, currIntervalEndTime); } - const currLocationMap: Map<string, LatestEntityLocation> = transformResults( + const currLocationMap: Map<string, LatestEntityLocation[]> = transformResults( currentIntervalResults, params.dateField, params.geoField ); + const prevLocationMap: Map<string, LatestEntityLocation[]> = new Map([ + ...Object.entries((state.prevLocationMap as Record<string, LatestEntityLocation[]>) || {}), + ]); const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( - state.prevLocationMap as Record<string, LatestEntityLocation>, + prevLocationMap, currLocationMap, services.alertInstanceFactory, shapesIdsNamesMap, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts index 98842c8cc2cba..1aba7d6fb1010 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts @@ -38,7 +38,6 @@ describe('alertType', () => { boundaryIndexId: 'testIndex', boundaryGeoField: 'testField', boundaryNameField: 'testField', - delayOffsetWithUnits: 'testOffset', }; expect(alertType.validate?.params?.validate(params)).toBeTruthy(); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts index 26b51060c2e73..40ad6454c3673 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts @@ -27,39 +27,47 @@ describe('geo_containment', () => { new Map([ [ '936', - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'AAL2019', - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'AAL2323', - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'n-ng1XQB6yyY-xQxnGSM', + location: [-84.71324851736426, 41.6677269525826], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'ABD5250', - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.192Z', + docId: 'GOng1XQB6yyY-xQxnGWM', + location: [6.073727197945118, 39.07997465226799], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], ]) ); @@ -77,39 +85,47 @@ describe('geo_containment', () => { new Map([ [ '936', - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'AAL2019', - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'AAL2323', - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'n-ng1XQB6yyY-xQxnGSM', + location: [-84.71324851736426, 41.6677269525826], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'ABD5250', - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.192Z', + docId: 'GOng1XQB6yyY-xQxnGWM', + location: [6.073727197945118, 39.07997465226799], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], ]) ); @@ -131,30 +147,36 @@ describe('geo_containment', () => { const currLocationMap = new Map([ [ 'a', - { - location: [0, 0], - shapeLocationId: '123', - dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId1', - }, + [ + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ], ], [ 'b', - { - location: [0, 0], - shapeLocationId: '456', - dateInShape: 'Wed Dec 09 2020 15:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId2', - }, + [ + { + location: [0, 0], + shapeLocationId: '456', + dateInShape: 'Wed Dec 16 2020 15:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId2', + }, + ], ], [ 'c', - { - location: [0, 0], - shapeLocationId: '789', - dateInShape: 'Wed Dec 09 2020 16:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId3', - }, + [ + { + location: [0, 0], + shapeLocationId: '789', + dateInShape: 'Wed Dec 23 2020 16:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId3', + }, + ], ], ]); @@ -215,7 +237,7 @@ describe('geo_containment', () => { const currentDateTime = new Date(); it('should use currently active entities if no older entity entries', () => { - const emptyPrevLocationMap = {}; + const emptyPrevLocationMap = new Map(); const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( emptyPrevLocationMap, currLocationMap, @@ -227,14 +249,19 @@ describe('geo_containment', () => { expect(testAlertActionArr).toMatchObject(expectedContext); }); it('should overwrite older identical entity entries', () => { - const prevLocationMapWithIdenticalEntityEntry = { - a: { - location: [0, 0], - shapeLocationId: '999', - dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId7', - }, - }; + const prevLocationMapWithIdenticalEntityEntry = new Map([ + [ + 'a', + [ + { + location: [0, 0], + shapeLocationId: '999', + dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId7', + }, + ], + ], + ]); const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( prevLocationMapWithIdenticalEntityEntry, currLocationMap, @@ -246,14 +273,19 @@ describe('geo_containment', () => { expect(testAlertActionArr).toMatchObject(expectedContext); }); it('should preserve older non-identical entity entries', () => { - const prevLocationMapWithNonIdenticalEntityEntry = { - d: { - location: [0, 0], - shapeLocationId: '999', - dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId7', - }, - }; + const prevLocationMapWithNonIdenticalEntityEntry = new Map([ + [ + 'd', + [ + { + location: [0, 0], + shapeLocationId: '999', + dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId7', + }, + ], + ], + ]); const expectedContextPlusD = [ { actionGroupId: 'Tracked entity contained', @@ -279,14 +311,17 @@ describe('geo_containment', () => { expect(allActiveEntriesMap.has('d')).toBeTruthy(); expect(testAlertActionArr).toMatchObject(expectedContextPlusD); }); + it('should remove "other" entries and schedule the expected number of actions', () => { - const emptyPrevLocationMap = {}; - const currLocationMapWithOther = new Map(currLocationMap).set('d', { - location: [0, 0], - shapeLocationId: OTHER_CATEGORY, - dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId1', - }); + const emptyPrevLocationMap = new Map(); + const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: OTHER_CATEGORY, + dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ]); expect(currLocationMapWithOther).not.toEqual(currLocationMap); const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( emptyPrevLocationMap, @@ -298,5 +333,115 @@ describe('geo_containment', () => { expect(allActiveEntriesMap).toEqual(currLocationMap); expect(testAlertActionArr).toMatchObject(expectedContext); }); + + it('should generate multiple alerts per entity if found in multiple shapes in interval', () => { + const emptyPrevLocationMap = new Map(); + const currLocationMapWithThreeMore = new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: '789', + dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId2', + }, + { + location: [0, 0], + shapeLocationId: '456', + dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId3', + }, + ]); + getActiveEntriesAndGenerateAlerts( + emptyPrevLocationMap, + currLocationMapWithThreeMore, + alertInstanceFactory, + emptyShapesIdsNamesMap, + currentDateTime + ); + let numEntitiesInShapes = 0; + currLocationMapWithThreeMore.forEach((v) => { + numEntitiesInShapes += v.length; + }); + expect(testAlertActionArr.length).toEqual(numEntitiesInShapes); + }); + + it('should not return entity as active entry if most recent location is "other"', () => { + const emptyPrevLocationMap = new Map(); + const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: OTHER_CATEGORY, + dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: '456', + dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ]); + expect(currLocationMapWithOther).not.toEqual(currLocationMap); + const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( + emptyPrevLocationMap, + currLocationMapWithOther, + alertInstanceFactory, + emptyShapesIdsNamesMap, + currentDateTime + ); + expect(allActiveEntriesMap).toEqual(currLocationMap); + }); + + it('should return entity as active entry if "other" not the latest location but remove "other" and earlier entries', () => { + const emptyPrevLocationMap = new Map(); + const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: OTHER_CATEGORY, + dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: '456', + dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ]); + const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( + emptyPrevLocationMap, + currLocationMapWithOther, + alertInstanceFactory, + emptyShapesIdsNamesMap, + currentDateTime + ); + expect(allActiveEntriesMap).toEqual( + new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ]) + ); + }); }); }); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts deleted file mode 100644 index 27478049d4880..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/alert_type.ts +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; -import { Logger } from 'src/core/server'; -import { STACK_ALERTS_FEATURE_ID } from '../../../common'; -import { getGeoThresholdExecutor } from './geo_threshold'; -import { - AlertType, - AlertTypeState, - AlertInstanceState, - AlertInstanceContext, - AlertTypeParams, -} from '../../../../alerts/server'; -import { Query } from '../../../../../../src/plugins/data/common/query'; - -export const GEO_THRESHOLD_ID = '.geo-threshold'; -export type TrackingEvent = 'entered' | 'exited'; -export const ActionGroupId = 'tracking threshold met'; - -const actionVariableContextToEntityDateTimeLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel', - { - defaultMessage: `The time the entity was detected in the current boundary`, - } -); - -const actionVariableContextFromEntityDateTimeLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel', - { - defaultMessage: `The last time the entity was recorded in the previous boundary`, - } -); - -const actionVariableContextToEntityLocationLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel', - { - defaultMessage: 'The most recently captured location of the entity', - } -); - -const actionVariableContextCrossingLineLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel', - { - defaultMessage: - 'GeoJSON line connecting the two locations that were used to determine the crossing event', - } -); - -const actionVariableContextFromEntityLocationLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel', - { - defaultMessage: 'The previously captured location of the entity', - } -); - -const actionVariableContextToBoundaryIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel', - { - defaultMessage: 'The current boundary id containing the entity (if any)', - } -); - -const actionVariableContextToBoundaryNameLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel', - { - defaultMessage: 'The boundary (if any) the entity has crossed into and is currently located', - } -); - -const actionVariableContextFromBoundaryNameLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel', - { - defaultMessage: 'The boundary (if any) the entity has crossed from and was previously located', - } -); - -const actionVariableContextFromBoundaryIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel', - { - defaultMessage: 'The previous boundary id containing the entity (if any)', - } -); - -const actionVariableContextToEntityDocumentIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel', - { - defaultMessage: 'The id of the crossing entity document', - } -); - -const actionVariableContextFromEntityDocumentIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel', - { - defaultMessage: 'The id of the crossing entity document', - } -); - -const actionVariableContextTimeOfDetectionLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel', - { - defaultMessage: 'The alert interval end time this change was recorded', - } -); - -const actionVariableContextEntityIdLabel = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel', - { - defaultMessage: 'The entity ID of the document that triggered the alert', - } -); - -const actionVariables = { - context: [ - // Alert-specific data - { name: 'entityId', description: actionVariableContextEntityIdLabel }, - { name: 'timeOfDetection', description: actionVariableContextTimeOfDetectionLabel }, - { name: 'crossingLine', description: actionVariableContextCrossingLineLabel }, - - // Corresponds to a specific document in the entity-index - { name: 'toEntityLocation', description: actionVariableContextToEntityLocationLabel }, - { - name: 'toEntityDateTime', - description: actionVariableContextToEntityDateTimeLabel, - }, - { name: 'toEntityDocumentId', description: actionVariableContextToEntityDocumentIdLabel }, - - // Corresponds to a specific document in the boundary-index - { name: 'toBoundaryId', description: actionVariableContextToBoundaryIdLabel }, - { name: 'toBoundaryName', description: actionVariableContextToBoundaryNameLabel }, - - // Corresponds to a specific document in the entity-index (from) - { name: 'fromEntityLocation', description: actionVariableContextFromEntityLocationLabel }, - { name: 'fromEntityDateTime', description: actionVariableContextFromEntityDateTimeLabel }, - { name: 'fromEntityDocumentId', description: actionVariableContextFromEntityDocumentIdLabel }, - - // Corresponds to a specific document in the boundary-index (from) - { name: 'fromBoundaryId', description: actionVariableContextFromBoundaryIdLabel }, - { name: 'fromBoundaryName', description: actionVariableContextFromBoundaryNameLabel }, - ], -}; - -export const ParamsSchema = schema.object({ - index: schema.string({ minLength: 1 }), - indexId: schema.string({ minLength: 1 }), - geoField: schema.string({ minLength: 1 }), - entity: schema.string({ minLength: 1 }), - dateField: schema.string({ minLength: 1 }), - trackingEvent: schema.string({ minLength: 1 }), - boundaryType: schema.string({ minLength: 1 }), - boundaryIndexTitle: schema.string({ minLength: 1 }), - boundaryIndexId: schema.string({ minLength: 1 }), - boundaryGeoField: schema.string({ minLength: 1 }), - boundaryNameField: schema.maybe(schema.string({ minLength: 1 })), - delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })), - indexQuery: schema.maybe(schema.any({})), - boundaryIndexQuery: schema.maybe(schema.any({})), -}); - -export interface GeoThresholdParams extends AlertTypeParams { - index: string; - indexId: string; - geoField: string; - entity: string; - dateField: string; - trackingEvent: string; - boundaryType: string; - boundaryIndexTitle: string; - boundaryIndexId: string; - boundaryGeoField: string; - boundaryNameField?: string; - delayOffsetWithUnits?: string; - indexQuery?: Query; - boundaryIndexQuery?: Query; -} -export interface GeoThresholdState extends AlertTypeState { - shapesFilters: Record<string, unknown>; - shapesIdsNamesMap: Record<string, unknown>; - prevLocationArr: GeoThresholdInstanceState[]; -} -export interface GeoThresholdInstanceState extends AlertInstanceState { - location: number[]; - shapeLocationId: string; - entityName: string; - dateInShape: string | null; - docId: string; -} -export interface GeoThresholdInstanceContext extends AlertInstanceContext { - entityId: string; - timeOfDetection: number; - crossingLine: string; - toEntityLocation: string; - toEntityDateTime: string | null; - toEntityDocumentId: string; - toBoundaryId: string; - toBoundaryName: unknown; - fromEntityLocation: string; - fromEntityDateTime: string | null; - fromEntityDocumentId: string; - fromBoundaryId: string; - fromBoundaryName: unknown; -} - -export type GeoThresholdAlertType = AlertType< - GeoThresholdParams, - GeoThresholdState, - GeoThresholdInstanceState, - GeoThresholdInstanceContext, - typeof ActionGroupId ->; -export function getAlertType(logger: Logger): GeoThresholdAlertType { - const alertTypeName = i18n.translate('xpack.stackAlerts.geoThreshold.alertTypeTitle', { - defaultMessage: 'Tracking threshold', - }); - - const actionGroupName = i18n.translate( - 'xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle', - { - defaultMessage: 'Tracking threshold met', - } - ); - - return { - id: GEO_THRESHOLD_ID, - name: alertTypeName, - actionGroups: [{ id: ActionGroupId, name: actionGroupName }], - defaultActionGroupId: ActionGroupId, - executor: getGeoThresholdExecutor(logger), - producer: STACK_ALERTS_FEATURE_ID, - validate: { - params: ParamsSchema, - }, - actionVariables, - minimumLicenseRequired: 'gold', - }; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts deleted file mode 100644 index 02ac19e7b6f1e..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/es_query_builder.ts +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ILegacyScopedClusterClient } from 'kibana/server'; -import { SearchResponse } from 'elasticsearch'; -import { Logger } from 'src/core/server'; -import { - Query, - IIndexPattern, - fromKueryExpression, - toElasticsearchQuery, - luceneStringToDsl, -} from '../../../../../../src/plugins/data/common'; - -export const OTHER_CATEGORY = 'other'; -// Consider dynamically obtaining from config? -const MAX_TOP_LEVEL_QUERY_SIZE = 0; -const MAX_SHAPES_QUERY_SIZE = 10000; -const MAX_BUCKETS_LIMIT = 65535; - -export const getEsFormattedQuery = (query: Query, indexPattern?: IIndexPattern) => { - let esFormattedQuery; - - const queryLanguage = query.language; - if (queryLanguage === 'kuery') { - const ast = fromKueryExpression(query.query); - esFormattedQuery = toElasticsearchQuery(ast, indexPattern); - } else { - esFormattedQuery = luceneStringToDsl(query.query); - } - return esFormattedQuery; -}; - -export async function getShapesFilters( - boundaryIndexTitle: string, - boundaryGeoField: string, - geoField: string, - callCluster: ILegacyScopedClusterClient['callAsCurrentUser'], - log: Logger, - alertId: string, - boundaryNameField?: string, - boundaryIndexQuery?: Query -) { - const filters: Record<string, unknown> = {}; - const shapesIdsNamesMap: Record<string, unknown> = {}; - // Get all shapes in index - const boundaryData: SearchResponse<Record<string, unknown>> = await callCluster('search', { - index: boundaryIndexTitle, - body: { - size: MAX_SHAPES_QUERY_SIZE, - ...(boundaryIndexQuery ? { query: getEsFormattedQuery(boundaryIndexQuery) } : {}), - }, - }); - - boundaryData.hits.hits.forEach(({ _index, _id }) => { - filters[_id] = { - geo_shape: { - [geoField]: { - indexed_shape: { - index: _index, - id: _id, - path: boundaryGeoField, - }, - }, - }, - }; - }); - if (boundaryNameField) { - boundaryData.hits.hits.forEach( - ({ _source, _id }: { _source: Record<string, unknown>; _id: string }) => { - shapesIdsNamesMap[_id] = _source[boundaryNameField]; - } - ); - } - return { - shapesFilters: filters, - shapesIdsNamesMap, - }; -} - -export async function executeEsQueryFactory( - { - entity, - index, - dateField, - boundaryGeoField, - geoField, - boundaryIndexTitle, - indexQuery, - }: { - entity: string; - index: string; - dateField: string; - boundaryGeoField: string; - geoField: string; - boundaryIndexTitle: string; - boundaryNameField?: string; - indexQuery?: Query; - }, - { callCluster }: { callCluster: ILegacyScopedClusterClient['callAsCurrentUser'] }, - log: Logger, - shapesFilters: Record<string, unknown> -) { - return async ( - gteDateTime: Date | null, - ltDateTime: Date | null - ): Promise<SearchResponse<unknown> | undefined> => { - let esFormattedQuery; - if (indexQuery) { - const gteEpochDateTime = gteDateTime ? new Date(gteDateTime).getTime() : null; - const ltEpochDateTime = ltDateTime ? new Date(ltDateTime).getTime() : null; - const dateRangeUpdatedQuery = - indexQuery.language === 'kuery' - ? `(${dateField} >= "${gteEpochDateTime}" and ${dateField} < "${ltEpochDateTime}") and (${indexQuery.query})` - : `(${dateField}:[${gteDateTime} TO ${ltDateTime}]) AND (${indexQuery.query})`; - esFormattedQuery = getEsFormattedQuery({ - query: dateRangeUpdatedQuery, - language: indexQuery.language, - }); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const esQuery: Record<string, any> = { - index, - body: { - size: MAX_TOP_LEVEL_QUERY_SIZE, - aggs: { - shapes: { - filters: { - other_bucket_key: OTHER_CATEGORY, - filters: shapesFilters, - }, - aggs: { - entitySplit: { - terms: { - size: MAX_BUCKETS_LIMIT / ((Object.keys(shapesFilters).length || 1) * 2), - field: entity, - }, - aggs: { - entityHits: { - top_hits: { - size: 1, - sort: [ - { - [dateField]: { - order: 'desc', - }, - }, - ], - docvalue_fields: [entity, dateField, geoField], - _source: false, - }, - }, - }, - }, - }, - }, - }, - query: esFormattedQuery - ? esFormattedQuery - : { - bool: { - must: [], - filter: [ - { - match_all: {}, - }, - { - range: { - [dateField]: { - ...(gteDateTime ? { gte: gteDateTime } : {}), - lt: ltDateTime, // 'less than' to prevent overlap between intervals - format: 'strict_date_optional_time', - }, - }, - }, - ], - should: [], - must_not: [], - }, - }, - stored_fields: ['*'], - docvalue_fields: [ - { - field: dateField, - format: 'date_time', - }, - ], - }, - }; - - let esResult: SearchResponse<unknown> | undefined; - try { - esResult = await callCluster('search', esQuery); - } catch (err) { - log.warn(`${err.message}`); - } - return esResult; - }; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts deleted file mode 100644 index a2375537ae6e5..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/geo_threshold.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import { SearchResponse } from 'elasticsearch'; -import { Logger } from 'src/core/server'; -import { executeEsQueryFactory, getShapesFilters, OTHER_CATEGORY } from './es_query_builder'; -import { - ActionGroupId, - GEO_THRESHOLD_ID, - GeoThresholdAlertType, - GeoThresholdInstanceState, -} from './alert_type'; - -export type LatestEntityLocation = GeoThresholdInstanceState; - -// Flatten agg results and get latest locations for each entity -export function transformResults( - results: SearchResponse<unknown> | undefined, - dateField: string, - geoField: string -): LatestEntityLocation[] { - if (!results) { - return []; - } - - return ( - _.chain(results) - .get('aggregations.shapes.buckets', {}) - // @ts-expect-error - .flatMap((bucket: unknown, bucketKey: string) => { - const subBuckets = _.get(bucket, 'entitySplit.buckets', []); - return _.map(subBuckets, (subBucket) => { - const locationFieldResult = _.get( - subBucket, - `entityHits.hits.hits[0].fields["${geoField}"][0]`, - '' - ); - const location = locationFieldResult - ? _.chain(locationFieldResult) - .split(', ') - .map((coordString) => +coordString) - .reverse() - .value() - : null; - const dateInShape = _.get( - subBucket, - `entityHits.hits.hits[0].fields["${dateField}"][0]`, - null - ); - const docId = _.get(subBucket, `entityHits.hits.hits[0]._id`); - - return { - location, - shapeLocationId: bucketKey, - entityName: subBucket.key, - dateInShape, - docId, - }; - }); - }) - .orderBy(['entityName', 'dateInShape'], ['asc', 'desc']) - .reduce((accu: LatestEntityLocation[], el: LatestEntityLocation) => { - if (!accu.length || el.entityName !== accu[accu.length - 1].entityName) { - accu.push(el); - } - return accu; - }, []) - .value() - ); -} - -interface EntityMovementDescriptor { - entityName: string; - currLocation: { - location: number[]; - shapeId: string; - date: string | null; - docId: string; - }; - prevLocation: { - location: number[]; - shapeId: string; - date: string | null; - docId: string; - }; -} - -export function getMovedEntities( - currLocationArr: LatestEntityLocation[], - prevLocationArr: LatestEntityLocation[], - trackingEvent: string -): EntityMovementDescriptor[] { - return ( - currLocationArr - // Check if shape has a previous location and has moved - .reduce( - ( - accu: EntityMovementDescriptor[], - { - entityName, - shapeLocationId, - dateInShape, - location, - docId, - }: { - entityName: string; - shapeLocationId: string; - dateInShape: string | null; - location: number[]; - docId: string; - } - ) => { - const prevLocationObj = prevLocationArr.find( - (locationObj: LatestEntityLocation) => locationObj.entityName === entityName - ); - if (!prevLocationObj) { - return accu; - } - if (shapeLocationId !== prevLocationObj.shapeLocationId) { - accu.push({ - entityName, - currLocation: { - location, - shapeId: shapeLocationId, - date: dateInShape, - docId, - }, - prevLocation: { - location: prevLocationObj.location, - shapeId: prevLocationObj.shapeLocationId, - date: prevLocationObj.dateInShape, - docId: prevLocationObj.docId, - }, - }); - } - return accu; - }, - [] - ) - // Do not track entries to or exits from 'other' - .filter((entityMovementDescriptor: EntityMovementDescriptor) => { - if (trackingEvent !== 'crossed') { - return trackingEvent === 'entered' - ? entityMovementDescriptor.currLocation.shapeId !== OTHER_CATEGORY - : entityMovementDescriptor.prevLocation.shapeId !== OTHER_CATEGORY; - } - return true; - }) - ); -} - -function getOffsetTime(delayOffsetWithUnits: string, oldTime: Date): Date { - const timeUnit = delayOffsetWithUnits.slice(-1); - const time: number = +delayOffsetWithUnits.slice(0, -1); - - const adjustedDate = new Date(oldTime.getTime()); - if (timeUnit === 's') { - adjustedDate.setSeconds(adjustedDate.getSeconds() - time); - } else if (timeUnit === 'm') { - adjustedDate.setMinutes(adjustedDate.getMinutes() - time); - } else if (timeUnit === 'h') { - adjustedDate.setHours(adjustedDate.getHours() - time); - } else if (timeUnit === 'd') { - adjustedDate.setDate(adjustedDate.getDate() - time); - } - return adjustedDate; -} - -export const getGeoThresholdExecutor = (log: Logger): GeoThresholdAlertType['executor'] => - async function ({ previousStartedAt, startedAt, services, params, alertId, state }) { - const { shapesFilters, shapesIdsNamesMap } = state.shapesFilters - ? state - : await getShapesFilters( - params.boundaryIndexTitle, - params.boundaryGeoField, - params.geoField, - services.callCluster, - log, - alertId, - params.boundaryNameField, - params.boundaryIndexQuery - ); - - const executeEsQuery = await executeEsQueryFactory(params, services, log, shapesFilters); - - let currIntervalStartTime = previousStartedAt; - let currIntervalEndTime = startedAt; - if (params.delayOffsetWithUnits) { - if (currIntervalStartTime) { - currIntervalStartTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalStartTime); - } - currIntervalEndTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalEndTime); - } - - // Start collecting data only on the first cycle - if (!currIntervalStartTime) { - log.debug(`alert ${GEO_THRESHOLD_ID}:${alertId} alert initialized. Collecting data`); - // Consider making first time window configurable? - const tempPreviousEndTime = new Date(currIntervalEndTime); - tempPreviousEndTime.setMinutes(tempPreviousEndTime.getMinutes() - 5); - const prevToCurrentIntervalResults: - | SearchResponse<unknown> - | undefined = await executeEsQuery(tempPreviousEndTime, currIntervalEndTime); - return { - prevLocationArr: transformResults( - prevToCurrentIntervalResults, - params.dateField, - params.geoField - ), - shapesFilters, - shapesIdsNamesMap, - }; - } - - const currentIntervalResults: SearchResponse<unknown> | undefined = await executeEsQuery( - currIntervalStartTime, - currIntervalEndTime - ); - // No need to compare if no changes in current interval - if (!_.get(currentIntervalResults, 'hits.total.value')) { - return state; - } - - const currLocationArr: LatestEntityLocation[] = transformResults( - currentIntervalResults, - params.dateField, - params.geoField - ); - - const movedEntities: EntityMovementDescriptor[] = getMovedEntities( - currLocationArr, - state.prevLocationArr, - params.trackingEvent - ); - - // Create alert instances - movedEntities.forEach(({ entityName, currLocation, prevLocation }) => { - const toBoundaryName = shapesIdsNamesMap[currLocation.shapeId] || currLocation.shapeId; - const fromBoundaryName = shapesIdsNamesMap[prevLocation.shapeId] || prevLocation.shapeId; - let alertInstance; - if (params.trackingEvent === 'entered') { - alertInstance = `${entityName}-${toBoundaryName || currLocation.shapeId}`; - } else if (params.trackingEvent === 'exited') { - alertInstance = `${entityName}-${fromBoundaryName || prevLocation.shapeId}`; - } else { - // == 'crossed' - alertInstance = `${entityName}-${fromBoundaryName || prevLocation.shapeId}-${ - toBoundaryName || currLocation.shapeId - }`; - } - services.alertInstanceFactory(alertInstance).scheduleActions(ActionGroupId, { - entityId: entityName, - timeOfDetection: new Date(currIntervalEndTime).getTime(), - crossingLine: `LINESTRING (${prevLocation.location[0]} ${prevLocation.location[1]}, ${currLocation.location[0]} ${currLocation.location[1]})`, - - toEntityLocation: `POINT (${currLocation.location[0]} ${currLocation.location[1]})`, - toEntityDateTime: currLocation.date, - toEntityDocumentId: currLocation.docId, - - toBoundaryId: currLocation.shapeId, - toBoundaryName, - - fromEntityLocation: `POINT (${prevLocation.location[0]} ${prevLocation.location[1]})`, - fromEntityDateTime: prevLocation.date, - fromEntityDocumentId: prevLocation.docId, - - fromBoundaryId: prevLocation.shapeId, - fromBoundaryName, - }); - }); - - // Combine previous results w/ current results for state of next run - const prevLocationArr = _.chain(currLocationArr) - .concat(state.prevLocationArr) - .orderBy(['entityName', 'dateInShape'], ['asc', 'desc']) - .reduce((accu: LatestEntityLocation[], el: LatestEntityLocation) => { - if (!accu.length || el.entityName !== accu[accu.length - 1].entityName) { - accu.push(el); - } - return accu; - }, []) - .value(); - - return { - prevLocationArr, - shapesFilters, - shapesIdsNamesMap, - }; - }; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap deleted file mode 100644 index 0cb04144fdb78..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/__snapshots__/alert_type.test.ts.snap +++ /dev/null @@ -1,60 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`alertType alert type creation structure is the expected value 1`] = ` -Object { - "context": Array [ - Object { - "description": "The entity ID of the document that triggered the alert", - "name": "entityId", - }, - Object { - "description": "The alert interval end time this change was recorded", - "name": "timeOfDetection", - }, - Object { - "description": "GeoJSON line connecting the two locations that were used to determine the crossing event", - "name": "crossingLine", - }, - Object { - "description": "The most recently captured location of the entity", - "name": "toEntityLocation", - }, - Object { - "description": "The time the entity was detected in the current boundary", - "name": "toEntityDateTime", - }, - Object { - "description": "The id of the crossing entity document", - "name": "toEntityDocumentId", - }, - Object { - "description": "The current boundary id containing the entity (if any)", - "name": "toBoundaryId", - }, - Object { - "description": "The boundary (if any) the entity has crossed into and is currently located", - "name": "toBoundaryName", - }, - Object { - "description": "The previously captured location of the entity", - "name": "fromEntityLocation", - }, - Object { - "description": "The last time the entity was recorded in the previous boundary", - "name": "fromEntityDateTime", - }, - Object { - "description": "The id of the crossing entity document", - "name": "fromEntityDocumentId", - }, - Object { - "description": "The previous boundary id containing the entity (if any)", - "name": "fromBoundaryId", - }, - Object { - "description": "The boundary (if any) the entity has crossed from and was previously located", - "name": "fromBoundaryName", - }, - ], -} -`; diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts deleted file mode 100644 index 0cfce2d47f189..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/alert_type.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { loggingSystemMock } from '../../../../../../../src/core/server/mocks'; -import { getAlertType, GeoThresholdParams } from '../alert_type'; - -describe('alertType', () => { - const logger = loggingSystemMock.create().get(); - - const alertType = getAlertType(logger); - - it('alert type creation structure is the expected value', async () => { - expect(alertType.id).toBe('.geo-threshold'); - expect(alertType.name).toBe('Tracking threshold'); - expect(alertType.actionGroups).toEqual([ - { id: 'tracking threshold met', name: 'Tracking threshold met' }, - ]); - - expect(alertType.actionVariables).toMatchSnapshot(); - }); - - it('validator succeeds with valid params', async () => { - const params: GeoThresholdParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: 'testEvent', - boundaryType: 'testType', - boundaryIndexTitle: 'testIndex', - boundaryIndexId: 'testIndex', - boundaryGeoField: 'testField', - boundaryNameField: 'testField', - delayOffsetWithUnits: 'testOffset', - }; - - expect(alertType.validate?.params?.validate(params)).toBeTruthy(); - }); - - it('validator fails with invalid params', async () => { - const paramsSchema = alertType.validate?.params; - if (!paramsSchema) throw new Error('params validator not set'); - - const params: GeoThresholdParams = { - index: 'testIndex', - indexId: 'testIndexId', - geoField: 'testField', - entity: 'testField', - dateField: 'testField', - trackingEvent: '', - boundaryType: 'testType', - boundaryIndexTitle: '', - boundaryIndexId: 'testIndex', - boundaryGeoField: 'testField', - boundaryNameField: 'testField', - }; - - expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( - `"[trackingEvent]: value has length [0] but it must have a minimum length of [1]."` - ); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts deleted file mode 100644 index d577a88e8e2f8..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_query_builder.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getEsFormattedQuery } from '../es_query_builder'; - -describe('esFormattedQuery', () => { - it('lucene queries are converted correctly', async () => { - const testLuceneQuery1 = { - query: `"airport": "Denver"`, - language: 'lucene', - }; - const esFormattedQuery1 = getEsFormattedQuery(testLuceneQuery1); - expect(esFormattedQuery1).toStrictEqual({ query_string: { query: '"airport": "Denver"' } }); - const testLuceneQuery2 = { - query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`, - language: 'lucene', - }; - const esFormattedQuery2 = getEsFormattedQuery(testLuceneQuery2); - expect(esFormattedQuery2).toStrictEqual({ - query_string: { - query: `title:"Fun with turnips" AND text:Cabbage, cabbage and more cabbage!`, - }, - }); - }); - - it('kuery queries are converted correctly', async () => { - const testKueryQuery1 = { - query: `"airport": "Denver"`, - language: 'kuery', - }; - const esFormattedQuery1 = getEsFormattedQuery(testKueryQuery1); - expect(esFormattedQuery1).toStrictEqual({ - bool: { minimum_should_match: 1, should: [{ match_phrase: { airport: 'Denver' } }] }, - }); - const testKueryQuery2 = { - query: `"airport": "Denver" and ("animal": "goat" or "animal": "narwhal")`, - language: 'kuery', - }; - const esFormattedQuery2 = getEsFormattedQuery(testKueryQuery2); - expect(esFormattedQuery2).toStrictEqual({ - bool: { - filter: [ - { bool: { should: [{ match_phrase: { airport: 'Denver' } }], minimum_should_match: 1 } }, - { - bool: { - should: [ - { - bool: { should: [{ match_phrase: { animal: 'goat' } }], minimum_should_match: 1 }, - }, - { - bool: { - should: [{ match_phrase: { animal: 'narwhal' } }], - minimum_should_match: 1, - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json deleted file mode 100644 index 70edbd09aa5a1..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "took" : 2760, - "timed_out" : false, - "_shards" : { - "total" : 1, - "successful" : 1, - "skipped" : 0, - "failed" : 0 - }, - "hits" : { - "total" : { - "value" : 10000, - "relation" : "gte" - }, - "max_score" : 0.0, - "hits" : [] - }, - "aggregations" : { - "shapes" : { - "meta" : { }, - "buckets" : { - "0DrJu3QB6yyY-xQxv6Ip" : { - "doc_count" : 1047, - "entitySplit" : { - "doc_count_error_upper_bound" : 0, - "sum_other_doc_count" : 957, - "buckets" : [ - { - "key" : "936", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "N-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.190Z" - ], - "location" : [ - "40.62806099653244, -82.8814151789993" - ], - "entity_id" : [ - "936" - ] - }, - "sort" : [ - 1601316101190 - ] - } - ] - } - } - }, - { - "key" : "AAL2019", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "iOng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "location" : [ - "39.006176185794175, -82.22068064846098" - ], - "entity_id" : [ - "AAL2019" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "AAL2323", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "n-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "location" : [ - "41.6677269525826, -84.71324851736426" - ], - "entity_id" : [ - "AAL2323" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "ABD5250", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "GOng1XQB6yyY-xQxnGWM", - "_score" : null, - "fields" : { - "@timestamp" : [ - "2020-09-28T18:01:41.192Z" - ], - "location" : [ - "39.07997465226799, 6.073727197945118" - ], - "entity_id" : [ - "ABD5250" - ] - }, - "sort" : [ - 1601316101192 - ] - } - ] - } - } - } - ] - } - } - } - } - } -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json deleted file mode 100644 index a4b7b6872b341..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/es_sample_response_with_nesting.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "took" : 2760, - "timed_out" : false, - "_shards" : { - "total" : 1, - "successful" : 1, - "skipped" : 0, - "failed" : 0 - }, - "hits" : { - "total" : { - "value" : 10000, - "relation" : "gte" - }, - "max_score" : 0.0, - "hits" : [] - }, - "aggregations" : { - "shapes" : { - "meta" : { }, - "buckets" : { - "0DrJu3QB6yyY-xQxv6Ip" : { - "doc_count" : 1047, - "entitySplit" : { - "doc_count_error_upper_bound" : 0, - "sum_other_doc_count" : 957, - "buckets" : [ - { - "key" : "936", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "N-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.190Z" - ], - "geo.coords.location" : [ - "40.62806099653244, -82.8814151789993" - ], - "entity_id" : [ - "936" - ] - }, - "sort" : [ - 1601316101190 - ] - } - ] - } - } - }, - { - "key" : "AAL2019", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "iOng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "geo.coords.location" : [ - "39.006176185794175, -82.22068064846098" - ], - "entity_id" : [ - "AAL2019" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "AAL2323", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "n-ng1XQB6yyY-xQxnGSM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.191Z" - ], - "geo.coords.location" : [ - "41.6677269525826, -84.71324851736426" - ], - "entity_id" : [ - "AAL2323" - ] - }, - "sort" : [ - 1601316101191 - ] - } - ] - } - } - }, - { - "key" : "ABD5250", - "doc_count" : 9, - "entityHits" : { - "hits" : { - "total" : { - "value" : 9, - "relation" : "eq" - }, - "max_score" : null, - "hits" : [ - { - "_index" : "flight_tracks", - "_id" : "GOng1XQB6yyY-xQxnGWM", - "_score" : null, - "fields" : { - "time_data.@timestamp" : [ - "2020-09-28T18:01:41.192Z" - ], - "geo.coords.location" : [ - "39.07997465226799, 6.073727197945118" - ], - "entity_id" : [ - "ABD5250" - ] - }, - "sort" : [ - 1601316101192 - ] - } - ] - } - } - } - ] - } - } - } - } - } -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts deleted file mode 100644 index 5b5197ac62a39..0000000000000 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_threshold/tests/geo_threshold.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import sampleJsonResponse from './es_sample_response.json'; -import sampleJsonResponseWithNesting from './es_sample_response_with_nesting.json'; -import { getMovedEntities, transformResults } from '../geo_threshold'; -import { OTHER_CATEGORY } from '../es_query_builder'; -import { SearchResponse } from 'elasticsearch'; - -describe('geo_threshold', () => { - describe('transformResults', () => { - const dateField = '@timestamp'; - const geoField = 'location'; - it('should correctly transform expected results', async () => { - const transformedResults = transformResults( - (sampleJsonResponse as unknown) as SearchResponse<unknown>, - dateField, - geoField - ); - expect(transformedResults).toEqual([ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - entityName: 'AAL2323', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - entityName: 'ABD5250', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - ]); - }); - - const nestedDateField = 'time_data.@timestamp'; - const nestedGeoField = 'geo.coords.location'; - it('should correctly transform expected results if fields are nested', async () => { - const transformedResults = transformResults( - (sampleJsonResponseWithNesting as unknown) as SearchResponse<unknown>, - nestedDateField, - nestedGeoField - ); - expect(transformedResults).toEqual([ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - entityName: 'AAL2323', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - entityName: 'ABD5250', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, - ]); - }); - - it('should return an empty array if no results', async () => { - const transformedResults = transformResults(undefined, dateField, geoField); - expect(transformedResults).toEqual([]); - }); - }); - - describe('getMovedEntities', () => { - it('should return empty array if only movements were within same shapes', async () => { - const currLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'sameShape1', - }, - { - dateInShape: '2020-08-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 38.006176185794175], - shapeLocationId: 'sameShape2', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'sameShape1', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'sameShape2', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities).toEqual([]); - }); - - it('should return result if entity has moved to different shape', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'currLocationDoc1', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'currLocationDoc2', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'thisOneDidntMove', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-27T18:01:41.190Z', - docId: 'prevLocationDoc1', - entityName: '936', - location: [-82.8814151789993, 20.62806099653244], - shapeLocationId: 'oldShapeLocation', - }, - { - dateInShape: '2020-09-27T18:01:41.191Z', - docId: 'prevLocationDoc2', - entityName: 'AAL2019', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: 'thisOneDidntMove', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities.length).toEqual(1); - }); - - it('should ignore "entered" results to "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: 'oldShapeLocation', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'entered'); - expect(movedEntities).toEqual([]); - }); - - it('should ignore "exited" results from "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'exited'); - expect(movedEntities).toEqual([]); - }); - - it('should not ignore "crossed" results from "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'crossed'); - expect(movedEntities.length).toEqual(1); - }); - - it('should not ignore "crossed" results to "other"', async () => { - const currLocationArr = [ - { - dateInShape: '2020-08-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: OTHER_CATEGORY, - }, - ]; - const prevLocationArr = [ - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - entityName: '936', - location: [-82.8814151789993, 41.62806099653244], - shapeLocationId: 'newShapeLocation', - }, - ]; - const movedEntities = getMovedEntities(currLocationArr, prevLocationArr, 'crossed'); - expect(movedEntities.length).toEqual(1); - }); - }); -}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/index.ts index 21a7ffc481323..5c35af5e344b9 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index.ts @@ -7,9 +7,8 @@ import { Logger } from 'src/core/server'; import { AlertingSetup, StackAlertsStartDeps } from '../types'; import { register as registerIndexThreshold } from './index_threshold'; -import { register as registerGeoThreshold } from './geo_threshold'; import { register as registerGeoContainment } from './geo_containment'; - +import { register as registerEsQuery } from './es_query'; interface RegisterAlertTypesParams { logger: Logger; data: Promise<StackAlertsStartDeps['triggersActionsUi']['data']>; @@ -18,6 +17,6 @@ interface RegisterAlertTypesParams { export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) { registerIndexThreshold(params); - registerGeoThreshold(params); registerGeoContainment(params); + registerEsQuery(params); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md index 9b0eb23950cc3..de5b57dfbffc6 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md @@ -6,7 +6,7 @@ The index threshold alert type is designed to run an ES query over indices, aggregating field values from documents, comparing them to threshold values, and scheduling actions to run when the thresholds are met. -And example would be checking a monitoring index for percent cpu usage field +An example would be checking a monitoring index for percent cpu usage field values that are greater than some threshold, which could then be used to invoke an action (email, slack, etc) to notify interested parties when the threshold is exceeded. diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 2366a872b855b..10dfabffddfcf 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -14,30 +14,10 @@ import { CoreQueryParamsSchemaProperties, TimeSeriesQuery, } from '../../../../triggers_actions_ui/server'; +import { ComparatorFns, getHumanReadableComparator } from '../lib'; export const ID = '.index-threshold'; - -enum Comparator { - GT = '>', - LT = '<', - GT_OR_EQ = '>=', - LT_OR_EQ = '<=', - BETWEEN = 'between', - NOT_BETWEEN = 'notBetween', -} - -const humanReadableComparators = new Map<string, string>([ - [Comparator.LT, 'less than'], - [Comparator.LT_OR_EQ, 'less than or equal to'], - [Comparator.GT_OR_EQ, 'greater than or equal to'], - [Comparator.GT, 'greater than'], - [Comparator.BETWEEN, 'between'], - [Comparator.NOT_BETWEEN, 'not between'], -]); - const ActionGroupId = 'threshold met'; -const ComparatorFns = getComparatorFns(); -export const ComparatorFnNames = new Set(ComparatorFns.keys()); export function getAlertType( logger: Logger, @@ -155,7 +135,14 @@ export function getAlertType( const compareFn = ComparatorFns.get(params.thresholdComparator); if (compareFn == null) { - throw new Error(getInvalidComparatorMessage(params.thresholdComparator)); + throw new Error( + i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', { + defaultMessage: 'invalid thresholdComparator specified: {comparator}', + values: { + comparator: params.thresholdComparator, + }, + }) + ); } const callCluster = services.callCluster; @@ -210,40 +197,3 @@ export function getAlertType( } } } - -export function getInvalidComparatorMessage(comparator: string) { - return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', { - defaultMessage: 'invalid thresholdComparator specified: {comparator}', - values: { - comparator, - }, - }); -} - -type ComparatorFn = (value: number, threshold: number[]) => boolean; - -function getComparatorFns(): Map<string, ComparatorFn> { - const fns: Record<string, ComparatorFn> = { - [Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0], - [Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0], - [Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0], - [Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0], - [Comparator.BETWEEN]: (value: number, threshold: number[]) => - value >= threshold[0] && value <= threshold[1], - [Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) => - value < threshold[0] || value > threshold[1], - }; - - const result = new Map<string, ComparatorFn>(); - for (const key of Object.keys(fns)) { - result.set(key, fns[key]); - } - - return result; -} - -function getHumanReadableComparator(comparator: string) { - return humanReadableComparators.has(comparator) - ? humanReadableComparators.get(comparator) - : comparator; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts index b51545770dd7b..2c83d5edc255a 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; -import { ComparatorFnNames, getInvalidComparatorMessage } from './alert_type'; +import { ComparatorFnNames } from '../lib'; import { CoreQueryParamsSchemaProperties, validateCoreQueryBody, @@ -54,5 +54,10 @@ function validateParams(anyParams: unknown): string | undefined { export function validateComparator(comparator: string): string | undefined { if (ComparatorFnNames.has(comparator)) return; - return getInvalidComparatorMessage(comparator); + return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', { + defaultMessage: 'invalid thresholdComparator specified: {comparator}', + values: { + comparator, + }, + }); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts b/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts new file mode 100644 index 0000000000000..cfa824d159686 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +enum Comparator { + GT = '>', + LT = '<', + GT_OR_EQ = '>=', + LT_OR_EQ = '<=', + BETWEEN = 'between', + NOT_BETWEEN = 'notBetween', +} + +const humanReadableComparators = new Map<string, string>([ + [Comparator.LT, 'less than'], + [Comparator.LT_OR_EQ, 'less than or equal to'], + [Comparator.GT_OR_EQ, 'greater than or equal to'], + [Comparator.GT, 'greater than'], + [Comparator.BETWEEN, 'between'], + [Comparator.NOT_BETWEEN, 'not between'], +]); + +export const ComparatorFns = getComparatorFns(); +export const ComparatorFnNames = new Set(ComparatorFns.keys()); + +type ComparatorFn = (value: number, threshold: number[]) => boolean; + +function getComparatorFns(): Map<string, ComparatorFn> { + const fns: Record<string, ComparatorFn> = { + [Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0], + [Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0], + [Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0], + [Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0], + [Comparator.BETWEEN]: (value: number, threshold: number[]) => + value >= threshold[0] && value <= threshold[1], + [Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) => + value < threshold[0] || value > threshold[1], + }; + + const result = new Map<string, ComparatorFn>(); + for (const key of Object.keys(fns)) { + result.set(key, fns[key]); + } + + return result; +} + +export function getHumanReadableComparator(comparator: string) { + return humanReadableComparators.has(comparator) + ? humanReadableComparators.get(comparator) + : comparator; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts new file mode 100644 index 0000000000000..7e40a7247a4c9 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ComparatorFns, ComparatorFnNames, getHumanReadableComparator } from './comparator_types'; diff --git a/x-pack/plugins/stack_alerts/server/feature.ts b/x-pack/plugins/stack_alerts/server/feature.ts index 448e1e698858b..e334b4642a00a 100644 --- a/x-pack/plugins/stack_alerts/server/feature.ts +++ b/x-pack/plugins/stack_alerts/server/feature.ts @@ -6,7 +6,6 @@ import { i18n } from '@kbn/i18n'; import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; -import { GEO_THRESHOLD_ID as GeoThreshold } from './alert_types/geo_threshold/alert_type'; import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containment/alert_type'; import { STACK_ALERTS_FEATURE_ID } from '../common'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server'; @@ -21,7 +20,7 @@ export const BUILT_IN_ALERTS_FEATURE = { management: { insightsAndAlerting: ['triggersActions'], }, - alerting: [IndexThreshold, GeoThreshold, GeoContainment], + alerting: [IndexThreshold, GeoContainment], privileges: { all: { app: [], @@ -30,7 +29,7 @@ export const BUILT_IN_ALERTS_FEATURE = { insightsAndAlerting: ['triggersActions'], }, alerting: { - all: [IndexThreshold, GeoThreshold, GeoContainment], + all: [IndexThreshold, GeoContainment], read: [], }, savedObject: { @@ -48,7 +47,7 @@ export const BUILT_IN_ALERTS_FEATURE = { }, alerting: { all: [], - read: [IndexThreshold, GeoThreshold, GeoContainment], + read: [IndexThreshold, GeoContainment], }, savedObject: { all: [], diff --git a/x-pack/plugins/stack_alerts/server/plugin.test.ts b/x-pack/plugins/stack_alerts/server/plugin.test.ts index 7226c2175a769..0273f373734fa 100644 --- a/x-pack/plugins/stack_alerts/server/plugin.test.ts +++ b/x-pack/plugins/stack_alerts/server/plugin.test.ts @@ -58,12 +58,31 @@ describe('AlertingBuiltins Plugin', () => { Object { "actionGroups": Array [ Object { - "id": "tracking threshold met", - "name": "Tracking threshold met", + "id": "Tracked entity contained", + "name": "Tracking containment met", }, ], - "id": ".geo-threshold", - "name": "Tracking threshold", + "id": ".geo-containment", + "name": "Tracking containment", + } + `); + + const esQueryArgs = alertingSetup.registerType.mock.calls[2][0]; + const testedEsQueryArgs = { + id: esQueryArgs.id, + name: esQueryArgs.name, + actionGroups: esQueryArgs.actionGroups, + }; + expect(testedEsQueryArgs).toMatchInlineSnapshot(` + Object { + "actionGroups": Array [ + Object { + "id": "query matched", + "name": "Query matched", + }, + ], + "id": ".es-query", + "name": "ES query", } `); diff --git a/x-pack/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts index 81d72c68b3a9e..a2a0ee11380ff 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -269,12 +269,13 @@ describe('TaskStore', () => { opts = {}, hits = generateFakeTasks(1), claimingOpts, + versionConflicts = 2, }: { opts: Partial<StoreOpts>; hits?: unknown[]; claimingOpts: OwnershipClaimingOpts; + versionConflicts?: number; }) { - const versionConflicts = 2; const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; esClient.search.mockResolvedValue(asApiResponse({ hits: { hits } })); esClient.updateByQuery.mockResolvedValue( @@ -971,6 +972,77 @@ if (doc['task.runAt'].size()!=0) { ]); }); + test('it returns version_conflicts that do not include conflicts that were proceeded against', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + { + _id: 'task:aaa', + _source: { + type: 'task', + task: { + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: 'claiming', + params: '{ "hello": "world" }', + state: '{ "baby": "Henhen" }', + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }, + }, + _seq_no: 1, + _primary_term: 2, + sort: ['a', 1], + }, + { + _id: 'task:bbb', + _source: { + type: 'task', + task: { + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'claiming', + params: '{ "shazm": 1 }', + state: '{ "henry": "The 8th" }', + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }, + }, + _seq_no: 3, + _primary_term: 4, + sort: ['b', 2], + }, + ]; + const maxDocs = 10; + const { + result: { stats: { tasksUpdated, tasksConflicted, tasksClaimed } = {} } = {}, + } = await testClaimAvailableTasks({ + opts: { + taskManagerId, + }, + claimingOpts: { + claimOwnershipUntil, + size: maxDocs, + }, + hits: tasks, + // assume there were 20 version conflists, but thanks to `conflicts="proceed"` + // we proceeded to claim tasks + versionConflicts: 20, + }); + + expect(tasksUpdated).toEqual(2); + // ensure we only count conflicts that *may* have counted against max_docs, no more than that + expect(tasksConflicted).toEqual(10 - tasksUpdated!); + expect(tasksClaimed).toEqual(2); + }); + test('pushes error from saved objects client to errors$', async () => { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; const store = new TaskStore({ diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index 5d17c6246088a..4b02e35c61582 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -529,7 +529,7 @@ export class TaskStore { private async updateByQuery( opts: UpdateByQuerySearchOpts = {}, // eslint-disable-next-line @typescript-eslint/naming-convention - { max_docs }: UpdateByQueryOpts = {} + { max_docs: max_docs }: UpdateByQueryOpts = {} ): Promise<UpdateByQueryResult> { const { query } = ensureQueryOnlyReturnsTaskObjects(opts); try { @@ -548,10 +548,22 @@ export class TaskStore { }, }); + /** + * When we run updateByQuery with conflicts='proceed', it's possible for the `version_conflicts` + * to count against the specified `max_docs`, as per https://github.com/elastic/elasticsearch/issues/63671 + * In order to correct for that happening, we only count `version_conflicts` if we haven't updated as + * many docs as we could have. + * This is still no more than an estimation, as there might have been less docuemnt to update that the + * `max_docs`, but we bias in favour of over zealous `version_conflicts` as that's the best indicator we + * have for an unhealthy cluster distribution of Task Manager polling intervals + */ + const conflictsCorrectedForContinuation = + max_docs && version_conflicts + updated > max_docs ? max_docs - updated : version_conflicts; + return { total, updated, - version_conflicts, + version_conflicts: conflictsCorrectedForContinuation, }; } catch (e) { this.errors$.next(e); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e2506cb6ca8f0..4369cbf35594d 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7369,7 +7369,6 @@ "xpack.enterpriseSearch.troubleshooting.standardAuth.title": "標準認証の{productName}はサポートされていません", "xpack.enterpriseSearch.workplaceSearch.activityFeedEmptyDefault.title": "組織には最近のアクティビティがありません", "xpack.enterpriseSearch.workplaceSearch.activityFeedNamedDefault.title": "{name}には最近のアクティビティがありません", - "xpack.enterpriseSearch.workplaceSearch.groups.addGroup.cancel.action": "キャンセル", "xpack.enterpriseSearch.workplaceSearch.groups.addGroup.heading": "グループを追加", "xpack.enterpriseSearch.workplaceSearch.groups.addGroup.submit.action": "グループを追加", "xpack.enterpriseSearch.workplaceSearch.groups.addGroupForm.action": "グループを作成", @@ -7381,7 +7380,6 @@ "xpack.enterpriseSearch.workplaceSearch.groups.filterUsers.buttonText": "ユーザー", "xpack.enterpriseSearch.workplaceSearch.groups.filterUsers.placeholder": "ユーザーをフィルター...", "xpack.enterpriseSearch.workplaceSearch.groups.groupDeleted": "グループ「{groupName}」が正常に削除されました。", - "xpack.enterpriseSearch.workplaceSearch.groups.groupManagerCancel": "キャンセル", "xpack.enterpriseSearch.workplaceSearch.groups.groupManagerHeaderTitle": "{label}を管理", "xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSelectAllToggle": "{action}すべて", "xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSourceEmpty.body": "まだ共有コンテンツソースが追加されていない可能性があります。", @@ -7406,7 +7404,6 @@ "xpack.enterpriseSearch.workplaceSearch.groups.noSourcesMessage": "共有コンテンツソースがありません", "xpack.enterpriseSearch.workplaceSearch.groups.noUsersFound": "ユーザーが見つかりません", "xpack.enterpriseSearch.workplaceSearch.groups.noUsersMessage": "ユーザーがありません", - "xpack.enterpriseSearch.workplaceSearch.groups.overview.cancelRemoveButtonText": "キャンセル", "xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmRemoveButtonText": "{name}を削除", "xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmRemoveDescription": "グループはWorkplace Searchから削除されます。{name}を削除してよろしいですか?", "xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmTitleText": "確認", @@ -11169,7 +11166,6 @@ "xpack.lens.configure.invalidConfigTooltipClick": "詳細はクリックしてください。", "xpack.lens.customBucketContainer.dragToReorder": "ドラッグして並べ替え", "xpack.lens.dataPanelWrapper.switchDatasource": "データソースに切り替える", - "xpack.lens.datatable.actionsColumnName": "アクション", "xpack.lens.datatable.breakdown": "内訳の基準", "xpack.lens.datatable.conjunctionSign": " & ", "xpack.lens.datatable.expressionHelpLabel": "データベースレンダー", @@ -11179,7 +11175,6 @@ "xpack.lens.datatable.titleLabel": "タイトル", "xpack.lens.datatable.visualizationName": "データベース", "xpack.lens.datatable.visualizationOf": "テーブル {operations}", - "xpack.lens.datatableSortedInReadOnlyMode": "{sortValue} 順で並べ替え", "xpack.lens.datatypes.boolean": "ブール", "xpack.lens.datatypes.date": "日付", "xpack.lens.datatypes.ipAddress": "IP", @@ -11193,7 +11188,6 @@ "xpack.lens.discover.visualizeFieldLegend": "Visualize フィールド", "xpack.lens.dragDrop.elementLifted": "位置 {position} のアイテム {itemLabel} を持ち上げました。", "xpack.lens.dragDrop.elementMoved": "位置 {prevPosition} から位置 {position} までアイテム {itemLabel} を移動しました", - "xpack.lens.dragDrop.reorderInstructions": "スペースバーを押すと、ドラッグを開始します。ドラッグするときには、矢印キーで並べ替えることができます。もう一度スペースバーを押すと終了します。", "xpack.lens.editLayerSettings": "レイヤー設定を編集", "xpack.lens.editLayerSettingsChartType": "レイヤー設定を編集、{chartType}", "xpack.lens.editorFrame.buildExpressionError": "グラフの準備中に予期しないエラーが発生しました", @@ -11215,8 +11209,6 @@ "xpack.lens.editorFrame.suggestionPanelTitle": "提案", "xpack.lens.embeddable.failure": "ビジュアライゼーションを表示できませんでした", "xpack.lens.embeddableDisplayName": "レンズ", - "xpack.lens.excludeValueButtonAriaLabel": "{value}を除外", - "xpack.lens.excludeValueButtonTooltip": "値を除外", "xpack.lens.fieldFormats.longSuffix.d": "日単位", "xpack.lens.fieldFormats.longSuffix.h": "時間単位", "xpack.lens.fieldFormats.longSuffix.m": "分単位", @@ -11247,8 +11239,6 @@ "xpack.lens.functions.renameColumns.idMap.help": "キーが古い列 ID で値が対応する新しい列 ID となるように JSON エンコーディングされたオブジェクトです。他の列 ID はすべてのそのままです。", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定した dateColumnId {columnId} は存在しません。", "xpack.lens.functions.timeScale.timeInfoMissingMessage": "日付ヒストグラム情報を取得できませんでした", - "xpack.lens.includeValueButtonAriaLabel": "{value}を含める", - "xpack.lens.includeValueButtonTooltip": "値を含める", "xpack.lens.indexPattern.allFieldsLabel": "すべてのフィールド", "xpack.lens.indexPattern.allFieldsLabelHelp": "使用可能なフィールドには、フィルターと一致する最初の 500 件のドキュメントのデータがあります。すべてのフィールドを表示するには、空のフィールドを展開します。一部のフィールドタイプは、完全なテキストおよびグラフィックフィールドを含む Lens では、ビジュアライゼーションできません。", "xpack.lens.indexPattern.availableFieldsLabel": "利用可能なフィールド", @@ -11452,8 +11442,6 @@ "xpack.lens.sugegstion.refreshSuggestionLabel": "更新", "xpack.lens.suggestion.refreshSuggestionTooltip": "選択したビジュアライゼーションに基づいて、候補を更新します。", "xpack.lens.suggestions.currentVisLabel": "現在のビジュアライゼーション", - "xpack.lens.tableRowMore": "詳細", - "xpack.lens.tableRowMoreDescription": "テーブル行コンテキストメニュー", "xpack.lens.timeScale.removeLabel": "時間単位で正規化を削除", "xpack.lens.visTypeAlias.description": "ドラッグアンドドロップエディターでビジュアライゼーションを作成します。いつでもビジュアライゼーションタイプを切り替えることができます。", "xpack.lens.visTypeAlias.note": "ほとんどのユーザーに推奨されます。", @@ -12606,7 +12594,6 @@ "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "インデックスパターン{indexPatternName}はすでに作成されています。", "xpack.ml.dataframe.analytics.create.errorCheckingIndexExists": "既存のインデックス名の取得中に次のエラーが発生しました:{error}", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "データフレーム分析ジョブの作成中にエラーが発生しました。", - "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "既存のデータフレーム分析ジョブIDの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "既存のインデックスパターンのタイトルの取得中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "データフレーム分析ジョブの開始中にエラーが発生しました。", "xpack.ml.dataframe.analytics.create.etaInputAriaLabel": "縮小が重みに適用されました", @@ -13073,8 +13060,6 @@ "xpack.ml.featureRegistry.mlFeatureName": "機械学習", "xpack.ml.fieldDataCard.cardBoolean.valuesLabel": "値", "xpack.ml.fieldDataCard.cardDate.summaryTableTitle": "まとめ", - "xpack.ml.fieldDataCard.cardIp.topValuesLabel": "トップの値", - "xpack.ml.fieldDataCard.cardKeyword.topValuesLabel": "トップの値", "xpack.ml.fieldDataCard.cardText.fieldMayBePopulatedDescription": "たとえば、ドキュメントマッピングで {copyToParam} パラメーターを使ったり、{includesParam} と {excludesParam} パラメーターを使用してインデックスした後に {sourceParam} フィールドから切り取ったりして入力される場合があります。", "xpack.ml.fieldDataCard.cardText.fieldNotPresentDescription": "このフィールドはクエリが実行されたドキュメントの {sourceParam} フィールドにありませんでした。", "xpack.ml.fieldDataCard.cardText.noExamplesForFieldsTitle": "このフィールドの例が取得されませんでした", @@ -13092,7 +13077,6 @@ "xpack.ml.fieldDataCardExpandedRow.numberContent.medianLabel": "中間", "xpack.ml.fieldDataCardExpandedRow.numberContent.minLabel": "分", "xpack.ml.fieldDataCardExpandedRow.numberContent.summaryTableTitle": "まとめ", - "xpack.ml.fieldDataCardExpandedRow.numberContent.topValuesTitle": "トップの値", "xpack.ml.fieldTitleBar.documentCountLabel": "ドキュメントカウント", "xpack.ml.fieldTypeIcon.booleanTypeAriaLabel": "ブールタイプ", "xpack.ml.fieldTypeIcon.dateTypeAriaLabel": "日付タイプ", @@ -14225,8 +14209,8 @@ "xpack.ml.splom.dynamicSizeLabel": "動的サイズ", "xpack.ml.splom.fieldSelectionLabel": "フィールド", "xpack.ml.splom.fieldSelectionPlaceholder": "フィールドを選択", - "xpack.ml.splom.RandomScoringLabel": "ランダムスコアリング", - "xpack.ml.splom.SampleSizeLabel": "サンプルサイズ", + "xpack.ml.splom.randomScoringLabel": "ランダムスコアリング", + "xpack.ml.splom.sampleSizeLabel": "サンプルサイズ", "xpack.ml.splom.toggleOff": "オフ", "xpack.ml.splom.toggleOn": "オン", "xpack.ml.splomSpec.outlierScoreThresholdName": "異常スコアしきい値:", @@ -17352,7 +17336,6 @@ "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigDescription": "kibana.ymlファイルは、特定のコネクターのみを許可するように構成されています。外部システムでケースを開けるようにするには、xpack.actions.enabledActiontypes設定に.[actionTypeId](例:.servicenow | .jira)を追加します。詳細は{link}をご覧ください。", "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigTitle": "Kibanaの構成ファイルで外部サービスを有効にする", "xpack.securitySolution.case.caseView.pushToServiceDisableByInvalidConnector": "外部サービスに更新を送信するために使用されるコネクターが削除されました。外部システムでケースを更新するには、別のコネクターを選択するか、新しいコネクターを作成してください。", - "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription": "外部システムでケースを開くには、ライセンスをプラチナに更新するか、30日間の無料トライアルを開始するか、AWS、GCP、またはAzureで{link}にサインアップする必要があります。", "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle": "E lastic Platinumへのアップグレード", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "外部システムでケースを開いて更新するには、このケースの外部インシデント管理システムを選択する必要があります。", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "外部コネクターを選択", @@ -18732,8 +18715,6 @@ "xpack.securitySolution.endpoint.policyDetails.malware.userNotification.placeholder": "カスタム通知メッセージを入力", "xpack.securitySolution.endpoint.policyDetails.supportedVersion": "エージェントバージョン {version}", "xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification": "通知メッセージをカスタマイズ", - "xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification.tooltip.a": "ユーザー通知オプションを選択すると、マルウェアが防御または検出されたときに、ホストユーザーに通知を表示します。", - "xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification.tooltip.b": " ユーザー通知は、以下のテキストボックスでカスタマイズできます。括弧内のタグを使用すると、該当するアクション(防御または検出など)とファイル名を動的に入力できます。", "xpack.securitySolution.endpoint.policyDetailsConfig.eventingEvents": "イベント", "xpack.securitySolution.endpoint.policyDetailsConfig.linux.events.file": "ファイル", "xpack.securitySolution.endpoint.policyDetailsConfig.linux.events.network": "ネットワーク", @@ -20822,58 +20803,6 @@ "xpack.stackAlerts.geoContainment.timeFieldLabel": "時間フィールド", "xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", "xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "閉じる", - "xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle": "追跡しきい値が満たされました", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel": "クロスエンティティドキュメントのID", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel": "クロスイベントを決定するために使用された2つの場所を接続するGeoJSON行", - "xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel": "エンティティを含む現在の境界ID(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel": "アラートをトリガーしたドキュメントのエンティティ ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel": "エンティティを含む以前の境界ID(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel": "エンティティがそこからクロスし、以前に検出された境界(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel": "前回エンティティが前の境界で記録された日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel": "クロスエンティティドキュメントのID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel": "エンティティの以前に取り込まれた場所", - "xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel": "この変更が記録された、アラート間隔終了日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel": "エンティティがその中にクロスし、現在検出されている境界(該当する場合)", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel": "現在の境界でエンティティが検出された日時", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel": "エンティティの直近に取り込まれた場所", - "xpack.stackAlerts.geoThreshold.alertTypeTitle": "地理追跡しきい値", - "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "境界名を選択", - "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "人間が読み取れる境界名(任意)", - "xpack.stackAlerts.geoThreshold.delayOffset": "遅延評価オフセット", - "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "遅延サイクルでアラートを評価し、データレイテンシに合わせて調整します", - "xpack.stackAlerts.geoThreshold.descriptionText": "エンティティが地理的境界に出入りするときにアラートを発行します。", - "xpack.stackAlerts.geoThreshold.entityByLabel": "グループ基準", - "xpack.stackAlerts.geoThreshold.entityIndexLabel": "インデックス", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "境界地理フィールドは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "境界インデックスパターンタイトルは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "境界タイプは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "日付フィールドが必要です。", - "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "エンティティは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "地理フィールドは必須です。", - "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "インデックスパターンが必要です。", - "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "追跡イベントは必須です。", - "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。", - "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空間フィールド", - "xpack.stackAlerts.geoThreshold.indexLabel": "インデックス", - "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "インデックスパターン", - "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "インデックスパターンを選択", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "インデックスパターンを作成します", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "次のことが必要です ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " 地理空間フィールドを含む", - "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "サンプルデータセットで始めましょう。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "地理空間データセットがありませんか?", - "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "地理空間フィールドを含むインデックスパターンが見つかりませんでした", - "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "境界を選択:", - "xpack.stackAlerts.geoThreshold.selectEntity": "エンティティを選択", - "xpack.stackAlerts.geoThreshold.selectGeoLabel": "ジオフィールドを選択", - "xpack.stackAlerts.geoThreshold.selectIndex": "条件を定義してください", - "xpack.stackAlerts.geoThreshold.selectLabel": "ジオフィールドを選択", - "xpack.stackAlerts.geoThreshold.selectOffset": "オフセットを選択(任意)", - "xpack.stackAlerts.geoThreshold.selectTimeLabel": "時刻フィールドを選択", - "xpack.stackAlerts.geoThreshold.timeFieldLabel": "時間フィールド", - "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "エンティティフィールドを選択", - "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "閉じる", - "xpack.stackAlerts.geoThreshold.whenEntityLabel": "エンティティ", "xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "しきい値一致", "xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "しきい値比較基準としきい値を説明する文字列", "xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "アラートがしきい値を超えた日付。", @@ -20888,13 +20817,7 @@ "xpack.stackAlerts.indexThreshold.alertTypeTitle": "インデックスしきい値", "xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "無効な thresholdComparator が指定されました:{comparator}", "xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]: 「{thresholdComparator}」比較子の場合には2つの要素が必要です", - "xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "閉じる", "xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。", - "xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "* で検索クエリの範囲を広げます。", - "xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "インデックス", - "xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "インデックス", - "xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "クエリを実行するインデックス", - "xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "時間フィールド", "xpack.stackAlerts.threshold.ui.alertType.defaultActionMessage": "アラート '\\{\\{alertName\\}\\}' はグループ '\\{\\{context.group\\}\\}' でアクティブです:\n\n- 値:\\{\\{context.value\\}\\}\n- 満たされた条件:\\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}\n- タイムスタンプ:\\{\\{context.date\\}\\}", "xpack.stackAlerts.threshold.ui.alertType.descriptionText": "アグリゲーションされたクエリがしきい値に達したときにアラートを発行します。", "xpack.stackAlerts.threshold.ui.conditionPrompt": "条件を定義してください", @@ -21454,9 +21377,6 @@ "xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey": " kibana.ymlファイルで", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey": "アラートを作成するには、値を設定します ", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle": "暗号化鍵を設定する必要があります", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError": "KibanaとElasticsearchの間でトランスポートレイヤーセキュリティを有効にし、kibana.ymlファイルで暗号化鍵を構成する必要があります。", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction": "方法を学習", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle": "追加の設定が必要です", "xpack.triggersActionsUI.components.healthCheck.tlsError": "アラートはAPIキーに依存し、キーを使用するにはElasticsearchとKibanaの間にTLSが必要です。", "xpack.triggersActionsUI.components.healthCheck.tlsErrorAction": "TLSを有効にする方法をご覧ください。", "xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle": "トランスポートレイヤーセキュリティを有効にする必要があります", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7b8ea35baf0ff..d2504c8752c05 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7388,7 +7388,6 @@ "xpack.enterpriseSearch.troubleshooting.standardAuth.title": "不支持使用标准身份验证的 {productName}", "xpack.enterpriseSearch.workplaceSearch.activityFeedEmptyDefault.title": "您的组织最近无活动", "xpack.enterpriseSearch.workplaceSearch.activityFeedNamedDefault.title": "{name} 最近无活动", - "xpack.enterpriseSearch.workplaceSearch.groups.addGroup.cancel.action": "取消", "xpack.enterpriseSearch.workplaceSearch.groups.addGroup.heading": "添加组", "xpack.enterpriseSearch.workplaceSearch.groups.addGroup.submit.action": "添加组", "xpack.enterpriseSearch.workplaceSearch.groups.addGroupForm.action": "创建组", @@ -7400,7 +7399,6 @@ "xpack.enterpriseSearch.workplaceSearch.groups.filterUsers.buttonText": "用户", "xpack.enterpriseSearch.workplaceSearch.groups.filterUsers.placeholder": "筛选用户......", "xpack.enterpriseSearch.workplaceSearch.groups.groupDeleted": "组“{groupName}”已成功删除。", - "xpack.enterpriseSearch.workplaceSearch.groups.groupManagerCancel": "取消", "xpack.enterpriseSearch.workplaceSearch.groups.groupManagerHeaderTitle": "管理 {label}", "xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSelectAllToggle": "全部{action}", "xpack.enterpriseSearch.workplaceSearch.groups.groupManagerSourceEmpty.body": "可能您尚未添加任何共享内容源。", @@ -7425,7 +7423,6 @@ "xpack.enterpriseSearch.workplaceSearch.groups.noSourcesMessage": "无共享内容源", "xpack.enterpriseSearch.workplaceSearch.groups.noUsersFound": "找不到用户", "xpack.enterpriseSearch.workplaceSearch.groups.noUsersMessage": "无用户", - "xpack.enterpriseSearch.workplaceSearch.groups.overview.cancelRemoveButtonText": "取消", "xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmRemoveButtonText": "删除 {name}", "xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmRemoveDescription": "您的组将从 Workplace Search 中删除。确定要移除 {name}?", "xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmTitleText": "确认", @@ -11198,7 +11195,6 @@ "xpack.lens.configure.invalidConfigTooltipClick": "单击了解更多详情。", "xpack.lens.customBucketContainer.dragToReorder": "拖动以重新排序", "xpack.lens.dataPanelWrapper.switchDatasource": "切换到数据源", - "xpack.lens.datatable.actionsColumnName": "操作", "xpack.lens.datatable.breakdown": "细分方式", "xpack.lens.datatable.conjunctionSign": " & ", "xpack.lens.datatable.expressionHelpLabel": "数据表呈现器", @@ -11208,7 +11204,6 @@ "xpack.lens.datatable.titleLabel": "标题", "xpack.lens.datatable.visualizationName": "数据表", "xpack.lens.datatable.visualizationOf": "表{operations}", - "xpack.lens.datatableSortedInReadOnlyMode": "按 {sortValue} 排序", "xpack.lens.datatypes.boolean": "布尔值", "xpack.lens.datatypes.date": "日期", "xpack.lens.datatypes.ipAddress": "IP", @@ -11222,7 +11217,6 @@ "xpack.lens.discover.visualizeFieldLegend": "可视化字段", "xpack.lens.dragDrop.elementLifted": "您已将项目 {itemLabel} 提升到位置 {position}", "xpack.lens.dragDrop.elementMoved": "您已将项目 {itemLabel} 从位置 {prevPosition} 移到位置 {position}", - "xpack.lens.dragDrop.reorderInstructions": "按空格键开始拖动。拖动时,使用方向键重新排序。再次按空格键结束操作。", "xpack.lens.editLayerSettings": "编辑图层设置", "xpack.lens.editLayerSettingsChartType": "编辑图层设置 {chartType}", "xpack.lens.editorFrame.buildExpressionError": "准备图表时发生意外错误", @@ -11244,8 +11238,6 @@ "xpack.lens.editorFrame.suggestionPanelTitle": "建议", "xpack.lens.embeddable.failure": "无法显示可视化", "xpack.lens.embeddableDisplayName": "lens", - "xpack.lens.excludeValueButtonAriaLabel": "排除 {value}", - "xpack.lens.excludeValueButtonTooltip": "排除值", "xpack.lens.fieldFormats.longSuffix.d": "每天", "xpack.lens.fieldFormats.longSuffix.h": "每小时", "xpack.lens.fieldFormats.longSuffix.m": "每分钟", @@ -11276,8 +11268,6 @@ "xpack.lens.functions.renameColumns.idMap.help": "旧列 ID 为键且相应新列 ID 为值的 JSON 编码对象。所有其他列 ID 都将保留。", "xpack.lens.functions.timeScale.dateColumnMissingMessage": "指定的 dateColumnId {columnId} 不存在。", "xpack.lens.functions.timeScale.timeInfoMissingMessage": "无法获取日期直方图信息", - "xpack.lens.includeValueButtonAriaLabel": "包括 {value}", - "xpack.lens.includeValueButtonTooltip": "包括值", "xpack.lens.indexPattern.allFieldsLabel": "所有字段", "xpack.lens.indexPattern.allFieldsLabelHelp": "可用字段在与您的筛选匹配的前 500 个文档中有数据。要查看所有字段,请展开空字段。一些字段类型无法在 Lens 中可视化,包括全文本字段和地理字段。", "xpack.lens.indexPattern.availableFieldsLabel": "可用字段", @@ -11481,8 +11471,6 @@ "xpack.lens.sugegstion.refreshSuggestionLabel": "刷新", "xpack.lens.suggestion.refreshSuggestionTooltip": "基于选定可视化刷新建议。", "xpack.lens.suggestions.currentVisLabel": "当前可视化", - "xpack.lens.tableRowMore": "更多", - "xpack.lens.tableRowMoreDescription": "表格行上下文菜单", "xpack.lens.timeScale.removeLabel": "删除按时间单位标准化", "xpack.lens.visTypeAlias.description": "使用拖放编辑器创建可视化。随时在可视化类型之间切换。", "xpack.lens.visTypeAlias.note": "适合绝大多数用户。", @@ -12635,7 +12623,6 @@ "xpack.ml.dataframe.analytics.create.duplicateIndexPatternErrorMessageError": "索引模式 {indexPatternName} 已存在。", "xpack.ml.dataframe.analytics.create.errorCheckingIndexExists": "获取现有索引名称时发生以下错误:{error}", "xpack.ml.dataframe.analytics.create.errorCreatingDataFrameAnalyticsJob": "创建数据帧分析作业时发生错误:", - "xpack.ml.dataframe.analytics.create.errorGettingDataFrameAnalyticsList": "获取现有数据帧分析作业 ID 时发生错误:", "xpack.ml.dataframe.analytics.create.errorGettingIndexPatternTitles": "获取现有索引模式标题时发生错误:", "xpack.ml.dataframe.analytics.create.errorStartingDataFrameAnalyticsJob": "启动数据帧分析作业时发生错误:", "xpack.ml.dataframe.analytics.create.etaInputAriaLabel": "缩小量已应用于权重", @@ -13104,8 +13091,6 @@ "xpack.ml.featureRegistry.mlFeatureName": "Machine Learning", "xpack.ml.fieldDataCard.cardBoolean.valuesLabel": "值", "xpack.ml.fieldDataCard.cardDate.summaryTableTitle": "摘要", - "xpack.ml.fieldDataCard.cardIp.topValuesLabel": "排名最前值", - "xpack.ml.fieldDataCard.cardKeyword.topValuesLabel": "排名最前值", "xpack.ml.fieldDataCard.cardText.fieldMayBePopulatedDescription": "例如,可以使用文档映射中的 {copyToParam} 参数进行填充,也可以在索引后通过使用 {includesParam} 和 {excludesParam} 参数从 {sourceParam} 字段中修剪。", "xpack.ml.fieldDataCard.cardText.fieldNotPresentDescription": "查询的文档的 {sourceParam} 字段中不存在此字段。", "xpack.ml.fieldDataCard.cardText.noExamplesForFieldsTitle": "没有获取此字段的示例", @@ -13123,7 +13108,6 @@ "xpack.ml.fieldDataCardExpandedRow.numberContent.medianLabel": "中值", "xpack.ml.fieldDataCardExpandedRow.numberContent.minLabel": "最小值", "xpack.ml.fieldDataCardExpandedRow.numberContent.summaryTableTitle": "摘要", - "xpack.ml.fieldDataCardExpandedRow.numberContent.topValuesTitle": "排名最前值", "xpack.ml.fieldTitleBar.documentCountLabel": "文档计数", "xpack.ml.fieldTypeIcon.booleanTypeAriaLabel": "布尔类型", "xpack.ml.fieldTypeIcon.dateTypeAriaLabel": "日期类型", @@ -14264,8 +14248,8 @@ "xpack.ml.splom.dynamicSizeLabel": "动态大小", "xpack.ml.splom.fieldSelectionLabel": "字段", "xpack.ml.splom.fieldSelectionPlaceholder": "选择字段", - "xpack.ml.splom.RandomScoringLabel": "随机评分", - "xpack.ml.splom.SampleSizeLabel": "样例大小", + "xpack.ml.splom.randomScoringLabel": "随机评分", + "xpack.ml.splom.sampleSizeLabel": "样例大小", "xpack.ml.splom.toggleOff": "关闭", "xpack.ml.splom.toggleOn": "开启", "xpack.ml.splomSpec.outlierScoreThresholdName": "离群值分数阈值:", @@ -17396,7 +17380,6 @@ "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigDescription": "kibana.yml 文件已配置为仅允许特定连接器。要在外部系统中打开案例,请将 .[actionTypeId](例如:.servicenow | .jira)添加到 xpack.actions.enabledActiontypes 设置。有关更多信息,请参阅{link}。", "xpack.securitySolution.case.caseView.pushToServiceDisableByConfigTitle": "在 Kibana 配置文件中启用外部服务", "xpack.securitySolution.case.caseView.pushToServiceDisableByInvalidConnector": "用于将更新发送到外部服务的连接器已删除。要在外部系统中更新案例,请选择不同的连接器或创建新的连接器。", - "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseDescription": "要在外部系统中打开案例,必须将许可证更新到白金级,开始为期 30 天的免费试用,或在 AWS、GCP 或 Azure 上快速部署 {link}。", "xpack.securitySolution.case.caseView.pushToServiceDisableByLicenseTitle": "升级到 Elastic 白金级", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigDescription": "要在外部系统中打开和更新案例,必须为此案例选择外部事件管理系统。", "xpack.securitySolution.case.caseView.pushToServiceDisableByNoCaseConfigTitle": "选择外部连接器", @@ -18779,8 +18762,6 @@ "xpack.securitySolution.endpoint.policyDetails.malware.userNotification.placeholder": "输入您的定制通知消息", "xpack.securitySolution.endpoint.policyDetails.supportedVersion": "代理版本 {version}", "xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification": "定制通知消息", - "xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification.tooltip.a": "选择用户通知选项后,在阻止或检测到恶意软件时将向主机用户显示通知。", - "xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification.tooltip.b": " 可在下方文本框中定制用户通知。括号中的标签可用于动态填充适用操作(如已阻止或已检测)和文件名。", "xpack.securitySolution.endpoint.policyDetailsConfig.eventingEvents": "事件", "xpack.securitySolution.endpoint.policyDetailsConfig.linux.events.file": "文件", "xpack.securitySolution.endpoint.policyDetailsConfig.linux.events.network": "网络", @@ -20870,58 +20851,6 @@ "xpack.stackAlerts.geoContainment.timeFieldLabel": "时间字段", "xpack.stackAlerts.geoContainment.topHitsSplitFieldSelectPlaceholder": "选择实体字段", "xpack.stackAlerts.geoContainment.ui.expressionPopover.closePopoverLabel": "关闭", - "xpack.stackAlerts.geoThreshold.actionGroupThresholdMetTitle": "已达到跟踪阈值", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingDocumentIdLabel": "穿越实体文档的 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextCrossingLineLabel": "连接用于确定穿越事件的两个位置的 GeoJSON 线", - "xpack.stackAlerts.geoThreshold.actionVariableContextCurrentBoundaryIdLabel": "包含实体的当前边界 ID(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextEntityIdLabel": "触发了告警的文档的实体 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryIdLabel": "包含实体的上一边界 ID(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromBoundaryNameLabel": "实体从中穿越出且先前所位于的边界(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDateTimeLabel": "实体上次在上一边界中记录的时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityDocumentIdLabel": "穿越实体文档的 ID", - "xpack.stackAlerts.geoThreshold.actionVariableContextFromEntityLocationLabel": "实体的先前捕获位置", - "xpack.stackAlerts.geoThreshold.actionVariableContextTimeOfDetectionLabel": "记录此更改的告警时间间隔结束时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextToBoundaryNameLabel": "实体已穿越进且当前位于的边界(如果有)", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityDateTimeLabel": "在当前边界中检测到实体的时间", - "xpack.stackAlerts.geoThreshold.actionVariableContextToEntityLocationLabel": "实体的最近捕获位置", - "xpack.stackAlerts.geoThreshold.alertTypeTitle": "地理跟踪阈值", - "xpack.stackAlerts.geoThreshold.boundaryNameSelect": "选择边界名称", - "xpack.stackAlerts.geoThreshold.boundaryNameSelectLabel": "可人工读取的边界名称(可选)", - "xpack.stackAlerts.geoThreshold.delayOffset": "已延迟的评估偏移", - "xpack.stackAlerts.geoThreshold.delayOffsetTooltip": "评估延迟周期内的告警,以针对数据延迟进行调整", - "xpack.stackAlerts.geoThreshold.descriptionText": "实体进入或离开地理边界时告警。", - "xpack.stackAlerts.geoThreshold.entityByLabel": "依据", - "xpack.stackAlerts.geoThreshold.entityIndexLabel": "索引", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryGeoFieldText": "“边界地理”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryIndexTitleText": "“边界索引模式标题”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredBoundaryTypeText": "“边界类型”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredDateFieldText": "“日期”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredEntityText": "“实体”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredGeoFieldText": "“地理”字段必填。", - "xpack.stackAlerts.geoThreshold.error.requiredIndexTitleText": "“索引模式”必填。", - "xpack.stackAlerts.geoThreshold.error.requiredTrackingEventText": "“跟踪事件”必填。", - "xpack.stackAlerts.geoThreshold.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。", - "xpack.stackAlerts.geoThreshold.geofieldLabel": "地理空间字段", - "xpack.stackAlerts.geoThreshold.indexLabel": "索引", - "xpack.stackAlerts.geoThreshold.indexPatternSelectLabel": "索引模式", - "xpack.stackAlerts.geoThreshold.indexPatternSelectPlaceholder": "选择索引模式", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisLinkTextDescription": "创建索引模式", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisPrefixDescription": "您将需要 ", - "xpack.stackAlerts.geoThreshold.noIndexPattern.doThisSuffixDescription": " (包含地理空间字段)。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.getStartedLinkText": "开始使用一些样例数据集。", - "xpack.stackAlerts.geoThreshold.noIndexPattern.hintDescription": "没有任何地理空间数据集?", - "xpack.stackAlerts.geoThreshold.noIndexPattern.messageTitle": "找不到任何具有地理空间字段的索引模式", - "xpack.stackAlerts.geoThreshold.selectBoundaryIndex": "选择边界:", - "xpack.stackAlerts.geoThreshold.selectEntity": "选择实体", - "xpack.stackAlerts.geoThreshold.selectGeoLabel": "选择地理字段", - "xpack.stackAlerts.geoThreshold.selectIndex": "定义条件", - "xpack.stackAlerts.geoThreshold.selectLabel": "选择地理字段", - "xpack.stackAlerts.geoThreshold.selectOffset": "选择偏移(可选)", - "xpack.stackAlerts.geoThreshold.selectTimeLabel": "选择时间字段", - "xpack.stackAlerts.geoThreshold.timeFieldLabel": "时间字段", - "xpack.stackAlerts.geoThreshold.topHitsSplitFieldSelectPlaceholder": "选择实体字段", - "xpack.stackAlerts.geoThreshold.ui.expressionPopover.closePopoverLabel": "关闭", - "xpack.stackAlerts.geoThreshold.whenEntityLabel": "当实体", "xpack.stackAlerts.indexThreshold.actionGroupThresholdMetTitle": "已达到阈值", "xpack.stackAlerts.indexThreshold.actionVariableContextConditionsLabel": "描述阈值比较运算符和阈值的字符串", "xpack.stackAlerts.indexThreshold.actionVariableContextDateLabel": "告警超过阈值的日期。", @@ -20936,13 +20865,7 @@ "xpack.stackAlerts.indexThreshold.alertTypeTitle": "索引阈值", "xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}", "xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]:对于“{thresholdComparator}”比较运算符,必须包含两个元素", - "xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "关闭", "xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。", - "xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "使用 * 可扩大您的查询范围。", - "xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "索引", - "xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "索引", - "xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "要查询的索引", - "xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "时间字段", "xpack.stackAlerts.threshold.ui.alertType.defaultActionMessage": "组“\\{\\{context.group\\}\\}”的告警“\\{\\{alertName\\}\\}”处于活动状态:\n\n- 值:\\{\\{context.value\\}\\}\n- 满足的条件:\\{\\{context.conditions\\}\\} 超过 \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}\n- 时间戳:\\{\\{context.date\\}\\}", "xpack.stackAlerts.threshold.ui.alertType.descriptionText": "聚合查询达到阈值时告警。", "xpack.stackAlerts.threshold.ui.conditionPrompt": "定义条件", @@ -21504,9 +21427,6 @@ "xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey": " 。", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey": "要创建告警,请在 kibana.yml 文件中设置以下项的值: ", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle": "必须设置加密密钥", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError": "必须在 Kibana 和 Elasticsearch 之间启用传输层安全并在 kibana.yml 文件中配置加密密钥。", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction": "了解操作方法", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle": "需要其他设置", "xpack.triggersActionsUI.components.healthCheck.tlsError": "Alerting 功能依赖于 API 密钥,这需要在 Elasticsearch 与 Kibana 之间启用 TLS。", "xpack.triggersActionsUI.components.healthCheck.tlsErrorAction": "了解如何启用 TLS。", "xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle": "必须启用传输层安全", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx index be6c72eef6f9a..8c6a16dcd4a02 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx @@ -56,9 +56,11 @@ describe('health check', () => { }); it('renders children if keys are enabled', async () => { - useKibanaMock().services.http.get = jest - .fn() - .mockResolvedValue({ isSufficientlySecure: true, hasPermanentEncryptionKey: true }); + useKibanaMock().services.http.get = jest.fn().mockResolvedValue({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + isAlertsAvailable: true, + }); const { queryByText } = render( <HealthContextProvider> <HealthCheck waitForCheck={true}> @@ -72,10 +74,11 @@ describe('health check', () => { expect(queryByText('should render')).toBeInTheDocument(); }); - test('renders warning if keys are disabled', async () => { - useKibanaMock().services.http.get = jest.fn().mockImplementationOnce(async () => ({ + test('renders warning if TLS is required', async () => { + useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ isSufficientlySecure: false, hasPermanentEncryptionKey: true, + isAlertsAvailable: true, })); const { queryAllByText } = render( <HealthContextProvider> @@ -104,9 +107,10 @@ describe('health check', () => { }); test('renders warning if encryption key is ephemeral', async () => { - useKibanaMock().services.http.get = jest.fn().mockImplementationOnce(async () => ({ + useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: false, + isAlertsAvailable: true, })); const { queryByText, queryByRole } = render( <HealthContextProvider> @@ -121,7 +125,7 @@ describe('health check', () => { const description = queryByRole(/banner/i); expect(description!.textContent).toMatchInlineSnapshot( - `"To create an alert, set a value for xpack.encryptedSavedObjects.encryptionKey in your kibana.yml file. Learn how.(opens in a new tab or window)"` + `"To create an alert, set a value for xpack.encryptedSavedObjects.encryptionKey in your kibana.yml file and ensure the Encrypted Saved Objects plugin is enabled. Learn how.(opens in a new tab or window)"` ); const action = queryByText(/Learn/i); @@ -132,9 +136,10 @@ describe('health check', () => { }); test('renders warning if encryption key is ephemeral and keys are disabled', async () => { - useKibanaMock().services.http.get = jest.fn().mockImplementationOnce(async () => ({ + useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ isSufficientlySecure: false, hasPermanentEncryptionKey: false, + isAlertsAvailable: true, })); const { queryByText } = render( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx index 66f7c1d36dfb2..3103d8f2a817c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx @@ -14,18 +14,24 @@ import { i18n } from '@kbn/i18n'; import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { DocLinksStart } from 'kibana/public'; -import { AlertingFrameworkHealth } from '../../types'; -import { health } from '../lib/alert_api'; +import { alertingFrameworkHealth } from '../lib/alert_api'; import './health_check.scss'; import { useHealthContext } from '../context/health_context'; import { useKibana } from '../../common/lib/kibana'; import { CenterJustifiedSpinner } from './center_justified_spinner'; +import { triggersActionsUiHealth } from '../../common/lib/health_api'; interface Props { inFlyout?: boolean; waitForCheck: boolean; } +interface HealthStatus { + isAlertsAvailable: boolean; + isSufficientlySecure: boolean; + hasPermanentEncryptionKey: boolean; +} + export const HealthCheck: React.FunctionComponent<Props> = ({ children, waitForCheck, @@ -33,12 +39,24 @@ export const HealthCheck: React.FunctionComponent<Props> = ({ }) => { const { http, docLinks } = useKibana().services; const { setLoadingHealthCheck } = useHealthContext(); - const [alertingHealth, setAlertingHealth] = React.useState<Option<AlertingFrameworkHealth>>(none); + const [alertingHealth, setAlertingHealth] = React.useState<Option<HealthStatus>>(none); React.useEffect(() => { (async function () { setLoadingHealthCheck(true); - setAlertingHealth(some(await health({ http }))); + const triggersActionsUiHealthStatus = await triggersActionsUiHealth({ http }); + const healthStatus: HealthStatus = { + ...triggersActionsUiHealthStatus, + isSufficientlySecure: false, + hasPermanentEncryptionKey: false, + }; + if (healthStatus.isAlertsAvailable) { + const alertingHealthResult = await alertingFrameworkHealth({ http }); + healthStatus.isSufficientlySecure = alertingHealthResult.isSufficientlySecure; + healthStatus.hasPermanentEncryptionKey = alertingHealthResult.hasPermanentEncryptionKey; + } + + setAlertingHealth(some(healthStatus)); setLoadingHealthCheck(false); })(); }, [http, setLoadingHealthCheck]); @@ -60,6 +78,8 @@ export const HealthCheck: React.FunctionComponent<Props> = ({ (healthCheck) => { return healthCheck?.isSufficientlySecure && healthCheck?.hasPermanentEncryptionKey ? ( <Fragment>{children}</Fragment> + ) : !healthCheck.isAlertsAvailable ? ( + <AlertsError docLinks={docLinks} className={className} /> ) : !healthCheck.isSufficientlySecure && !healthCheck.hasPermanentEncryptionKey ? ( <TlsAndEncryptionError docLinks={docLinks} className={className} /> ) : !healthCheck.hasPermanentEncryptionKey ? ( @@ -77,7 +97,7 @@ interface PromptErrorProps { className?: string; } -const TlsAndEncryptionError = ({ +const EncryptionError = ({ // eslint-disable-next-line @typescript-eslint/naming-convention docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, className, @@ -90,27 +110,37 @@ const TlsAndEncryptionError = ({ title={ <h2> <FormattedMessage - id="xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle" - defaultMessage="Additional setup required" + id="xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle" + defaultMessage="Encrypted saved objects are not available" /> </h2> } body={ <div className={`${className}__body`}> <p role="banner"> - {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError', { - defaultMessage: - 'You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. ', - })} + {i18n.translate( + 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey', + { + defaultMessage: 'To create an alert, set a value for ', + } + )} + <EuiCode>{'xpack.encryptedSavedObjects.encryptionKey'}</EuiCode> + {i18n.translate( + 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey', + { + defaultMessage: + ' in your kibana.yml file and ensure the Encrypted Saved Objects plugin is enabled. ', + } + )} <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alerting-getting-started.html#alerting-setup-prerequisites`} + href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`} external target="_blank" > {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction', + 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAction', { - defaultMessage: 'Learn how', + defaultMessage: 'Learn how.', } )} </EuiLink> @@ -120,7 +150,7 @@ const TlsAndEncryptionError = ({ /> ); -const EncryptionError = ({ +const TlsError = ({ // eslint-disable-next-line @typescript-eslint/naming-convention docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, className, @@ -133,38 +163,26 @@ const EncryptionError = ({ title={ <h2> <FormattedMessage - id="xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle" - defaultMessage="You must set an encryption key" + id="xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle" + defaultMessage="You must enable Transport Layer Security" /> </h2> } body={ <div className={`${className}__body`}> <p role="banner"> - {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey', - { - defaultMessage: 'To create an alert, set a value for ', - } - )} - <EuiCode>{'xpack.encryptedSavedObjects.encryptionKey'}</EuiCode> - {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey', - { - defaultMessage: ' in your kibana.yml file. ', - } - )} + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsError', { + defaultMessage: + 'Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. ', + })} <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`} + href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`} external target="_blank" > - {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAction', - { - defaultMessage: 'Learn how.', - } - )} + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsErrorAction', { + defaultMessage: 'Learn how to enable TLS.', + })} </EuiLink> </p> </div> @@ -172,7 +190,46 @@ const EncryptionError = ({ /> ); -const TlsError = ({ +const AlertsError = ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, + className, +}: PromptErrorProps) => ( + <EuiEmptyPrompt + iconType="watchesApp" + data-test-subj="alertsNeededEmptyPrompt" + className={className} + titleSize="xs" + title={ + <h2> + <FormattedMessage + id="xpack.triggersActionsUI.components.healthCheck.alertsErrorTitle" + defaultMessage="You must enable Alerts and Actions" + /> + </h2> + } + body={ + <div className={`${className}__body`}> + <p role="banner"> + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.alertsError', { + defaultMessage: 'To create an alert, set alerts and actions plugins enabled. ', + })} + <EuiLink + href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html`} + external + target="_blank" + > + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.alertsErrorAction', { + defaultMessage: 'Learn how to enable Alerts and Actions.', + })} + </EuiLink> + </p> + </div> + } + /> +); + +const TlsAndEncryptionError = ({ // eslint-disable-next-line @typescript-eslint/naming-convention docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, className, @@ -185,26 +242,29 @@ const TlsError = ({ title={ <h2> <FormattedMessage - id="xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle" - defaultMessage="You must enable Transport Layer Security" + id="xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle" + defaultMessage="Additional setup required" /> </h2> } body={ <div className={`${className}__body`}> <p role="banner"> - {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsError', { + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError', { defaultMessage: - 'Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. ', + 'You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. ', })} <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`} + href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alerting-getting-started.html#alerting-setup-prerequisites`} external target="_blank" > - {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsErrorAction', { - defaultMessage: 'Learn how to enable TLS.', - })} + {i18n.translate( + 'xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction', + { + defaultMessage: 'Learn how', + } + )} </EuiLink> </p> </div> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index ea654bb21e88b..f3d49c52855ab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -25,7 +25,7 @@ import { updateAlert, muteAlertInstance, unmuteAlertInstance, - health, + alertingFrameworkHealth, mapFiltersToKql, } from './alert_api'; import uuid from 'uuid'; @@ -801,9 +801,9 @@ describe('unmuteAlerts', () => { }); }); -describe('health', () => { - test('should call health API', async () => { - const result = await health({ http }); +describe('alertingFrameworkHealth', () => { + test('should call alertingFrameworkHealth API', async () => { + const result = await alertingFrameworkHealth({ http }); expect(result).toEqual(undefined); expect(http.get.mock.calls).toMatchInlineSnapshot(` Array [ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index 52ab33566da74..f774b3d35bb29 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -282,6 +282,10 @@ export async function unmuteAlerts({ await Promise.all(ids.map((id) => unmuteAlert({ id, http }))); } -export async function health({ http }: { http: HttpSetup }): Promise<AlertingFrameworkHealth> { +export async function alertingFrameworkHealth({ + http, +}: { + http: HttpSetup; +}): Promise<AlertingFrameworkHealth> { return await http.get(`${BASE_ALERT_API_PATH}/_health`); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 889dfe8289b13..3c32b5bc729dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -25,7 +25,14 @@ jest.mock('../../../common/lib/kibana'); jest.mock('../../lib/alert_api', () => ({ loadAlertTypes: jest.fn(), - health: jest.fn(() => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true })), + alertingFrameworkHealth: jest.fn(() => ({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + })), +})); + +jest.mock('../../../common/lib/health_api', () => ({ + triggersActionsUiHealth: jest.fn(() => ({ isAlertsAvailable: true })), })); const actionTypeRegistry = actionTypeRegistryMock.create(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index baf0f55c415db..df7729bb407b3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -18,11 +18,25 @@ import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { ReactWrapper } from 'enzyme'; import AlertEdit from './alert_edit'; import { useKibana } from '../../../common/lib/kibana'; +import { ALERTS_FEATURE_ID } from '../../../../../alerts/common'; jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; +jest.mock('../../lib/alert_api', () => ({ + loadAlertTypes: jest.fn(), + updateAlert: jest.fn().mockRejectedValue({ body: { message: 'Fail message' } }), + alertingFrameworkHealth: jest.fn(() => ({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + })), +})); + +jest.mock('../../../common/lib/health_api', () => ({ + triggersActionsUiHealth: jest.fn(() => ({ isAlertsAvailable: true })), +})); + describe('alert_edit', () => { let wrapper: ReactWrapper<any>; let mockedCoreSetup: ReturnType<typeof coreMock.createSetup>; @@ -48,12 +62,32 @@ describe('alert_edit', () => { }, }; - // eslint-disable-next-line react-hooks/rules-of-hooks - useKibanaMock().services.http.get = jest.fn().mockResolvedValue({ - isSufficientlySecure: true, - hasPermanentEncryptionKey: true, - }); - + const { loadAlertTypes } = jest.requireMock('../../lib/alert_api'); + const alertTypes = [ + { + id: 'my-alert-type', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + ], + defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + producer: ALERTS_FEATURE_ID, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, + }, + actionVariables: { + context: [], + state: [], + params: [], + }, + }, + ]; const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -79,7 +113,7 @@ describe('alert_edit', () => { }, actionConnectorFields: null, }); - + loadAlertTypes.mockResolvedValue(alertTypes); const alert: Alert = { id: 'ab5661e0-197e-45ee-b477-302d89193b5e', params: { @@ -145,19 +179,15 @@ describe('alert_edit', () => { }); } - it('renders alert add flyout', async () => { + it('renders alert edit flyout', async () => { await setup(); expect(wrapper.find('[data-test-subj="editAlertFlyoutTitle"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="saveEditedAlertButton"]').exists()).toBeTruthy(); }); it('displays a toast message on save for server errors', async () => { - useKibanaMock().services.http.get = jest.fn().mockResolvedValue([]); await setup(); - const err = new Error() as any; - err.body = {}; - err.body.message = 'Fail message'; - useKibanaMock().services.http.put = jest.fn().mockRejectedValue(err); + await act(async () => { wrapper.find('[data-test-subj="saveEditedAlertButton"]').first().simulate('click'); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index 8ca3edb1c68df..bd50bf3270f1a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -26,7 +26,13 @@ jest.mock('../../../lib/action_connector_api', () => ({ jest.mock('../../../lib/alert_api', () => ({ loadAlerts: jest.fn(), loadAlertTypes: jest.fn(), - health: jest.fn(() => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true })), + alertingFrameworkHealth: jest.fn(() => ({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + })), +})); +jest.mock('../../../../common/lib/health_api', () => ({ + triggersActionsUiHealth: jest.fn(() => ({ isAlertsAvailable: true })), })); jest.mock('react-router-dom', () => ({ useHistory: () => ({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index 5656aa9de7795..f44f6e87c7a19 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -29,7 +29,7 @@ import { loadAlertState, loadAlertInstanceSummary, loadAlertTypes, - health, + alertingFrameworkHealth, } from '../../../lib/alert_api'; import { useKibana } from '../../../../common/lib/kibana'; @@ -131,7 +131,7 @@ export function withBulkAlertOperations<T>( loadAlertInstanceSummary({ http, alertId }) } loadAlertTypes={async () => loadAlertTypes({ http })} - getHealth={async () => health({ http })} + getHealth={async () => alertingFrameworkHealth({ http })} /> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.test.ts new file mode 100644 index 0000000000000..d22fd538ad0ca --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from 'src/core/public/mocks'; +import { triggersActionsUiHealth } from './health_api'; + +describe('triggersActionsUiHealth', () => { + const http = httpServiceMock.createStartContract(); + + test('should call triggersActionsUiHealth API', async () => { + const result = await triggersActionsUiHealth({ http }); + expect(result).toEqual(undefined); + expect(http.get.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/triggers_actions_ui/_health", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.ts new file mode 100644 index 0000000000000..752f5b3e2ca08 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { HttpSetup } from 'kibana/public'; + +const TRIGGERS_ACTIONS_UI_API_ROOT = '/api/triggers_actions_ui'; + +export async function triggersActionsUiHealth({ http }: { http: HttpSetup }): Promise<any> { + return await http.get(`${TRIGGERS_ACTIONS_UI_API_ROOT}/_health`); +} diff --git a/x-pack/plugins/triggers_actions_ui/server/data/index.ts b/x-pack/plugins/triggers_actions_ui/server/data/index.ts index 6ee2b4bb8a5fe..cc76af90bcde6 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/index.ts @@ -13,6 +13,7 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, } from './lib'; // future enhancement: make these configurable? diff --git a/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts b/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts index 096a928249fd5..a3fe2220a86fd 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts @@ -9,4 +9,5 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, } from './core_query_types'; diff --git a/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts b/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts index 17a2b2929f0cf..2cda40c18db0c 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// the business logic of this code is from watcher, in: -// x-pack/plugins/watcher/server/routes/api/register_list_fields_route.ts - import { schema, TypeOf } from '@kbn/config-schema'; import { IRouter, diff --git a/x-pack/plugins/triggers_actions_ui/server/index.ts b/x-pack/plugins/triggers_actions_ui/server/index.ts index abd61f2bd3541..5e35293419b17 100644 --- a/x-pack/plugins/triggers_actions_ui/server/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/index.ts @@ -14,6 +14,7 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, MAX_INTERVALS, MAX_GROUPS, DEFAULT_GROUPS, diff --git a/x-pack/plugins/triggers_actions_ui/server/plugin.ts b/x-pack/plugins/triggers_actions_ui/server/plugin.ts index c0d29341e217b..69be37f665887 100644 --- a/x-pack/plugins/triggers_actions_ui/server/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/server/plugin.ts @@ -5,12 +5,21 @@ */ import { Logger, Plugin, CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { PluginSetupContract as AlertsPluginSetup } from '../../alerts/server'; +import { EncryptedSavedObjectsPluginSetup } from '../../encrypted_saved_objects/server'; import { getService, register as registerDataService } from './data'; +import { createHealthRoute } from './routes/health'; +const BASE_ROUTE = '/api/triggers_actions_ui'; export interface PluginStartContract { data: ReturnType<typeof getService>; } +interface PluginsSetup { + encryptedSavedObjects?: EncryptedSavedObjectsPluginSetup; + alerts?: AlertsPluginSetup; +} + export class TriggersActionsPlugin implements Plugin<void, PluginStartContract> { private readonly logger: Logger; private readonly data: PluginStartContract['data']; @@ -20,13 +29,16 @@ export class TriggersActionsPlugin implements Plugin<void, PluginStartContract> this.data = getService(); } - public async setup(core: CoreSetup): Promise<void> { + public async setup(core: CoreSetup, plugins: PluginsSetup): Promise<void> { + const router = core.http.createRouter(); registerDataService({ logger: this.logger, data: this.data, - router: core.http.createRouter(), - baseRoute: '/api/triggers_actions_ui', + router, + baseRoute: BASE_ROUTE, }); + + createHealthRoute(this.logger, router, BASE_ROUTE, plugins.alerts !== undefined); } public async start(): Promise<PluginStartContract> { diff --git a/x-pack/plugins/triggers_actions_ui/server/routes/health.ts b/x-pack/plugins/triggers_actions_ui/server/routes/health.ts new file mode 100644 index 0000000000000..1ea9cb748bcd7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/server/routes/health.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'kibana/server'; +import { Logger } from '../../../../../src/core/server'; + +export function createHealthRoute( + logger: Logger, + router: IRouter, + baseRoute: string, + isAlertsAvailable: boolean +) { + const path = `${baseRoute}/_health`; + logger.debug(`registering triggers_actions_ui health route GET ${path}`); + router.get( + { + path, + validate: false, + }, + handler + ); + async function handler( + ctx: RequestHandlerContext, + req: KibanaRequest<unknown, unknown, unknown>, + res: KibanaResponseFactory + ): Promise<IKibanaResponse> { + const result = { isAlertsAvailable }; + + logger.debug(`route ${path} response: ${JSON.stringify(result)}`); + return res.ok({ body: result }); + } +} diff --git a/x-pack/plugins/upgrade_assistant/public/application/app.tsx b/x-pack/plugins/upgrade_assistant/public/application/app.tsx index 17eff71f1039b..2b245bceceb6c 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app.tsx @@ -16,10 +16,10 @@ export interface AppDependencies extends ContextValue { i18n: I18nStart; } -export const RootComponent = ({ i18n, ...contexValue }: AppDependencies) => { +export const RootComponent = ({ i18n, ...contextValue }: AppDependencies) => { return ( <i18n.Context> - <AppContextProvider value={contexValue}> + <AppContextProvider value={contextValue}> <div data-test-subj="upgradeAssistantRoot"> <EuiPageHeader> <EuiPageHeaderSection> diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/latest_minor_banner.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/latest_minor_banner.tsx index 43d0364425cbb..d9ec183231739 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/latest_minor_banner.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/latest_minor_banner.tsx @@ -10,40 +10,49 @@ import { EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CURRENT_MAJOR_VERSION, NEXT_MAJOR_VERSION } from '../../../common/version'; +import { useAppContext } from '../app_context'; -export const LatestMinorBanner: React.FunctionComponent = () => ( - <EuiCallOut - title={ - <FormattedMessage - id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutTitle" - defaultMessage="Issues list might be incomplete" - /> - } - color="warning" - iconType="help" - > - <p> - <FormattedMessage - id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.calloutDetail" - defaultMessage="The complete list of {breakingChangesDocButton} in Elasticsearch {nextEsVersion} +export const LatestMinorBanner: React.FunctionComponent = () => { + const { docLinks } = useAppContext(); + + const { ELASTIC_WEBSITE_URL } = docLinks; + const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference`; + + return ( + <EuiCallOut + title={ + <FormattedMessage + id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutTitle" + defaultMessage="Issues list might be incomplete" + /> + } + color="warning" + iconType="help" + > + <p> + <FormattedMessage + id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.calloutDetail" + defaultMessage="The complete list of {breakingChangesDocButton} in Elasticsearch {nextEsVersion} will be available in the final {currentEsVersion} minor release. When the list is complete, this warning will go away." - values={{ - breakingChangesDocButton: ( - <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes.html" - target="_blank" - > - <FormattedMessage - id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.breackingChangesDocButtonLabel" - defaultMessage="deprecations and breaking changes" - /> - </EuiLink> - ), - nextEsVersion: `${NEXT_MAJOR_VERSION}.x`, - currentEsVersion: `${CURRENT_MAJOR_VERSION}.x`, - }} - /> - </p> - </EuiCallOut> -); + values={{ + breakingChangesDocButton: ( + <EuiLink + href={`${esDocBasePath}/master/breaking-changes.html`} // Pointing to master here, as we want to direct users to breaking changes for the next major ES version + target="_blank" + external + > + <FormattedMessage + id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.breackingChangesDocButtonLabel" + defaultMessage="deprecations and breaking changes" + /> + </EuiLink> + ), + nextEsVersion: `${NEXT_MAJOR_VERSION}.x`, + currentEsVersion: `${CURRENT_MAJOR_VERSION}.x`, + }} + /> + </p> + </EuiCallOut> + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx index 463c0c9d016b3..6a99bd24ef26b 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx @@ -17,6 +17,19 @@ const promisesToResolve = () => new Promise((resolve) => setTimeout(resolve, 0)) const mockHttp = httpServiceMock.createSetupContract(); +jest.mock('../app_context', () => { + return { + useAppContext: () => { + return { + docLinks: { + DOC_LINK_VERSION: 'current', + ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', + }, + }; + }, + }; +}); + describe('UpgradeAssistantTabs', () => { test('renders loading state', async () => { mockHttp.get.mockReturnValue( diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap index dda051e715234..5aa4a469e4f02 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap @@ -40,7 +40,8 @@ exports[`CheckupTab render with deprecations 1`] = ` values={ Object { "snapshotRestoreDocsButton": <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html" + external={true} + href="https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshot-restore.html" target="_blank" > <FormattedMessage @@ -321,7 +322,8 @@ exports[`CheckupTab render with error 1`] = ` values={ Object { "snapshotRestoreDocsButton": <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html" + external={true} + href="https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshot-restore.html" target="_blank" > <FormattedMessage @@ -386,7 +388,8 @@ exports[`CheckupTab render without deprecations 1`] = ` values={ Object { "snapshotRestoreDocsButton": <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html" + external={true} + href="https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshot-restore.html" target="_blank" > <FormattedMessage diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx index a4054bacba448..3a1e042a3aa5f 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx @@ -20,6 +20,19 @@ const defaultProps = { setSelectedTabIndex: jest.fn(), }; +jest.mock('../../../app_context', () => { + return { + useAppContext: () => { + return { + docLinks: { + DOC_LINK_VERSION: 'current', + ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', + }, + }; + }, + }; +}); + /** * Mostly a dumb container with copy, test the three main states. */ diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.tsx index 5688903b8f7cd..02cbc87483e55 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.tsx @@ -5,7 +5,7 @@ */ import { find } from 'lodash'; -import React, { Fragment } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { EuiCallOut, @@ -20,211 +20,65 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { NEXT_MAJOR_VERSION } from '../../../../../common/version'; import { LoadingErrorBanner } from '../../error_banner'; +import { useAppContext } from '../../../app_context'; import { GroupByOption, LevelFilterOption, LoadingState, - UpgradeAssistantTabComponent, UpgradeAssistantTabProps, } from '../../types'; import { CheckupControls } from './controls'; import { GroupedDeprecations } from './deprecations/grouped'; -interface CheckupTabProps extends UpgradeAssistantTabProps { +export interface CheckupTabProps extends UpgradeAssistantTabProps { checkupLabel: string; showBackupWarning?: boolean; } -interface CheckupTabState { - currentFilter: LevelFilterOption; - search: string; - currentGroupBy: GroupByOption; -} - /** * Displays a list of deprecations that filterable and groupable. Can be used for cluster, * nodes, or indices checkups. */ -export class CheckupTab extends UpgradeAssistantTabComponent<CheckupTabProps, CheckupTabState> { - constructor(props: CheckupTabProps) { - super(props); - - this.state = { - // initialize to all filters - currentFilter: LevelFilterOption.all, - search: '', - currentGroupBy: GroupByOption.message, - }; - } - - public render() { - const { - alertBanner, - checkupLabel, - deprecations, - loadingError, - loadingState, - refreshCheckupData, - setSelectedTabIndex, - showBackupWarning = false, - } = this.props; - const { currentFilter, currentGroupBy } = this.state; - - return ( - <Fragment> - <EuiSpacer /> - <EuiText grow={false}> - <p> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.tabDetail" - defaultMessage="These {strongCheckupLabel} issues need your attention. Resolve them before upgrading to Elasticsearch {nextEsVersion}." - values={{ - strongCheckupLabel: <strong>{checkupLabel}</strong>, - nextEsVersion: `${NEXT_MAJOR_VERSION}.x`, - }} - /> - </p> - </EuiText> - - <EuiSpacer /> - - {alertBanner && ( - <Fragment> - {alertBanner} - <EuiSpacer /> - </Fragment> - )} - - {showBackupWarning && ( - <Fragment> - <EuiCallOut - title={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutTitle" - defaultMessage="Back up your indices now" - /> - } - color="warning" - iconType="help" - > - <p> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.calloutDetail" - defaultMessage="Back up your data using the {snapshotRestoreDocsButton}." - values={{ - snapshotRestoreDocsButton: ( - <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html" - target="_blank" - > - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.snapshotRestoreDocsButtonLabel" - defaultMessage="snapshot and restore APIs" - /> - </EuiLink> - ), - }} - /> - </p> - </EuiCallOut> - <EuiSpacer /> - </Fragment> - )} - - <EuiPageContent> - <EuiPageContentBody> - {loadingState === LoadingState.Error ? ( - <LoadingErrorBanner loadingError={loadingError} /> - ) : deprecations && deprecations.length > 0 ? ( - <Fragment> - <CheckupControls - allDeprecations={deprecations} - loadingState={loadingState} - loadData={refreshCheckupData} - currentFilter={currentFilter} - onFilterChange={this.changeFilter} - onSearchChange={this.changeSearch} - availableGroupByOptions={this.availableGroupByOptions()} - currentGroupBy={currentGroupBy} - onGroupByChange={this.changeGroupBy} - /> - <EuiSpacer /> - {this.renderCheckupData()} - </Fragment> - ) : ( - <EuiEmptyPrompt - iconType="faceHappy" - title={ - <h2> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle" - defaultMessage="All clear!" - /> - </h2> - } - body={ - <Fragment> - <p data-test-subj="upgradeAssistantIssueSummary"> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel" - defaultMessage="You have no {strongCheckupLabel} issues." - values={{ - strongCheckupLabel: <strong>{checkupLabel}</strong>, - }} - /> - </p> - <p> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail" - defaultMessage="Check the {overviewTabButton} for next steps." - values={{ - overviewTabButton: ( - <EuiLink onClick={() => setSelectedTabIndex(0)}> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail.overviewTabButtonLabel" - defaultMessage="Overview tab" - /> - </EuiLink> - ), - }} - /> - </p> - </Fragment> - } - /> - )} - </EuiPageContentBody> - </EuiPageContent> - </Fragment> - ); - } - - private changeFilter = (filter: LevelFilterOption) => { - this.setState({ currentFilter: filter }); +export const CheckupTab: FunctionComponent<CheckupTabProps> = ({ + alertBanner, + checkupLabel, + deprecations, + loadingError, + loadingState, + refreshCheckupData, + setSelectedTabIndex, + showBackupWarning = false, +}) => { + const [currentFilter, setCurrentFilter] = useState<LevelFilterOption>(LevelFilterOption.all); + const [search, setSearch] = useState<string>(''); + const [currentGroupBy, setCurrentGroupBy] = useState<GroupByOption>(GroupByOption.message); + + const { docLinks } = useAppContext(); + + const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; + const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; + + const changeFilter = (filter: LevelFilterOption) => { + setCurrentFilter(filter); }; - private changeSearch = (search: string) => { - this.setState({ search }); + const changeSearch = (newSearch: string) => { + setSearch(newSearch); }; - private changeGroupBy = (groupBy: GroupByOption) => { - this.setState({ currentGroupBy: groupBy }); + const changeGroupBy = (groupBy: GroupByOption) => { + setCurrentGroupBy(groupBy); }; - private availableGroupByOptions() { - const { deprecations } = this.props; - + const availableGroupByOptions = () => { if (!deprecations) { return []; } return Object.keys(GroupByOption).filter((opt) => find(deprecations, opt)) as GroupByOption[]; - } - - private renderCheckupData() { - const { deprecations } = this.props; - const { currentFilter, currentGroupBy, search } = this.state; + }; + const renderCheckupData = () => { return ( <GroupedDeprecations currentGroupBy={currentGroupBy} @@ -233,5 +87,134 @@ export class CheckupTab extends UpgradeAssistantTabComponent<CheckupTabProps, Ch allDeprecations={deprecations} /> ); - } -} + }; + + return ( + <> + <EuiSpacer /> + <EuiText grow={false}> + <p> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.tabDetail" + defaultMessage="These {strongCheckupLabel} issues need your attention. Resolve them before upgrading to Elasticsearch {nextEsVersion}." + values={{ + strongCheckupLabel: <strong>{checkupLabel}</strong>, + nextEsVersion: `${NEXT_MAJOR_VERSION}.x`, + }} + /> + </p> + </EuiText> + + <EuiSpacer /> + + {alertBanner && ( + <> + {alertBanner} + <EuiSpacer /> + </> + )} + + {showBackupWarning && ( + <> + <EuiCallOut + title={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutTitle" + defaultMessage="Back up your indices now" + /> + } + color="warning" + iconType="help" + > + <p> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.calloutDetail" + defaultMessage="Back up your data using the {snapshotRestoreDocsButton}." + values={{ + snapshotRestoreDocsButton: ( + <EuiLink + href={`${esDocBasePath}/snapshot-restore.html`} + target="_blank" + external + > + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.snapshotRestoreDocsButtonLabel" + defaultMessage="snapshot and restore APIs" + /> + </EuiLink> + ), + }} + /> + </p> + </EuiCallOut> + <EuiSpacer /> + </> + )} + + <EuiPageContent> + <EuiPageContentBody> + {loadingState === LoadingState.Error ? ( + <LoadingErrorBanner loadingError={loadingError} /> + ) : deprecations && deprecations.length > 0 ? ( + <> + <CheckupControls + allDeprecations={deprecations} + loadingState={loadingState} + loadData={refreshCheckupData} + currentFilter={currentFilter} + onFilterChange={changeFilter} + onSearchChange={changeSearch} + availableGroupByOptions={availableGroupByOptions()} + currentGroupBy={currentGroupBy} + onGroupByChange={changeGroupBy} + /> + <EuiSpacer /> + {renderCheckupData()} + </> + ) : ( + <EuiEmptyPrompt + iconType="faceHappy" + title={ + <h2> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle" + defaultMessage="All clear!" + /> + </h2> + } + body={ + <> + <p data-test-subj="upgradeAssistantIssueSummary"> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel" + defaultMessage="You have no {strongCheckupLabel} issues." + values={{ + strongCheckupLabel: <strong>{checkupLabel}</strong>, + }} + /> + </p> + <p> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail" + defaultMessage="Check the {overviewTabButton} for next steps." + values={{ + overviewTabButton: ( + <EuiLink onClick={() => setSelectedTabIndex(0)}> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail.overviewTabButtonLabel" + defaultMessage="Overview tab" + /> + </EuiLink> + ), + }} + /> + </p> + </> + } + /> + )} + </EuiPageContentBody> + </EuiPageContent> + </> + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx index 1c9a079bcf1eb..772d558a0d20d 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.test.tsx @@ -5,9 +5,9 @@ */ import React from 'react'; -import { shallowWithIntl } from '@kbn/test/jest'; +import { shallow } from 'enzyme'; -import { IndexDeprecationTableProps, IndexDeprecationTableUI } from './index_table'; +import { IndexDeprecationTableProps, IndexDeprecationTable } from './index_table'; describe('IndexDeprecationTable', () => { const defaultProps = { @@ -22,7 +22,7 @@ describe('IndexDeprecationTable', () => { // This test simply verifies that the props passed to EuiBaseTable are the ones // expected. test('render', () => { - expect(shallowWithIntl(<IndexDeprecationTableUI {...defaultProps} />)).toMatchInlineSnapshot(` + expect(shallow(<IndexDeprecationTable {...defaultProps} />)).toMatchInlineSnapshot(` <EuiBasicTable columns={ Array [ diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx index fff8215e77ae6..d360e2a44d8c1 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/index_table.tsx @@ -8,7 +8,7 @@ import { sortBy } from 'lodash'; import React from 'react'; import { EuiBasicTable } from '@elastic/eui'; -import { injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { ReindexButton } from './reindex'; import { AppContext } from '../../../../app_context'; import { EnrichedDeprecationInfo } from '../../../../../../common/types'; @@ -22,7 +22,7 @@ export interface IndexDeprecationDetails { details?: string; } -export interface IndexDeprecationTableProps extends ReactIntl.InjectedIntlProps { +export interface IndexDeprecationTableProps { indices: IndexDeprecationDetails[]; } @@ -33,7 +33,7 @@ interface IndexDeprecationTableState { pageSize: number; } -export class IndexDeprecationTableUI extends React.Component< +export class IndexDeprecationTable extends React.Component< IndexDeprecationTableProps, IndexDeprecationTableState > { @@ -49,24 +49,27 @@ export class IndexDeprecationTableUI extends React.Component< } public render() { - const { intl } = this.props; const { pageIndex, pageSize, sortField, sortDirection } = this.state; const columns = [ { field: 'index', - name: intl.formatMessage({ - id: 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel', - defaultMessage: 'Index', - }), + name: i18n.translate( + 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.indexColumnLabel', + { + defaultMessage: 'Index', + } + ), sortable: true, }, { field: 'details', - name: intl.formatMessage({ - id: 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.detailsColumnLabel', - defaultMessage: 'Details', - }), + name: i18n.translate( + 'xpack.upgradeAssistant.checkupTab.deprecations.indexTable.detailsColumnLabel', + { + defaultMessage: 'Details', + } + ), }, ]; @@ -169,5 +172,3 @@ export class IndexDeprecationTableUI extends React.Component< }; } } - -export const IndexDeprecationTable = injectI18n(IndexDeprecationTableUI); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx index 318d2bc7baffe..6428edfbe904d 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx @@ -11,6 +11,19 @@ import React from 'react'; import { ReindexWarning } from '../../../../../../../../common/types'; import { idForWarning, WarningsFlyoutStep } from './warnings_step'; +jest.mock('../../../../../../app_context', () => { + return { + useAppContext: () => { + return { + docLinks: { + DOC_LINK_VERSION: 'current', + ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', + }, + }; + }, + }; +}); + describe('WarningsFlyoutStep', () => { const defaultProps = { advanceNextStep: jest.fn(), diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx index c3ef0fde6e749..9f48c77ec38e1 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { useState } from 'react'; import { EuiButton, @@ -21,6 +21,7 @@ import { EuiText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { useAppContext } from '../../../../../../app_context'; import { ReindexWarning } from '../../../../../../../../common/types'; interface CheckedIds { @@ -37,7 +38,7 @@ const WarningCheckbox: React.FunctionComponent<{ documentationUrl: string; onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; }> = ({ checkedIds, warning, label, onChange, description, documentationUrl }) => ( - <Fragment> + <> <EuiText> <EuiCheckbox id={idForWarning(warning)} @@ -48,7 +49,7 @@ const WarningCheckbox: React.FunctionComponent<{ <p className="upgWarningsStep__warningDescription"> {description} <br /> - <EuiLink href={documentationUrl} target="_blank"> + <EuiLink href={documentationUrl} target="_blank" external> <FormattedMessage id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.documentationLinkLabel" defaultMessage="Documentation" @@ -58,7 +59,7 @@ const WarningCheckbox: React.FunctionComponent<{ </EuiText> <EuiSpacer /> - </Fragment> + </> ); interface WarningsConfirmationFlyoutProps { @@ -68,175 +69,169 @@ interface WarningsConfirmationFlyoutProps { advanceNextStep: () => void; } -interface WarningsConfirmationFlyoutState { - checkedIds: CheckedIds; -} - /** * Displays warning text about destructive changes required to reindex this index. The user * must acknowledge each change before being allowed to proceed. */ -export class WarningsFlyoutStep extends React.Component< - WarningsConfirmationFlyoutProps, - WarningsConfirmationFlyoutState -> { - constructor(props: WarningsConfirmationFlyoutProps) { - super(props); - - this.state = { - checkedIds: props.warnings.reduce((checkedIds, warning) => { - checkedIds[idForWarning(warning)] = false; - return checkedIds; - }, {} as { [id: string]: boolean }), - }; - } - - public render() { - const { warnings, closeFlyout, advanceNextStep, renderGlobalCallouts } = this.props; - const { checkedIds } = this.state; - - // Do not allow to proceed until all checkboxes are checked. - const blockAdvance = Object.values(checkedIds).filter((v) => v).length < warnings.length; - - return ( - <Fragment> - <EuiFlyoutBody> - {renderGlobalCallouts()} - <EuiCallOut - title={ +export const WarningsFlyoutStep: React.FunctionComponent<WarningsConfirmationFlyoutProps> = ({ + warnings, + renderGlobalCallouts, + closeFlyout, + advanceNextStep, +}) => { + const [checkedIds, setCheckedIds] = useState<CheckedIds>( + warnings.reduce((initialCheckedIds, warning) => { + initialCheckedIds[idForWarning(warning)] = false; + return initialCheckedIds; + }, {} as { [id: string]: boolean }) + ); + + // Do not allow to proceed until all checkboxes are checked. + const blockAdvance = Object.values(checkedIds).filter((v) => v).length < warnings.length; + + const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const optionId = e.target.id; + + setCheckedIds((prev) => ({ + ...prev, + ...{ + [optionId]: !checkedIds[optionId], + }, + })); + }; + + const { docLinks } = useAppContext(); + const { ELASTIC_WEBSITE_URL } = docLinks; + const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference`; + const observabilityDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/observability`; + + // TODO: Revisit warnings returned for 8.0 upgrade; many of these are likely obselete now + return ( + <> + <EuiFlyoutBody> + {renderGlobalCallouts()} + <EuiCallOut + title={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutTitle" + defaultMessage="This index requires destructive changes that can't be undone" + /> + } + color="danger" + iconType="alert" + > + <p> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutDetail" + defaultMessage="Back up your index, then proceed with the reindex by accepting each breaking change." + /> + </p> + </EuiCallOut> + + <EuiSpacer /> + + {warnings.includes(ReindexWarning.allField) && ( + <WarningCheckbox + checkedIds={checkedIds} + onChange={onChange} + warning={ReindexWarning.allField} + label={ <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutTitle" - defaultMessage="This index requires destructive changes that can't be undone" + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.allFieldWarningTitle" + defaultMessage="{allField} will be removed" + values={{ + allField: <EuiCode>_all</EuiCode>, + }} /> } - color="danger" - iconType="alert" - > - <p> + description={ <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutDetail" - defaultMessage="Back up your index, then proceed with the reindex by accepting each breaking change." - /> - </p> - </EuiCallOut> - - <EuiSpacer /> - - {warnings.includes(ReindexWarning.allField) && ( - <WarningCheckbox - checkedIds={checkedIds} - onChange={this.onChange} - warning={ReindexWarning.allField} - label={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.allFieldWarningTitle" - defaultMessage="{allField} will be removed" - values={{ - allField: <EuiCode>_all</EuiCode>, - }} - /> - } - description={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.allFieldWarningDetail" - defaultMessage="The {allField} meta field is no longer supported in 7.0. Reindexing removes + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.allFieldWarningDetail" + defaultMessage="The {allField} meta field is no longer supported in 7.0. Reindexing removes the {allField} field in the new index. Ensure that no application code or scripts reply on this field." - values={{ - allField: <EuiCode>_all</EuiCode>, - }} - /> - } - documentationUrl="https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_the_literal__all_literal_meta_field_is_now_disabled_by_default" - /> - )} - - {warnings.includes(ReindexWarning.apmReindex) && ( - <WarningCheckbox - checkedIds={checkedIds} - onChange={this.onChange} - warning={ReindexWarning.apmReindex} - label={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningTitle" - defaultMessage="This index will be converted to ECS format" - /> - } - description={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningDetail" - defaultMessage="Starting in version 7.0.0, APM data will be represented in the Elastic Common Schema. + values={{ + allField: <EuiCode>_all</EuiCode>, + }} + /> + } + documentationUrl={`${esDocBasePath}/6.0/breaking_60_mappings_changes.html#_the_literal__all_literal_meta_field_is_now_disabled_by_default`} + /> + )} + + {warnings.includes(ReindexWarning.apmReindex) && ( + <WarningCheckbox + checkedIds={checkedIds} + onChange={onChange} + warning={ReindexWarning.apmReindex} + label={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningTitle" + defaultMessage="This index will be converted to ECS format" + /> + } + description={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningDetail" + defaultMessage="Starting in version 7.0.0, APM data will be represented in the Elastic Common Schema. Historical APM data will not visible until it's reindexed." - /> - } - documentationUrl="https://www.elastic.co/guide/en/apm/get-started/master/apm-release-notes.html" - /> - )} - - {warnings.includes(ReindexWarning.booleanFields) && ( - <WarningCheckbox - checkedIds={checkedIds} - onChange={this.onChange} - warning={ReindexWarning.booleanFields} - label={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.booleanFieldsWarningTitle" - defaultMessage="Boolean data in {_source} might change" - values={{ _source: <EuiCode>_source</EuiCode> }} - /> - } - description={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.booleanFieldsWarningDetail" - defaultMessage="If a document contain a boolean field that is neither {true} or {false} + /> + } + documentationUrl={`${observabilityDocBasePath}/master/whats-new.html`} + /> + )} + + {warnings.includes(ReindexWarning.booleanFields) && ( + <WarningCheckbox + checkedIds={checkedIds} + onChange={onChange} + warning={ReindexWarning.booleanFields} + label={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.booleanFieldsWarningTitle" + defaultMessage="Boolean data in {_source} might change" + values={{ _source: <EuiCode>_source</EuiCode> }} + /> + } + description={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.booleanFieldsWarningDetail" + defaultMessage="If a document contain a boolean field that is neither {true} or {false} (for example, {yes}, {on}, {one}), reindexing converts these fields to {true} or {false}. Ensure that no application code or scripts rely on boolean fields in the deprecated format." - values={{ - true: <EuiCode>true</EuiCode>, - false: <EuiCode>false</EuiCode>, - yes: <EuiCode>"yes"</EuiCode>, - on: <EuiCode>"on"</EuiCode>, - one: <EuiCode>1</EuiCode>, - }} - /> - } - documentationUrl="https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_field" - /> - )} - </EuiFlyoutBody> - <EuiFlyoutFooter> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left"> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton fill color="danger" onClick={advanceNextStep} disabled={blockAdvance}> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.continueButtonLabel" - defaultMessage="Continue with reindex" - /> - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlyoutFooter> - </Fragment> - ); - } - - private onChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const optionId = e.target.id; - const nextCheckedIds = { - ...this.state.checkedIds, - ...{ - [optionId]: !this.state.checkedIds[optionId], - }, - }; - - this.setState({ checkedIds: nextCheckedIds }); - }; -} + values={{ + true: <EuiCode>true</EuiCode>, + false: <EuiCode>false</EuiCode>, + yes: <EuiCode>"yes"</EuiCode>, + on: <EuiCode>"on"</EuiCode>, + one: <EuiCode>1</EuiCode>, + }} + /> + } + documentationUrl={`${esDocBasePath}/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_field`} + /> + )} + </EuiFlyoutBody> + <EuiFlyoutFooter> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left"> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel" + defaultMessage="Cancel" + /> + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton fill color="danger" onClick={advanceNextStep} disabled={blockAdvance}> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.continueButtonLabel" + defaultMessage="Continue with reindex" + /> + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutFooter> + </> + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx index 0e6c79dc47b53..7a1ffb955db5c 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/deprecation_logging_toggle.tsx @@ -7,13 +7,13 @@ import React from 'react'; import { EuiLoadingSpinner, EuiSwitch } from '@elastic/eui'; -import { injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { HttpSetup } from 'src/core/public'; import { LoadingState } from '../../types'; -interface DeprecationLoggingTabProps extends ReactIntl.InjectedIntlProps { +interface DeprecationLoggingTabProps { http: HttpSetup; } @@ -22,7 +22,7 @@ interface DeprecationLoggingTabState { loggingEnabled?: boolean; } -export class DeprecationLoggingToggleUI extends React.Component< +export class DeprecationLoggingToggle extends React.Component< DeprecationLoggingTabProps, DeprecationLoggingTabState > { @@ -59,27 +59,29 @@ export class DeprecationLoggingToggleUI extends React.Component< } private renderLoggingState() { - const { intl } = this.props; const { loggingEnabled, loadingState } = this.state; if (loadingState === LoadingState.Error) { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', - defaultMessage: 'Could not load logging state', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.errorLabel', + { + defaultMessage: 'Could not load logging state', + } + ); } else if (loggingEnabled) { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', - defaultMessage: 'On', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.enabledLabel', + { + defaultMessage: 'On', + } + ); } else { - return intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel', - defaultMessage: 'Off', - }); + return i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingToggleSwitch.disabledLabel', + { + defaultMessage: 'Off', + } + ); } } @@ -117,5 +119,3 @@ export class DeprecationLoggingToggleUI extends React.Component< } }; } - -export const DeprecationLoggingToggle = injectI18n(DeprecationLoggingToggleUI); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx index 85d275b080e13..dd392f6d1b294 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx @@ -17,7 +17,7 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { CURRENT_MAJOR_VERSION, NEXT_MAJOR_VERSION } from '../../../../../common/version'; import { UpgradeAssistantTabProps } from '../../types'; @@ -54,7 +54,7 @@ const WAIT_FOR_RELEASE_STEP = { // Swap in this step for the one above it on the last minor release. // @ts-ignore -const START_UPGRADE_STEP = (isCloudEnabled: boolean) => ({ +const START_UPGRADE_STEP = (isCloudEnabled: boolean, esDocBasePath: string) => ({ title: i18n.translate('xpack.upgradeAssistant.overviewTab.steps.startUpgradeStep.stepTitle', { defaultMessage: 'Start your upgrade', }), @@ -73,10 +73,7 @@ const START_UPGRADE_STEP = (isCloudEnabled: boolean) => ({ defaultMessage="Follow {instructionButton} to start your upgrade." values={{ instructionButton: ( - <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/setup-upgrade.html" - target="_blank" - > + <EuiLink href={`${esDocBasePath}/setup-upgrade.html`} target="_blank" external> <FormattedMessage id="xpack.upgradeAssistant.overviewTab.steps.startUpgradeStepOnPrem.stepDetail.instructionButtonLabel" defaultMessage="these instructions" @@ -92,10 +89,9 @@ const START_UPGRADE_STEP = (isCloudEnabled: boolean) => ({ ), }); -export const StepsUI: FunctionComponent<UpgradeAssistantTabProps & ReactIntl.InjectedIntlProps> = ({ +export const Steps: FunctionComponent<UpgradeAssistantTabProps> = ({ checkupData, setSelectedTabIndex, - intl, }) => { const checkupDataTyped = (checkupData! as unknown) as { [checkupType: string]: any[] }; const countByType = Object.keys(checkupDataTyped).reduce((counts, checkupType) => { @@ -104,7 +100,10 @@ export const StepsUI: FunctionComponent<UpgradeAssistantTabProps & ReactIntl.Inj }, {} as { [checkupType: string]: number }); // Uncomment when START_UPGRADE_STEP is in use! - const { http /* , isCloudEnabled */ } = useAppContext(); + const { docLinks, http /* , isCloudEnabled */ } = useAppContext(); + + const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; + const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; return ( <EuiSteps @@ -113,15 +112,18 @@ export const StepsUI: FunctionComponent<UpgradeAssistantTabProps & ReactIntl.Inj steps={[ { title: countByType.cluster - ? intl.formatMessage({ - id: 'xpack.upgradeAssistant.overviewTab.steps.clusterStep.issuesRemainingStepTitle', - defaultMessage: 'Check for issues with your cluster', - }) - : intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.clusterStep.noIssuesRemainingStepTitle', - defaultMessage: 'Your cluster settings are ready', - }), + ? i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.clusterStep.issuesRemainingStepTitle', + { + defaultMessage: 'Check for issues with your cluster', + } + ) + : i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.clusterStep.noIssuesRemainingStepTitle', + { + defaultMessage: 'Your cluster settings are ready', + } + ), status: countByType.cluster ? 'warning' : 'complete', children: ( <EuiText> @@ -168,15 +170,18 @@ export const StepsUI: FunctionComponent<UpgradeAssistantTabProps & ReactIntl.Inj }, { title: countByType.indices - ? intl.formatMessage({ - id: 'xpack.upgradeAssistant.overviewTab.steps.indicesStep.issuesRemainingStepTitle', - defaultMessage: 'Check for issues with your indices', - }) - : intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.indicesStep.noIssuesRemainingStepTitle', - defaultMessage: 'Your index settings are ready', - }), + ? i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.indicesStep.issuesRemainingStepTitle', + { + defaultMessage: 'Check for issues with your indices', + } + ) + : i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.indicesStep.noIssuesRemainingStepTitle', + { + defaultMessage: 'Your index settings are ready', + } + ), status: countByType.indices ? 'warning' : 'complete', children: ( <EuiText> @@ -222,10 +227,12 @@ export const StepsUI: FunctionComponent<UpgradeAssistantTabProps & ReactIntl.Inj ), }, { - title: intl.formatMessage({ - id: 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.stepTitle', - defaultMessage: 'Review the Elasticsearch deprecation logs', - }), + title: i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.stepTitle', + { + defaultMessage: 'Review the Elasticsearch deprecation logs', + } + ), children: ( <Fragment> <EuiText grow={false}> @@ -237,8 +244,9 @@ export const StepsUI: FunctionComponent<UpgradeAssistantTabProps & ReactIntl.Inj values={{ deprecationLogsDocButton: ( <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/logging.html#deprecation-logging" + href={`${esDocBasePath}/logging.html#deprecation-logging`} target="_blank" + external > <FormattedMessage id="xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.deprecationLogs.deprecationLogsDocButtonLabel" @@ -255,11 +263,12 @@ export const StepsUI: FunctionComponent<UpgradeAssistantTabProps & ReactIntl.Inj <EuiSpacer /> <EuiFormRow - label={intl.formatMessage({ - id: - 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingLabel', - defaultMessage: 'Enable deprecation logging?', - })} + label={i18n.translate( + 'xpack.upgradeAssistant.overviewTab.steps.deprecationLogsStep.enableDeprecationLoggingLabel', + { + defaultMessage: 'Enable deprecation logging?', + } + )} describedByIds={['deprecation-logging']} > <DeprecationLoggingToggle http={http} /> @@ -270,10 +279,8 @@ export const StepsUI: FunctionComponent<UpgradeAssistantTabProps & ReactIntl.Inj // Swap in START_UPGRADE_STEP on the last minor release. WAIT_FOR_RELEASE_STEP, - // START_UPGRADE_STEP(isCloudEnabled), + // START_UPGRADE_STEP(isCloudEnabled, esDocBasePath), ]} /> ); }; - -export const Steps = injectI18n(StepsUI); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx index 28b74c5affbdf..b3d20a6acd3e3 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.test.tsx @@ -59,10 +59,13 @@ describe('useBarChartsHooks', () => { const firstChartItems = result.current[0]; const lastChartItems = result.current[4]; - // first chart items last item should be x 199, since we only display 150 items + // first chart items last item should be x 149, since we only display 150 items expect(firstChartItems[firstChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS - 1); - // since here are 5 charts, last chart first item should be x 800 + // first chart will only contain x values from 0 - 149; + expect(firstChartItems.find((item) => item.x > 149)).toBe(undefined); + + // since here are 5 charts, last chart first item should be x 600 expect(lastChartItems[0].x).toBe(CANVAS_MAX_ITEMS * 4); expect(lastChartItems[lastChartItems.length - 1].x).toBe(CANVAS_MAX_ITEMS * 5 - 1); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts index 3345b30f5239f..7beb0be28902b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/use_bar_charts.ts @@ -17,17 +17,16 @@ export const useBarCharts = ({ data = [] }: UseBarHookProps) => { useEffect(() => { if (data.length > 0) { - let chartIndex = 1; + let chartIndex = 0; - const firstCanvasItems = data.filter((item) => item.x <= CANVAS_MAX_ITEMS); - - const chartsN: Array<IWaterfallContext['data']> = [firstCanvasItems]; + const chartsN: Array<IWaterfallContext['data']> = []; data.forEach((item) => { // Subtract 1 to account for x value starting from 0 if (item.x === CANVAS_MAX_ITEMS * chartIndex && !chartsN[item.x / CANVAS_MAX_ITEMS]) { - chartsN.push([]); + chartsN.push([item]); chartIndex++; + return; } chartsN[chartIndex - 1].push(item); }); diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/README.md b/x-pack/plugins/vis_type_timeseries_enhanced/README.md deleted file mode 100644 index 33aa16d8574ae..0000000000000 --- a/x-pack/plugins/vis_type_timeseries_enhanced/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# vis_type_timeseries_enhanced - -The `vis_type_timeseries_enhanced` plugin is the x-pack counterpart to the OSS `vis_type_timeseries` plugin. - -It exists to provide Elastic-licensed services, or parts of services, which -enhance existing OSS functionality from `vis_type_timeseries`. - -Currently the `vis_type_timeseries_enhanced` plugin doesn't return any APIs which you can -consume directly. - diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/kibana.json b/x-pack/plugins/vis_type_timeseries_enhanced/kibana.json deleted file mode 100644 index 4b296856c3f97..0000000000000 --- a/x-pack/plugins/vis_type_timeseries_enhanced/kibana.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "visTypeTimeseriesEnhanced", - "version": "8.0.0", - "kibanaVersion": "kibana", - "server": true, - "ui": false, - "requiredPlugins": [ - "visTypeTimeseries" - ] -} diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts b/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts deleted file mode 100644 index 0598a691ab7c5..0000000000000 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Plugin, PluginInitializerContext, Logger, CoreSetup } from 'src/core/server'; -import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; -import { RollupSearchStrategy } from './search_strategies/rollup_search_strategy'; - -interface VisTypeTimeseriesEnhancedSetupDependencies { - visTypeTimeseries: VisTypeTimeseriesSetup; -} - -export class VisTypeTimeseriesEnhanced - implements Plugin<void, void, VisTypeTimeseriesEnhancedSetupDependencies, any> { - private logger: Logger; - - constructor(initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get('vis_type_timeseries_enhanced'); - } - - public async setup( - core: CoreSetup, - { visTypeTimeseries }: VisTypeTimeseriesEnhancedSetupDependencies - ) { - this.logger.debug('Starting plugin'); - - visTypeTimeseries.addSearchStrategy(new RollupSearchStrategy()); - } - - public start() {} -} diff --git a/x-pack/plugins/watcher/public/application/models/watch/default_watch.js b/x-pack/plugins/watcher/public/application/models/watch/default_watch.js new file mode 100644 index 0000000000000..51b28f8bd0332 --- /dev/null +++ b/x-pack/plugins/watcher/public/application/models/watch/default_watch.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export const defaultWatch = { + trigger: { + schedule: { + interval: '30m', + }, + }, + input: { + search: { + request: { + body: { + size: 0, + query: { + match_all: {}, + }, + }, + indices: ['*'], + }, + }, + }, + condition: { + compare: { + 'ctx.payload.hits.total': { + gte: 10, + }, + }, + }, + actions: { + 'my-logging-action': { + logging: { + text: 'There are {{ctx.payload.hits.total}} documents in your index. Threshold is 10.', + }, + }, + }, +}; diff --git a/x-pack/plugins/watcher/public/application/models/watch/default_watch.json b/x-pack/plugins/watcher/public/application/models/watch/default_watch.json deleted file mode 100644 index 22c78660a0bb0..0000000000000 --- a/x-pack/plugins/watcher/public/application/models/watch/default_watch.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "trigger": { - "schedule": { - "interval": "30m" - } - }, - "input": { - "search": { - "request": { - "body": { - "size": 0, - "query" : { - "match_all": {} - } - }, - "indices": [ "*" ] - } - } - }, - "condition": { - "compare": { - "ctx.payload.hits.total": { - "gte": 10 - } - }}, - "actions": { - "my-logging-action": { - "logging": { - "text": "There are {{ctx.payload.hits.total}} documents in your index. Threshold is 10." - } - } - } -} diff --git a/x-pack/plugins/watcher/public/application/models/watch/index.d.ts b/x-pack/plugins/watcher/public/application/models/watch/index.d.ts index 73ee2279d3912..b9158bdd9ed70 100644 --- a/x-pack/plugins/watcher/public/application/models/watch/index.d.ts +++ b/x-pack/plugins/watcher/public/application/models/watch/index.d.ts @@ -5,3 +5,4 @@ */ export const Watch: any; +export const defaultWatch: any; diff --git a/x-pack/plugins/watcher/public/application/models/watch/index.js b/x-pack/plugins/watcher/public/application/models/watch/index.js index 9a74f6e548409..0741e5c5400df 100644 --- a/x-pack/plugins/watcher/public/application/models/watch/index.js +++ b/x-pack/plugins/watcher/public/application/models/watch/index.js @@ -5,3 +5,4 @@ */ export { Watch } from './watch'; +export { defaultWatch } from './default_watch'; diff --git a/x-pack/plugins/watcher/public/application/models/watch/json_watch.js b/x-pack/plugins/watcher/public/application/models/watch/json_watch.js index 0e67c8b18ca5e..24378d42b7451 100644 --- a/x-pack/plugins/watcher/public/application/models/watch/json_watch.js +++ b/x-pack/plugins/watcher/public/application/models/watch/json_watch.js @@ -6,11 +6,12 @@ import uuid from 'uuid'; import { get } from 'lodash'; -import { BaseWatch } from './base_watch'; -import { ACTION_TYPES, WATCH_TYPES } from '../../../../common/constants'; -import defaultWatchJson from './default_watch.json'; import { i18n } from '@kbn/i18n'; +import { ACTION_TYPES, WATCH_TYPES } from '../../../../common/constants'; +import { BaseWatch } from './base_watch'; +import { defaultWatch } from './default_watch'; + /** * {@code JsonWatch} allows a user to create a Watch by writing the raw JSON. */ @@ -20,11 +21,11 @@ export class JsonWatch extends BaseWatch { props.id = typeof props.id === 'undefined' ? uuid.v4() : props.id; super(props); const existingWatch = get(props, 'watch'); - this.watch = existingWatch ? existingWatch : defaultWatchJson; + this.watch = existingWatch ? existingWatch : defaultWatch; this.watchString = get( props, 'watchString', - JSON.stringify(existingWatch ? existingWatch : defaultWatchJson, null, 2) + JSON.stringify(existingWatch ? existingWatch : defaultWatch, null, 2) ); this.id = props.id; } @@ -113,7 +114,6 @@ export class JsonWatch extends BaseWatch { return new JsonWatch(upstreamWatch); } - static defaultWatchJson = defaultWatchJson; static typeName = i18n.translate('xpack.watcher.models.jsonWatch.typeName', { defaultMessage: 'Advanced Watch', }); diff --git a/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts b/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts index 6f243e130c235..7b4876f542292 100644 --- a/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts +++ b/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts @@ -8,4 +8,4 @@ import { getWatch } from '../../__fixtures__'; export const WATCH_ID = 'my-test-watch'; -export const WATCH = { watch: getWatch({ id: WATCH_ID }) }; +export const WATCH: any = { watch: getWatch({ id: WATCH_ID }) }; diff --git a/x-pack/plugins/watcher/tests_client_integration/watch_create_json.test.ts b/x-pack/plugins/watcher/tests_client_integration/watch_create_json.test.ts index b3fbb8235f251..9bd8f8bbd7d57 100644 --- a/x-pack/plugins/watcher/tests_client_integration/watch_create_json.test.ts +++ b/x-pack/plugins/watcher/tests_client_integration/watch_create_json.test.ts @@ -5,11 +5,12 @@ */ import { act } from 'react-dom/test-utils'; + +import { getExecuteDetails } from '../__fixtures__'; +import { defaultWatch } from '../public/application/models/watch'; import { setupEnvironment, pageHelpers, nextTick, wrapBodyResponse } from './helpers'; import { WatchCreateJsonTestBed } from './helpers/watch_create_json.helpers'; import { WATCH } from './helpers/jest_constants'; -import defaultWatchJson from '../public/application/models/watch/default_watch.json'; -import { getExecuteDetails } from '../__fixtures__'; const { setup } = pageHelpers.watchCreateJson; @@ -117,7 +118,7 @@ describe('<JsonWatchEdit /> create route', () => { }, }, ], - watch: defaultWatchJson, + watch: defaultWatch, }) ); }); @@ -172,7 +173,7 @@ describe('<JsonWatchEdit /> create route', () => { const latestRequest = server.requests[server.requests.length - 1]; - const actionModes = Object.keys(defaultWatchJson.actions).reduce( + const actionModes = Object.keys(defaultWatch.actions).reduce( (actionAccum: any, action) => { actionAccum[action] = 'simulate'; return actionAccum; @@ -186,7 +187,7 @@ describe('<JsonWatchEdit /> create route', () => { isNew: true, isActive: true, actions: [], - watch: defaultWatchJson, + watch: defaultWatch, }; expect(latestRequest.requestBody).toEqual( @@ -234,7 +235,7 @@ describe('<JsonWatchEdit /> create route', () => { const latestRequest = server.requests[server.requests.length - 1]; - const actionModes = Object.keys(defaultWatchJson.actions).reduce( + const actionModes = Object.keys(defaultWatch.actions).reduce( (actionAccum: any, action) => { actionAccum[action] = ACTION_MODE; return actionAccum; @@ -248,7 +249,7 @@ describe('<JsonWatchEdit /> create route', () => { isNew: true, isActive: true, actions: [], - watch: defaultWatchJson, + watch: defaultWatch, }; const triggeredTime = `now+${TRIGGERED_TIME}s`; diff --git a/x-pack/plugins/watcher/tests_client_integration/watch_edit.test.ts b/x-pack/plugins/watcher/tests_client_integration/watch_edit.test.ts index eefe9d03c05ef..c24d939c9237e 100644 --- a/x-pack/plugins/watcher/tests_client_integration/watch_edit.test.ts +++ b/x-pack/plugins/watcher/tests_client_integration/watch_edit.test.ts @@ -7,12 +7,13 @@ import { act } from 'react-dom/test-utils'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import axios from 'axios'; +import { getRandomString } from '@kbn/test/jest'; + +import { getWatch } from '../__fixtures__'; +import { defaultWatch } from '../public/application/models/watch'; import { setupEnvironment, pageHelpers, nextTick, wrapBodyResponse } from './helpers'; import { WatchEditTestBed } from './helpers/watch_edit.helpers'; import { WATCH } from './helpers/jest_constants'; -import defaultWatchJson from '../public/application/models/watch/default_watch.json'; -import { getWatch } from '../__fixtures__'; -import { getRandomString } from '@kbn/test/jest'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); @@ -69,7 +70,7 @@ describe('<WatchEdit />', () => { expect(exists('jsonWatchForm')).toBe(true); expect(find('nameInput').props().value).toBe(watch.name); expect(find('idInput').props().value).toBe(watch.id); - expect(JSON.parse(codeEditor.props().value as string)).toEqual(defaultWatchJson); + expect(JSON.parse(codeEditor.props().value as string)).toEqual(defaultWatch); // ID should not be editable expect(find('idInput').props().readOnly).toEqual(true); @@ -112,7 +113,7 @@ describe('<WatchEdit />', () => { }, }, ], - watch: defaultWatchJson, + watch: defaultWatch, }) ); }); diff --git a/x-pack/plugins/watcher/tsconfig.json b/x-pack/plugins/watcher/tsconfig.json new file mode 100644 index 0000000000000..4680847ba486d --- /dev/null +++ b/x-pack/plugins/watcher/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*", + "public/**/*", + "common/**/*", + "tests_client_integration/**/*", + "__fixtures__/*", + "../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + ] +} diff --git a/x-pack/test/accessibility/apps/index_lifecycle_management.ts b/x-pack/test/accessibility/apps/index_lifecycle_management.ts new file mode 100644 index 0000000000000..744a21cf381a8 --- /dev/null +++ b/x-pack/test/accessibility/apps/index_lifecycle_management.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +const TEST_POLICY_NAME = 'ilm-a11y-test'; +const TEST_POLICY_ALL_PHASES = { + policy: { + phases: { + hot: { + actions: {}, + }, + warm: { + actions: {}, + }, + cold: { + actions: {}, + }, + delete: { + actions: {}, + }, + }, + }, +}; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { common } = getPageObjects(['common']); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const esClient = getService('es'); + const a11y = getService('a11y'); + + const findPolicyLinkInListView = async (policyName: string) => { + const links = await testSubjects.findAll('policyTablePolicyNameLink'); + for (const link of links) { + const name = await link.getVisibleText(); + if (name === policyName) { + return link; + } + } + throw new Error(`Could not find ${policyName} in policy table`); + }; + + describe('Index Lifecycle Management', async () => { + before(async () => { + await esClient.ilm.putLifecycle({ policy: TEST_POLICY_NAME, body: TEST_POLICY_ALL_PHASES }); + await common.navigateToApp('indexLifecycleManagement'); + }); + + after(async () => { + await esClient.ilm.deleteLifecycle({ policy: TEST_POLICY_NAME }); + }); + + it('List policies view', async () => { + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + await common.navigateToApp('indexLifecycleManagement'); + return testSubjects.exists('policyTablePolicyNameLink') ? true : false; + }); + await a11y.testAppSnapshot(); + }); + + it('Edit policy with all phases view', async () => { + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + await common.navigateToApp('indexLifecycleManagement'); + return testSubjects.exists('policyTablePolicyNameLink'); + }); + const link = await findPolicyLinkInListView(TEST_POLICY_NAME); + await link.click(); + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + return testSubjects.exists('policyTitle'); + }); + await a11y.testAppSnapshot(); + }); + }); +} diff --git a/x-pack/test/accessibility/apps/ml.ts b/x-pack/test/accessibility/apps/ml.ts index 799911cd77a9f..b1fd96c4d160f 100644 --- a/x-pack/test/accessibility/apps/ml.ts +++ b/x-pack/test/accessibility/apps/ml.ts @@ -12,8 +12,7 @@ export default function ({ getService }: FtrProviderContext) { const a11y = getService('a11y'); const ml = getService('ml'); - // flaky tests, see https://github.com/elastic/kibana/issues/88592 - describe.skip('ml', () => { + describe('ml', () => { const esArchiver = getService('esArchiver'); before(async () => { @@ -239,6 +238,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('data frame analytics create job select index pattern modal', async () => { + await ml.navigation.navigateToMl(); await ml.navigation.navigateToDataFrameAnalytics(); await ml.dataFrameAnalytics.startAnalyticsCreation(); await a11y.testAppSnapshot(); @@ -261,6 +261,8 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); await ml.testExecution.logTestStep('enables the source data preview histogram charts'); await ml.dataFrameAnalyticsCreation.enableSourceDataPreviewHistogramCharts(); + await ml.testExecution.logTestStep('displays the include fields selection'); + await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); await a11y.testAppSnapshot(); }); diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index cf13a009c2821..67bfdd7a07b9d 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -27,6 +27,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/roles'), require.resolve('./apps/kibana_overview'), require.resolve('./apps/ingest_node_pipelines'), + require.resolve('./apps/index_lifecycle_management'), require.resolve('./apps/ml'), require.resolve('./apps/lens'), ], diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index cf944008c08d6..4193843c63bce 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -17,6 +17,7 @@ interface CreateTestConfigOptions { disabledPlugins?: string[]; ssl?: boolean; enableActionsProxy: boolean; + rejectUnauthorized?: boolean; } // test.not-enabled is specifically not enabled @@ -39,7 +40,12 @@ const enabledActionTypes = [ ]; export function createTestConfig(name: string, options: CreateTestConfigOptions) { - const { license = 'trial', disabledPlugins = [], ssl = false } = options; + const { + license = 'trial', + disabledPlugins = [], + ssl = false, + rejectUnauthorized = true, + } = options; return async ({ readConfigFile }: FtrConfigProviderContext) => { const xPackApiIntegrationTestsConfig = await readConfigFile( @@ -95,6 +101,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.alerts.invalidateApiKeysTask.interval="15s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, + `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, ...actionsProxyUrl, '--xpack.eventLog.logEntries=true', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index e7ce0638c6319..18f3c83b00141 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -5,6 +5,7 @@ */ import http from 'http'; +import https from 'https'; import { Plugin, CoreSetup, IRouter } from 'kibana/server'; import { EncryptedSavedObjectsPluginStart } from '../../../../../../../plugins/encrypted_saved_objects/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../../plugins/features/server'; @@ -47,7 +48,13 @@ export function getAllExternalServiceSimulatorPaths(): string[] { } export async function getWebhookServer(): Promise<http.Server> { - return await initWebhook(); + const { httpServer } = await initWebhook(); + return httpServer; +} + +export async function getHttpsWebhookServer(): Promise<https.Server> { + const { httpsServer } = await initWebhook(); + return httpsServer; } export async function getSlackServer(): Promise<http.Server> { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts index a34293090d7af..116f0604a37c9 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts @@ -3,16 +3,35 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import fs from 'fs'; import expect from '@kbn/expect'; import http from 'http'; +import https from 'https'; +import { promisify } from 'util'; import { fromNullable, map, filter, getOrElse } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; import { constant } from 'fp-ts/lib/function'; +import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils'; export async function initPlugin() { - const payloads: string[] = []; + const httpsServerKey = await promisify(fs.readFile)(KBN_KEY_PATH, 'utf8'); + const httpsServerCert = await promisify(fs.readFile)(KBN_CERT_PATH, 'utf8'); + + return { + httpServer: http.createServer(createServerCallback()), + httpsServer: https.createServer( + { + key: httpsServerKey, + cert: httpsServerCert, + }, + createServerCallback() + ), + }; +} - return http.createServer((request, response) => { +function createServerCallback() { + const payloads: string[] = []; + return (request: http.IncomingMessage, response: http.ServerResponse) => { const credentials = pipe( fromNullable(request.headers.authorization), map((authorization) => authorization.split(/\s+/)), @@ -77,7 +96,7 @@ export async function initPlugin() { return; }); } - }); + }; } function validateAuthentication(credentials: any, res: any) { diff --git a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts index 6336d834c3943..5b093dfb28eab 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts @@ -28,6 +28,7 @@ interface GetEventLogParams { id: string; provider: string; actions: Map<string, { gte: number } | { equal: number }>; + filter?: string; } // Return event log entries given the specified parameters; for the `actions` @@ -37,7 +38,9 @@ export async function getEventLog(params: GetEventLogParams): Promise<IValidated const supertest = getService('supertest'); const spacePrefix = getUrlPrefix(spaceId); - const url = `${spacePrefix}/api/event_log/${type}/${id}/_find?per_page=5000`; + const url = `${spacePrefix}/api/event_log/${type}/${id}/_find?per_page=5000${ + params.filter ? `&filter=${params.filter}` : '' + }`; const { body: result } = await supertest.get(url).expect(200); if (!result.total) { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts index 9a3b2e7c137a4..a4f80c62cf2e9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts @@ -519,6 +519,7 @@ export default function ({ getService }: FtrProviderContext) { id: actionId, provider: 'actions', actions: new Map([['execute', { equal: 1 }]]), + filter: 'event.action:(execute)', }); }); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index 8ed979a171169..2a256266697e6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -11,10 +11,6 @@ import { setupSpacesAndUsers, tearDown } from '..'; export default function alertingTests({ loadTestFile, getService }: FtrProviderContext) { describe('Alerts', () => { describe('legacy alerts', () => { - before(async () => { - await setupSpacesAndUsers(getService); - }); - after(async () => { await tearDown(getService); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index f9860b642f13a..2b770395786b3 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -11,4 +11,5 @@ export default createTestConfig('spaces_only', { disabledPlugins: ['security'], license: 'trial', enableActionsProxy: false, + rejectUnauthorized: false, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts index acfbad007d722..1748e770929d6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts @@ -5,11 +5,15 @@ */ import http from 'http'; +import https from 'https'; import getPort from 'get-port'; import expect from '@kbn/expect'; import { URL, format as formatUrl } from 'url'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import { getWebhookServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; +import { + getWebhookServer, + getHttpsWebhookServer, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export export default function webhookTest({ getService }: FtrProviderContext) { @@ -43,32 +47,65 @@ export default function webhookTest({ getService }: FtrProviderContext) { } describe('webhook action', () => { - let webhookSimulatorURL: string = ''; - let webhookServer: http.Server; - before(async () => { - webhookServer = await getWebhookServer(); - const availablePort = await getPort({ port: 9000 }); - webhookServer.listen(availablePort); - webhookSimulatorURL = `http://localhost:${availablePort}`; - }); + describe('with http endpoint', () => { + let webhookSimulatorURL: string = ''; + let webhookServer: http.Server; + before(async () => { + webhookServer = await getWebhookServer(); + const availablePort = await getPort({ port: 9000 }); + webhookServer.listen(availablePort); + webhookSimulatorURL = `http://localhost:${availablePort}`; + }); + + it('webhook can be executed without username and password', async () => { + const webhookActionId = await createWebhookAction(webhookSimulatorURL); + const { body: result } = await supertest + .post(`/api/actions/action/${webhookActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'success', + }, + }) + .expect(200); - it('webhook can be executed without username and password', async () => { - const webhookActionId = await createWebhookAction(webhookSimulatorURL); - const { body: result } = await supertest - .post(`/api/actions/action/${webhookActionId}/_execute`) - .set('kbn-xsrf', 'test') - .send({ - params: { - body: 'success', - }, - }) - .expect(200); + expect(result.status).to.eql('ok'); + }); - expect(result.status).to.eql('ok'); + after(() => { + webhookServer.close(); + }); }); - after(() => { - webhookServer.close(); + describe('with https endpoint and rejectUnauthorized=false', () => { + let webhookSimulatorURL: string = ''; + let webhookServer: https.Server; + + before(async () => { + webhookServer = await getHttpsWebhookServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + webhookServer.listen(availablePort); + webhookSimulatorURL = `https://localhost:${availablePort}`; + }); + + it('should support the POST method against webhook target', async () => { + const webhookActionId = await createWebhookAction(webhookSimulatorURL, { method: 'post' }); + const { body: result } = await supertest + .post(`/api/actions/action/${webhookActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'success_post_method', + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + }); + + after(() => { + webhookServer.close(); + }); }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts new file mode 100644 index 0000000000000..a1ae35a29bf23 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { Spaces } from '../../../../scenarios'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { + ESTestIndexTool, + ES_TEST_INDEX_NAME, + getUrlPrefix, + ObjectRemover, +} from '../../../../../common/lib'; +import { createEsDocuments } from './create_test_data'; + +const ALERT_TYPE_ID = '.es-query'; +const ACTION_TYPE_ID = '.index'; +const ES_TEST_INDEX_SOURCE = 'builtin-alert:es-query'; +const ES_TEST_INDEX_REFERENCE = '-na-'; +const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`; + +const ALERT_INTERVALS_TO_WRITE = 5; +const ALERT_INTERVAL_SECONDS = 3; +const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; +const ES_GROUPS_TO_WRITE = 3; + +// eslint-disable-next-line import/no-default-export +export default function alertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('legacyEs'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME); + + describe('alert', async () => { + let endDate: string; + let actionId: string; + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + await esTestIndexToolOutput.destroy(); + await esTestIndexToolOutput.setup(); + + actionId = await createAction(supertest, objectRemover); + + // write documents in the future, figure out the end date + const endDateMillis = Date.now() + (ALERT_INTERVALS_TO_WRITE - 1) * ALERT_INTERVAL_MILLIS; + endDate = new Date(endDateMillis).toISOString(); + + // write documents from now to the future end date in groups + createEsDocumentsInGroups(ES_GROUPS_TO_WRITE); + }); + + afterEach(async () => { + await objectRemover.removeAll(); + await esTestIndexTool.destroy(); + await esTestIndexToolOutput.destroy(); + }); + + it('runs correctly: threshold on hit count < >', async () => { + await createAlert({ + name: 'never fire', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + thresholdComparator: '>', + threshold: [-1], + }); + + const docs = await waitForDocs(2); + for (let i = 0; i < docs.length; i++) { + const doc = docs[i]; + const { previousTimestamp, hits } = doc._source; + const { name, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(title).to.be(`alert 'always fire' matched query`); + const messagePattern = /alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); + expect(hits).not.to.be.empty(); + + // during the first execution, the latestTimestamp value should be empty + // since this alert always fires, the latestTimestamp value should be updated each execution + if (!i) { + expect(previousTimestamp).to.be.empty(); + } else { + expect(previousTimestamp).not.to.be.empty(); + } + } + }); + + it('runs correctly with query: threshold on hit count < >', async () => { + const rangeQuery = (rangeThreshold: number) => { + return { + query: { + bool: { + filter: [ + { + range: { + testedValue: { + gte: rangeThreshold, + }, + }, + }, + ], + }, + }, + }; + }; + + await createAlert({ + name: 'never fire', + esQuery: JSON.stringify(rangeQuery(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1)), + thresholdComparator: '>=', + threshold: [0], + }); + + await createAlert({ + name: 'fires once', + esQuery: JSON.stringify( + rangeQuery(Math.floor((ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2)) + ), + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(1); + for (const doc of docs) { + const { previousTimestamp, hits } = doc._source; + const { name, title, message } = doc._source.params; + + expect(name).to.be('fires once'); + expect(title).to.be(`alert 'fires once' matched query`); + const messagePattern = /alert 'fires once' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); + expect(hits).not.to.be.empty(); + expect(previousTimestamp).to.be.empty(); + } + }); + + async function createEsDocumentsInGroups(groups: number) { + await createEsDocuments( + es, + esTestIndexTool, + endDate, + ALERT_INTERVALS_TO_WRITE, + ALERT_INTERVAL_MILLIS, + groups + ); + } + + async function waitForDocs(count: number): Promise<any[]> { + return await esTestIndexToolOutput.waitForDocs( + ES_TEST_INDEX_SOURCE, + ES_TEST_INDEX_REFERENCE, + count + ); + } + + interface CreateAlertParams { + name: string; + timeField?: string; + esQuery: string; + thresholdComparator: string; + threshold: number[]; + timeWindowSize?: number; + } + + async function createAlert(params: CreateAlertParams): Promise<string> { + const action = { + id: actionId, + group: 'query matched', + params: { + documents: [ + { + source: ES_TEST_INDEX_SOURCE, + reference: ES_TEST_INDEX_REFERENCE, + params: { + name: '{{{alertName}}}', + value: '{{{context.value}}}', + title: '{{{context.title}}}', + message: '{{{context.message}}}', + }, + hits: '{{context.hits}}', + date: '{{{context.date}}}', + previousTimestamp: '{{{state.latestTimestamp}}}', + }, + ], + }, + }; + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send({ + name: params.name, + consumer: 'alerts', + enabled: true, + alertTypeId: ALERT_TYPE_ID, + schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` }, + actions: [action], + params: { + index: [ES_TEST_INDEX_NAME], + timeField: params.timeField || 'date', + esQuery: params.esQuery, + timeWindowSize: params.timeWindowSize || ALERT_INTERVAL_SECONDS * 5, + timeWindowUnit: 's', + thresholdComparator: params.thresholdComparator, + threshold: params.threshold, + }, + }) + .expect(200); + + const alertId = createdAlert.id; + objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts'); + + return alertId; + } + }); +} + +async function createAction(supertest: any, objectRemover: ObjectRemover): Promise<string> { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'index action for es query FT', + actionTypeId: ACTION_TYPE_ID, + config: { + index: ES_TEST_OUTPUT_INDEX_NAME, + }, + secrets: {}, + }) + .expect(200); + + const actionId = createdAction.id; + objectRemover.add(Spaces.space1.id, actionId, 'action', 'actions'); + + return actionId; +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts new file mode 100644 index 0000000000000..7299827a72253 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { times } from 'lodash'; +import { v4 as uuid } from 'uuid'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib'; + +// default end date +export const END_DATE = '2020-01-01T00:00:00Z'; + +export const DOCUMENT_SOURCE = 'queryDataEndpointTests'; +export const DOCUMENT_REFERENCE = '-na-'; + +export async function createEsDocuments( + es: any, + esTestIndexTool: ESTestIndexTool, + endDate: string = END_DATE, + intervals: number = 1, + intervalMillis: number = 1000, + groups: number = 2 +) { + const endDateMillis = Date.parse(endDate) - intervalMillis / 2; + + let testedValue = 0; + times(intervals, (interval) => { + const date = endDateMillis - interval * intervalMillis; + + // don't need await on these, wait at the end of the function + times(groups, () => { + createEsDocument(es, date, testedValue++); + }); + }); + + const totalDocuments = intervals * groups; + await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, totalDocuments); +} + +async function createEsDocument(es: any, epochMillis: number, testedValue: number) { + const document = { + source: DOCUMENT_SOURCE, + reference: DOCUMENT_REFERENCE, + date: new Date(epochMillis).toISOString(), + date_epoch_millis: epochMillis, + testedValue, + }; + + const response = await es.index({ + id: uuid(), + index: ES_TEST_INDEX_NAME, + body: document, + }); + + if (response.result !== 'created') { + throw new Error(`document not created: ${JSON.stringify(response)}`); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts new file mode 100644 index 0000000000000..574f35e123fe8 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function alertingTests({ loadTestFile }: FtrProviderContext) { + describe('es_query', () => { + loadTestFile(require.resolve('./alert')); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts index c0147cbedcdfe..f59ef6829f892 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts @@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; export default function alertingTests({ loadTestFile }: FtrProviderContext) { describe('builtin alertTypes', () => { loadTestFile(require.resolve('./index_threshold')); + loadTestFile(require.resolve('./es_query')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index d3e1370bef285..5ff7b0d45a019 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -84,6 +84,23 @@ export default function eventLogTests({ getService }: FtrProviderContext) { }); }); + // get the filtered events only with action 'new-instance' + const filteredEvents = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: alertId, + provider: 'alerting', + actions: new Map([['new-instance', { equal: 1 }]]), + filter: 'event.action:(new-instance)', + }); + }); + + expect(getEventsByAction(filteredEvents, 'execute').length).equal(0); + expect(getEventsByAction(filteredEvents, 'execute-action').length).equal(0); + expect(getEventsByAction(events, 'new-instance').length).equal(1); + const executeEvents = getEventsByAction(events, 'execute'); const executeActionEvents = getEventsByAction(events, 'execute-action'); const newInstanceEvents = getEventsByAction(events, 'new-instance'); diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index b634e7117e607..9f9082c959ca5 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -41,7 +41,7 @@ export default function ({ getService }) { type: 'index-pattern', }, ]); - expect(resp.body.migrationVersion).to.eql({ map: '7.10.0' }); + expect(resp.body.migrationVersion).to.eql({ map: '7.12.0' }); expect(resp.body.attributes.layerListJSON.includes('indexPatternRefName')).to.be(true); }); }); diff --git a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts b/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts index 2d148f4c2c0f7..79d5e68344432 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts @@ -13,10 +13,13 @@ import { LOG_ENTRIES_PATH, logEntriesRequestRT, logEntriesResponseRT, +} from '../../../../plugins/infra/common/http_api'; + +import { LogTimestampColumn, LogFieldColumn, LogMessageColumn, -} from '../../../../plugins/infra/common/http_api'; +} from '../../../../plugins/infra/common/log_entry'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/search/search.ts b/x-pack/test/api_integration/apis/search/search.ts index 0c08b834a2778..2115976bcced1 100644 --- a/x-pack/test/api_integration/apis/search/search.ts +++ b/x-pack/test/api_integration/apis/search/search.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { verifyErrorResponse } from '../../../../../test/api_integration/apis/search/verify_error'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -90,6 +91,23 @@ export default function ({ getService }: FtrProviderContext) { expect(resp2.body.isRunning).to.be(false); }); + it('should fail without kbn-xref header', async () => { + const resp = await supertest + .post(`/internal/search/ese`) + .send({ + params: { + body: { + query: { + match_all: {}, + }, + }, + }, + }) + .expect(400); + + verifyErrorResponse(resp.body, 400, 'Request must contain a kbn-xsrf header.'); + }); + it('should return 400 when unknown index type is provided', async () => { const resp = await supertest .post(`/internal/search/ese`) @@ -106,7 +124,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('Unknown indexType'); + verifyErrorResponse(resp.body, 400, 'Unknown indexType'); }); it('should return 400 if invalid id is provided', async () => { @@ -124,7 +142,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should return 404 if unkown id is provided', async () => { @@ -143,12 +161,11 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(404); - - expect(resp.body.message).to.contain('resource_not_found_exception'); + verifyErrorResponse(resp.body, 404, 'resource_not_found_exception', true); }); it('should return 400 with a bad body', async () => { - await supertest + const resp = await supertest .post(`/internal/search/ese`) .set('kbn-xsrf', 'foo') .send({ @@ -160,6 +177,8 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); + + verifyErrorResponse(resp.body, 400, 'parsing_exception', true); }); }); @@ -186,8 +205,7 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(400); - - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should return 400 if rollup search is without non-existent index', async () => { @@ -207,7 +225,7 @@ export default function ({ getService }: FtrProviderContext) { }) .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should rollup search', async () => { @@ -241,7 +259,7 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send() .expect(400); - expect(resp.body.message).to.contain('illegal_argument_exception'); + verifyErrorResponse(resp.body, 400, 'illegal_argument_exception', true); }); it('should delete a search', async () => { diff --git a/x-pack/test/apm_api_integration/common/registry.ts b/x-pack/test/apm_api_integration/common/registry.ts index 8c918eae5a5a8..ba43db2fd5835 100644 --- a/x-pack/test/apm_api_integration/common/registry.ts +++ b/x-pack/test/apm_api_integration/common/registry.ts @@ -36,55 +36,69 @@ let configName: APMFtrConfigName | undefined; let running: boolean = false; -export const registry = { - init: (config: APMFtrConfigName) => { - configName = config; - callbacks.length = 0; - running = false; - }, - when: ( - title: string, - conditions: RunCondition | RunCondition[], - callback: (condition: RunCondition) => void - ) => { - const allConditions = castArray(conditions); - - if (!allConditions.length) { - throw new Error('At least one condition should be defined'); - } +function when( + title: string, + conditions: RunCondition | RunCondition[], + callback: (condition: RunCondition) => void, + skip?: boolean +) { + const allConditions = castArray(conditions); + + if (!allConditions.length) { + throw new Error('At least one condition should be defined'); + } - if (running) { - throw new Error("Can't add tests when running"); - } + if (running) { + throw new Error("Can't add tests when running"); + } - const frame = maybe(callsites()[1]); + const frame = maybe(callsites()[1]); - const file = frame?.getFileName(); + const file = frame?.getFileName(); - if (!file) { - throw new Error('Could not infer file for suite'); - } + if (!file) { + throw new Error('Could not infer file for suite'); + } - allConditions.forEach((matchedCondition) => { - callbacks.push({ - ...matchedCondition, - runs: [ - { - cb: () => { - const suite = describe(title, () => { + allConditions.forEach((matchedCondition) => { + callbacks.push({ + ...matchedCondition, + runs: [ + { + cb: () => { + const suite: ReturnType<typeof describe> = (skip ? describe.skip : describe)( + title, + () => { callback(matchedCondition); - }); + } + ) as any; - suite.file = file; - suite.eachTest((test) => { - test.file = file; - }); - }, + suite.file = file; + suite.eachTest((test) => { + test.file = file; + }); }, - ], - }); + }, + ], }); + }); +} + +when.skip = ( + title: string, + conditions: RunCondition | RunCondition[], + callback: (condition: RunCondition) => void +) => { + when(title, conditions, callback, true); +}; + +export const registry = { + init: (config: APMFtrConfigName) => { + configName = config; + callbacks.length = 0; + running = false; }, + when, run: (context: FtrProviderContext) => { if (!configName) { throw new Error(`registry was not init() before running`); diff --git a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap index f23601fccb174..eee0ec7f9ad38 100644 --- a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap +++ b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap @@ -8,7 +8,7 @@ Array [ }, Object { "x": 1607435880000, - "y": 4, + "y": 0.133333333333333, }, Object { "x": 1607435910000, @@ -16,7 +16,7 @@ Array [ }, Object { "x": 1607435940000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607435970000, @@ -24,11 +24,11 @@ Array [ }, Object { "x": 1607436000000, - "y": 3, + "y": 0.1, }, Object { "x": 1607436030000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436060000, @@ -40,7 +40,7 @@ Array [ }, Object { "x": 1607436120000, - "y": 4, + "y": 0.133333333333333, }, Object { "x": 1607436150000, @@ -56,7 +56,7 @@ Array [ }, Object { "x": 1607436240000, - "y": 6, + "y": 0.2, }, Object { "x": 1607436270000, @@ -68,15 +68,15 @@ Array [ }, Object { "x": 1607436330000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436360000, - "y": 5, + "y": 0.166666666666667, }, Object { "x": 1607436390000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436420000, @@ -88,11 +88,11 @@ Array [ }, Object { "x": 1607436480000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436510000, - "y": 5, + "y": 0.166666666666667, }, Object { "x": 1607436540000, @@ -104,11 +104,11 @@ Array [ }, Object { "x": 1607436600000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436630000, - "y": 7, + "y": 0.233333333333333, }, Object { "x": 1607436660000, @@ -124,7 +124,7 @@ Array [ }, Object { "x": 1607436750000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436780000, @@ -132,15 +132,15 @@ Array [ }, Object { "x": 1607436810000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436840000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436870000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436900000, @@ -152,11 +152,11 @@ Array [ }, Object { "x": 1607436960000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436990000, - "y": 4, + "y": 0.133333333333333, }, Object { "x": 1607437020000, @@ -168,11 +168,11 @@ Array [ }, Object { "x": 1607437080000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437110000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437140000, @@ -184,15 +184,15 @@ Array [ }, Object { "x": 1607437200000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607437230000, - "y": 7, + "y": 0.233333333333333, }, Object { "x": 1607437260000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437290000, @@ -200,11 +200,11 @@ Array [ }, Object { "x": 1607437320000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437350000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607437380000, @@ -216,11 +216,11 @@ Array [ }, Object { "x": 1607437440000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437470000, - "y": 3, + "y": 0.1, }, Object { "x": 1607437500000, @@ -232,7 +232,7 @@ Array [ }, Object { "x": 1607437560000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437590000, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index c3c7ecd0aba81..00a20abe367ae 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -265,7 +265,7 @@ export default ({ getService }: FtrProviderContext) => { }; const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 10, [id]); const signalsOpen = await getSignalsByRuleIds(supertest, ['eql-rule']); const sequenceSignal = signalsOpen.hits.hits.find( (signal) => signal._source.signal.depth === 2 diff --git a/x-pack/test/fleet_api_integration/apis/epm/data_stream.ts b/x-pack/test/fleet_api_integration/apis/epm/data_stream.ts index 574ff6dd615ad..a43f51a1655e5 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/data_stream.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/data_stream.ts @@ -12,8 +12,6 @@ export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; const supertest = getService('supertest'); const es = getService('es'); - const dockerServers = getService('dockerServers'); - const server = dockerServers.get('registry'); const pkgName = 'datastreams'; const pkgVersion = '0.1.0'; const pkgUpdateVersion = '0.2.0'; @@ -21,6 +19,7 @@ export default function (providerContext: FtrProviderContext) { const pkgUpdateKey = `${pkgName}-${pkgUpdateVersion}`; const logsTemplateName = `logs-${pkgName}.test_logs`; const metricsTemplateName = `metrics-${pkgName}.test_metrics`; + const namespaces = ['default', 'foo', 'bar']; const uninstallPackage = async (pkg: string) => { await supertest.delete(`/api/fleet/epm/packages/${pkg}`).set('kbn-xsrf', 'xxxx'); @@ -35,86 +34,105 @@ export default function (providerContext: FtrProviderContext) { describe('datastreams', async () => { skipIfNoDockerRegistry(providerContext); + beforeEach(async () => { await installPackage(pkgKey); - await es.transport.request({ - method: 'POST', - path: `/${logsTemplateName}-default/_doc`, - body: { - '@timestamp': '2015-01-01', - logs_test_name: 'test', - data_stream: { - dataset: `${pkgName}.test_logs`, - namespace: 'default', - type: 'logs', - }, - }, - }); - await es.transport.request({ - method: 'POST', - path: `/${metricsTemplateName}-default/_doc`, - body: { - '@timestamp': '2015-01-01', - logs_test_name: 'test', - data_stream: { - dataset: `${pkgName}.test_metrics`, - namespace: 'default', - type: 'metrics', - }, - }, - }); + await Promise.all( + namespaces.map(async (namespace) => { + const createLogsRequest = es.transport.request({ + method: 'POST', + path: `/${logsTemplateName}-${namespace}/_doc`, + body: { + '@timestamp': '2015-01-01', + logs_test_name: 'test', + data_stream: { + dataset: `${pkgName}.test_logs`, + namespace, + type: 'logs', + }, + }, + }); + const createMetricsRequest = es.transport.request({ + method: 'POST', + path: `/${metricsTemplateName}-${namespace}/_doc`, + body: { + '@timestamp': '2015-01-01', + logs_test_name: 'test', + data_stream: { + dataset: `${pkgName}.test_metrics`, + namespace, + type: 'metrics', + }, + }, + }); + return Promise.all([createLogsRequest, createMetricsRequest]); + }) + ); }); + afterEach(async () => { - if (!server.enabled) return; - await es.transport.request({ - method: 'DELETE', - path: `/_data_stream/${logsTemplateName}-default`, - }); - await es.transport.request({ - method: 'DELETE', - path: `/_data_stream/${metricsTemplateName}-default`, - }); + await Promise.all( + namespaces.map(async (namespace) => { + const deleteLogsRequest = es.transport.request({ + method: 'DELETE', + path: `/_data_stream/${logsTemplateName}-${namespace}`, + }); + const deleteMetricsRequest = es.transport.request({ + method: 'DELETE', + path: `/_data_stream/${metricsTemplateName}-${namespace}`, + }); + return Promise.all([deleteLogsRequest, deleteMetricsRequest]); + }) + ); await uninstallPackage(pkgKey); await uninstallPackage(pkgUpdateKey); }); + it('should list the logs and metrics datastream', async function () { - const resLogsDatastream = await es.transport.request({ - method: 'GET', - path: `/_data_stream/${logsTemplateName}-default`, - }); - const resMetricsDatastream = await es.transport.request({ - method: 'GET', - path: `/_data_stream/${metricsTemplateName}-default`, + namespaces.forEach(async (namespace) => { + const resLogsDatastream = await es.transport.request({ + method: 'GET', + path: `/_data_stream/${logsTemplateName}-${namespace}`, + }); + const resMetricsDatastream = await es.transport.request({ + method: 'GET', + path: `/_data_stream/${metricsTemplateName}-${namespace}`, + }); + expect(resLogsDatastream.body.data_streams.length).equal(1); + expect(resLogsDatastream.body.data_streams[0].indices.length).equal(1); + expect(resMetricsDatastream.body.data_streams.length).equal(1); + expect(resMetricsDatastream.body.data_streams[0].indices.length).equal(1); }); - expect(resLogsDatastream.body.data_streams.length).equal(1); - expect(resLogsDatastream.body.data_streams[0].indices.length).equal(1); - expect(resMetricsDatastream.body.data_streams.length).equal(1); - expect(resMetricsDatastream.body.data_streams[0].indices.length).equal(1); }); it('after update, it should have rolled over logs datastream because mappings are not compatible and not metrics', async function () { await installPackage(pkgUpdateKey); - const resLogsDatastream = await es.transport.request({ - method: 'GET', - path: `/_data_stream/${logsTemplateName}-default`, - }); - const resMetricsDatastream = await es.transport.request({ - method: 'GET', - path: `/_data_stream/${metricsTemplateName}-default`, + namespaces.forEach(async (namespace) => { + const resLogsDatastream = await es.transport.request({ + method: 'GET', + path: `/_data_stream/${logsTemplateName}-${namespace}`, + }); + const resMetricsDatastream = await es.transport.request({ + method: 'GET', + path: `/_data_stream/${metricsTemplateName}-${namespace}`, + }); + expect(resLogsDatastream.body.data_streams[0].indices.length).equal(2); + expect(resMetricsDatastream.body.data_streams[0].indices.length).equal(1); }); - expect(resLogsDatastream.body.data_streams[0].indices.length).equal(2); - expect(resMetricsDatastream.body.data_streams[0].indices.length).equal(1); }); + it('should be able to upgrade a package after a rollover', async function () { - await es.transport.request({ - method: 'POST', - path: `/${logsTemplateName}-default/_rollover`, - }); - const resLogsDatastream = await es.transport.request({ - method: 'GET', - path: `/_data_stream/${logsTemplateName}-default`, + namespaces.forEach(async (namespace) => { + await es.transport.request({ + method: 'POST', + path: `/${logsTemplateName}-${namespace}/_rollover`, + }); + const resLogsDatastream = await es.transport.request({ + method: 'GET', + path: `/_data_stream/${logsTemplateName}-${namespace}`, + }); + expect(resLogsDatastream.body.data_streams[0].indices.length).equal(2); }); - expect(resLogsDatastream.body.data_streams[0].indices.length).equal(2); await installPackage(pkgUpdateKey); }); }); diff --git a/x-pack/test/functional/apps/canvas/index.js b/x-pack/test/functional/apps/canvas/index.js index b7031cf0e55da..d5f7540f48c83 100644 --- a/x-pack/test/functional/apps/canvas/index.js +++ b/x-pack/test/functional/apps/canvas/index.js @@ -26,6 +26,7 @@ export default function canvasApp({ loadTestFile, getService }) { loadTestFile(require.resolve('./custom_elements')); loadTestFile(require.resolve('./feature_controls/canvas_security')); loadTestFile(require.resolve('./feature_controls/canvas_spaces')); + loadTestFile(require.resolve('./lens')); loadTestFile(require.resolve('./reports')); }); } diff --git a/x-pack/test/functional/apps/canvas/lens.ts b/x-pack/test/functional/apps/canvas/lens.ts new file mode 100644 index 0000000000000..e74795de6c7ea --- /dev/null +++ b/x-pack/test/functional/apps/canvas/lens.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function canvasLensTest({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['canvas', 'common', 'header', 'lens']); + const esArchiver = getService('esArchiver'); + + describe('lens in canvas', function () { + before(async () => { + await esArchiver.load('canvas/lens'); + // open canvas home + await PageObjects.common.navigateToApp('canvas'); + // load test workpad + await PageObjects.common.navigateToApp('canvas', { + hash: '/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31/page/1', + }); + }); + + it('renders lens visualization', async () => { + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.lens.assertMetric('Maximum of bytes', '16,788'); + }); + }); +} diff --git a/x-pack/test/functional/apps/dashboard/_async_dashboard.ts b/x-pack/test/functional/apps/dashboard/_async_dashboard.ts index 8851c83dea1ff..fceccb4609bd7 100644 --- a/x-pack/test/functional/apps/dashboard/_async_dashboard.ts +++ b/x-pack/test/functional/apps/dashboard/_async_dashboard.ts @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const browser = getService('browser'); const kibanaServer = getService('kibanaServer'); + const esArchiver = getService('esArchiver'); const log = getService('log'); const pieChart = getService('pieChart'); const find = getService('find'); @@ -29,6 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('sample data dashboard', function describeIndexTests() { before(async () => { + await esArchiver.emptyKibanaIndex(); await PageObjects.common.sleep(5000); await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { useActualUrl: true, diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index 112a855c61201..b5f55180419ef 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -29,7 +29,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); - describe('dashboard feature controls security', () => { + // FLAKY: https://github.com/elastic/kibana/issues/86950 + describe.skip('dashboard feature controls security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 642526d74b687..db8ede58ca9d4 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -34,6 +34,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./chart_data')); loadTestFile(require.resolve('./drag_and_drop')); loadTestFile(require.resolve('./lens_reporting')); + loadTestFile(require.resolve('./lens_tagging')); // 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/lens_tagging.ts b/x-pack/test/functional/apps/lens/lens_tagging.ts new file mode 100644 index 0000000000000..970eaa89548d2 --- /dev/null +++ b/x-pack/test/functional/apps/lens/lens_tagging.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const listingTable = getService('listingTable'); + const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); + const retry = getService('retry'); + const find = getService('find'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const PageObjects = getPageObjects([ + 'common', + 'tagManagement', + 'header', + 'dashboard', + 'visualize', + 'lens', + ]); + + const lensTag = 'extreme-lens-tag'; + const lensTitle = 'lens tag test'; + + describe('lens tagging', () => { + before(async () => { + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + it('adds a new tag to a Lens visualization', async () => { + // create lens + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await PageObjects.visualize.clickLensWidget(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'ip', + }); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsApp_saveButton'); + + await PageObjects.visualize.setSaveModalValues(lensTitle, { + saveAsNew: false, + redirectToOrigin: true, + }); + await testSubjects.click('savedObjectTagSelector'); + await testSubjects.click(`tagSelectorOption-action__create`); + + expect(await PageObjects.tagManagement.tagModal.isOpened()).to.be(true); + + await PageObjects.tagManagement.tagModal.fillForm( + { + name: lensTag, + color: '#FFCC33', + description: '', + }, + { + submit: true, + } + ); + + expect(await PageObjects.tagManagement.tagModal.isOpened()).to.be(false); + await testSubjects.click('confirmSaveSavedObjectButton'); + await retry.waitForWithTimeout('Save modal to disappear', 1000, () => + testSubjects + .missingOrFail('confirmSaveSavedObjectButton') + .then(() => true) + .catch(() => false) + ); + }); + + it('retains its saved object tags after save and return', async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.saveAndReturn(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.waitUntilTableIsLoaded(); + + // open the filter dropdown + const filterButton = await find.byCssSelector('.euiFilterGroup .euiFilterButton'); + await filterButton.click(); + await testSubjects.click( + `tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(lensTag)}` + ); + // click elsewhere to close the filter dropdown + const searchFilter = await find.byCssSelector('main .euiFieldSearch'); + await searchFilter.click(); + // wait until the table refreshes + await listingTable.waitUntilTableIsLoaded(); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain(lensTitle); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index f2d91c2ae577f..badcadedd7138 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -514,6 +514,28 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); + it('should transition from unique count to last value', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'cardinality', + field: 'ip', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-dimensionTrigger', + operation: 'last_value', + field: 'bytes', + isPreviousIncompatible: true, + }); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql( + 'Last value of bytes' + ); + }); + it('should allow to change index pattern', async () => { await PageObjects.lens.switchFirstLayerIndexPattern('log*'); expect(await PageObjects.lens.getFirstLayerIndexPattern()).to.equal('log*'); @@ -539,5 +561,40 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(true); }); + + it('should able to sort a table by a column', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.searchForItemWithName('lnsXYvis'); + await PageObjects.lens.clickVisualizeListItemTitle('lnsXYvis'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + // Sort by number + await PageObjects.lens.changeTableSortingBy(2, 'asc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 2)).to.eql('17,246'); + // Now sort by IP + await PageObjects.lens.changeTableSortingBy(0, 'asc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('78.83.247.30'); + // Change the sorting + await PageObjects.lens.changeTableSortingBy(0, 'desc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('169.228.188.120'); + // Remove the sorting + await PageObjects.lens.changeTableSortingBy(0, 'none'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.isDatatableHeaderSorted(0)).to.eql(false); + }); + + it('should able to use filters cell actions in table', async () => { + const firstCellContent = await PageObjects.lens.getDatatableCellText(0, 0); + await PageObjects.lens.clickTableCellAction(0, 0, 'lensDatatableFilterOut'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect( + await find.existsByCssSelector( + `[data-test-subj*="filter-value-${firstCellContent}"][data-test-subj*="filter-negated"]` + ) + ).to.eql(true); + }); }); } diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 6b42306c08c92..b0f1e316e626a 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -40,6 +40,17 @@ export default function ({ getService }: FtrProviderContext) { modelMemory: '60mb', createIndexPattern: true, expected: { + scatterplotMatrixColorStats: [ + // background + { key: '#000000', value: 94 }, + // tick/grid/axis + { key: '#DDDDDD', value: 1 }, + { key: '#D3DAE6', value: 1 }, + { key: '#F5F7FA', value: 1 }, + // scatterplot circles + { key: '#6A717D', value: 1 }, + { key: '#54B39A', value: 1 }, + ], row: { type: 'classification', status: 'stopped', @@ -89,6 +100,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the include fields selection'); await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + await ml.testExecution.logTestStep('displays the scatterplot matrix'); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', + testData.expected.scatterplotMatrixColorStats + ); + await ml.testExecution.logTestStep('continues to the additional options step'); await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); @@ -207,6 +224,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlDFExpandableSection-splom', + testData.expected.scatterplotMatrixColorStats + ); }); it('displays the analytics job in the map view', async () => { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 53daa0cae2522..419239d1d15ca 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -49,6 +49,27 @@ export default function ({ getService }: FtrProviderContext) { { chartAvailable: true, id: 'Exterior2nd', legend: '3 categories' }, { chartAvailable: true, id: 'Fireplaces', legend: '0 - 3' }, ], + scatterplotMatrixColorStatsWizard: [ + // background + { key: '#000000', value: 91 }, + // tick/grid/axis + { key: '#6A717D', value: 2 }, + { key: '#F5F7FA', value: 2 }, + { key: '#D3DAE6', value: 1 }, + // scatterplot circles + { key: '#54B399', value: 1 }, + { key: '#54B39A', value: 1 }, + ], + scatterplotMatrixColorStatsResults: [ + // background + { key: '#000000', value: 91 }, + // tick/grid/axis, grey markers + // the red outlier color is not above the 1% threshold. + { key: '#6A717D', value: 2 }, + { key: '#98A2B3', value: 1 }, + { key: '#F5F7FA', value: 2 }, + { key: '#D3DAE6', value: 1 }, + ], row: { type: 'outlier_detection', status: 'stopped', @@ -105,6 +126,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the include fields selection'); await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + await ml.testExecution.logTestStep('displays the scatterplot matrix'); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', + testData.expected.scatterplotMatrixColorStatsWizard + ); + await ml.testExecution.logTestStep('continues to the additional options step'); await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); @@ -221,6 +248,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertOutlierTablePanelExists(); await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlDFExpandableSection-splom', + testData.expected.scatterplotMatrixColorStatsResults + ); }); it('displays the analytics job in the map view', async () => { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index fef22fcebc3ed..f1d19a82caa9b 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -39,6 +39,16 @@ export default function ({ getService }: FtrProviderContext) { modelMemory: '20mb', createIndexPattern: true, expected: { + scatterplotMatrixColorStats: [ + // background + { key: '#000000', value: 80 }, + // tick/grid/axis + { key: '#6A717D', value: 1 }, + { key: '#F5F7FA', value: 2 }, + { key: '#D3DAE6', value: 1 }, + // because a continuous color scale is used for the scatterplot circles, + // none of the generated colors is above the 1% threshold. + ], row: { type: 'regression', status: 'stopped', @@ -89,6 +99,12 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('displays the include fields selection'); await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + await ml.testExecution.logTestStep('displays the scatterplot matrix'); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlAnalyticsCreateJobWizardScatterplotMatrixFormRow', + testData.expected.scatterplotMatrixColorStats + ); + await ml.testExecution.logTestStep('continues to the additional options step'); await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); @@ -207,6 +223,10 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsResults.assertResultsTableExists(); await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + await ml.dataFrameAnalyticsScatterplot.assertScatterplotMatrix( + 'mlDFExpandableSection-splom', + testData.expected.scatterplotMatrixColorStats + ); }); it('displays the analytics job in the map view', async () => { diff --git a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts index 531eba54f931d..10926a831d36b 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts @@ -112,6 +112,47 @@ export default function ({ getService }: FtrProviderContext) { fieldNameFiltersResultCount: 1, }, }, + { + suiteSuffix: 'with a file containing geo field', + filePath: path.join(__dirname, 'files_to_import', 'geo_file.csv'), + indexName: 'user-import_2', + createIndexPattern: false, + fieldTypeFilters: [ML_JOB_FIELD_TYPES.GEO_POINT], + fieldNameFilters: ['Coordinates'], + expected: { + results: { + title: 'geo_file.csv', + numberOfFields: 3, + }, + metricFields: [], + nonMetricFields: [ + { + fieldName: 'Context', + type: ML_JOB_FIELD_TYPES.UNKNOWN, + docCountFormatted: '0 (0%)', + exampleCount: 0, + }, + { + fieldName: 'Coordinates', + type: ML_JOB_FIELD_TYPES.GEO_POINT, + docCountFormatted: '13 (100%)', + exampleCount: 7, + }, + { + fieldName: 'Location', + type: ML_JOB_FIELD_TYPES.KEYWORD, + docCountFormatted: '13 (100%)', + exampleCount: 7, + }, + ], + visibleMetricFieldsCount: 0, + totalMetricFieldsCount: 0, + populatedFieldsCount: 3, + totalFieldsCount: 3, + fieldTypeFiltersResultCount: 1, + fieldNameFiltersResultCount: 1, + }, + }, ]; const testDataListNegative = [ diff --git a/x-pack/test/functional/apps/ml/data_visualizer/files_to_import/geo_file.csv b/x-pack/test/functional/apps/ml/data_visualizer/files_to_import/geo_file.csv new file mode 100644 index 0000000000000..df7417f474d83 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/files_to_import/geo_file.csv @@ -0,0 +1,14 @@ +Coordinates,Location,Context +POINT (-2.516919 51.423683),On or near A4175, +POINT (-2.515072 51.419357),On or near Stockwood Hill, +POINT (-2.509126 51.416137),On or near St Francis Road, +POINT (-2.509384 51.40959),On or near Barnard Walk, +POINT (-2.509126 51.416137),On or near St Francis Road, +POINT (-2.516919 51.423683),On or near A4175, +POINT (-2.511571 51.414895),On or near Orchard Close, +POINT (-2.534338 51.417697),On or near Scotland Lane, +POINT (-2.509384 51.40959),On or near Barnard Walk, +POINT (-2.495055 51.422132),On or near Cross Street, +POINT (-2.509384 51.40959),On or near Barnard Walk, +POINT (-2.495055 51.422132),On or near Cross Street, +POINT (-2.509126 51.416137),On or near St Francis Road, \ No newline at end of file diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts index 0833f84960ea6..01d7ca6af4cc3 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts @@ -24,6 +24,11 @@ interface TestData { sourceIndexOrSavedSearch: string; fieldNameFilters: string[]; fieldTypeFilters: string[]; + rowsPerPage?: 10 | 25 | 50; + sampleSizeValidations: Array<{ + size: number; + expected: { field: string; docCountFormatted: string }; + }>; expected: { totalDocCountFormatted: string; metricFields?: MetricFieldVisConfig[]; @@ -47,6 +52,10 @@ export default function ({ getService }: FtrProviderContext) { sourceIndexOrSavedSearch: 'ft_farequote', fieldNameFilters: ['airline', '@timestamp'], fieldTypeFilters: [ML_JOB_FIELD_TYPES.KEYWORD], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], expected: { totalDocCountFormatted: '86,274', metricFields: [ @@ -132,6 +141,10 @@ export default function ({ getService }: FtrProviderContext) { sourceIndexOrSavedSearch: 'ft_farequote_kuery', fieldNameFilters: ['@version'], fieldTypeFilters: [ML_JOB_FIELD_TYPES.DATE, ML_JOB_FIELD_TYPES.TEXT], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], expected: { totalDocCountFormatted: '34,415', metricFields: [ @@ -217,6 +230,10 @@ export default function ({ getService }: FtrProviderContext) { sourceIndexOrSavedSearch: 'ft_farequote_lucene', fieldNameFilters: ['@version.keyword', 'type'], fieldTypeFilters: [ML_JOB_FIELD_TYPES.NUMBER], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], expected: { totalDocCountFormatted: '34,416', metricFields: [ @@ -297,6 +314,41 @@ export default function ({ getService }: FtrProviderContext) { }, }; + const sampleLogTestData: TestData = { + suiteTitle: 'geo point field', + sourceIndexOrSavedSearch: 'ft_module_sample_logs', + fieldNameFilters: ['geo.coordinates'], + fieldTypeFilters: [ML_JOB_FIELD_TYPES.GEO_POINT], + rowsPerPage: 50, + expected: { + totalDocCountFormatted: '408', + metricFields: [], + // only testing the geo_point fields + nonMetricFields: [ + { + fieldName: 'geo.coordinates', + type: ML_JOB_FIELD_TYPES.GEO_POINT, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '408 (100%)', + exampleCount: 10, + }, + ], + emptyFields: [], + visibleMetricFieldsCount: 4, + totalMetricFieldsCount: 5, + populatedFieldsCount: 35, + totalFieldsCount: 36, + fieldNameFiltersResultCount: 1, + fieldTypeFiltersResultCount: 1, + }, + sampleSizeValidations: [ + { size: 1000, expected: { field: 'geo.coordinates', docCountFormatted: '408 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '408 (100%)' } }, + ], + }; + function runTests(testData: TestData) { it(`${testData.suiteTitle} loads the source data in the data visualizer`, async () => { await ml.testExecution.logTestStep( @@ -332,6 +384,10 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); + if (testData.rowsPerPage) { + await ml.dataVisualizerTable.ensureNumRowsPerPage(testData.rowsPerPage); + } + await ml.dataVisualizerTable.assertSearchPanelExist(); await ml.dataVisualizerTable.assertSampleSizeInputExists(); await ml.dataVisualizerTable.assertFieldTypeInputExists(); @@ -376,8 +432,14 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep( `${testData.suiteTitle} sample size control changes non-metric fields` ); - await ml.dataVisualizerTable.setSampleSizeInputValue(1000, 'airline', '1000 (100%)'); - await ml.dataVisualizerTable.setSampleSizeInputValue(5000, '@timestamp', '5000 (100%)'); + for (const sampleSizeCase of testData.sampleSizeValidations) { + const { size, expected } = sampleSizeCase; + await ml.dataVisualizerTable.setSampleSizeInputValue( + size, + expected.field, + expected.docCountFormatted + ); + } await ml.testExecution.logTestStep('sets and resets field type filter correctly'); await ml.dataVisualizerTable.setFieldTypeFilter( @@ -411,7 +473,10 @@ export default function ({ getService }: FtrProviderContext) { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('ml/farequote'); + await esArchiver.loadIfNeeded('ml/module_sample_logs'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.createIndexPatternIfNeeded('ft_module_sample_logs', '@timestamp'); await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded(); await ml.testResources.createSavedSearchFarequoteKueryIfNeeded(); await ml.testResources.setKibanaTimeZoneToUTC(); @@ -447,5 +512,15 @@ export default function ({ getService }: FtrProviderContext) { runTests(farequoteLuceneSearchTestData); }); + + describe('with module_sample_logs ', function () { + // Run tests on full farequote index. + it(`${sampleLogTestData.suiteTitle} loads the data visualizer selector page`, async () => { + // Start navigation from the base of the ML app. + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataVisualizer(); + }); + runTests(sampleLogTestData); + }); }); } diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 1815942a06a9a..fc508f8477ebe 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -232,6 +232,7 @@ export default async function ({ readConfigFile }) { { feature: { canvas: ['all'], + visualize: ['all'], }, spaces: ['*'], }, diff --git a/x-pack/test/functional/es_archives/canvas/lens/data.json b/x-pack/test/functional/es_archives/canvas/lens/data.json new file mode 100644 index 0000000000000..dca7d31d71082 --- /dev/null +++ b/x-pack/test/functional/es_archives/canvas/lens/data.json @@ -0,0 +1,190 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana_1", + "source": { + "space": { + "_reserved": true, + "color": "#00bfb3", + "description": "This is your default space!", + "name": "Default" + }, + "type": "space", + "updated_at": "2018-11-06T18:20:26.703Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "canvas-workpad:workpad-1705f884-6224-47de-ba49-ca224fe6ec31", + "index": ".kibana_1", + "source": { + "canvas-workpad": { + "@created": "2018-11-19T19:17:12.646Z", + "@timestamp": "2018-11-19T19:36:28.499Z", + "assets": { + }, + "colors": [ + "#37988d", + "#c19628", + "#b83c6f", + "#3f9939", + "#1785b0", + "#ca5f35", + "#45bdb0", + "#f2bc33", + "#e74b8b", + "#4fbf48", + "#1ea6dc", + "#fd7643", + "#72cec3", + "#f5cc5d", + "#ec77a8", + "#7acf74", + "#4cbce4", + "#fd986f", + "#a1ded7", + "#f8dd91", + "#f2a4c5", + "#a6dfa2", + "#86d2ed", + "#fdba9f", + "#000000", + "#444444", + "#777777", + "#BBBBBB", + "#FFFFFF", + "rgba(255,255,255,0)" + ], + "height": 920, + "id": "workpad-1705f884-6224-47de-ba49-ca224fe6ec31", + "isWriteable": true, + "name": "Test Workpad", + "page": 0, + "pages": [ + { + "elements": [ + { + "expression": "savedLens id=\"my-lens-vis\" timerange={timerange from=\"2014-01-01\" to=\"2018-01-01\"}", + "id": "element-8f64a10a-01f3-4a71-a682-5b627cbe4d0e", + "position": { + "angle": 0, + "height": 238, + "left": 33.5, + "top": 20, + "width": 338 + } + } + ], + "id": "page-c38cd459-10fe-45f9-847b-2cbd7ec74319", + "style": { + "background": "#fff" + }, + "transition": { + } + } + ], + "width": 840 + }, + "type": "canvas-workpad", + "updated_at": "2018-11-19T19:36:28.511Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "lens:my-lens-vis", + "index": ".kibana_1", + "source": { + "lens": { + "expression": "", + "state": { + "datasourceMetaData": { + "filterableIndexPatterns": [ + { + "id": "logstash-lens", + "title": "logstash-lens" + } + ] + }, + "datasourceStates": { + "indexpattern": { + "currentIndexPatternId": "logstash-lens", + "layers": { + "c61a8afb-a185-4fae-a064-fb3846f6c451": { + "columnOrder": [ + "2cd09808-3915-49f4-b3b0-82767eba23f7" + ], + "columns": { + "2cd09808-3915-49f4-b3b0-82767eba23f7": { + "dataType": "number", + "isBucketed": false, + "label": "Maximum of bytes", + "operationType": "max", + "scale": "ratio", + "sourceField": "bytes" + } + }, + "indexPatternId": "logstash-lens" + } + } + } + }, + "filters": [], + "query": { + "language": "kuery", + "query": "" + }, + "visualization": { + "accessor": "2cd09808-3915-49f4-b3b0-82767eba23f7", + "layerId": "c61a8afb-a185-4fae-a064-fb3846f6c451" + } + }, + "title": "Artistpreviouslyknownaslens", + "visualizationType": "lnsMetric" + }, + "references": [], + "type": "lens", + "updated_at": "2019-10-16T00:28:08.979Z" + } + } +} + +{ + "type": "doc", + "value": { + "index": "logstash-lens", + "id": "1", + "source": { + "@timestamp": "2015-09-20T02:00:00.000Z", + "bytes": 16788 + } + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:logstash-lens", + "index": ".kibana_1", + "source": { + "index-pattern" : { + "title" : "logstash-lens", + "timeFieldName" : "@timestamp", + "fields" : "[]" + }, + "type" : "index-pattern", + "references" : [ ], + "migrationVersion" : { + "index-pattern" : "7.6.0" + }, + "updated_at" : "2020-08-19T08:39:09.998Z" + }, + "type": "_doc" + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/canvas/lens/mappings.json b/x-pack/test/functional/es_archives/canvas/lens/mappings.json new file mode 100644 index 0000000000000..811bfaaae0d2c --- /dev/null +++ b/x-pack/test/functional/es_archives/canvas/lens/mappings.json @@ -0,0 +1,409 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "dynamic": "strict", + "properties": { + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "id": { + "index": false, + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "lens": { + "properties": { + "expression": { + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "type": "object" + }, + "namespace": { + "type": "keyword" + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "disabledFeatures": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "telemetry": { + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "index": "logstash-lens", + "mappings": { + "properties": { + "@timestamp": { + "type": "date" + }, + "bytes": { + "type": "float" + } + } + }, + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "0" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/visualize/default/data.json b/x-pack/test/functional/es_archives/visualize/default/data.json index fe29bad0fa381..26b033e28b4da 100644 --- a/x-pack/test/functional/es_archives/visualize/default/data.json +++ b/x-pack/test/functional/es_archives/visualize/default/data.json @@ -125,26 +125,8 @@ { "type": "doc", "value": { - "id": "custom-space:index-pattern:metricbeat-*", - "index": ".kibana_1", - "source": { - "index-pattern": { - "fieldFormatMap": "{\"aerospike.namespace.device.available.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.device.free.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.device.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.device.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.free.pct\":{\"id\":\"percent\",\"params\":{}},\"aerospike.namespace.memory.used.data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.index.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.sindex.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aerospike.namespace.memory.used.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.ec2.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"aws.rds.disk_usage.bin_log.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.free_local_storage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.free_storage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.freeable_memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.latency.commit\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.ddl\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.dml\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.insert\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.read\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.select\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.update\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.latency.write\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.replica_lag.sec\":{\"id\":\"duration\",\"params\":{}},\"aws.rds.swap_usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.rds.volume_used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_daily_storage.bucket.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.downloaded.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.latency.first_byte.ms\":{\"id\":\"duration\",\"params\":{}},\"aws.s3_request.latency.total_request.ms\":{\"id\":\"duration\",\"params\":{}},\"aws.s3_request.requests.select_returned.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.requests.select_scanned.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.s3_request.uploaded.bytes\":{\"id\":\"bytes\",\"params\":{}},\"aws.sqs.oldest_message_age.sec\":{\"id\":\"duration\",\"params\":{}},\"aws.sqs.sent_message_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_disk.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.degraded.ratio\":{\"id\":\"percent\",\"params\":{}},\"ceph.cluster_status.misplace.ratio\":{\"id\":\"percent\",\"params\":{}},\"ceph.cluster_status.pg.avail_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.data_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.total_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.pg.used_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.traffic.read_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.cluster_status.traffic.write_bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.log.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.misc.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.sst.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.monitor_health.store_stats.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.total.byte\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.used.byte\":{\"id\":\"bytes\",\"params\":{}},\"ceph.osd_df.used.pct\":{\"id\":\"percent\",\"params\":{}},\"ceph.pool_disk.stats.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"ceph.pool_disk.stats.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"client.bytes\":{\"id\":\"bytes\",\"params\":{}},\"client.nat.port\":{\"id\":\"string\",\"params\":{}},\"client.port\":{\"id\":\"string\",\"params\":{}},\"coredns.stats.dns.request.duration.ns.sum\":{\"id\":\"duration\",\"params\":{}},\"couchbase.bucket.data.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.disk.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.quota.ram.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.bucket.quota.use.pct\":{\"id\":\"percent\",\"params\":{}},\"couchbase.cluster.hdd.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.quota.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.used.by_data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.hdd.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.total.per_node.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.total.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.used.per_node.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.quota.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.used.by_data.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.cluster.ram.used.value.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.couch.docs.data_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.couch.docs.disk_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"couchbase.node.mcd_memory.allocated.bytes\":{\"id\":\"bytes\",\"params\":{}},\"destination.bytes\":{\"id\":\"bytes\",\"params\":{}},\"destination.nat.port\":{\"id\":\"string\",\"params\":{}},\"destination.port\":{\"id\":\"string\",\"params\":{}},\"docker.cpu.core.*.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.kernel.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.system.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.cpu.user.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.diskio.summary.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.commit.peak\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.commit.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.limit\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.private_working_set.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.rss.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.memory.rss.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.usage.max\":{\"id\":\"bytes\",\"params\":{}},\"docker.memory.usage.pct\":{\"id\":\"percent\",\"params\":{}},\"docker.memory.usage.total\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.inbound.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"docker.network.outbound.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.primaries.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.primaries.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.total.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.summary.total.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.total.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.index.total.store.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.heap.init.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.heap.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.nonheap.init.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.jvm.memory.nonheap.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.fs.summary.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.indices.segments.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.old.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.survivor.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.peak.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.peak_max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"elasticsearch.node.stats.jvm.mem.pools.young.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.disk.mvcc_db_total_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.memory.go_memstats_alloc.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.network.client_grpc_received.bytes\":{\"id\":\"bytes\",\"params\":{}},\"etcd.network.client_grpc_sent.bytes\":{\"id\":\"bytes\",\"params\":{}},\"event.duration\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"nanoseconds\",\"outputFormat\":\"asMilliseconds\",\"outputPrecision\":1}},\"event.sequence\":{\"id\":\"string\",\"params\":{}},\"event.severity\":{\"id\":\"string\",\"params\":{}},\"golang.heap.allocations.active\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.allocated\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.idle\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.allocations.total\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.gc.next_gc_limit\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.obtained\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.released\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.stack\":{\"id\":\"bytes\",\"params\":{}},\"golang.heap.system.total\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.info.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"haproxy.info.memory.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.info.ssl.frontend.session_reuse.pct\":{\"id\":\"percent\",\"params\":{}},\"haproxy.stat.compressor.bypassed.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.compressor.response.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"haproxy.stat.throttle.pct\":{\"id\":\"percent\",\"params\":{}},\"http.request.body.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.request.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.body.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.bytes\":{\"id\":\"bytes\",\"params\":{}},\"http.response.status_code\":{\"id\":\"string\",\"params\":{}},\"kibana.stats.process.memory.heap.size_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kibana.stats.process.memory.heap.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kibana.stats.process.memory.heap.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.apiserver.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.cpu.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.cpu.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.logs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.logs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.logs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.request.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.memory.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.memory.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.container.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.container.rootfs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.controllermanager.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.fs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.allocatable.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.network.rx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.network.tx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.node.runtime.imagefs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.cpu.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.cpu.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.memory.usage.limit.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.usage.node.pct\":{\"id\":\"percent\",\"params\":{}},\"kubernetes.pod.memory.working_set.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.network.rx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.pod.network.tx.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.proxy.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.http.request.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.http.response.size.bytes.sum\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.process.memory.resident.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.scheduler.process.memory.virtual.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.system.memory.workingset.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.available.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.capacity.bytes\":{\"id\":\"bytes\",\"params\":{}},\"kubernetes.volume.fs.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.avg_obj_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.data_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.extent_free_list.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.file_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.index_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.dbstats.storage_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.replstatus.headroom.max\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.headroom.min\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.lag.max\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.lag.min\":{\"id\":\"duration\",\"params\":{}},\"mongodb.replstatus.oplog.size.allocated\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.replstatus.oplog.size.used\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.extra_info.heap_usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.dirty.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.maximum.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.cache.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.max_file_size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mongodb.status.wired_tiger.log.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"mysql.status.bytes.received\":{\"id\":\"bytes\",\"params\":{}},\"mysql.status.bytes.sent\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.cpu\":{\"id\":\"percent\",\"params\":{}},\"nats.stats.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.mem.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"nats.stats.uptime\":{\"id\":\"duration\",\"params\":{}},\"nats.subscriptions.cache.hit_rate\":{\"id\":\"percent\",\"params\":{}},\"network.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.data_file.size.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"oracle.tablespace.space.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"process.pgid\":{\"id\":\"string\",\"params\":{}},\"process.pid\":{\"id\":\"string\",\"params\":{}},\"process.ppid\":{\"id\":\"string\",\"params\":{}},\"process.thread.id\":{\"id\":\"string\",\"params\":{}},\"rabbitmq.connection.frame_max\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.disk.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.disk.free.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.gc.reclaimed.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.io.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.io.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.node.mem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"rabbitmq.queue.consumers.utilisation.pct\":{\"id\":\"percent\",\"params\":{}},\"rabbitmq.queue.memory.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.active\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.allocated\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.fragmentation.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.resident\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.allocator_stats.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.fragmentation.bytes\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.max.value\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.dataset\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.lua\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.peak\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.rss\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.memory.used.value\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.buffer.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.copy_on_write.last_size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.rewrite.buffer.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.rewrite.current_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.aof.rewrite.last_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.aof.size.base\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.aof.size.current\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.persistence.rdb.bgsave.current_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.rdb.bgsave.last_time.sec\":{\"id\":\"duration\",\"params\":{}},\"redis.info.persistence.rdb.copy_on_write.last_size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.replication.backlog.size\":{\"id\":\"bytes\",\"params\":{}},\"redis.info.replication.master.last_io_seconds_ago\":{\"id\":\"duration\",\"params\":{}},\"redis.info.replication.master.sync.last_io_seconds_ago\":{\"id\":\"duration\",\"params\":{}},\"redis.info.replication.master.sync.left_bytes\":{\"id\":\"bytes\",\"params\":{}},\"server.bytes\":{\"id\":\"bytes\",\"params\":{}},\"server.nat.port\":{\"id\":\"string\",\"params\":{}},\"server.port\":{\"id\":\"string\",\"params\":{}},\"source.bytes\":{\"id\":\"bytes\",\"params\":{}},\"source.nat.port\":{\"id\":\"string\",\"params\":{}},\"source.port\":{\"id\":\"string\",\"params\":{}},\"system.core.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.iowait.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.irq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.nice.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.softirq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.steal.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.system.pct\":{\"id\":\"percent\",\"params\":{}},\"system.core.user.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.idle.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.idle.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.iowait.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.iowait.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.irq.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.irq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.nice.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.nice.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.softirq.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.softirq.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.steal.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.steal.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.system.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.system.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.total.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.user.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.cpu.user.pct\":{\"id\":\"percent\",\"params\":{}},\"system.diskio.iostat.read.per_sec.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.iostat.write.per_sec.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.read.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.diskio.write.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.entropy.pct\":{\"id\":\"percent\",\"params\":{}},\"system.filesystem.available\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.free\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.total\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.filesystem.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.fsstat.total_size.free\":{\"id\":\"bytes\",\"params\":{}},\"system.fsstat.total_size.total\":{\"id\":\"bytes\",\"params\":{}},\"system.fsstat.total_size.used\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.actual.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.default_size\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.free\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.reserved\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.surplus\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.total\":{\"id\":\"number\",\"params\":{}},\"system.memory.hugepages.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.hugepages.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.swap.free\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.total\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.swap.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.memory.total\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.memory.used.pct\":{\"id\":\"percent\",\"params\":{}},\"system.network.in.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.network.out.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.blkio.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.kmem_tcp.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.mem.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.usage.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.memsw.usage.max.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.active_anon.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.active_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.cache.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.hierarchical_memory_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.hierarchical_memsw_limit.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.inactive_anon.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.inactive_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.mapped_file.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.rss_huge.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.swap.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cgroup.memory.stats.unevictable.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.cpu.total.norm.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.cpu.total.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.memory.rss.bytes\":{\"id\":\"bytes\",\"params\":{}},\"system.process.memory.rss.pct\":{\"id\":\"percent\",\"params\":{}},\"system.process.memory.share\":{\"id\":\"bytes\",\"params\":{}},\"system.process.memory.size\":{\"id\":\"bytes\",\"params\":{}},\"system.socket.summary.tcp.memory\":{\"id\":\"bytes\",\"params\":{}},\"system.socket.summary.udp.memory\":{\"id\":\"bytes\",\"params\":{}},\"system.uptime.duration.ms\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"milliseconds\"}},\"url.port\":{\"id\":\"string\",\"params\":{}},\"vsphere.datastore.capacity.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.datastore.capacity.used.pct\":{\"id\":\"percent\",\"params\":{}},\"vsphere.host.memory.free.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.host.memory.total.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.host.memory.used.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.free.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.total.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.used.guest.bytes\":{\"id\":\"bytes\",\"params\":{}},\"vsphere.virtualmachine.memory.used.host.bytes\":{\"id\":\"bytes\",\"params\":{}},\"windows.service.uptime.ms\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"milliseconds\"}}}", - "timeFieldName": "@timestamp", - "title": "metricbeat-*" - }, - "migrationVersion": { - "index-pattern": "7.6.0" - }, - "type": "index-pattern", - "updated_at": "2020-01-22T15:34:59.061Z" - } - } -} - -{ - "type": "doc", - "value": { + "index": ".kibana", + "type": "doc", "id": "index-pattern:logstash-*", "index": ".kibana_1", "source": { @@ -297,4 +279,4 @@ "updated_at": "2019-07-17T17:54:26.378Z" } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 31a4d6e29fc35..17f9fb036129a 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -202,7 +202,7 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }) .lnsDragDrop`; const dropping = `[data-test-subj='${dimension}']:nth-of-type(${ endIndex + 1 - }) [data-test-subj='lnsDragDrop-reorderableDrop'`; + }) [data-test-subj='lnsDragDrop-reorderableDropLayer'`; await browser.html5DragAndDrop(dragging, dropping); await PageObjects.header.waitUntilLoadingHasFinished(); }, @@ -506,13 +506,8 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * @param index - index of th element in datatable */ async getDatatableHeaderText(index = 0) { - return find - .byCssSelector( - `[data-test-subj="lnsDataTable"] thead th:nth-child(${ - index + 1 - }) .euiTableCellContent__text` - ) - .then((el) => el.getVisibleText()); + const el = await this.getDatatableHeader(index); + return el.getVisibleText(); }, /** @@ -522,13 +517,55 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * @param colIndex - index of column of the cell */ async getDatatableCellText(rowIndex = 0, colIndex = 0) { - return find - .byCssSelector( - `[data-test-subj="lnsDataTable"] tr:nth-child(${rowIndex + 1}) td:nth-child(${ - colIndex + 1 - })` - ) - .then((el) => el.getVisibleText()); + const el = await this.getDatatableCell(rowIndex, colIndex); + return el.getVisibleText(); + }, + + async getDatatableHeader(index = 0) { + return find.byCssSelector( + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridHeader"] [role=columnheader]:nth-child(${ + index + 1 + })` + ); + }, + + async getDatatableCell(rowIndex = 0, colIndex = 0) { + return await find.byCssSelector( + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridRow"]:nth-child(${ + rowIndex + 2 // this is a bit specific for EuiDataGrid: the first row is the Header + }) [data-test-subj="dataGridRowCell"]:nth-child(${colIndex + 1})` + ); + }, + + async isDatatableHeaderSorted(index = 0) { + return find.existsByCssSelector( + `[data-test-subj="lnsDataTable"] [data-test-subj="dataGridHeader"] [role=columnheader]:nth-child(${ + index + 1 + }) [data-test-subj^="dataGridHeaderCellSortingIcon"]` + ); + }, + + async changeTableSortingBy(colIndex = 0, direction: 'none' | 'asc' | 'desc') { + const el = await this.getDatatableHeader(colIndex); + await el.click(); + let buttonEl; + if (direction !== 'none') { + buttonEl = await find.byCssSelector( + `[data-test-subj^="dataGridHeaderCellActionGroup"] [title="Sort ${direction}"]` + ); + } else { + buttonEl = await find.byCssSelector( + `[data-test-subj^="dataGridHeaderCellActionGroup"] li[class$="selected"] [title^="Sort"]` + ); + } + return buttonEl.click(); + }, + + async clickTableCellAction(rowIndex = 0, colIndex = 0, actionTestSub: string) { + const el = await this.getDatatableCell(rowIndex, colIndex); + await el.focus(); + const action = await el.findByTestSubject(actionTestSub); + return action.click(); }, /** diff --git a/x-pack/test/functional/services/canvas_element.ts b/x-pack/test/functional/services/canvas_element.ts index e2a42c5dc43c3..08ac38d970225 100644 --- a/x-pack/test/functional/services/canvas_element.ts +++ b/x-pack/test/functional/services/canvas_element.ts @@ -43,9 +43,13 @@ export async function CanvasElementProvider({ getService }: FtrProviderContext) public async getImageData(selector: string): Promise<number[]> { return await driver.executeScript( ` - const el = document.querySelector('${selector}'); - const ctx = el.getContext('2d'); - return ctx.getImageData(0, 0, el.width, el.height).data; + try { + const el = document.querySelector('${selector}'); + const ctx = el.getContext('2d'); + return ctx.getImageData(0, 0, el.width, el.height).data; + } catch(e) { + return []; + } ` ); } diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts b/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.ts new file mode 100644 index 0000000000000..3472e5079c79a --- /dev/null +++ b/x-pack/test/functional/services/ml/data_frame_analytics_scatterplot.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; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export function MachineLearningDataFrameAnalyticsScatterplotProvider({ + getService, +}: FtrProviderContext) { + const canvasElement = getService('canvasElement'); + const testSubjects = getService('testSubjects'); + + return new (class AnalyticsScatterplot { + public async assertScatterplotMatrix( + dataTestSubj: string, + expectedColorStats: Array<{ + key: string; + value: number; + }> + ) { + await testSubjects.existOrFail(dataTestSubj); + await testSubjects.existOrFail('mlScatterplotMatrix'); + + const actualColorStats = await canvasElement.getColorStats( + `[data-test-subj="mlScatterplotMatrix"] canvas`, + expectedColorStats, + 1 + ); + expect(actualColorStats.every((d) => d.withinTolerance)).to.eql( + true, + `Color stats for scatterplot matrix should be within tolerance. Expected: '${JSON.stringify( + expectedColorStats + )}' (got '${JSON.stringify(actualColorStats)}')` + ); + } + })(); +} diff --git a/x-pack/test/functional/services/ml/data_visualizer_table.ts b/x-pack/test/functional/services/ml/data_visualizer_table.ts index ad4625ed4dcb4..4772b3c894471 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_table.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_table.ts @@ -288,6 +288,16 @@ export function MachineLearningDataVisualizerTableProvider( await this.ensureDetailsClosed(fieldName); } + public async assertExamplesList(fieldName: string, expectedExamplesCount: number) { + const examplesList = await testSubjects.find( + this.detailsSelector(fieldName, 'mlFieldDataExamplesList') + ); + const examplesListItems = await examplesList.findAllByTagName('li'); + expect(examplesListItems).to.have.length( + expectedExamplesCount, + `Expected example list item count for field '${fieldName}' to be '${expectedExamplesCount}' (got '${examplesListItems.length}')` + ); + } public async assertTextFieldContents( fieldName: string, docCountFormatted: string, @@ -297,14 +307,33 @@ export function MachineLearningDataVisualizerTableProvider( await this.assertFieldDocCount(fieldName, docCountFormatted); await this.ensureDetailsOpen(fieldName); - const examplesList = await testSubjects.find( - this.detailsSelector(fieldName, 'mlFieldDataExamplesList') - ); - const examplesListItems = await examplesList.findAllByTagName('li'); - expect(examplesListItems).to.have.length( - expectedExamplesCount, - `Expected example list item count for field '${fieldName}' to be '${expectedExamplesCount}' (got '${examplesListItems.length}')` - ); + await this.assertExamplesList(fieldName, expectedExamplesCount); + await this.ensureDetailsClosed(fieldName); + } + + public async assertGeoPointFieldContents( + fieldName: string, + docCountFormatted: string, + expectedExamplesCount: number + ) { + await this.assertRowExists(fieldName); + await this.assertFieldDocCount(fieldName, docCountFormatted); + await this.ensureDetailsOpen(fieldName); + + await this.assertExamplesList(fieldName, expectedExamplesCount); + + await testSubjects.existOrFail(this.detailsSelector(fieldName, 'mlEmbeddedMapContent')); + + await this.ensureDetailsClosed(fieldName); + } + + public async assertUnknownFieldContents(fieldName: string, docCountFormatted: string) { + await this.assertRowExists(fieldName); + await this.assertFieldDocCount(fieldName, docCountFormatted); + await this.ensureDetailsOpen(fieldName); + + await testSubjects.existOrFail(this.detailsSelector(fieldName, 'mlDVDocumentStatsContent')); + await this.ensureDetailsClosed(fieldName); } @@ -321,10 +350,14 @@ export function MachineLearningDataVisualizerTableProvider( await this.assertKeywordFieldContents(fieldName, docCountFormatted, exampleCount); } else if (fieldType === ML_JOB_FIELD_TYPES.TEXT) { await this.assertTextFieldContents(fieldName, docCountFormatted, exampleCount); + } else if (fieldType === ML_JOB_FIELD_TYPES.GEO_POINT) { + await this.assertGeoPointFieldContents(fieldName, docCountFormatted, exampleCount); + } else if (fieldType === ML_JOB_FIELD_TYPES.UNKNOWN) { + await this.assertUnknownFieldContents(fieldName, docCountFormatted); } } - public async ensureNumRowsPerPage(n: 10 | 25 | 100) { + public async ensureNumRowsPerPage(n: 10 | 25 | 50) { const paginationButton = 'mlDataVisualizerTable > tablePaginationPopoverButton'; await retry.tryForTime(10000, async () => { await testSubjects.existOrFail(paginationButton); diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index c1a9ac304dd69..aa87bc5dc4772 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -17,6 +17,7 @@ import { MachineLearningDataFrameAnalyticsProvider } from './data_frame_analytic import { MachineLearningDataFrameAnalyticsCreationProvider } from './data_frame_analytics_creation'; import { MachineLearningDataFrameAnalyticsEditProvider } from './data_frame_analytics_edit'; import { MachineLearningDataFrameAnalyticsResultsProvider } from './data_frame_analytics_results'; +import { MachineLearningDataFrameAnalyticsScatterplotProvider } from './data_frame_analytics_scatterplot'; import { MachineLearningDataFrameAnalyticsMapProvider } from './data_frame_analytics_map'; import { MachineLearningDataFrameAnalyticsTableProvider } from './data_frame_analytics_table'; import { MachineLearningDataVisualizerProvider } from './data_visualizer'; @@ -63,6 +64,9 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataFrameAnalyticsResults = MachineLearningDataFrameAnalyticsResultsProvider(context); const dataFrameAnalyticsMap = MachineLearningDataFrameAnalyticsMapProvider(context); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); + const dataFrameAnalyticsScatterplot = MachineLearningDataFrameAnalyticsScatterplotProvider( + context + ); const dataVisualizer = MachineLearningDataVisualizerProvider(context); const dataVisualizerTable = MachineLearningDataVisualizerTableProvider(context, commonUI); @@ -105,6 +109,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { dataFrameAnalyticsResults, dataFrameAnalyticsMap, dataFrameAnalyticsTable, + dataFrameAnalyticsScatterplot, dataVisualizer, dataVisualizerFileBased, dataVisualizerIndexBased, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 352652d9601dc..52e9422da2da4 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -29,10 +29,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } - async function defineAlert(alertName: string) { + async function defineAlert(alertName: string, alertType?: string) { + alertType = alertType || '.index-threshold'; await pageObjects.triggersActionsUI.clickCreateAlertButton(); await testSubjects.setValue('alertNameInput', alertName); - await testSubjects.click('.index-threshold-SelectOption'); + await testSubjects.click(`${alertType}-SelectOption`); await testSubjects.click('selectIndexExpression'); const comboBox = await find.byCssSelector('#indexSelectSearchBox'); await comboBox.click(); @@ -217,5 +218,26 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('confirmAlertCloseModal > confirmModalCancelButton'); await testSubjects.missingOrFail('confirmAlertCloseModal'); }); + + it('should successfully test valid es_query alert', async () => { + const alertName = generateUniqueKey(); + await defineAlert(alertName, '.es-query'); + + // Valid query + await testSubjects.setValue('queryJsonEditor', '{"query":{"match_all":{}}}', { + clearWithKeyboard: true, + }); + await testSubjects.click('testQuery'); + await testSubjects.existOrFail('testQuerySuccess'); + await testSubjects.missingOrFail('testQueryError'); + + // Invalid query + await testSubjects.setValue('queryJsonEditor', '{"query":{"foo":{}}}', { + clearWithKeyboard: true, + }); + await testSubjects.click('testQuery'); + await testSubjects.missingOrFail('testQuerySuccess'); + await testSubjects.existOrFail('testQueryError'); + }); }); }; diff --git a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts index 11182bbcdb3b0..4a95a15169b59 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security.config.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security.config.ts @@ -32,7 +32,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { kbnTestServer: { ...apiConfig.get('kbnTestServer'), serverArgs: [ - `--migrations.enableV2=false`, `--elasticsearch.hosts=${formatUrl(esTestConfig.getUrlParts())}`, `--logging.json=false`, `--server.maxPayloadBytes=1679958`, diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index f53c1c589daab..ce1b58433362b 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -255,11 +255,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { events: { file: false, network: true, process: true }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, }, windows: { @@ -274,11 +279,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, antivirus_registration: { enabled: false, @@ -399,11 +409,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { events: { file: true, network: true, process: true }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, }, windows: { @@ -418,11 +433,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, antivirus_registration: { enabled: false, @@ -536,11 +556,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { events: { file: true, network: true, process: true }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, }, windows: { @@ -555,11 +580,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, antivirus_registration: { enabled: false, diff --git a/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts b/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts index 5f54ab2539c5d..82e47896ce411 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts @@ -16,7 +16,7 @@ import { GetFullAgentPolicyResponse, GetPackagesResponse, } from '../../../plugins/fleet/common'; -import { factory as policyConfigFactory } from '../../../plugins/security_solution/common/endpoint/models/policy_config'; +import { policyFactory } from '../../../plugins/security_solution/common/endpoint/models/policy_config'; import { Immutable } from '../../../plugins/security_solution/common/endpoint/types'; // NOTE: import path below should be the deep path to the actual module - else we get CI errors @@ -178,7 +178,7 @@ export function EndpointPolicyTestResourcesProvider({ getService }: FtrProviderC streams: [], config: { policy: { - value: policyConfigFactory(), + value: policyFactory(), }, }, }, diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts index 03635efb6113d..3e417551c3cb9 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts @@ -30,9 +30,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await searchSessions.deleteAllSearchSessions(); }); - it('Restore using non-existing sessionId errors out. Refresh starts a new session and completes.', async () => { + it('Restore using non-existing sessionId errors out. Refresh starts a new session and completes. Back button restores a session.', async () => { await PageObjects.dashboard.loadSavedDashboard('Not Delayed'); - const url = await browser.getCurrentUrl(); + let url = await browser.getCurrentUrl(); const fakeSessionId = '__fake__'; const savedSessionURL = `${url}&searchSessionId=${fakeSessionId}`; await browser.get(savedSessionURL); @@ -53,6 +53,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Sum of Bytes by Extension' ); expect(session2).not.to.be(fakeSessionId); + + // back button should restore the session: + url = await browser.getCurrentUrl(); + expect(url).not.to.contain('searchSessionId'); + + await browser.goBack(); + + url = await browser.getCurrentUrl(); + expect(url).to.contain('searchSessionId'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('restored'); + expect( + await dashboardPanelActions.getSearchSessionIdByTitle('Sum of Bytes by Extension') + ).to.be(fakeSessionId); }); it('Saves and restores a session', async () => { @@ -82,6 +96,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // should leave session state untouched await PageObjects.dashboard.switchToEditMode(); await searchSessions.expectState('restored'); + + // navigating to a listing page clears the session + await PageObjects.dashboard.gotoDashboardLandingPage(); + await searchSessions.missingOrFail(); }); }); } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts index ce6c8978c7d67..25291fd74b322 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background_relative_time.ts @@ -18,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'visChart', 'home', 'timePicker', + 'maps', ]); const dashboardPanelActions = getService('dashboardPanelActions'); const inspector = getService('inspector'); @@ -112,5 +113,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.debug('Checking vega chart rendered'); const tsvb = await find.existsByCssSelector('.vgaVis__view'); expect(tsvb).to.be(true); + log.debug('Checking map rendered'); + await dashboardPanelActions.openInspectorByTitle( + '[Flights] Origin and Destination Flight Time' + ); + await testSubjects.click('inspectorRequestChooser'); + await testSubjects.click(`inspectorRequestChooserFlight Origin Location`); + const requestStats = await inspector.getTableData(); + const totalHits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits'); + expect(totalHits).to.equal('0'); + await inspector.close(); } } diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/discover/async_search.ts b/x-pack/test/send_search_to_background_integration/tests/apps/discover/async_search.ts index d64df98c98601..b5e65158c573a 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/discover/async_search.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/discover/async_search.ts @@ -13,6 +13,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const browser = getService('browser'); const inspector = getService('inspector'); const PageObjects = getPageObjects(['discover', 'common', 'timePicker', 'header']); + const searchSessions = getService('searchSessions'); describe('discover async search', () => { before(async () => { @@ -31,18 +32,33 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(searchSessionId2).not.to.be(searchSessionId1); }); - it('search session id should be picked up from the URL, non existing session id errors out', async () => { - const url = await browser.getCurrentUrl(); + it('search session id should be picked up from the URL, non existing session id errors out, back button restores a session', async () => { + let url = await browser.getCurrentUrl(); const fakeSearchSessionId = '__test__'; const savedSessionURL = url + `&searchSessionId=${fakeSearchSessionId}`; await browser.navigateTo(savedSessionURL); await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('restored'); await testSubjects.existOrFail('discoverNoResultsError'); // expect error because of fake searchSessionId const searchSessionId1 = await getSearchSessionId(); expect(searchSessionId1).to.be(fakeSearchSessionId); await queryBar.clickQuerySubmitButton(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('completed'); const searchSessionId2 = await getSearchSessionId(); expect(searchSessionId2).not.to.be(searchSessionId1); + + // back button should restore the session: + url = await browser.getCurrentUrl(); + expect(url).not.to.contain('searchSessionId'); + + await browser.goBack(); + + url = await browser.getCurrentUrl(); + expect(url).to.contain('searchSessionId'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('restored'); + expect(await getSearchSessionId()).to.be(fakeSearchSessionId); }); }); diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 6368751fedf75..783315b36efaf 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../src/plugins/legacy_export/tsconfig.json" }, { "path": "../../src/plugins/navigation/tsconfig.json" }, { "path": "../../src/plugins/newsfeed/tsconfig.json" }, { "path": "../../src/plugins/saved_objects/tsconfig.json" }, @@ -36,10 +37,11 @@ { "path": "../../src/plugins/ui_actions/tsconfig.json" }, { "path": "../../src/plugins/url_forwarding/tsconfig.json" }, - { "path": "../plugins/actions/tsconfig.json"}, - { "path": "../plugins/alerts/tsconfig.json"}, + { "path": "../plugins/actions/tsconfig.json" }, + { "path": "../plugins/alerts/tsconfig.json" }, { "path": "../plugins/console_extensions/tsconfig.json" }, { "path": "../plugins/data_enhanced/tsconfig.json" }, + { "path": "../plugins/enterprise_search/tsconfig.json" }, { "path": "../plugins/global_search/tsconfig.json" }, { "path": "../plugins/global_search_providers/tsconfig.json" }, { "path": "../plugins/features/tsconfig.json" }, @@ -58,6 +60,10 @@ { "path": "../plugins/beats_management/tsconfig.json" }, { "path": "../plugins/cloud/tsconfig.json" }, { "path": "../plugins/saved_objects_tagging/tsconfig.json" }, - { "path": "../plugins/global_search_bar/tsconfig.json" } + { "path": "../plugins/global_search_bar/tsconfig.json" }, + { "path": "../plugins/ingest_pipelines/tsconfig.json" }, + { "path": "../plugins/license_management/tsconfig.json" }, + { "path": "../plugins/painless_lab/tsconfig.json" }, + { "path": "../plugins/watcher/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index a6eb098b5d678..c93bc2c5bd181 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -7,6 +7,7 @@ "plugins/apm/e2e/cypress/**/*", "plugins/apm/ftr_e2e/**/*", "plugins/apm/scripts/**/*", + "plugins/canvas/**/*", "plugins/console_extensions/**/*", "plugins/data_enhanced/**/*", "plugins/discover_enhanced/**/*", @@ -17,8 +18,13 @@ "plugins/features/**/*", "plugins/embeddable_enhanced/**/*", "plugins/event_log/**/*", + "plugins/enterprise_search/**/*", "plugins/licensing/**/*", "plugins/lens/**/*", + "plugins/maps/**/*", + "plugins/maps_file_upload/**/*", + "plugins/maps_legacy_licensing/**/*", + "plugins/reporting/**/*", "plugins/searchprofiler/**/*", "plugins/security_solution/cypress/**/*", "plugins/task_manager/**/*", @@ -26,7 +32,6 @@ "plugins/translations/**/*", "plugins/triggers_actions_ui/**/*", "plugins/ui_actions_enhanced/**/*", - "plugins/vis_type_timeseries_enhanced/**/*", "plugins/spaces/**/*", "plugins/security/**/*", "plugins/stack_alerts/**/*", @@ -35,6 +40,10 @@ "plugins/cloud/**/*", "plugins/saved_objects_tagging/**/*", "plugins/global_search_bar/**/*", + "plugins/ingest_pipelines/**/*", + "plugins/license_management/**/*", + "plugins/painless_lab/**/*", + "plugins/watcher/**/*", "test/**/*" ], "compilerOptions": { @@ -43,15 +52,13 @@ }, "references": [ { "path": "../src/core/tsconfig.json" }, - { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, - { "path": "../src/plugins/management/tsconfig.json" }, { "path": "../src/plugins/bfetch/tsconfig.json" }, { "path": "../src/plugins/charts/tsconfig.json" }, { "path": "../src/plugins/console/tsconfig.json" }, { "path": "../src/plugins/dashboard/tsconfig.json" }, - { "path": "../src/plugins/discover/tsconfig.json" }, { "path": "../src/plugins/data/tsconfig.json" }, { "path": "../src/plugins/dev_tools/tsconfig.json" }, + { "path": "../src/plugins/discover/tsconfig.json" }, { "path": "../src/plugins/embeddable/tsconfig.json" }, { "path": "../src/plugins/es_ui_shared/tsconfig.json" }, { "path": "../src/plugins/expressions/tsconfig.json" }, @@ -61,47 +68,61 @@ { "path": "../src/plugins/kibana_react/tsconfig.json" }, { "path": "../src/plugins/kibana_usage_collection/tsconfig.json" }, { "path": "../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../src/plugins/legacy_export/tsconfig.json" }, + { "path": "../src/plugins/management/tsconfig.json" }, { "path": "../src/plugins/navigation/tsconfig.json" }, { "path": "../src/plugins/newsfeed/tsconfig.json" }, - { "path": "../src/plugins/saved_objects/tsconfig.json" }, + { "path": "../src/plugins/presentation_util/tsconfig.json" }, { "path": "../src/plugins/saved_objects_management/tsconfig.json" }, { "path": "../src/plugins/saved_objects_tagging_oss/tsconfig.json" }, - { "path": "../src/plugins/presentation_util/tsconfig.json" }, + { "path": "../src/plugins/saved_objects/tsconfig.json" }, { "path": "../src/plugins/security_oss/tsconfig.json" }, { "path": "../src/plugins/share/tsconfig.json" }, - { "path": "../src/plugins/telemetry/tsconfig.json" }, { "path": "../src/plugins/telemetry_collection_manager/tsconfig.json" }, - { "path": "../src/plugins/url_forwarding/tsconfig.json" }, + { "path": "../src/plugins/telemetry_management_section/tsconfig.json" }, + { "path": "../src/plugins/telemetry/tsconfig.json" }, { "path": "../src/plugins/ui_actions/tsconfig.json" }, { "path": "../src/plugins/url_forwarding/tsconfig.json" }, { "path": "../src/plugins/usage_collection/tsconfig.json" }, + { "path": "./plugins/actions/tsconfig.json" }, + { "path": "./plugins/alerts/tsconfig.json" }, + { "path": "./plugins/beats_management/tsconfig.json" }, + { "path": "./plugins/canvas/tsconfig.json" }, + { "path": "./plugins/cloud/tsconfig.json" }, { "path": "./plugins/console_extensions/tsconfig.json" }, { "path": "./plugins/data_enhanced/tsconfig.json" }, { "path": "./plugins/discover_enhanced/tsconfig.json" }, - { "path": "./plugins/global_search/tsconfig.json" }, - { "path": "./plugins/global_search_providers/tsconfig.json" }, - { "path": "./plugins/features/tsconfig.json" }, - { "path": "./plugins/graph/tsconfig.json" }, { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, + { "path": "./plugins/enterprise_search/tsconfig.json" }, { "path": "./plugins/event_log/tsconfig.json" }, - { "path": "./plugins/licensing/tsconfig.json" }, + { "path": "./plugins/features/tsconfig.json" }, + { "path": "./plugins/global_search_bar/tsconfig.json" }, + { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/global_search/tsconfig.json" }, + { "path": "./plugins/graph/tsconfig.json" }, { "path": "./plugins/lens/tsconfig.json" }, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/licensing/tsconfig.json" }, + { "path": "./plugins/maps_file_upload/tsconfig.json" }, + { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, + { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/searchprofiler/tsconfig.json" }, + { "path": "./plugins/security/tsconfig.json" }, + { "path": "./plugins/spaces/tsconfig.json" }, + { "path": "./plugins/stack_alerts/tsconfig.json" }, { "path": "./plugins/task_manager/tsconfig.json" }, { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, - { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, - { "path": "./plugins/vis_type_timeseries_enhanced/tsconfig.json" }, { "path": "./plugins/translations/tsconfig.json" }, - { "path": "./plugins/spaces/tsconfig.json" }, - { "path": "./plugins/security/tsconfig.json" }, - { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, - { "path": "./plugins/beats_management/tsconfig.json" }, - { "path": "./plugins/cloud/tsconfig.json" }, - { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, - { "path": "./plugins/global_search_bar/tsconfig.json" }, - { "path": "./plugins/actions/tsconfig.json"}, - { "path": "./plugins/alerts/tsconfig.json"}, { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, - { "path": "./plugins/stack_alerts/tsconfig.json"} + { "path": "./plugins/stack_alerts/tsconfig.json"}, + { "path": "./plugins/ingest_pipelines/tsconfig.json"}, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/triggers_actions_ui/tsconfig.json" }, + { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, + { "path": "./plugins/watcher/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index 6a9e54e2e7adf..eff35147a1da9 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -3,23 +3,37 @@ "references": [ { "path": "./plugins/actions/tsconfig.json"}, { "path": "./plugins/alerts/tsconfig.json"}, - { "path": "./plugins/dashboard_enhanced/tsconfig.json" }, - { "path": "./plugins/licensing/tsconfig.json" }, - { "path": "./plugins/lens/tsconfig.json" }, + { "path": "./plugins/beats_management/tsconfig.json" }, + { "path": "./plugins/canvas/tsconfig.json" }, + { "path": "./plugins/cloud/tsconfig.json" }, { "path": "./plugins/console_extensions/tsconfig.json" }, - { "path": "./plugins/discover_enhanced/tsconfig.json" }, + { "path": "./plugins/dashboard_enhanced/tsconfig.json" }, { "path": "./plugins/data_enhanced/tsconfig.json" }, - { "path": "./plugins/global_search/tsconfig.json" }, - { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/discover_enhanced/tsconfig.json" }, + { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, + { "path": "./plugins/enterprise_search/tsconfig.json" }, { "path": "./plugins/event_log/tsconfig.json"}, { "path": "./plugins/features/tsconfig.json" }, + { "path": "./plugins/global_search_bar/tsconfig.json" }, + { "path": "./plugins/global_search_providers/tsconfig.json" }, + { "path": "./plugins/global_search/tsconfig.json" }, { "path": "./plugins/graph/tsconfig.json" }, - { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/lens/tsconfig.json" }, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/licensing/tsconfig.json" }, + { "path": "./plugins/maps_file_upload/tsconfig.json" }, + { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, + { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/reporting/tsconfig.json" }, + { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, { "path": "./plugins/searchprofiler/tsconfig.json" }, + { "path": "./plugins/security/tsconfig.json" }, + { "path": "./plugins/spaces/tsconfig.json" }, + { "path": "./plugins/stack_alerts/tsconfig.json"}, { "path": "./plugins/task_manager/tsconfig.json" }, { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, - { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, - { "path": "./plugins/vis_type_timeseries_enhanced/tsconfig.json" }, { "path": "./plugins/translations/tsconfig.json" }, { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, { "path": "./plugins/spaces/tsconfig.json" }, @@ -29,6 +43,11 @@ { "path": "./plugins/beats_management/tsconfig.json" }, { "path": "./plugins/cloud/tsconfig.json" }, { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, - { "path": "./plugins/global_search_bar/tsconfig.json" } + { "path": "./plugins/global_search_bar/tsconfig.json" }, + { "path": "./plugins/ingest_pipelines/tsconfig.json" }, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/painless_lab/tsconfig.json" }, + { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, + { "path": "./plugins/watcher/tsconfig.json" } ] } diff --git a/x-pack/typings/elasticsearch/aggregations.d.ts b/x-pack/typings/elasticsearch/aggregations.d.ts index 0328877aae8fe..fcb32fa6c0372 100644 --- a/x-pack/typings/elasticsearch/aggregations.d.ts +++ b/x-pack/typings/elasticsearch/aggregations.d.ts @@ -7,7 +7,7 @@ import { Unionize, UnionToIntersection } from 'utility-types'; import { ESSearchHit, MaybeReadonlyArray, ESSourceOptions, ESHitsOf } from '.'; -type SortOrder = 'asc' | 'desc'; +export type SortOrder = 'asc' | 'desc'; type SortInstruction = Record<string, SortOrder | { order: SortOrder }>; export type SortOptions = SortOrder | SortInstruction | SortInstruction[]; diff --git a/x-pack/typings/elasticsearch/index.d.ts b/x-pack/typings/elasticsearch/index.d.ts index 049e1e52c66d9..81443947855bc 100644 --- a/x-pack/typings/elasticsearch/index.d.ts +++ b/x-pack/typings/elasticsearch/index.d.ts @@ -70,6 +70,7 @@ export interface ESSearchBody { aggs?: AggregationInputMap; track_total_hits?: boolean | number; collapse?: CollapseQuery; + search_after?: Array<string | number>; _source?: ESSourceOptions; } diff --git a/yarn.lock b/yarn.lock index ed861b58773b9..1b8cc2f8dc6e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1997,6 +1997,11 @@ resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.0.tgz#860ce718b0b73f4009e153541faff2cb6b85d047" integrity sha512-4Th98KlMHr5+JkxfcoDT//6vY8vM+iSPrLNpHhRyLx2CFYi8e2RfqPLdpbnpo0Q5lQC5hNB79yes07zb02fvCw== +"@bazel/ibazel@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@bazel/ibazel/-/ibazel-0.14.0.tgz#86fa0002bed2ce1123b7ad98d4dd4623a0d93244" + integrity sha512-s0gyec6lArcRDwVfIP6xpY8iEaFpzrSpyErSppd3r2O49pOEg7n6HGS/qJ8ncvme56vrDk6crl/kQ6VAdEO+rg== + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -3512,6 +3517,10 @@ version "0.0.0" uid "" +"@kbn/tinymath@link:packages/kbn-tinymath": + version "0.0.0" + uid "" + "@kbn/ui-framework@link:packages/kbn-ui-framework": version "0.0.0" uid "" @@ -4436,7 +4445,7 @@ core-js "^3.0.1" ts-dedent "^1.1.1" -"@storybook/addon-docs@6.0.26": +"@storybook/addon-docs@6.0.26", "@storybook/addon-docs@^6.0.26": version "6.0.26" resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-6.0.26.tgz#bd7fc1fcdc47bb7992fa8d3254367e8c3bba373d" integrity sha512-3t8AOPkp8ZW74h7FnzxF3wAeb1wRyYjMmgJZxqzgi/x7K0i1inbCq8MuJnytuTcZ7+EK4HR6Ih7o9tJuAtIBLw== @@ -8439,7 +8448,7 @@ axe-core@^4.0.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.0.2.tgz#c7cf7378378a51fcd272d3c09668002a4990b1cb" integrity sha512-arU1h31OGFu+LPrOLGZ7nB45v940NMDMEJeNmbutu57P+UFDVnkZg3e+J1I2HJRZ9hT7gO8J91dn/PMrAiKakA== -axios@^0.21.0, axios@^0.21.1: +axios@^0.21.1: version "0.21.1" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== @@ -10228,13 +10237,13 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^87.0.3: - version "87.0.5" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-87.0.5.tgz#5a56bae6e23fc5eaa0c5ac3b76f936e4dd0989a1" - integrity sha512-bWAKdZANrt3LXMUOKFP+DgW7DjVKfihCbjej6URkUcKsvbQBDYpf5YY5d/dXE3SOSzIFZ7fmLxogusxpsupCJg== +chromedriver@^88.0.0: + version "88.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-88.0.0.tgz#6833fffd516db23c811eeafa1ee1069b5a12fd2f" + integrity sha512-EE8rXh7mikxk3VWKjUsz0KCUX8d3HkQ4HgMNJhWrWjzju12dKPPVHO9MY+YaAI5ryXrXGNf0Y4HcNKgW36P/CA== dependencies: "@testim/chrome-version" "^1.0.7" - axios "^0.21.0" + axios "^0.21.1" del "^6.0.0" extract-zip "^2.0.1" https-proxy-agent "^5.0.0" @@ -28051,11 +28060,6 @@ tinygradient@0.4.3: "@types/tinycolor2" "^1.4.0" tinycolor2 "^1.0.0" -tinymath@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/tinymath/-/tinymath-1.2.1.tgz#f97ed66c588cdbf3c19dfba2ae266ee323db7e47" - integrity sha512-8CYutfuHR3ywAJus/3JUhaJogZap1mrUQGzNxdBiQDhP3H0uFdQenvaXvqI8lMehX4RsanRZzxVfjMBREFdQaA== - tinyqueue@^1.1.0: version "1.2.3" resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-1.2.3.tgz#b6a61de23060584da29f82362e45df1ec7353f3d"