diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 00000000000000..b71007690f1cf7 --- /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 00000000000000..661e7aeadf36f8 --- /dev/null +++ b/.bazeliskversion @@ -0,0 +1 @@ +1.7.3 diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 00000000000000..fd469d1203a821 --- /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 00000000000000..a53d1b80724832 --- /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 00000000000000..fcdb2e109f68cf --- /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 b40cd91a45c572..11a39faa9aed01 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 6aa93d4a1056a6..b05e834f5a459e 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 dac1cc8986a1cd..b900d1b6d6b4e6 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 b323a88ef06bc8..0dee07d00d2bea 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 c8b9b075e0e61d..00000000000000 --- 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/.eslintignore b/.eslintignore index 4ef96ebab062aa..5d25f3a78c1ec0 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 29528c249d2793..d8b9a9d7cdd998 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 0630937d5ac4b5..9e31bd31b4037e 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 f74870578ecb1a..81d57be9b2d951 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 79d022a2d701b9..2d7dd52e3ef9e2 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 ce99c9c49e1983..0a8abf4a149cf5 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 a49d5f2b07f4c8..9506d98cbe50ee 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 c9d170b5e5c3d2..c33c9c2678ca49 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 00000000000000..8246b60823ff9b --- /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 c84b65027dee6c..5cddcf18e067ff 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 00000000000000..0828157524fa16 --- /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 fd4ed75352b1f7..0ab1c89c1d8f75 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.plugininitializercontext.config.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md index 4ab0cb74f809f4..3b5754eb4fa392 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 106fdaad9bc22d..e5de046eccf1da 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 18760170afa1f2..90a19d53bd5e1e 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/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 b5ac4a4e53887e..5f8966f0227ac5 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 8fd17e6b1a1d95..e96fe8b8e08dc6 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 00000000000000..bcf220a9a27e62 --- /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 df302e9f3b0d39..2af9cc14e36689 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 1c6370c7d03561..b4eecca665e827 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 29a511d57d7bd6..00000000000000 --- 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 dfeef1cdce22c6..00000000000000 --- 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 00000000000000..8e3b9a7bfeb3f9 --- /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 371f7b7c15362e..7fb8717fae0039 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/redirects.asciidoc b/docs/redirects.asciidoc index 4449b58b4bab3b..c7bdff800bb0bd 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/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index 7436536d227817..cc6e3638728086 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 7c5a957d1cf794..279739e95b5228 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 00000000000000..ce2bd6a42a4b5c 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 00000000000000..ce8b8e92181a97 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 00000000000000..61fe724ea14125 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 00000000000000..1894ad2b445f8d 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 4a0598cc569cdc..2c961dca444741 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 00000000000000..227abd9441e15a 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 00000000000000..3b89eed44b28f8 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 00000000000000..abe75d5ab6fc14 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 00000000000000..9ca4b7f43f427e 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 00000000000000..79e0a3dca86587 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 00000000000000..7a97c952e10398 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 00000000000000..28bff6d13c871b 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 00000000000000..887e8eea27c5c4 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 00000000000000..67458200c50d1c 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 00000000000000..ef7634485bccdb 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 a41b42f259471a..fb91f6a6a1c9a2 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 99728c5471dfb7..2064abb7e36a15 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 9ac5e57254e5a4..f1833772c82a1c 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 00000000000000..1b478aa85bdbaf --- /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 42949a70141311..920e0c8ba5192e 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 c0f8bf0ecb5089..2e978c543cc69d 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 cd6f399ddcce2a..59f788767004ca 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 96d1f794a691ce..e55916d7d348c1 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 9518279f357662..929735ffc15f2e 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 c037c5f0308c8d..c12a147fddddc7 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/src/cli_commands/snapshot.js b/packages/kbn-es/src/cli_commands/snapshot.js index d66c352a356aa2..711992a5895edf 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 60f8a327594d64..f554dd8a1b8e59 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -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 1e9e19f4533afa..80ff4eb6f83b03 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 55c0e41ea96409..b9562f20d81b71 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-legacy-logging/src/setup_logging.test.ts b/packages/kbn-legacy-logging/src/setup_logging.test.ts new file mode 100644 index 00000000000000..6386b400329b96 --- /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 4370e4ab77d68b..ffe3be558f366f 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 1a4fb390d0c179..a13976d1487388 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -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 96356e01f8c044..089ff163a692dc 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/dist/index.js b/packages/kbn-pm/dist/index.js index 95ab46582723e6..df04965cd8c32b 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 @@ -8827,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 @@ -8865,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 @@ -8881,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) { @@ -47935,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__); @@ -48029,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'); @@ -48395,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 = [ @@ -48612,7 +48695,7 @@ const chalkTag = (chalk, ...strings) => { } if (template === undefined) { - template = __webpack_require__(380); + template = __webpack_require__(382); } return template(chalk, parts.join('')); @@ -48641,7 +48724,7 @@ module.exports = chalk; /***/ }), -/* 375 */ +/* 377 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -48687,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; @@ -48812,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 = {}; @@ -48899,7 +48982,7 @@ module.exports = convert; /***/ }), -/* 377 */ +/* 379 */ /***/ (function(module, exports, __webpack_require__) { /* MIT license */ @@ -49744,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. @@ -49847,7 +49930,7 @@ module.exports = function (fromModel) { /***/ }), -/* 379 */ +/* 381 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -49893,7 +49976,7 @@ module.exports = { /***/ }), -/* 380 */ +/* 382 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -50034,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; @@ -50076,7 +50159,7 @@ exports.toggle = (force, writableStream) => { /***/ }), -/* 382 */ +/* 384 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -50092,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); @@ -50116,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'; @@ -50149,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'); @@ -50384,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); @@ -50557,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 = {}; @@ -50641,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). @@ -51515,7 +51598,7 @@ convert.rgb.gray = function (rgb) { /***/ }), -/* 390 */ +/* 392 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -51674,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. @@ -51777,7 +51860,7 @@ module.exports = function (fromModel) { /***/ }), -/* 392 */ +/* 394 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -51915,7 +51998,7 @@ module.exports = { /***/ }), -/* 393 */ +/* 395 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52050,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"; @@ -52078,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, @@ -52184,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 || {}; @@ -52202,7 +52285,7 @@ module.exports = function(options, defaults) { }; /***/ }), -/* 398 */ +/* 400 */ /***/ (function(module, exports, __webpack_require__) { var clone = (function() { @@ -52374,7 +52457,7 @@ if ( true && module.exports) { /***/ }), -/* 399 */ +/* 401 */ /***/ (function(module, exports) { module.exports = [ @@ -52430,7 +52513,7 @@ module.exports = [ /***/ }), -/* 400 */ +/* 402 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52446,7 +52529,7 @@ module.exports = ({stream = process.stdout} = {}) => { /***/ }), -/* 401 */ +/* 403 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(138) @@ -52597,7 +52680,7 @@ MuteStream.prototype.close = proxy('close') /***/ }), -/* 402 */ +/* 404 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52654,7 +52737,7 @@ const RunCommand = { }; /***/ }), -/* 403 */ +/* 405 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52664,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 @@ -52739,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 @@ -52802,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); @@ -52947,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 */ @@ -53226,7 +53309,7 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 406 */ +/* 408 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53305,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 */ @@ -53328,7 +53411,7 @@ function auditTime(duration, scheduler) { /***/ }), -/* 408 */ +/* 410 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53375,7 +53458,7 @@ var BufferSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 409 */ +/* 411 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53476,7 +53559,7 @@ var BufferSkipCountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 410 */ +/* 412 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53637,7 +53720,7 @@ function dispatchBufferClose(arg) { /***/ }), -/* 411 */ +/* 413 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53756,7 +53839,7 @@ var BufferToggleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 412 */ +/* 414 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53849,7 +53932,7 @@ var BufferWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 413 */ +/* 415 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53909,7 +53992,7 @@ var CatchSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 414 */ +/* 416 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53925,7 +54008,7 @@ function combineAll(project) { /***/ }), -/* 415 */ +/* 417 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53957,7 +54040,7 @@ function combineLatest() { /***/ }), -/* 416 */ +/* 418 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53977,7 +54060,7 @@ function concat() { /***/ }), -/* 417 */ +/* 419 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53993,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) { @@ -54009,7 +54092,7 @@ function concatMapTo(innerObservable, resultSelector) { /***/ }), -/* 419 */ +/* 421 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54074,7 +54157,7 @@ var CountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 420 */ +/* 422 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54159,7 +54242,7 @@ var DebounceSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 421 */ +/* 423 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54235,7 +54318,7 @@ function dispatchNext(subscriber) { /***/ }), -/* 422 */ +/* 424 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54285,7 +54368,7 @@ var DefaultIfEmptySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 423 */ +/* 425 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54293,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 */ @@ -54392,7 +54475,7 @@ var DelayMessage = /*@__PURE__*/ (function () { /***/ }), -/* 424 */ +/* 426 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54406,7 +54489,7 @@ function isDate(value) { /***/ }), -/* 425 */ +/* 427 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54552,7 +54635,7 @@ var SubscriptionDelaySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 426 */ +/* 428 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54590,7 +54673,7 @@ var DeMaterializeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 427 */ +/* 429 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54666,7 +54749,7 @@ var DistinctSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 428 */ +/* 430 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54737,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) { @@ -54753,7 +54836,7 @@ function distinctUntilKeyChanged(key, compare) { /***/ }), -/* 430 */ +/* 432 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54761,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 */ @@ -54785,7 +54868,7 @@ function elementAt(index, defaultValue) { /***/ }), -/* 431 */ +/* 433 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54851,7 +54934,7 @@ function defaultErrorFactory() { /***/ }), -/* 432 */ +/* 434 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54913,7 +54996,7 @@ var TakeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 433 */ +/* 435 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54935,7 +55018,7 @@ function endWith() { /***/ }), -/* 434 */ +/* 436 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54997,7 +55080,7 @@ var EverySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 435 */ +/* 437 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55051,7 +55134,7 @@ var SwitchFirstSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 436 */ +/* 438 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55145,7 +55228,7 @@ var ExhaustMapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 437 */ +/* 439 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55257,7 +55340,7 @@ var ExpandSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 438 */ +/* 440 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55295,7 +55378,7 @@ var FinallySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 439 */ +/* 441 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55367,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) { @@ -55383,7 +55466,7 @@ function findIndex(predicate, thisArg) { /***/ }), -/* 441 */ +/* 443 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55391,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 */ @@ -55410,7 +55493,7 @@ function first(predicate, defaultValue) { /***/ }), -/* 442 */ +/* 444 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55447,7 +55530,7 @@ var IgnoreElementsSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 443 */ +/* 445 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55491,7 +55574,7 @@ var IsEmptySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 444 */ +/* 446 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55499,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 */ @@ -55518,7 +55601,7 @@ function last(predicate, defaultValue) { /***/ }), -/* 445 */ +/* 447 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55595,7 +55678,7 @@ var TakeLastSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 446 */ +/* 448 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55634,7 +55717,7 @@ var MapToSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 447 */ +/* 449 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55684,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) { @@ -55703,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 */ @@ -55732,7 +55815,7 @@ function reduce(accumulator, seed) { /***/ }), -/* 450 */ +/* 452 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55814,7 +55897,7 @@ var ScanSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 451 */ +/* 453 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55834,7 +55917,7 @@ function merge() { /***/ }), -/* 452 */ +/* 454 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55859,7 +55942,7 @@ function mergeMapTo(innerObservable, resultSelector, concurrent) { /***/ }), -/* 453 */ +/* 455 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55968,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) { @@ -55987,7 +56070,7 @@ function min(comparer) { /***/ }), -/* 455 */ +/* 457 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56036,7 +56119,7 @@ var MulticastOperator = /*@__PURE__*/ (function () { /***/ }), -/* 456 */ +/* 458 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56126,7 +56209,7 @@ var OnErrorResumeNextSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 457 */ +/* 459 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56174,7 +56257,7 @@ var PairwiseSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 458 */ +/* 460 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56197,7 +56280,7 @@ function partition(predicate, thisArg) { /***/ }), -/* 459 */ +/* 461 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56237,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 */ @@ -56257,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 */ @@ -56275,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 */ @@ -56293,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 */ @@ -56316,7 +56399,7 @@ function publishReplay(bufferSize, windowTime, selectorOrScheduler, scheduler) { /***/ }), -/* 464 */ +/* 466 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56343,7 +56426,7 @@ function race() { /***/ }), -/* 465 */ +/* 467 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56408,7 +56491,7 @@ var RepeatSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 466 */ +/* 468 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56502,7 +56585,7 @@ var RepeatWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 467 */ +/* 469 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56555,7 +56638,7 @@ var RetrySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 468 */ +/* 470 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56641,7 +56724,7 @@ var RetryWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 469 */ +/* 471 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56696,7 +56779,7 @@ var SampleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 470 */ +/* 472 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56756,7 +56839,7 @@ function dispatchNotification(state) { /***/ }), -/* 471 */ +/* 473 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56879,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 */ @@ -56902,7 +56985,7 @@ function share() { /***/ }), -/* 473 */ +/* 475 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56971,7 +57054,7 @@ function shareReplayOperator(_a) { /***/ }), -/* 474 */ +/* 476 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57051,7 +57134,7 @@ var SingleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 475 */ +/* 477 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57093,7 +57176,7 @@ var SkipSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 476 */ +/* 478 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57155,7 +57238,7 @@ var SkipLastSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 477 */ +/* 479 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57212,7 +57295,7 @@ var SkipUntilSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 478 */ +/* 480 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57268,7 +57351,7 @@ var SkipWhileSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 479 */ +/* 481 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57297,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) { @@ -57328,7 +57411,7 @@ var SubscribeOnOperator = /*@__PURE__*/ (function () { /***/ }), -/* 481 */ +/* 483 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57392,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 */ @@ -57410,7 +57493,7 @@ function switchAll() { /***/ }), -/* 483 */ +/* 485 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57498,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) { @@ -57514,7 +57597,7 @@ function switchMapTo(innerObservable, resultSelector) { /***/ }), -/* 485 */ +/* 487 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57562,7 +57645,7 @@ var TakeUntilSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 486 */ +/* 488 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57630,7 +57713,7 @@ var TakeWhileSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 487 */ +/* 489 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57718,7 +57801,7 @@ var TapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 488 */ +/* 490 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57820,7 +57903,7 @@ var ThrottleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 489 */ +/* 491 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57829,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 */ @@ -57918,7 +58001,7 @@ function dispatchNext(arg) { /***/ }), -/* 490 */ +/* 492 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57926,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 */ @@ -57962,7 +58045,7 @@ var TimeInterval = /*@__PURE__*/ (function () { /***/ }), -/* 491 */ +/* 493 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57970,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 */ @@ -57987,7 +58070,7 @@ function timeout(due, scheduler) { /***/ }), -/* 492 */ +/* 494 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57995,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 */ @@ -58066,7 +58149,7 @@ var TimeoutWithSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 493 */ +/* 495 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58096,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) { @@ -58119,7 +58202,7 @@ function toArray() { /***/ }), -/* 495 */ +/* 497 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58197,7 +58280,7 @@ var WindowSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 496 */ +/* 498 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58287,7 +58370,7 @@ var WindowCountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 497 */ +/* 499 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58457,7 +58540,7 @@ function dispatchWindowClose(state) { /***/ }), -/* 498 */ +/* 500 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58600,7 +58683,7 @@ var WindowToggleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 499 */ +/* 501 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58697,7 +58780,7 @@ var WindowSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 500 */ +/* 502 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58792,7 +58875,7 @@ var WithLatestFromSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 501 */ +/* 503 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58814,7 +58897,7 @@ function zip() { /***/ }), -/* 502 */ +/* 504 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58830,7 +58913,7 @@ function zipAll(project) { /***/ }), -/* 503 */ +/* 505 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58840,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; } @@ -58911,7 +58994,7 @@ function toArray(value) { } /***/ }), -/* 504 */ +/* 506 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58919,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; } @@ -59076,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); @@ -59108,7 +59191,7 @@ module.exports = (list, patterns, options = {}) => { /***/ }), -/* 506 */ +/* 508 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59120,7 +59203,7 @@ module.exports = (...arguments_) => { /***/ }), -/* 507 */ +/* 509 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59135,7 +59218,7 @@ module.exports = arrayDiffer; /***/ }), -/* 508 */ +/* 510 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59165,7 +59248,7 @@ module.exports = arrify; /***/ }), -/* 509 */ +/* 511 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59224,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"]; }); /* @@ -59242,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); @@ -59380,7 +59463,7 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { } /***/ }), -/* 512 */ +/* 514 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59388,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 @@ -59546,7 +59629,7 @@ module.exports = (source, destination, { /***/ }), -/* 513 */ +/* 515 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59634,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; @@ -59789,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)); @@ -59802,7 +59885,7 @@ module.exports = function () { /***/ }), -/* 516 */ +/* 518 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59871,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; @@ -59887,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. */ @@ -59965,7 +60048,7 @@ function isString(source) { /***/ }), -/* 519 */ +/* 521 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60003,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. */ @@ -60100,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. @@ -60255,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) { @@ -60286,7 +60369,7 @@ module.exports = function globParent(str) { /***/ }), -/* 523 */ +/* 525 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -60317,7 +60400,7 @@ module.exports = function isGlob(str) { /***/ }), -/* 524 */ +/* 526 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60467,7 +60550,7 @@ module.exports.win32 = win32; /***/ }), -/* 525 */ +/* 527 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60478,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; /** @@ -61351,7 +61434,7 @@ module.exports = micromatch; /***/ }), -/* 526 */ +/* 528 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61361,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 = {}; @@ -61676,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; /** @@ -61838,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) { @@ -61887,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) { @@ -62175,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. @@ -62292,7 +62375,7 @@ exports.error = function(regexp, msg) { /***/ }), -/* 531 */ +/* 533 */ /***/ (function(module, exports) { module.exports = { @@ -62308,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 }]; @@ -62396,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' }; @@ -62419,7 +62502,7 @@ exports.end = function() { /***/ }), -/* 534 */ +/* 536 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62432,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; @@ -62464,7 +62547,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 535 */ +/* 537 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62483,7 +62566,7 @@ module.exports = function isObject(val) { /***/ }), -/* 536 */ +/* 538 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62496,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') { @@ -62512,7 +62595,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 537 */ +/* 539 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -62647,7 +62730,7 @@ function isBuffer(val) { /***/ }), -/* 538 */ +/* 540 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62660,7 +62743,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(539); +var typeOf = __webpack_require__(541); // accessor descriptor properties var accessor = { @@ -62723,7 +62806,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 539 */ +/* 541 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -62858,7 +62941,7 @@ function isBuffer(val) { /***/ }), -/* 540 */ +/* 542 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62871,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 @@ -62914,7 +62997,7 @@ module.exports = function isDataDescriptor(obj, prop) { /***/ }), -/* 541 */ +/* 543 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63049,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') { @@ -63116,7 +63199,7 @@ function isEnum(obj, key) { /***/ }), -/* 543 */ +/* 545 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63129,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); @@ -63137,7 +63220,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 544 */ +/* 546 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63150,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 @@ -63181,7 +63264,7 @@ module.exports = function isPlainObject(o) { /***/ }), -/* 545 */ +/* 547 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63228,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. @@ -63307,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') { @@ -63374,7 +63457,7 @@ function isEnum(obj, key) { /***/ }), -/* 548 */ +/* 550 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63387,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); @@ -63395,7 +63478,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 549 */ +/* 551 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63445,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 = {}; } @@ -63485,7 +63568,7 @@ function hasOwn(obj, key) { /***/ }), -/* 551 */ +/* 553 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63505,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 @@ -63794,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, { @@ -64144,7 +64227,7 @@ utils.escapeRegex = function(str) { /***/ }), -/* 554 */ +/* 556 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64157,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') { @@ -64322,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') { @@ -64389,7 +64472,7 @@ function isEnum(obj, key) { /***/ }), -/* 556 */ +/* 558 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64402,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); @@ -64410,7 +64493,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 557 */ +/* 559 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64439,7 +64522,7 @@ function flat(arr, res) { /***/ }), -/* 558 */ +/* 560 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64453,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. @@ -64654,7 +64737,7 @@ module.exports = fillRange; /***/ }), -/* 559 */ +/* 561 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64667,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); @@ -64683,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; /** @@ -64805,7 +64888,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 561 */ +/* 563 */ /***/ (function(module, exports) { /*! @@ -64832,7 +64915,7 @@ function isSlowBuffer (obj) { /***/ }), -/* 562 */ +/* 564 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64909,7 +64992,7 @@ function repeat(str, num) { /***/ }), -/* 563 */ +/* 565 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64922,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) { @@ -65210,7 +65293,7 @@ module.exports = toRegexRange; /***/ }), -/* 564 */ +/* 566 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65235,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 @@ -65602,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; /** @@ -66101,7 +66184,7 @@ exports = module.exports = Node; /***/ }), -/* 567 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -66114,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') { @@ -66139,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; /** @@ -67165,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 @@ -67276,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 = {}; @@ -67457,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. @@ -67899,7 +67982,7 @@ module.exports.namespace = namespace; /***/ }), -/* 572 */ +/* 574 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67912,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') { @@ -67937,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 @@ -68205,7 +68288,7 @@ module.exports.namespace = namespace; /***/ }), -/* 574 */ +/* 576 */ /***/ (function(module, exports, __webpack_require__) { @@ -68374,7 +68457,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 575 */ +/* 577 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68387,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; @@ -68411,7 +68494,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 576 */ +/* 578 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68424,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') { @@ -68451,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. @@ -68495,7 +68578,7 @@ function isObject(val) { /***/ }), -/* 578 */ +/* 580 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68508,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') { @@ -68535,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)) { @@ -68572,7 +68655,7 @@ function arrayify(val) { /***/ }), -/* 580 */ +/* 582 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68608,7 +68691,7 @@ module.exports = function union(init) { /***/ }), -/* 581 */ +/* 583 */ /***/ (function(module, exports) { /*! @@ -68664,7 +68747,7 @@ function toString(val) { /***/ }), -/* 582 */ +/* 584 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68677,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)) { @@ -68726,7 +68809,7 @@ function isValidKey(key) { /***/ }), -/* 583 */ +/* 585 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68739,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)) { @@ -68765,7 +68848,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 584 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68778,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)) { @@ -68791,7 +68874,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 585 */ +/* 587 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68804,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; @@ -68812,7 +68895,7 @@ module.exports = function isObject(val) { /***/ }), -/* 586 */ +/* 588 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -68823,7 +68906,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 587 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68866,7 +68949,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 588 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68879,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); @@ -68889,7 +68972,7 @@ module.exports = function(val, prop) { /***/ }), -/* 589 */ +/* 591 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68902,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 @@ -68956,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; /** @@ -69081,7 +69164,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 591 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69094,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)) { @@ -69143,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; @@ -69214,7 +69297,7 @@ module.exports = mixinDeep; /***/ }), -/* 593 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69227,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); @@ -69235,7 +69318,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 594 */ +/* 596 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69258,7 +69341,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 595 */ +/* 597 */ /***/ (function(module, exports) { /*! @@ -69285,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 @@ -69657,7 +69740,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 597 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69671,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); /** @@ -69685,7 +69768,7 @@ module.exports = utils; /***/ }), -/* 598 */ +/* 600 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69698,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') { @@ -69723,7 +69806,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 599 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69736,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') { @@ -69752,7 +69835,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 600 */ +/* 602 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -69905,7 +69988,7 @@ function isBuffer(val) { /***/ }), -/* 601 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69918,7 +70001,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(602); +var typeOf = __webpack_require__(604); // accessor descriptor properties var accessor = { @@ -69981,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; /** @@ -70103,7 +70186,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 603 */ +/* 605 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70116,7 +70199,7 @@ module.exports = function kindOf(val) { -var typeOf = __webpack_require__(604); +var typeOf = __webpack_require__(606); // data descriptor properties var data = { @@ -70165,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; /** @@ -70287,7 +70370,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 605 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70300,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); /** @@ -70384,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. @@ -70565,7 +70648,7 @@ module.exports.has = has; /***/ }), -/* 607 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70653,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`. @@ -70816,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(); @@ -70837,7 +70920,7 @@ module.exports = Compiler; /***/ }), -/* 609 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70850,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') { @@ -70965,7 +71048,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 610 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70979,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) { @@ -70995,7 +71078,7 @@ module.exports = utils; /***/ }), -/* 611 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71004,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__) { /** @@ -71020,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; @@ -71202,7 +71285,7 @@ function localstorage() { /***/ }), -/* 613 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { @@ -71218,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. @@ -71410,7 +71493,7 @@ function coerce(val) { /***/ }), -/* 614 */ +/* 616 */ /***/ (function(module, exports) { /** @@ -71568,7 +71651,7 @@ function plural(ms, n, name) { /***/ }), -/* 615 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71584,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; @@ -71763,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, @@ -71822,13 +71905,13 @@ exports.enable(load()); /***/ }), -/* 616 */ +/* 618 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 617 */ +/* 619 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71838,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 @@ -71883,7 +71966,7 @@ exports.last = function(arr, n) { /***/ }), -/* 618 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -71891,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; -*- */ @@ -71907,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 @@ -72319,7 +72402,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 620 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72359,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, @@ -72465,7 +72548,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 621 */ +/* 623 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72538,7 +72621,7 @@ exports.decode = function (charCode) { /***/ }), -/* 622 */ +/* 624 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72961,7 +73044,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 623 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72971,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"; @@ -73088,7 +73171,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 624 */ +/* 626 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73098,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 @@ -73173,7 +73256,7 @@ exports.MappingList = MappingList; /***/ }), -/* 625 */ +/* 627 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73183,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; @@ -74261,7 +74344,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 626 */ +/* 628 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74378,7 +74461,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 627 */ +/* 629 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74498,7 +74581,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 628 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74508,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). @@ -74917,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) @@ -75225,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 @@ -75288,7 +75371,7 @@ void (function(root, factory) { /***/ }), -/* 631 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75306,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. @@ -75323,7 +75406,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 633 */ +/* 635 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75424,7 +75507,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 634 */ +/* 636 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75447,7 +75530,7 @@ module.exports = urix /***/ }), -/* 635 */ +/* 637 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75461,7 +75544,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 636 */ +/* 638 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75469,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()`. @@ -75613,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`. @@ -76153,7 +76236,7 @@ module.exports = Parser; /***/ }), -/* 638 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76260,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 @@ -76281,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') { @@ -76348,7 +76431,7 @@ function isEnum(obj, key) { /***/ }), -/* 641 */ +/* 643 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76361,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); @@ -76369,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; @@ -76453,7 +76536,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 643 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76464,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; /** @@ -77298,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') { @@ -77365,7 +77448,7 @@ function isEnum(obj, key) { /***/ }), -/* 645 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77378,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); @@ -77386,7 +77469,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 646 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77732,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 @@ -78126,7 +78209,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 648 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78139,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)) { @@ -78153,7 +78236,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 649 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78181,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"; @@ -78201,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`. @@ -78323,7 +78406,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 652 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78336,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 @@ -78709,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__;/*! @@ -78737,7 +78820,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 654 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78750,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; @@ -78782,7 +78865,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 655 */ +/* 657 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78836,7 +78919,7 @@ function diffArray(one, two) { /***/ }), -/* 656 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78849,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') { @@ -78878,7 +78961,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 657 */ +/* 659 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -79013,7 +79096,7 @@ function isBuffer(val) { /***/ }), -/* 658 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79023,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; /** @@ -79351,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 @@ -79527,7 +79610,7 @@ module.exports = function(extglob) { /***/ }), -/* 660 */ +/* 662 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79537,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 @@ -79745,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 @@ -79839,7 +79922,7 @@ module.exports = function(brackets) { /***/ }), -/* 662 */ +/* 664 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79868,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 @@ -80094,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; /** @@ -80135,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 @@ -80298,7 +80381,7 @@ module.exports = parsers; /***/ }), -/* 666 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80311,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') { @@ -80336,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 @@ -80412,7 +80495,7 @@ utils.createRegex = function(str) { /***/ }), -/* 668 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80422,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 @@ -80497,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; /** @@ -80587,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"; @@ -80607,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 `\\`. @@ -80910,7 +80993,7 @@ utils.unixify = function(options) { /***/ }), -/* 672 */ +/* 674 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80923,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; @@ -80955,7 +81038,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 673 */ +/* 675 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -81090,7 +81173,7 @@ function isBuffer(val) { /***/ }), -/* 674 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81109,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() { @@ -81172,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; @@ -81264,7 +81347,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 676 */ +/* 678 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81272,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 }; @@ -81305,7 +81388,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 677 */ +/* 679 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81314,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 @@ -81692,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; @@ -81876,7 +81959,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 679 */ +/* 681 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -82013,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; @@ -82094,7 +82177,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 681 */ +/* 683 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82155,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 @@ -82226,7 +82309,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 683 */ +/* 685 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82255,7 +82338,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 684 */ +/* 686 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82263,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 }; @@ -82310,7 +82393,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 685 */ +/* 687 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82337,7 +82420,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 686 */ +/* 688 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82373,7 +82456,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 687 */ +/* 689 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82381,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 }; @@ -82405,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; @@ -82480,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; @@ -82570,7 +82653,7 @@ exports.default = DeepFilter; /***/ }), -/* 690 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82601,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; @@ -82693,7 +82776,7 @@ exports.default = EntryFilter; /***/ }), -/* 692 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82713,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() { @@ -82764,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. */ @@ -82802,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), @@ -82821,7 +82904,7 @@ exports.prepare = prepare; /***/ }), -/* 695 */ +/* 697 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82844,7 +82927,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 696 */ +/* 698 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82896,7 +82979,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 697 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82927,7 +83010,7 @@ exports.default = FileSystem; /***/ }), -/* 698 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82947,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) { @@ -83017,7 +83100,7 @@ exports.default = ReaderStream; /***/ }), -/* 699 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83036,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() { @@ -83098,7 +83181,7 @@ exports.default = ReaderSync; /***/ }), -/* 700 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83117,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() { @@ -83164,7 +83247,7 @@ exports.default = FileSystemSync; /***/ }), -/* 701 */ +/* 703 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83180,7 +83263,7 @@ exports.flatten = flatten; /***/ }), -/* 702 */ +/* 704 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83201,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]; @@ -83273,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') { @@ -83322,7 +83405,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 705 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83413,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/**', @@ -83521,7 +83604,7 @@ module.exports.sync = options => { /***/ }), -/* 707 */ +/* 709 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -83990,7 +84073,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 708 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84065,7 +84148,7 @@ module.exports = (input, options) => { /***/ }), -/* 709 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84083,7 +84166,7 @@ module.exports = input => { /***/ }), -/* 710 */ +/* 712 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84096,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; @@ -84116,7 +84199,7 @@ module.exports = function hasGlob(val) { /***/ }), -/* 711 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -84147,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; @@ -84271,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'; @@ -84567,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) { @@ -84618,7 +84701,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 715 */ +/* 717 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84640,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) { @@ -84659,7 +84742,7 @@ module.exports = CpFileError; /***/ }), -/* 717 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(112).inherits; @@ -84715,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); @@ -84821,7 +84904,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 719 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84829,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'); @@ -84984,7 +85067,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 720 */ +/* 722 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -86586,7 +86669,7 @@ function coerce (version, options) { /***/ }), -/* 721 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86627,7 +86710,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 722 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86673,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( @@ -86695,7 +86778,7 @@ module.exports.default = pFilter; /***/ }), -/* 724 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86774,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/commands/bootstrap.ts b/packages/kbn-pm/src/commands/bootstrap.ts index 0ad420899870dd..8cd346a56f2785 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/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 f101372f5bbce7..957c4bdf7f6aae 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 00000000000000..4e19974590e83b --- /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 b6dcd40b53d2ed..08bfa5eb404ca6 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 ebedb314f9594d..ed88944ed862d7 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 00000000000000..1094c4286c851b --- /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 00000000000000..c578a02ede1fb9 --- /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 00000000000000..0c7460a8189ddb --- /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 00000000000000..60f821e6d15bfa --- /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 00000000000000..2fb97d8aa416aa --- /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 00000000000000..34fd593672b5a8 --- /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 00000000000000..aa9eaba1ce3b24 --- /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 00000000000000..5a4d6802a85ea2 --- /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 00000000000000..017a6617027615 --- /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 00000000000000..7fbbabe481073f --- /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 00000000000000..66b9e9eaf4f0dd --- /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 00000000000000..0385f52793c272 --- /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 00000000000000..b037999b7ac8af --- /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 00000000000000..de14ac8749ae14 --- /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 00000000000000..20fd8ac9e20609 --- /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 00000000000000..889e2305cbd9e8 --- /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 00000000000000..d7fd3877001c93 --- /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 00000000000000..911482541b1d33 --- /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 00000000000000..16ed2d0dcb54f4 --- /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 00000000000000..db90697edc3466 --- /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 00000000000000..ab5805cc0a77ef --- /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 00000000000000..08964c784ba88f --- /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 00000000000000..6a771f4f543369 --- /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 00000000000000..07fb8376438d67 --- /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 00000000000000..79417031d5ed8a --- /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 00000000000000..13cebbfdf662a8 --- /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 00000000000000..ee37d77b10e71d --- /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 00000000000000..6f1e3cd4972e5d --- /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 00000000000000..44509bedfd0888 --- /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 00000000000000..93c23077a9d694 --- /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 00000000000000..4c7d8414602dfb --- /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 00000000000000..6334b510e550bb --- /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 00000000000000..5dd625cf7f0d3c --- /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 00000000000000..b44b9679fc7f82 --- /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 00000000000000..51f911e2dcad03 --- /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 00000000000000..ffe5c3a9cb8e24 --- /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 00000000000000..31f9b618bb1db0 --- /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 00000000000000..1e8847e6dfd2bc --- /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 00000000000000..f08ffa8bdc197a --- /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 00000000000000..5156a70b38d69f --- /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 00000000000000..2c55b2256e0f6d --- /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 00000000000000..a5bccdef7661ba --- /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 00000000000000..8e5fd256bf1585 --- /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 00000000000000..b13a86d5c21224 --- /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 00000000000000..56ea4c35f1459b --- /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 00000000000000..60196e85688550 --- /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 00000000000000..60dfcf4800631b --- /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 00000000000000..cab8e024e60b33 --- /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 00000000000000..e61956bd63e556 --- /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 00000000000000..09ae042d23de69 --- /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 00000000000000..56b4fc48a62ad7 --- /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 00000000000000..8b8b57c5a1ba14 --- /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 00000000000000..0809c9ba1e9d5e --- /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 00000000000000..7e6015bf304cff --- /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 00000000000000..9e4461512fe06f --- /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 00000000000000..f91cbd3c58059f --- /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 00000000000000..8ce78851e7844b --- /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 00000000000000..f3eea83c3fb80c --- /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 00000000000000..0bb25d772ae2cd --- /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 00000000000000..c977f68117724c --- /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 00000000000000..59a71352ac6809 --- /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 00000000000000..19f80e9bb7b06d --- /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 00000000000000..a333541b147eab --- /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 00000000000000..de142b997039b5 --- /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 00000000000000..e0edfaa8388f0b --- /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 00000000000000..ab4de7b958e689 --- /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 00000000000000..6fb1c1fa18b98b --- /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 00000000000000..e7dd56b4c6fc43 --- /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 00000000000000..9612ce4274d114 --- /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 00000000000000..ba3fc35b7e70c8 --- /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 00000000000000..6f33140d41ef06 --- /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 00000000000000..f3a35d1f456956 --- /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 00000000000000..7f1cdd019401d2 --- /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 00000000000000..05193aa2177a68 --- /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 00000000000000..0b97d3d2695be8 --- /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 00000000000000..2b259f2b847713 --- /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 00000000000000..920986d5e13682 --- /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 00000000000000..bea0a9a8377d76 --- /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 00000000000000..35a37abe35a683 --- /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 00000000000000..b4db587f30230e --- /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 00000000000000..a170140598d06d --- /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 00000000000000..3b91a5f79c8d32 --- /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 00000000000000..9cdc1fb85a562b --- /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 00000000000000..a7d8c3d1352535 --- /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 00000000000000..ba6960c0c1d8a9 --- /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 00000000000000..eb0b8d0c7a0e26 --- /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 00000000000000..d58c190876e2cd --- /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 00000000000000..7569cf90b2e355 --- /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/mocks.ts b/src/core/server/mocks.ts index 2d053300273fbc..b86e2e4c6dedbe 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -69,9 +69,12 @@ export function pluginInitializerContextConfigMock(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 00000000000000..fd8234d72bd171 --- /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 00000000000000..748a1e31906402 --- /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 3d212bc5558286..c71102df9929b6 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 5b0e2ee21a8878..3b7dc70b9c054f 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 9a1403bda3bca4..dd2831f77f5372 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 17cb209897c25f..a918580392caa5 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 6ba652abda3d59..741f715ba6ebe6 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 e93586ec7ce4c2..e4b89a949d3cf7 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 281d2ce8d03cf8..dac93ff29b68f6 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 c8bc4b2e141230..ecef84a6e297ce 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/server.api.md b/src/core/server/server.api.md index fc90284ffe5b26..aadd16bde0ee62 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1841,13 +1841,13 @@ export type PluginInitializer { - // (undocumented) config: { legacy: { globalConfig$: Observable; + get: () => SharedGlobalConfig; }; create: () => Observable; - createIfExists: () => Observable; + get: () => T; }; // (undocumented) env: { @@ -1855,7 +1855,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; @@ -3139,5 +3139,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 60f3f90428d40a..cc1087a422e39b 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/dev/build/args.test.ts b/src/dev/build/args.test.ts index 584f0dfe54b74e..745b9d0b910c8b 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 5070e325f40a43..2d26d7db3a5e39 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 66206738297115..df4ba45517cc11 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 ca9debfdc1ae18..3e3a0a493f2d14 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 dc0bf4ce6a8336..36d052ebad937b 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 ba57a5f3dbfc99..fd0224d3de13ba 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 07a86927d5a359..4780457fe80544 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 1e6e6156c3ed91..229bd5242228c8 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 30e3b60dcee836..1598f00354bf89 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 26a6a9d6e4a03c..c92de567cb4465 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 4805ba0ba9bc06..8de2b5e9361e5f 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 1a0e325a2486a4..eb4708b6ac5553 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 5e2a0b72769fe9..d896e9cfa671c8 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 a5cc2d9527cbfb..f4e9d11ca9c21c 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 00000000000000..5b345d3c9e2074 --- /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 00000000000000..3f58e4e03a1785 --- /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 61f578ba339715..e5e21e312b0ddb 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 14b35f8786d02a..376467f9f2e555 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 1a870e3c878f7a..d18e2e49d6b83a 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 d1ebcfa1e8399c..675b5a682f272a 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 e1e2a49439de38..7ea181715717bc 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,11 +265,6 @@ export function DashboardApp({ }; }, [dashboardStateManager, dashboardContainer, onAppLeave, embeddable]); - // Refresh the dashboard container when lastReloadTime changes - useEffect(() => { - refreshDashboardContainer(lastReloadTime); - }, [lastReloadTime, refreshDashboardContainer]); - return (
{savedDashboard && dashboardStateManager && dashboardContainer && viewMode && ( @@ -242,7 +286,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 90706a11b8ce22..c52bd1b4d47b80 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 bce8a661634f6e..faec6b4f6f24b3 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 00000000000000..6dbc76803af90a --- /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 00000000000000..d14638b9e231f8 --- /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 00000000000000..d4703d14627a46 --- /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 ecebef2ec3c9c2..fe4f8ea4112896 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, @@ -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 cc784f5f81c9e8..4bd43d1cd64a90 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 00000000000000..3da6a8050f14c4 --- /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 00000000000000..0ae14cdcf71975 --- /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/search/search_source/mocks.ts b/src/plugins/data/common/search/search_source/mocks.ts index 328f05fac8594a..08fe2b07096bb7 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 6d7654c6659f23..c2a4beb9b61a52 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 554e8385881f23..bb60f0d7b4ad48 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 00000000000000..ae48468abc209d --- /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 00000000000000..dc892d95ae3974 --- /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 00000000000000..88134e1c6ea03b --- /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 00000000000000..725a847aa0e3f5 --- /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 00000000000000..7f2a3b2e6e1439 --- /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 00000000000000..ff6879f2b89609 --- /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 00000000000000..cd6e1cb2c5977d --- /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 9e493f46b0781b..f533af2db9672e 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 19035eb8a93286..991f3063cac0a0 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 adb422c1d18e75..6a4cb9c494b4f9 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 fff06d2e1bfb64..d241eecfd8d5dd 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 00000000000000..929f25e234a604 --- /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 8a4248e48185bc..6d11f3a16b09e8 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 ee2703b888bf17..6b9ce1b422481f 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 d62cb311bf6a43..5806ef8676b9bd 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 d140e713f9440d..7d303543a0c57d 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 5ae01eccdd920c..bfd73951c31c48 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 f6ca9ef1a993de..6dfc8faea769ea 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 426302689c8f0a..b7d9be485a3033 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 8e66729825e39c..eeef46381732e8 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 a11bbe11f3f959..c176a50627b92e 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 00000000000000..e30b7bdaa84022 --- /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 6578774f65a3c8..fc30e2f29c3ef2 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 02f200d5435dda..a847931a49123d 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 f47a42cf9d82b4..2cde6d19e4c187 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 f1a6fc09ee21f4..63593bbe84a088 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 81bcb3b02e100e..21560b13288407 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 73523b218df7cc..e8c2f1d397ba5a 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 6e71b5c21f943a..8370e6659554d6 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(); @@ -466,7 +465,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() ); }; @@ -477,7 +477,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( @@ -503,6 +504,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 }); @@ -582,20 +590,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() @@ -622,6 +617,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 00000000000000..abec6aedeaf5c5 --- /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 00000000000000..a53d7d6d2c3332 --- /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 c769e263655abd..65a8dded110924 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 00000000000000..f9428e30569f7e --- /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 1b7406496bb813..4d522f47ea87ff 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 1394ceab1dd18e..ea16b81615e424 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 62478f1d2830f8..1d780a5573e2a7 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 00000000000000..bef7bc394a6ccd --- /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 354cf1d504b287..f859e0728269a1 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 00000000000000..89e83586dc1157 --- /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 ec27895eed6665..36c7d7119ffe5f 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 00000000000000..818c0d77392832 --- /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 00000000000000..f02a5e340b1a07 --- /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 4b1b610d560e27..b2ac9ed6c739e8 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 fa9cf5a52371d6..29936da0117c1e 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 a4dfea82cdb59c..5524563c034a82 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 c7f04bc9cdbe3f..890de8f6ed6a14 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 fbd3c3f933791d..037c6f9fc666d1 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 700420447bf4fe..0935ecd20111f0 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 f95ffe5c3d7b6f..821118ea4640dd 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 664f34ca7ad518..01e80cfc7184d3 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/presentation_util/README.md b/src/plugins/presentation_util/README.md deleted file mode 100755 index 047423a0a90369..00000000000000 --- 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 00000000000000..35b80e36345345 --- /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 00000000000000..cb9991e2160197 --- /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 8aaf9be6ef5c66..b156ef4ae764c7 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 58a70c9db7dd5c..7c7b12f52ab5f1 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 00000000000000..2044ecdd713e18 --- /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 00000000000000..b1bf9ed6958420 --- /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 baf40a1ea0ae4e..586ddd1320641c 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 cbc1d0eb04e27a..5d3618b0346567 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 00000000000000..01b143e612461f --- /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 00000000000000..59f1f9fd7a43b4 --- /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 00000000000000..981ff1527f9819 --- /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 00000000000000..5165380780fa90 --- /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 00000000000000..732cc19e147631 --- /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 00000000000000..f36b2779793581 --- /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 00000000000000..acfe4bd33e26ac --- /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 00000000000000..a129b0d94479f1 --- /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 00000000000000..5048fe50cc0257 --- /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 00000000000000..536cad3a9d1317 --- /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 00000000000000..33c091022421c0 --- /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 00000000000000..862fa4f952c1e1 --- /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 00000000000000..a2bde357fd4c0f --- /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 ae5646bd9bbae4..7371ebc6f736e5 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 00000000000000..5f56c70a2f849f --- /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 00000000000000..d12b98f38a03f5 --- /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 00000000000000..e9b6a11242036c --- /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 00000000000000..dfa8ad3be04e7c --- /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 1e3756f45e9537..a9657db2888486 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 6702255ee2e2c2..f87169d4b828ad 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/security_oss/public/plugin.mock.ts b/src/plugins/security_oss/public/plugin.mock.ts index 53d96b9c7a3032..8fe8d56ea6576a 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 7760ea321992dc..8b1d28b1606d45 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 9a7191519131c8..e883b550fde04f 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 5b21c449b6a9f6..ff2b411d638092 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 5901d2452e9aa9..ca9025f242b78c 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 2b85564ee9ef9c..5a3b335115e0da 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 55baf72cc45203..26fa1c9113f21f 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 cc3649d33d8768..7284be6a8719c1 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 88bb51389b001d..31c9631571d356 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 a6318af602b4de..985066915f1ddc 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 1dab41566da672..f66c011d1f928e 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 5339266a47448d..415133f7110613 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 d4e3064747ab05..105bfd53ce739c 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 1755e25138e8f4..996efce4ce66e5 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 6c30895635fe5c..443b700386c15d 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 015a371bd2a355..787a8ff1b2051d 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 7dd7bfe780b527..2df6f002481b56 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 31baeadce65270..158c1d74964b30 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 91d73cecdf4011..f4ac715b5b0f27 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 81bf8920c54fc6..21b746656c0431 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 9e7272f14f146f..f3bf854f00ef42 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 a4fc48ccc6266c..97876ec2579f05 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 966daca87a2082..bf7088145f347b 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 c6f7474ed86bfb..00dbf179450113 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 803926ad58c50a..791ff4efd3936b 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 00000000000000..953624e476dc8c --- /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 e3fbe2daa3756e..8e5c2fdabca5d4 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 60fa51d0995dbf..5b5a1bd5db79e1 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 d94362e6816424..d13efcfe37149a 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 d97e948551b1aa..a20df9145d987c 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 b690ad0fb03251..2797839988ded5 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 06ff882190ce5f..bcb158ebfe2bb8 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 9d3296e76727ea..1ea197976fb4f3 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 adcd7e8bbf0d51..43b61f37ba3d3b 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 8b813ee06b1b3d..c70c4406a34f2d 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 376ef84de23c32..c18a7d4dfcfbda 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 157e355f93434c..3e5d890c39ff48 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 9100de38ae3878..a7e3b9dc7e024f 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 d63288745986cb..15132483b36597 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 6971adaa55ec3b..7c3915955419f9 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 bf91b50ed9cf68..00000000000000 --- 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 693045edeb7d01..00000000000000 --- 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 00000000000000..ced1dc1bdc2177 --- /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 00000000000000..c0ca7f04810d04 --- /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 00000000000000..ea74a48dc9a746 --- /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 00000000000000..03fdce9bd8d938 --- /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 00000000000000..1b7ac79312329b --- /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 00000000000000..97d231c5f7a6f2 --- /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 00000000000000..a9b650fe4c58d0 --- /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 00000000000000..0a477e5f62a7a1 --- /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 00000000000000..92dfc873e2715a --- /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 00000000000000..921e604354b2ef --- /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 00000000000000..c2eb37980b7418 --- /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 00000000000000..5e6f45790ae2d4 --- /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 00000000000000..e671b9059f3580 --- /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 00000000000000..0022f686376596 --- /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 00000000000000..33e63e7ef317c1 --- /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 00000000000000..fd176e5d20a2f5 --- /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 00000000000000..6a31eb0b378334 --- /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 8b6ebbe9c75942..2fd7e4fd606fd8 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 af396dbf778d25..926c03e79bff98 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 ae4d23db48ee4a..14dea362bc8c57 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 00000000000000..c013056ba4566f --- /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 b37eadd9b67e52..7e353ca86698a4 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 7f5c7d0dc08a20..2256a7a7f550df 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 38e2b59009b38f..34131ae2dc7fbd 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 b0d931c6c87fa7..02da16c9e67cac 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 455e51a8f58d42..ae11e1de486ea4 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 d923851a68d9c9..5d884889367bc3 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 daa419b5f31b4a..d9dafa73356712 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 00000000000000..2b7706d4f9d9f2 --- /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 c4aefb397cd8ae..d782937bce40a9 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 00000000000000..80fd1c8740f2c4 --- /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 00000000000000..2ded3ce8c27459 --- /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 111ee7b0041ed3..8d02e08549663d 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 15f05106130de0..57fa9b2ae48015 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/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index c6a194ace9c25d..d29d17484486cd 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/search/bsearch.ts b/test/api_integration/apis/search/bsearch.ts new file mode 100644 index 00000000000000..504680d28bf83a --- /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 2f21825d6902f8..6e90bf0f22c51f 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 00000000000000..6fbf6565d7a9e1 --- /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 fc13189a407537..155705f81fa8a9 100644 --- a/test/api_integration/apis/search/search.ts +++ b/test/api_integration/apis/search/search.ts @@ -8,11 +8,21 @@ 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.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 +38,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 +77,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 +93,8 @@ export default function ({ getService }: FtrProviderContext) { }, }) .expect(404); + + verifyErrorResponse(resp.body, 404); expect(resp.body.message).to.contain('banana not found'); }); @@ -74,11 +113,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 +150,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 00000000000000..a5754ff47973ec --- /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/functional/apps/dashboard/dashboard_save.ts b/test/functional/apps/dashboard/dashboard_save.ts index 27cbba7db393d0..e36136cd451419 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 8481065c18466c..10cdd7e866af9b 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 1176dd6395d2c6..bf0a0275538325 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 6e6c53ec049859..ec6c455ecc9795 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 95abbf9fa8a78d..5a891af0de93db 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 438dd6f8adce20..a9fe2026112b69 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 07811c9c68e45c..754406938e47b6 100644 --- a/test/functional/apps/management/_import_objects.ts +++ b/test/functional/apps/management/_import_objects.ts @@ -23,7 +23,8 @@ 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 kibanaServer.uiSettings.replace({}); diff --git a/test/functional/apps/management/_scripted_fields_preview.js b/test/functional/apps/management/_scripted_fields_preview.js index 104d41b7e2a756..46619b89dfc595 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/fixtures/es_archiver/visualize/data.json b/test/functional/fixtures/es_archiver/visualize/data.json index 56397351562de6..66941e201e9bad 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/plugin_functional/test_suites/data_plugin/session.ts b/test/plugin_functional/test_suites/data_plugin/session.ts index ac958ead321bc6..5567958cfd8783 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/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index 9e387f97a016e8..6e28f9c3ef56ab 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 00000000000000..66fb5ae5370bc7 --- /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 c48d9032466a36..78ed804f884305 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 14d7268c6f36de..88c0fe528b88c8 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 00000000000000..33b1c8a2b51833 --- /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/visual_regression/config.ts b/test/visual_regression/config.ts index c4951760fc7563..60219efc61e6ca 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 00000000000000..6f79ee834b3dc0 --- /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 00000000000000..98aad0cb877956 --- /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 334a3febfddda4..d8fb2804242bc3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ "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/**/*", @@ -28,7 +29,6 @@ "src/plugins/kibana_utils/**/*", "src/plugins/management/**/*", "src/plugins/maps_legacy/**/*", - "src/plugins/maps_oss/**/*", "src/plugins/navigation/**/*", "src/plugins/newsfeed/**/*", "src/plugins/region_map/**/*", @@ -54,6 +54,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/**/*", @@ -86,7 +87,6 @@ { "path": "./src/plugins/kibana_utils/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 +111,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 a8eecd278160c9..9a65b385b78202 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,8 +15,6 @@ { "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" }, @@ -24,19 +22,19 @@ { "path": "./src/plugins/kibana_utils/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,6 +48,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/typings/index.d.ts b/typings/index.d.ts index 782cc4271a06b5..8223d85d53289f 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/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index e393f3a5d2150f..609d8f78aeb969 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 e49692568cec8a..3032d88c26d98f 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 74ad1267e93551..6c4f897691136e 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 dd634f3c25a326..e1684f7aadb434 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 bfac437f3500a7..f95c4286b3f266 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 51c034e510024f..39382620775ad7 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 8622f4a9d4c0bb..0064f48ae973fe 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 c0dd60f1cfcb0c..3a2e28a884b096 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 00000000000000..8158987213cd27 --- /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 8b6c25e1c3f241..cd97213a64dcc9 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 67ab495fc96788..e403fd99fe9859 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 56c58054ca7993..c8b771b647e0d7 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 ebac80e70f4a8b..396f59094a2d9b 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 91c71a78a8ee08..5d803e504593ec 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 4afbbb3a33615d..b8a3467b27b548 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 d701fad0e0c2fa..6fdd1cb28d7bbf 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 7e8770ffbd6292..aa33389303081f 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 f5e1b2e4411e36..791bfbaf5d69b4 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 e106b17ad223fb..23e16b7463914a 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 78c6b91b57dc0c..a70a452737dc62 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 759ca929682638..da2ad9bb3990dc 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 45f962429ad2ba..a49889570f4bf5 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 d78237beb98a14..51a4e3f8571533 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 a1c4041628bd51..bd23aba6185446 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 f3cdf82bfe8cd0..2ade4f9e9dcb47 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 ccd25da2397bba..688e75aece43cc 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 fca99f81d62bd2..a14a1c7d4c8af6 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 97d8b64fb65356..326f0a6ed5f8b2 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 a13204f8bb1d83..ec31de4f2afd5d 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 1f75d439200e3b..107d86f111deb1 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 1a6412f9ceb5b0..4ef0e7da166e65 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 96faf6d338b908..108fe06bcbcaa0 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 e73f8d91b08476..cfac23e624a04e 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 02026eb7297279..5d2c5a24b3edd1 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 a9fce2f0a9ebf5..4ca25013e9691b 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 088e30db4e3ce4..857110d2f53c45 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 dbbd2a029caa95..80614e6b1336d7 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 fa6d2663c94ab9..76063deee0f5e2 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 6c4857bff4e811..4e59dfd0998110 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 695613a59eff15..fa33c1226ec9e0 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 1543f8d7a07cea..22400a08a2a092 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 f545c0fc96633e..0bcf02c6f83ae8 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 cbdfec642fa74b..a17b46d7d7abe7 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/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index a41faba2e93820..b76dd85f5ee6cc 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 4f319e4dd70165..1251f9c2f1becd 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/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index 29071f96e3a06f..bde826a568da95 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/canvas/canvas_plugin_src/functions/common/math.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts index e36644530eae86..0f200a92a41f12 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 58a2354b5cf384..ff5a4506ab82ae 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 7dee587895485a..a04f39c66bc26b 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 f79f189f363d4b..b528eb63ef2b6c 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 1cbfafe8103ed7..ed1f1d5e6c706d 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 aed9861e1250cf..54e1adbeddd785 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 a9296bd9a12417..238b2edc3bd6d7 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 bfc36932a8a073..6c1dd086c86673 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 ada159e07f6ae9..4933b1b4ba51dc 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 7f5fe8b2cce127..fbde9f7f63f418 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 0b7ef38fe8f6da..ae5063e173525e 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 03121e749d0dc4..f26408b1200f18 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 3c2e989cc8e51c..7fbdc24c112a1d 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 981334ff8d9f25..3697d5dad2dae8 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 ed000741bc5420..d94802bf2a772a 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 98f3d8b48829d5..6c1b546b49aa19 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 31a75acbba4ecc..9d0a5e0a9f51d7 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 fd1dc869d60ec8..da1fe8473e36de 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 7151e72a44780c..d33ba57050d4b3 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 a7424882f10722..4068272bbaf113 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 d651e649128f93..023d87c7c35656 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 fdb5d69d35515b..70120ccad6f541 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 ab3f1b932dc3cb..e7cf153b9cd0f2 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 a4661dc3401df4..79aa11cfa2d80a 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 947972fa310c9a..3018540e5bf8eb 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 36b2d3f9f04c66..c8ac4f714e5c42 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 f4e715b1bbc491..95225cf13ff3bb 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 212d9f51328315..9c9ecb718fd5f7 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 8c88afbadfd9ef..a36435688505d4 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 288dd0dc3a5be8..f0d2ebcc20128a 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 00000000000000..3e3986082e2076 --- /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 03bb931dc9b261..33f913563daacf 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 b82c6de8fc363c..398f73f2721a65 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 40911496d6494a..3a12b50cf8f68b 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 efc3b6044a8045..236deb9c7462cd 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 b744a6dc048107..974ae9283dd98a 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 cb88f04a9b835c..cf854df9f04f29 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 00000000000000..b79a396445e3d8 --- /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 1a6fc724e2cf21..22b0f3272ff7d8 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 3230895da77059..b2ddd0310f8f59 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 54ed59b30952af..694d9807b5a4d3 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 c4b09276880d99..29bfd71cb32b40 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 68906e2927a0da..22847843826da2 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 0901ff27378034..59e33893a18eb7 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 537de02a0fee5c..0caf804ea2a08f 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 e1a990e5c4710a..f3c503d4b27cbc 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 6866a89687a741..e82c3aff701194 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 00000000000000..34161ba80dab8e --- /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 00000000000000..fc2639d87a2f93 --- /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 00000000000000..1814aba7497f6b --- /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 00000000000000..e14ef0b1f26318 --- /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 00000000000000..88f7e858bef62d --- /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 00000000000000..41690dfe26e716 --- /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 00000000000000..99363c00caaf7c --- /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 00000000000000..5909ceec4555c3 --- /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 00000000000000..853f04ee1aa777 --- /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 00000000000000..9db9c140d7f504 --- /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 00000000000000..e032e42eca3a62 --- /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 00000000000000..261d0f75c1cee1 --- /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 00000000000000..b0dc8254c084b9 --- /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 00000000000000..16743405e0b5e1 --- /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 ae9c9ca4506382..ddad726b04c260 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 a3977a0c07a803..8bee8fd4407b7f 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 06bf77d35372fe..e5bff981cb000b 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 d3c3bff5a29471..e6a3e1ca5809b2 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 99485340f6b885..7705d342ecdce5 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 53c1dc8b845b12..d5d864f35f6819 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 f25b044e8a56fc..efd2de9223c980 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 3510a2a0e82210..708863ba0e5c89 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 9747609aaf0664..754a349c2fe944 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 3f2867871765ca..0814ba16e39dca 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 bc55753acf1524..f1eb3a2f69a98e 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 dc14c4a83bff30..283a790b615719 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 72c718f3747141..8e404e34b5f3e4 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 da8595b43859f3..8a54d529b2dd00 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 74e31e77974ee1..714da0d8e45dd0 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 dc6e837be61d8f..73ad9e2e973d82 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 cdd055fd367efe..2374bcb1b2d039 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 2256d5ae7946a3..e1b562d9561eee 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 e33cd9b0e9e71f..3a8861ee1e20e6 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 48cbaeef70c1ae..616dae98e29f20 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 6c46c849c79bc0..1b6acf341c08eb 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 b6620756699d51..9832387a563e32 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 5a83717aa00301..2e22c9b76cf6fb 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 bfdca6791edc13..18ab05a3676c65 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 41e9bfa19e0f0f..7f12f7d29671a7 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 7e774616ff5989..00000000000000 --- 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 8fa3ccdcb863e6..4d4ff5f52ef20c 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 00000000000000..3bee87dbfda3dc --- /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 00000000000000..3ecb28d1d47297 --- /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/enterprise_search/public/applications/shared/doc_links/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/index.ts new file mode 100644 index 00000000000000..a926efd59a5747 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/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 { 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 0a80f8e3610253..cfff8cc5578368 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 383fd4b11108a4..9af5bfc0c3d403 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 f5f534807fabfc..2ce7eed2368402 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 8a83e9aad5fd9b..7357e84f27a417 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 6eedc9270b83fb..17fbbf517f3473 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 d10de7a7701711..ec1b8cfcba958f 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 1e4b51e157724a..ef1bb03b7921cd 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 d08f807691c2be..058645bd308624 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 6fe87290737f5c..61cf1ed34fdcc8 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 7d891953e618bd..8ac3edeb0f3081 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 e6e428ecab115b..1bab035b8f379a 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 28aeaec2b47dfb..8e3bd1c6ab2b53 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 0e6cbb25601285..3b04456b1f59ca 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 cf066f3157e39a..19ccfab11a7298 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 aed99bdd950c54..d43afd589468f1 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 3278140a2dfe64..3ca70979cc2471 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 aa7bc4d917886c..7f033d8f8d97a6 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 a80680d219aef4..cdd883413481d6 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 587916a741d666..e220e07153867b 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 5ee484250ca629..48e285cdcc7788 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 c1a65d1c52b656..95096331a49d3b 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 d2f26cd6726dfa..77f77ad3d3cb90 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 fa975c8b11ce09..00b548043aae56 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 a0797305de6cae..be20eefa1b481a 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 2c3aa6114c7da9..c9d68201f33eed 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 16aceacbddcd5b..3f7d99629ca4a1 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 728d21eb1530fa..cac74d37f9f666 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 8e3a116e3ac331..ee877e8f61ad64 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 8d3219be9b02a9..5f47fa2d5927b2 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 00000000000000..48b8a06b2549ca --- /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 766aa511ebb2d2..2402a862a62e7d 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 cbfb22915c4eb6..ba7cb95b65a8f5 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 6f55c03746aa80..a1cf7b2ca0a254 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 e90acd929a9909..2e7a028e43aec0 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 76352a66706500..6c9f912a98ce8a 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 6911196afa81d2..a81df1aab83bdb 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 00000000000000..4db5c60d5800d4 --- /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 00000000000000..c767dfaba86f94 --- /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 00000000000000..a2db1bbc15a152 --- /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 00000000000000..bca0d5edc32d60 --- /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 00000000000000..41df1a1acc5156 --- /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 00000000000000..abb1308081f0ca --- /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 00000000000000..df843b330d411f --- /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 aaeae08d552d49..e21b62b5000675 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 632bb425f203e0..5f467c872447d0 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 99445108b315af..f2792be8e65359 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 00000000000000..12f84278e9ead7 --- /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 00000000000000..0aa218dfc28839 --- /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 00000000000000..6b4c50770b49f4 --- /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 545b3b15171453..32f08e685c75da 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 5d4c33f319fccb..4488dc74556ca6 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 5f26399618e38f..fc137b4e45b13b 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 c1777d6979c5c0..26f249d3b2c06a 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 ad1409e33589fd..adc7ed011aa143 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 63453c6327da25..091f997fe62ea9 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 9249288d339393..0bc675fee928dd 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 51dd7d6e95d153..82b8f06c251a37 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 3bf726de718567..e2e31864eb31f6 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 8bfb32b5ed2b0f..2e9161ca1c5341 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 5ba4de914c7249..ece669293fdffc 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 bdc5714f7e2fed..409375f81d6fed 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 e09fbfc80b196a..d0df9b73dd88a9 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 8925f3386dfb84..fcead1bc897492 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 253b614dc228aa..a0eb1547a3d631 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 dcc686e565b8e9..7b4149819dc789 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 0333fb024a7179..2d2478843c4541 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 fd75139d4cd454..e1fa2a0b18b593 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 @@ -335,8 +335,6 @@ function getBaseTemplate( properties: mappings.properties, _meta, }, - // To be filled with the aliases that we need - aliases: {}, }, data_stream: { hidden }, composed_of: composedOfTemplates, 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 1a50b5c9df767c..44065a9346c5de 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 0dcdfeb7b38013..ff96e2724c8921 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/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 09c81efe163b58..21f028d1fec601 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/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 8fb51ade921a9e..3fa245ff96d377 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 7adc37d1897d19..fe3e6d79f84d76 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_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/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_documents_accordion/add_documents_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_documents_accordion/add_documents_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_documents_accordion/add_documents_accordion.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_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_documents_accordion/add_documents_accordion.tsx 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/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/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/add_documents_accordion/index.ts rename to x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_documents_accordion/index.ts 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 100% 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 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 ae3dd9d673ebeb..05de8c7079eab2 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 00000000000000..6a51f4f54f27cf --- /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 beb165973d3cd3..ce079f87da6c52 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 ffd82b0bbaf355..ac8612a36dd7ea 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 a7ffe7ba02caab..b1b2e04e7d0dc9 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 d5592b87dda51b..00000000000000 --- 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/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 9df3f41fbd855b..d473d728dc361e 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 28e1f6da607421..2dcda656c779b0 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 e769e402ff0e1d..c4961b80c51228 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 4fa35bd9148899..a3ac7322db31f3 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 087cfdc9f3a8af..b191b8829347cb 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 23460d442cfa8f..00000000000000 --- 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 00000000000000..a4eb99a972b9b6 --- /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 00000000000000..a8328f5eefdca1 --- /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 00000000000000..83a8d026f13156 --- /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 00000000000000..4779d42859a795 --- /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 00000000000000..dad9aa30b7712c --- /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 00000000000000..38534482b81fad --- /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 00000000000000..5e5db2c6458095 --- /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 00000000000000..df5dba749a60c1 --- /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 00000000000000..171074d6e6797c --- /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 00000000000000..4f1a1141fdaa84 --- /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 7d95d73143870d..00000000000000 --- 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 d0811e0ad05a6f..60d9461a5e0d9e 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 57289fc0ac169f..e8a0abb0316dba 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 42d2ff6a220c04..cf23d56adb9157 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 088246ccf4b9ce..f067093891d295 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 e4f787a2651866..3df9e8a5145bc3 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/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 94cf13a5c50a4e..63f8bfb97d8f87 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 10618cc7545562..7c0036de621243 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/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 3fb7186aeac59a..9848551e7873ff 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 9feed918635b3f..907ef3a700ce66 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -22,10 +22,14 @@ 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; @@ -641,6 +645,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/license_management/tsconfig.json b/x-pack/plugins/license_management/tsconfig.json new file mode 100644 index 00000000000000..e6cb0101ee838b --- /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/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index b00281588734d4..c9391e1aac7490 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/kibana.json b/x-pack/plugins/maps/kibana.json index 2536601d0e6b19..744cc18c36f3e1 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 1d1f8a511c4fa9..c091aba14687a7 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 64c35bd2074398..afb3df5be73de8 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/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 5b33738a91a28f..88150da84f23f1 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/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index 0cb24be445c6ed..2304bb277da49d 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 @@ -616,6 +616,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 +636,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) { 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 6ec51b8e118cb7..24b7e0dec519c4 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 9c851dcedb3fac..916a8a291e6b4c 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 504212ea1ea84a..98d3ba6267c6db 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 5a923f0ce4292e..b70a433f2c7291 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 967131e900fc62..64a5cd575a19da 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 12f1ef4829a4a8..235e8e3a651ee1 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 @@ -147,6 +147,7 @@ export class ESTermSource extends AbstractESAggSource { rightSource: `${this._descriptor.indexPatternTitle}:${this._termField.getName()}`, }, }), + searchSessionId: searchFilters.searchSessionId, }); const countPropertyName = this.getAggKey(AGG_TYPE.COUNT); 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 a7919ad058e4b5..d7a5eea1516023 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 e2529fff66f3b5..78a9f82bb698f5 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 1bcee961db9e13..d47f130d4ede39 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 @@ -25,7 +25,7 @@ import { ILayer } from '../../../classes/layers/layer'; import { JoinDescriptor } from '../../../../common/descriptor_types'; import { IField } from '../../../classes/fields/field'; -interface Props { +export interface Props { joins: JoinDescriptor[]; layer: ILayer; layerDisplayName: string; 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 33d684b320208b..c0462f824cd06d 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 93476f6e14da53..36d07e38708181 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 726e2c3be78460..9cbbdec5e3d175 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 820453f166a463..21a8abcbaa4e91 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 3f56d8d50b0f07..edf626612cb698 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 ef320c73bce2fe..a0f3aa40e75dd6 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 4d669dfbe235e5..fd0a0d55d2c1bc 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 bcdc23bddd2ebd..623e548aa85fa2 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -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 99c9311a2a4548..56e342a95be51c 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 4173328a41d571..5bd0bd7346ab1e 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 273d1de6fddfe5..52df65d6d2ecc3 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 1395f2c5ce2fe8..f068abee48b93b 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 817fbf36561039..c0a378f38fc138 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 7010c281d24c61..803b9defe9a246 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 7fefc6662ada74..398c05b8ed69ac 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 8876b9536ce923..21ce5993b7c89e 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 be6a7f5fe6fa78..7f4215f4b12758 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/tsconfig.json b/x-pack/plugins/maps/tsconfig.json new file mode 100644 index 00000000000000..b70459c690c072 --- /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/vis_type_timeseries_enhanced/tsconfig.json b/x-pack/plugins/maps_file_upload/tsconfig.json similarity index 53% rename from x-pack/plugins/vis_type_timeseries_enhanced/tsconfig.json rename to x-pack/plugins/maps_file_upload/tsconfig.json index c5ec5571917bd0..f068d62b71739d 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/tsconfig.json +++ b/x-pack/plugins/maps_file_upload/tsconfig.json @@ -7,9 +7,9 @@ "declaration": true, "declarationMap": true }, - "include": ["*.ts", "server/**/*"], + "include": ["common/**/*", "public/**/*", "server/**/*", "mappings.ts"], "references": [ - { "path": "../../../src/core/tsconfig.json" }, - { "path": "../../../src/plugins/vis_type_timeseries/tsconfig.json" } + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" } ] } diff --git a/src/plugins/maps_oss/tsconfig.json b/x-pack/plugins/maps_legacy_licensing/tsconfig.json similarity index 65% rename from src/plugins/maps_oss/tsconfig.json rename to x-pack/plugins/maps_legacy_licensing/tsconfig.json index 03c30c3c49fd36..90e8265515a160 100644 --- a/src/plugins/maps_oss/tsconfig.json +++ b/x-pack/plugins/maps_legacy_licensing/tsconfig.json @@ -7,8 +7,8 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*", "config.ts"], + "include": ["public/**/*"], "references": [ - { "path": "../visualizations/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 4f4d9851c49578..d20ad4a3689489 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/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 274a5ff0ffbb49..935f1f7f54df84 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/ml_embedded_map.tsx b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx index d5fdc9d52a1028..12c7d6ac69bb11 100644 --- 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 @@ -8,6 +8,7 @@ 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, @@ -81,21 +82,13 @@ export function MlEmbeddedMapComponent({ viewMode: ViewMode.VIEW, isLayerTOCOpen: false, hideFilterActions: true, - // Zoom Lat/Lon values are set to make sure map is in center in the panel - // It will also omit Greenland/Antarctica etc. NOTE: Can be removed when initialLocation is set - mapCenter: { - lon: 11, - lat: 20, - zoom: 1, - }, // can use mapSettings to center map on anomalies mapSettings: { disableInteractive: false, hideToolbarOverlay: false, hideLayerControl: false, hideViewControl: false, - // Doesn't currently work with GEO_JSON. Will uncomment when https://github.com/elastic/kibana/pull/88294 is in - // initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent + 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 }, }; 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 a35a314bec985c..0be9e00b70f935 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 448dcd8b2e1ba2..872580f47d0b71 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_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 a277ae6e6a66e6..998460d75f6f07 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 0b88f52e555c0b..f5bfd3075f26bd 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/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 00000000000000..fc1621e962f36c --- /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 774372f678c9b1..9921b5f9918440 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 3dc1c0234584d4..077e60db4760a7 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 00000000000000..451fa602315d79 --- /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 3f5f016fc365a6..2178c837458e92 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 f6889c9a6f24c2..4ba9d4ea14f10a 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 280ac85a5a2bc2..470fe11759d271 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 298dcad4ce488d..7b246e557d7a57 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 514449385bf0b5..3747e84f437653 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 a30280f1220c03..a306211defc87e 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 402c922a0034ff..799187cc37dfdc 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/osquery/README.md b/x-pack/plugins/osquery/README.md new file mode 100755 index 00000000000000..e0861fab2040bd --- /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 00000000000000..f6027d416beb15 --- /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 00000000000000..6f29a2020c9441 --- /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 00000000000000..7611e5424e2974 --- /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 00000000000000..812b30bcc13f14 --- /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 d2665ec1e28137..be12e829108a9c 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 00000000000000..45192d03a10b65 --- /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 00000000000000..9ba22e83b4b4d3 --- /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 00000000000000..c25979cbcdceea --- /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 00000000000000..19b16bd4bc6d24 --- /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 00000000000000..d2fc5d61527a52 --- /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 00000000000000..c3b7b1d0b84367 --- /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 00000000000000..b01e9514bf4251 --- /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 00000000000000..4a4c76adb097bf --- /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 00000000000000..27cbe433f9bf74 --- /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 00000000000000..c5c5d1e140d0a0 --- /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 00000000000000..b8190463f5da5c --- /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 00000000000000..18f7583d122317 --- /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 00000000000000..451f1455f55d4d --- /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 00000000000000..47d13233719413 --- /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 00000000000000..6482b892bc18d6 --- /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 00000000000000..2c8618f4edcd0c --- /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 00000000000000..0ef253ada26200 --- /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 00000000000000..641a10209c150b --- /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 00000000000000..1533d46417d0a4 --- /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 00000000000000..91d7958c813a35 --- /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 00000000000000..35de2e0459ceb9 --- /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 00000000000000..a449fb9130e6f2 --- /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 00000000000000..2563612f09bfb5 --- /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 00000000000000..e4bbf4781e8814 --- /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 00000000000000..0c1f13dac2e697 --- /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 00000000000000..ff9a8d1aa64c92 --- /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 00000000000000..076fa027475735 --- /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 00000000000000..64a570ef5525be --- /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 00000000000000..fc58184f40afe5 --- /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 00000000000000..70882ffcc2e5cf --- /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 00000000000000..65df2591338e42 --- /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 00000000000000..58133db6aa1b0d --- /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 00000000000000..0d6e3877eae551 --- /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 00000000000000..4a7bd02d0442bd --- /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 00000000000000..bde03be3f5edcc --- /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 00000000000000..05606d556528c6 --- /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 17c5c87e3ccc2c..8132491df8534d 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 00000000000000..f6e90b9460506f --- /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 00000000000000..68424d848a9c7f --- /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 00000000000000..9f908e16c2eb2c --- /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 00000000000000..54c8ecebc60c02 --- /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 00000000000000..2c54606bf3fbb3 --- /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 00000000000000..917e915d9d9dc2 --- /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 00000000000000..9f908e16c2eb2c --- /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 00000000000000..3bf2d81e5e0925 --- /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 00000000000000..3112d7cbf221e5 --- /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 00000000000000..192f5b1eb410ce --- /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 00000000000000..1c0083b8252e8b --- /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 00000000000000..9f908e16c2eb2c --- /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 00000000000000..a95ad5e4ce163a --- /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 00000000000000..ad1a09486961a7 --- /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 00000000000000..1a5c826df3310a --- /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 00000000000000..5d378d79acc7a4 --- /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 00000000000000..e922e030c9330c --- /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 00000000000000..d805555791e2a9 --- /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 00000000000000..b9cb71d4adb475 --- /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 00000000000000..b4fb307a62b6c3 --- /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 00000000000000..49ff7e2bfb4da7 --- /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 00000000000000..a0e549e77467bb --- /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 00000000000000..32b0a30c24e7a3 --- /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 00000000000000..5626e78069d014 --- /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 00000000000000..564b91873e11da --- /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 00000000000000..a6d7fbd404321b --- /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 00000000000000..458d2263fe9c26 --- /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 00000000000000..23aa94b46a569c --- /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 00000000000000..e55b3d6cd6a5b4 --- /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 00000000000000..646d2637a4c406 --- /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 00000000000000..40f934b4690f97 --- /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 00000000000000..5600284b8c147c --- /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 00000000000000..41698d3a1740d9 --- /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 00000000000000..9f908e16c2eb2c --- /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 00000000000000..69b350e461a5c1 --- /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 00000000000000..54c8ecebc60c02 --- /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 00000000000000..2fc5f9ae869a77 --- /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 00000000000000..5f7503a00702c4 --- /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 00000000000000..faaccfc29d5f10 --- /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 00000000000000..633a95b8f91a73 --- /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 00000000000000..e46c71798eb9fa --- /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 00000000000000..c74ef6c95a2e71 --- /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 00000000000000..3e59faa55d057f --- /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 00000000000000..f975865950a4d8 --- /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 00000000000000..75cdb67deed4db --- /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 00000000000000..29af1df3a9e0c6 --- /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 00000000000000..09e317786e20f8 --- /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 00000000000000..f22066134cbca5 --- /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 00000000000000..c5a6342c180ed9 --- /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 00000000000000..4a049ca670cc61 --- /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 00000000000000..8b80427d4d0e07 --- /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 00000000000000..615343c738d789 --- /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 00000000000000..935a6cd7b215e8 --- /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 00000000000000..dc2e741dbe3e46 --- /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 00000000000000..1460a0e5d331ea --- /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 00000000000000..c099e2762d741c --- /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 00000000000000..bc2bf63958a096 --- /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 00000000000000..8d8a255f2fcddb --- /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 00000000000000..51ef28b4c3478b --- /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 00000000000000..a869b21e06d4d5 --- /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/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index b154978d041f45..7706aa9d650c7c 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 @@ -13,7 +13,7 @@ import { ReportDocument } from '../../lib/store'; import { TaskRunResult } from '../../lib/tasks'; import { ExportTypeDefinition } from '../../types'; -interface ErrorFromPayload { +export interface ErrorFromPayload { message: string; } diff --git a/x-pack/plugins/reporting/tsconfig.json b/x-pack/plugins/reporting/tsconfig.json new file mode 100644 index 00000000000000..88e8d343f4700f --- /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 fafd0c27728426..7e2c634b2f1cf3 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 b3259b19cf2c0c..b9d7f6dfe7a5aa 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 14941b019421b8..614aac6ea80411 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 1b70a13935b7d4..932afc6af3f162 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 00000000000000..2666b477921fcf --- /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 00000000000000..6004ef533d5adc --- /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 fab5bd9daae002..f72373a6544a07 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 6923bf00055f6d..ef10bba7428eeb 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 da2260ad55e8bf..e791b68f12f404 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 fb457933f4b54a..92a736ae601df8 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 8b5871a6a67db0..857582aac76381 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 37123dedfd6613..2c9dc14aa05b23 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 2bfd2fbf0054c2..ac70a1cae148e7 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 66681e77b7eb9e..2a59dd33399c57 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 7836960b1a6941..5143dc27e7d7ad 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 a04ecb1f9ccaa4..c2b5790b1ae123 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 71fd74570c16af..009053067064a6 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 b1b5f2b087eeed..93890656b4a7f9 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 44767471dd9e78..9f3dcd168ba5f6 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 2656e2496c2fce..dbdf3d914efab0 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 d34dc168ba7a2d..bc56e404c891de 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/use_push_to_service/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/helpers.tsx index 43f2a2a6e12f11..396ce0725eb3a8 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 f4539b8019d431..16f1b8965bb0b0 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 257cb171a4a9af..ed2f77657fb5ef 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 f9115963c745dc..70576482fbe89e 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 8652e48fd834d8..2b2bd1a782f75e 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 fabd1187698a77..79aaaab61324ee 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 41acb91f2ae96f..ff2441d361c2cc 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 00000000000000..b2213fb8fc8c41 --- /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 00000000000000..980db8ed61f8fe --- /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 fd24a8451fcbea..3fb962df232bc0 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 23c9ff5e49586f..3e501a5276d5b3 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 e289a1973cf6ed..8ce5c4aeef4b6c 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/common/components/threat_match/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.test.tsx index 36033c358766df..ce6ca7ebc22ddf 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 c99e63ff4eda08..51b724bff2e5d0 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 b4f97808b54c41..b3a74c76977152 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 349dae76301d49..90a996c06e4924 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 d3936e10bd877a..8aa4af21b03ccb 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 90492bc46e2b0b..66af24025656e6 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 5fa2997193bd90..d1ec40c627cb81 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 6b2a443ec45a5d..db56d1e34b641d 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 0cbd885db2d546..f3af5faaed25c0 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 00000000000000..fa067a53f25731 --- /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 00000000000000..a74cf8680fa485 --- /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 b72dd3b2f84dd7..191c3955caa9ba 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 3bef1d8edd048e..9022e3a32163c4 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 5ebdb38b8dd5c5..bfdc1d1ceee215 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 00000000000000..7821bb23a7ca38 --- /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 2bbd27994fc771..fe8e0fd8ceb970 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 d83d4e0caa9771..bdbe13af401517 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 706c2645a4dddc..3b84558d344e7f 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 fbca46097dcd9c..48bfe71b4722bf 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 ddf50e9edae518..2bec8f9a2d0a24 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 038f974e1394ec..bab419813e1aa0 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 a437974e93ba30..729336b697e4d3 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 0d585b44638153..86f24594fc57ed 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 8451eb0dfbe6c4..716585071c3f63 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 7952bd396b72a8..34818e7f0e4e21 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 2d993c7be08b01..f7066cd42e4c17 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 59cc7fba017e23..00206279be2295 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 70ffc1f8a9fc40..bda268c1fad003 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 cc286b4c478d3e..4d54acb5eae133 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 7fe9987e3b724f..0e0b891629d5e8 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 228e8cc1c43854..a37e404cdc5227 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 ce5eb03d60cd06..aea4df5b2a6fd9 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 1280f1c351c2b7..55fc7703de44bd 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 a0bf2b37e8a125..8710f696fad410 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 8e631e497e57bd..1bc49c716a3c35 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 d4c7d0102ebd46..795f7dda52499c 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 00000000000000..eb2dd4b2fe8d8b --- /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 00000000000000..dee6418b4f3ff8 --- /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 ec3d35cbb65856..7f9e8b42490fa8 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 d287ada74eebcf..2710e4afb59688 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 1e7f440ed67887..114c6ba969227e 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 225592fa8e6868..8e7c4d2d4daf5f 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 00000000000000..d74edef896c650 --- /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 00000000000000..92425433bf814c --- /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], + }, + }; + } + return searchQuery; +}; diff --git a/x-pack/plugins/stack_alerts/kibana.json b/x-pack/plugins/stack_alerts/kibana.json index 884d33ef669e5c..80eb177f92024b 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 00000000000000..5dc7c9248135cc --- /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 00000000000000..6fe61be0240428 --- /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 00000000000000..96a45da3d08088 --- /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 00000000000000..bba0e309783059 --- /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 00000000000000..62b343ffd6d2fe --- /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 00000000000000..803c4bde873b44 --- /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 00000000000000..15aff9c9a64951 --- /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 00000000000000..d54e24e21d61e6 --- /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 d1f64c9298f15d..c67043106c0d1b 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 8ba632633a3af0..00000000000000 --- 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 ce59adc688c368..00000000000000 --- 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 93918c82d664cb..00000000000000 --- 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 0cff207e674e53..00000000000000 --- 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 f2d2f7848a4f95..00000000000000 --- 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 c8158b0a6feaa1..00000000000000 --- 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 2a08a4b32f076f..00000000000000 --- 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 a83667cfd92c64..00000000000000 --- 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 a552d6d998c7e3..00000000000000 --- 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 ef6e6f6f5e18fe..00000000000000 --- 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 3f487135f0474d..00000000000000 --- 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 9cc5b1eb069ae0..00000000000000 --- 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 7a511f681ecaa1..00000000000000 --- 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 1a9710eb08eb09..026383cd92f200 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 8348a797972aeb..00c170e2915049 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 00000000000000..882580a00e951f --- /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 00000000000000..67d0ac0df8ffeb --- /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 00000000000000..c5f57a056b002f --- /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 00000000000000..b8190340c4d686 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts @@ -0,0 +1,307 @@ +/* + * 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: timestamp } } }] } }, + ], + }, + }, + ], + }, + } + : 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 00000000000000..09ad66f248fee1 --- /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 00000000000000..2e7cd15d323e72 --- /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 85e02b21cc78cd..6b6f17bf4ba2a2 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 24232e47225f01..1648ad9ad2a623 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 98842c8cc2cba2..1aba7d6fb10109 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 26b51060c2e734..40ad6454c36731 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 27478049d48809..00000000000000 --- 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 02ac19e7b6f1e9..00000000000000 --- 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 a2375537ae6e5c..00000000000000 --- 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 0cb04144fdb78d..00000000000000 --- 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 0cfce2d47f1898..00000000000000 --- 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 d577a88e8e2f8c..00000000000000 --- 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 70edbd09aa5a13..00000000000000 --- 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 a4b7b6872b3415..00000000000000 --- 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 5b5197ac62a393..00000000000000 --- 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 21a7ffc4813232..5c35af5e344b9f 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 9b0eb23950cc3a..de5b57dfbffc62 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 2366a872b855b1..10dfabffddfcfe 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 b51545770dd7bf..2c83d5edc255aa 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 00000000000000..cfa824d1596867 --- /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 00000000000000..7e40a7247a4c90 --- /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 448e1e698858bd..e334b4642a00a4 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 7226c2175a7699..0273f373734fa0 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 81d72c68b3a9e4..a2a0ee11380ff6 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 5d17c6246088a3..4b02e35c615826 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 d798bc9b42c95d..d0634d6cd87a26 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", @@ -11215,8 +11210,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 +11240,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 +11443,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 +12595,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": "縮小が重みに適用されました", @@ -17349,7 +17337,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": "外部コネクターを選択", @@ -18729,8 +18716,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": "ネットワーク", @@ -20819,58 +20804,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": "アラートがしきい値を超えた日付。", @@ -20885,13 +20818,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": "条件を定義してください", @@ -21451,9 +21378,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 c6f2965803813d..4ca6d11aa8940f 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", @@ -11244,8 +11239,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 +11269,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 +11472,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 +12624,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": "缩小量已应用于权重", @@ -17393,7 +17381,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": "选择外部连接器", @@ -18776,8 +18763,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": "网络", @@ -20867,58 +20852,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": "告警超过阈值的日期。", @@ -20933,13 +20866,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": "定义条件", @@ -21501,9 +21428,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 be6c72eef6f9a1..8c6a16dcd4a02a 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 66f7c1d36dfb21..3103d8f2a817c9 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 ea654bb21e88b9..f3d49c52855ab8 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 52ab33566da74d..f774b3d35bb290 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 889dfe8289b130..3c32b5bc729dd6 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 baf0f55c415dbe..df7729bb407b32 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 8ca3edb1c68df3..bd50bf3270f1a3 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 5656aa9de7795f..f44f6e87c7a190 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 00000000000000..d22fd538ad0cac --- /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 00000000000000..752f5b3e2ca08f --- /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 6ee2b4bb8a5fe8..cc76af90bcde6e 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 096a928249fd5a..a3fe2220a86fd6 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 17a2b2929f0cf4..2cda40c18db0ca 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 abd61f2bd3541f..5e35293419b172 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 c0d29341e217bb..69be37f6658875 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 00000000000000..1ea9cb748bcd7b --- /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 17eff71f1039ba..2b245bceceb6cb 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 43d0364425cbb3..d9ec1832317391 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 463c0c9d016b3c..6a99bd24ef26b6 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 dda051e715234e..5aa4a469e4f020 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 a4054bacba448c..3a1e042a3aa5f9 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 5688903b8f7cd9..02cbc87483e55d 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 1c9a079bcf1eba..772d558a0d20dd 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 fff8215e77ae63..d360e2a44d8c1b 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 318d2bc7baffef..6428edfbe904d9 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 c3ef0fde6e749c..9f48c77ec38e1b 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 0e6c79dc47b537..7a1ffb955db5c2 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 85d275b080e138..dd392f6d1b2946 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 28b74c5affbdf1..b3d20a6acd3e38 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 3345b30f5239f5..7beb0be28902b8 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 33aa16d8574aea..00000000000000 --- 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 4b296856c3f974..00000000000000 --- 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 0598a691ab7c56..00000000000000 --- 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 00000000000000..51b28f8bd03324 --- /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 22c78660a0bb0e..00000000000000 --- 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 73ee2279d3912f..b9158bdd9ed704 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 9a74f6e548409d..0741e5c5400df9 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 0e67c8b18ca5e6..24378d42b7451d 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 6f243e130c2356..7b4876f542292f 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 b3fbb8235f251d..9bd8f8bbd7d57d 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 eefe9d03c05ef7..c24d939c9237eb 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 00000000000000..4680847ba486d4 --- /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 00000000000000..744a21cf381a86 --- /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 799911cd77a9fe..b1fd96c4d160f6 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 cf13a009c28217..67bfdd7a07b9d0 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 cf944008c08d61..4193843c63bceb 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 e7ce0638c63199..18f3c83b001413 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 a34293090d7af3..116f0604a37c96 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 6336d834c39438..5b093dfb28eab5 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 9a3b2e7c137a42..a4f80c62cf2e9c 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/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index f9860b642f13a8..2b770395786b36 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 acfbad007d7220..1748e770929d62 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 00000000000000..a1ae35a29bf23f --- /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 00000000000000..7299827a72253d --- /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 00000000000000..574f35e123fe87 --- /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 c0147cbedcdfed..f59ef6829f892a 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 d3e1370bef2858..5ff7b0d45a0191 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/search/search.ts b/x-pack/test/api_integration/apis/search/search.ts index 0c08b834a27783..2115976bcced1a 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 8c918eae5a5a8a..ba43db2fd58355 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 f23601fccb1747..eee0ec7f9ad38a 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/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index 112a855c612013..b5f55180419ef8 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 642526d74b6874..db8ede58ca9d45 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 00000000000000..970eaa89548d2b --- /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 f2d91c2ae577f9..badcadedd7138b 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/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 31a4d6e29fc35f..dabead6ffbdad8 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -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_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 352652d9601dc5..52e9422da2da41 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/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index f53c1c589daabe..ce1b58433362b0 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 5f54ab2539c5d4..82e47896ce4116 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 03635efb6113d4..7e878e763bfc1c 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 () => { 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 ce6c8978c7d670..25291fd74b3225 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 d64df98c986011..b5e65158c573a2 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 6368751fedf75c..5232af0dd304b2 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -40,6 +40,7 @@ { "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 +59,9 @@ { "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/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 a6eb098b5d678f..1be6b5cf84cda3 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,9 @@ "plugins/cloud/**/*", "plugins/saved_objects_tagging/**/*", "plugins/global_search_bar/**/*", + "plugins/license_management/**/*", + "plugins/painless_lab/**/*", + "plugins/watcher/**/*", "test/**/*" ], "compilerOptions": { @@ -43,15 +51,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 +67,55 @@ { "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/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/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 6a9e54e2e7adf3..ed209cd2415861 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -3,32 +3,40 @@ "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" }, - { "path": "./plugins/security/tsconfig.json" }, - { "path": "./plugins/stack_alerts/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/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 0328877aae8fe1..fcb32fa6c03727 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 049e1e52c66d98..81443947855bc5 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 ed861b58773b9f..1b8cc2f8dc6e2c 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"