diff --git a/.bazelignore b/.bazelignore new file mode 100644 index 0000000000000..b71007690f1cf --- /dev/null +++ b/.bazelignore @@ -0,0 +1,15 @@ +# Bazel does not support wildcards like .gitignore +# Issues are opened for to include that feature but not available yet +# https://github.com/bazelbuild/bazel/issues/7093 +# https://github.com/bazelbuild/bazel/issues/8106 +.ci +.git +.github +.idea +.teamcity +.yarn-local-mirror +bazel-cache +bazel-dist +build +node_modules +target diff --git a/.bazeliskversion b/.bazeliskversion new file mode 100644 index 0000000000000..661e7aeadf36f --- /dev/null +++ b/.bazeliskversion @@ -0,0 +1 @@ +1.7.3 diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000000000..fd469d1203a82 --- /dev/null +++ b/.bazelrc @@ -0,0 +1,9 @@ +# Inspired on from https://raw.githubusercontent.com/bazelbuild/rules_nodejs/master/.bazelrc +# Import shared settings first so we can override below +import %workspace%/.bazelrc.common + +# Remote cache settings for local env +# build --remote_cache=https://storage.googleapis.com/kibana-bazel-cache +# build --incompatible_remote_results_ignore_disk=true +# build --remote_accept_cached=true +# build --remote_upload_local_results=false diff --git a/.bazelrc.common b/.bazelrc.common new file mode 100644 index 0000000000000..a53d1b8072483 --- /dev/null +++ b/.bazelrc.common @@ -0,0 +1,118 @@ +# Inspired on from https://raw.githubusercontent.com/bazelbuild/rules_nodejs/master/common.bazelrc +# Common Bazel settings for JavaScript/NodeJS workspaces +# This rc file is automatically discovered when Bazel is run in this workspace, +# see https://docs.bazel.build/versions/master/guide.html#bazelrc +# +# The full list of Bazel options: https://docs.bazel.build/versions/master/command-line-reference.html + +# Cache action outputs on disk so they persist across output_base and bazel shutdown (eg. changing branches) +build --disk_cache=bazel-cache/disk-cache + +# Bazel repo cache settings +build --repository_cache=bazel-cache/repository-cache + +# Bazel will create symlinks from the workspace directory to output artifacts. +# Build results will be placed in a directory called "bazel-dist/bin" +# This will still create a bazel-out symlink in +# the project directory, which must be excluded from the +# editor's search path. +build --symlink_prefix=bazel-dist/ +# To disable the symlinks altogether (including bazel-out) we can use +# build --symlink_prefix=/ +# however this makes it harder to find outputs. + +# Prevents the creation of bazel-out dir +build --experimental_no_product_name_out_symlink + +# Make direct file system calls to create symlink trees +build --experimental_inprocess_symlink_creation + +# Incompatible flags to run with +build --incompatible_no_implicit_file_export +build --incompatible_restrict_string_escapes + +# Log configs +## different from default +common --color=yes +build --show_task_finish +build --noshow_progress +build --noshow_loading_progress + +## enforced default values +build --show_result=1 + +# Specifies desired output mode for running tests. +# Valid values are +# 'summary' to output only test status summary +# 'errors' to also print test logs for failed tests +# 'all' to print logs for all tests +# 'streamed' to output logs for all tests in real time +# (this will force tests to be executed locally one at a time regardless of --test_strategy value). +test --test_output=errors + +# Support for debugging NodeJS tests +# Add the Bazel option `--config=debug` to enable this +# --test_output=streamed +# Stream stdout/stderr output from each test in real-time. +# See https://docs.bazel.build/versions/master/user-manual.html#flag--test_output for more details. +# --test_strategy=exclusive +# Run one test at a time. +# --test_timeout=9999 +# Prevent long running tests from timing out +# See https://docs.bazel.build/versions/master/user-manual.html#flag--test_timeout for more details. +# --nocache_test_results +# Always run tests +# --node_options=--inspect-brk +# Pass the --inspect-brk option to all tests which enables the node inspector agent. +# See https://nodejs.org/de/docs/guides/debugging-getting-started/#command-line-options for more details. +# --define=VERBOSE_LOGS=1 +# Rules will output verbose logs if the VERBOSE_LOGS environment variable is set. `VERBOSE_LOGS` will be passed to +# `nodejs_binary` and `nodejs_test` via the default value of the `default_env_vars` attribute of those rules. +# --compilation_mode=dbg +# Rules may change their build outputs if the compilation mode is set to dbg. For example, +# mininfiers such as terser may make their output more human readable when this is set. `COMPILATION_MODE` will be passed to +# `nodejs_binary` and `nodejs_test` via the default value of the `default_env_vars` attribute of those rules. +# See https://docs.bazel.build/versions/master/user-manual.html#flag--compilation_mode for more details. +test:debug --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results --define=VERBOSE_LOGS=1 +# Use bazel run with `--config=debug` to turn on the NodeJS inspector agent. +# The node process will break before user code starts and wait for the debugger to connect. +run:debug --define=VERBOSE_LOGS=1 -- --node_options=--inspect-brk +# The following option will change the build output of certain rules such as terser and may not be desirable in all cases +build:debug --compilation_mode=dbg + +# Turn off legacy external runfiles +# This prevents accidentally depending on this feature, which Bazel will remove. +build --nolegacy_external_runfiles +run --nolegacy_external_runfiles +test --nolegacy_external_runfiles + +# Turn on --incompatible_strict_action_env which was on by default +# in Bazel 0.21.0 but turned off again in 0.22.0. Follow +# https://github.com/bazelbuild/bazel/issues/7026 for more details. +# This flag is needed to so that the bazel cache is not invalidated +# when running bazel via `yarn bazel`. +# See https://github.com/angular/angular/issues/27514. +build --incompatible_strict_action_env +run --incompatible_strict_action_env +test --incompatible_strict_action_env + +# Do not build runfile trees by default. If an execution strategy relies on runfile +# symlink tree, the tree is created on-demand. See: https://github.com/bazelbuild/bazel/issues/6627 +# and https://github.com/bazelbuild/bazel/commit/03246077f948f2790a83520e7dccc2625650e6df +build --nobuild_runfile_links + +# When running `bazel coverage` --instrument_test_targets needs to be set in order to +# collect coverage information from test targets +coverage --instrument_test_targets + +# Settings for CI +# Bazel flags for CI are in /src/dev/ci_setup/.bazelrc-ci + +# Load any settings specific to the current user. +# .bazelrc.user should appear in .gitignore so that settings are not shared with team members +# This needs to be last statement in this +# config, as the user configuration should be able to overwrite flags from this file. +# See https://docs.bazel.build/versions/master/best-practices.html#bazelrc +# (Note that we use .bazelrc.user so the file appears next to .bazelrc in directory listing, +# rather than user.bazelrc as suggested in the Bazel docs) +try-import %workspace%/.bazelrc.user diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000000000..fcdb2e109f68c --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +4.0.0 diff --git a/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index b40cd91a45c57..11a39faa9aed0 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -29,6 +29,7 @@ kibanaPipeline(timeoutMinutes: 150) { withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { parallel([ 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh'), 'kibana-oss-agent': workers.functional('kibana-oss-tests', { kibanaPipeline.buildOss() }, [ 'oss-ciGroup1': kibanaPipeline.ossCiGroupProcess(1), 'oss-ciGroup2': kibanaPipeline.ossCiGroupProcess(2), diff --git a/.ci/jobs.yml b/.ci/jobs.yml index 6aa93d4a1056a..b05e834f5a459 100644 --- a/.ci/jobs.yml +++ b/.ci/jobs.yml @@ -2,6 +2,7 @@ JOB: - kibana-intake + - x-pack-intake - kibana-firefoxSmoke - kibana-ciGroup1 - kibana-ciGroup2 diff --git a/.ci/teamcity/default/jest.sh b/.ci/teamcity/default/jest.sh index dac1cc8986a1c..b900d1b6d6b4e 100755 --- a/.ci/teamcity/default/jest.sh +++ b/.ci/teamcity/default/jest.sh @@ -1,4 +1,10 @@ #!/bin/bash -# This file is temporary and can be removed once #85850 has been -# merged and the changes included in open PR's (~3 days after merging) +set -euo pipefail + +source "$(dirname "${0}")/../util.sh" + +export JOB=kibana-default-jest + +checks-reporter-with-killswitch "Jest Unit Tests" \ + node scripts/jest x-pack --ci --verbose --maxWorkers=5 diff --git a/.ci/teamcity/oss/jest.sh b/.ci/teamcity/oss/jest.sh index b323a88ef06bc..0dee07d00d2be 100755 --- a/.ci/teamcity/oss/jest.sh +++ b/.ci/teamcity/oss/jest.sh @@ -1,13 +1,10 @@ #!/bin/bash -# This file is temporary and can be removed once #85850 has been -# merged and the changes included in open PR's (~3 days after merging) - set -euo pipefail source "$(dirname "${0}")/../util.sh" export JOB=kibana-oss-jest -checks-reporter-with-killswitch "Jest Unit Tests" \ - node scripts/jest --ci --verbose +checks-reporter-with-killswitch "OSS Jest Unit Tests" \ + node scripts/jest --config jest.config.oss.js --ci --verbose --maxWorkers=5 diff --git a/.ci/teamcity/tests/jest.sh b/.ci/teamcity/tests/jest.sh deleted file mode 100755 index c8b9b075e0e61..0000000000000 --- a/.ci/teamcity/tests/jest.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(dirname "${0}")/../util.sh" - -export JOB=kibana-jest - -checks-reporter-with-killswitch "Jest Unit Tests" \ - node scripts/jest --ci --verbose --coverage diff --git a/.ci/teamcity/tests/test_projects.sh b/.ci/teamcity/tests/test_projects.sh index 2553650930392..06dd3607a6799 100755 --- a/.ci/teamcity/tests/test_projects.sh +++ b/.ci/teamcity/tests/test_projects.sh @@ -5,4 +5,4 @@ set -euo pipefail source "$(dirname "${0}")/../util.sh" checks-reporter-with-killswitch "Test Projects" \ - yarn kbn run test --exclude kibana --oss --skip-kibana-plugins + yarn kbn run test --exclude kibana --oss --skip-kibana-plugins --skip-missing diff --git a/.eslintignore b/.eslintignore index 4ef96ebab062a..5d25f3a78c1ec 100644 --- a/.eslintignore +++ b/.eslintignore @@ -36,6 +36,7 @@ snapshots.js # package overrides /packages/elastic-eslint-config-kibana /packages/kbn-interpreter/src/common/lib/grammar.js +/packages/kbn-tinymath/src/grammar.js /packages/kbn-plugin-generator/template /packages/kbn-pm/dist /packages/kbn-test/src/functional_test_runner/__tests__/fixtures/ @@ -43,3 +44,6 @@ snapshots.js /packages/kbn-ui-framework/dist /packages/kbn-ui-shared-deps/flot_charts /packages/kbn-monaco/src/painless/antlr + +# Bazel +/bazel-* diff --git a/.eslintrc.js b/.eslintrc.js index 29528c249d279..d8b9a9d7cdd99 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1192,6 +1192,32 @@ module.exports = { }, }, + /** + * Osquery overrides + */ + { + extends: ['eslint:recommended', 'plugin:react/recommended'], + plugins: ['react'], + files: ['x-pack/plugins/osquery/**/*.{js,mjs,ts,tsx}'], + rules: { + 'arrow-body-style': ['error', 'as-needed'], + 'prefer-arrow-callback': 'error', + 'no-unused-vars': 'off', + 'react/prop-types': 'off', + }, + }, + { + // typescript and javascript for front end react performance + files: ['x-pack/plugins/osquery/public/**/!(*.test).{js,mjs,ts,tsx}'], + plugins: ['react', 'react-perf'], + rules: { + 'react-perf/jsx-no-new-object-as-prop': 'error', + 'react-perf/jsx-no-new-array-as-prop': 'error', + 'react-perf/jsx-no-new-function-as-prop': 'error', + 'react/jsx-no-bind': 'error', + }, + }, + /** * Prettier disables all conflicting rules, listing as last override so it takes precedence */ diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0630937d5ac4b..dea2c12756b08 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 @@ -330,6 +329,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/.gitignore b/.gitignore index 79d022a2d701b..2d7dd52e3ef9e 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,7 @@ report.asciidoc # Yarn local mirror content .yarn-local-mirror + +# Bazel +/bazel-* +/.bazelrc.user diff --git a/.teamcity/src/Extensions.kt b/.teamcity/src/Extensions.kt index ce99c9c49e198..0a8abf4a149cf 100644 --- a/.teamcity/src/Extensions.kt +++ b/.teamcity/src/Extensions.kt @@ -20,21 +20,20 @@ fun BuildType.kibanaAgent(size: Int) { } val testArtifactRules = """ - target/junit/**/* target/kibana-* - target/kibana-coverage/**/* - target/kibana-security-solution/**/*.png target/test-metrics/* + target/kibana-security-solution/**/*.png + target/junit/**/* target/test-suites-ci-plan.json - test/**/screenshots/diff/*.png - test/**/screenshots/failure/*.png test/**/screenshots/session/*.png + test/**/screenshots/failure/*.png + test/**/screenshots/diff/*.png test/functional/failure_debug/html/*.html - x-pack/test/**/screenshots/diff/*.png - x-pack/test/**/screenshots/failure/*.png x-pack/test/**/screenshots/session/*.png - x-pack/test/functional/apps/reporting/reports/session/*.pdf + x-pack/test/**/screenshots/failure/*.png + x-pack/test/**/screenshots/diff/*.png x-pack/test/functional/failure_debug/html/*.html + x-pack/test/functional/apps/reporting/reports/session/*.pdf """.trimIndent() fun BuildType.addTestSettings() { diff --git a/.teamcity/src/builds/test/AllTests.kt b/.teamcity/src/builds/test/AllTests.kt index a49d5f2b07f4c..9506d98cbe50e 100644 --- a/.teamcity/src/builds/test/AllTests.kt +++ b/.teamcity/src/builds/test/AllTests.kt @@ -9,5 +9,5 @@ object AllTests : BuildType({ description = "All Non-Functional Tests" type = Type.COMPOSITE - dependsOn(QuickTests, Jest, JestIntegration, OssApiServerIntegration) + dependsOn(QuickTests, Jest, XPackJest, JestIntegration, OssApiServerIntegration) }) diff --git a/.teamcity/src/builds/test/Jest.kt b/.teamcity/src/builds/test/Jest.kt index c9d170b5e5c3d..c33c9c2678ca4 100644 --- a/.teamcity/src/builds/test/Jest.kt +++ b/.teamcity/src/builds/test/Jest.kt @@ -12,7 +12,7 @@ object Jest : BuildType({ kibanaAgent(8) steps { - runbld("Jest Unit", "./.ci/teamcity/tests/jest.sh") + runbld("Jest Unit", "./.ci/teamcity/oss/jest.sh") } addTestSettings() diff --git a/.teamcity/src/builds/test/XPackJest.kt b/.teamcity/src/builds/test/XPackJest.kt new file mode 100644 index 0000000000000..8246b60823ff9 --- /dev/null +++ b/.teamcity/src/builds/test/XPackJest.kt @@ -0,0 +1,19 @@ +package builds.test + +import addTestSettings +import jetbrains.buildServer.configs.kotlin.v2019_2.BuildType +import kibanaAgent +import runbld + +object XPackJest : BuildType({ + name = "X-Pack Jest Unit" + description = "Executes X-Pack Jest Unit Tests" + + kibanaAgent(16) + + steps { + runbld("X-Pack Jest Unit", "./.ci/teamcity/default/jest.sh") + } + + addTestSettings() +}) diff --git a/.teamcity/src/projects/Kibana.kt b/.teamcity/src/projects/Kibana.kt index c84b65027dee6..5cddcf18e067f 100644 --- a/.teamcity/src/projects/Kibana.kt +++ b/.teamcity/src/projects/Kibana.kt @@ -77,6 +77,7 @@ fun Kibana(config: KibanaConfiguration = KibanaConfiguration()) : Project { name = "Jest" buildType(Jest) + buildType(XPackJest) buildType(JestIntegration) } diff --git a/WORKSPACE.bazel b/WORKSPACE.bazel new file mode 100644 index 0000000000000..0828157524fa1 --- /dev/null +++ b/WORKSPACE.bazel @@ -0,0 +1,3 @@ +workspace( + name = "kibana", +) diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index fd4ed75352b1f..0ab1c89c1d8f7 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -150,8 +150,8 @@ It also provides a stateful version of it on the start contract. Content is fetched from the remote (https://feeds.elastic.co and https://feeds-staging.elastic.co in dev mode) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. -|{kib-repo}blob/{branch}/src/plugins/presentation_util/README.md[presentationUtil] -|Utilities and components used by the presentation-related plugins +|{kib-repo}blob/{branch}/src/plugins/presentation_util/README.mdx[presentationUtil] +|The Presentation Utility Plugin is a set of common, shared components and toolkits for solutions within the Presentation space, (e.g. Dashboards, Canvas). |{kib-repo}blob/{branch}/src/plugins/region_map/README.md[regionMap] @@ -460,6 +460,10 @@ Elastic. |This plugin provides shared components and services for use across observability solutions, as well as the observability landing page UI. +|{kib-repo}blob/{branch}/x-pack/plugins/osquery/README.md[osquery] +|This plugin adds extended support to Security Solution Fleet Osquery integration + + |{kib-repo}blob/{branch}/x-pack/plugins/painless_lab/README.md[painlessLab] |This plugin helps users learn how to use the Painless scripting language. @@ -555,10 +559,6 @@ in their infrastructure. |NOTE: This plugin contains implementation of URL drilldown. For drilldowns infrastructure code refer to ui_actions_enhanced plugin. -|{kib-repo}blob/{branch}/x-pack/plugins/vis_type_timeseries_enhanced/README.md[visTypeTimeseriesEnhanced] -|The vis_type_timeseries_enhanced plugin is the x-pack counterpart to the OSS vis_type_timeseries plugin. - - |{kib-repo}blob/{branch}/x-pack/plugins/watcher/README.md[watcher] |This plugins adopts some conventions in addition to or in place of conventions in Kibana (at the time of the plugin's creation): diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md index 4ab0cb74f809f..3b5754eb4fa39 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.config.md @@ -4,14 +4,17 @@ ## PluginInitializerContext.config property +Accessors for the plugin's configuration + Signature: ```typescript config: { legacy: { globalConfig$: Observable; + get: () => SharedGlobalConfig; }; create: () => Observable; - createIfExists: () => Observable; + get: () => T; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md index 106fdaad9bc22..e5de046eccf1d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.logger.md @@ -4,8 +4,29 @@ ## PluginInitializerContext.logger property + instance already bound to the plugin's logging context + Signature: ```typescript logger: LoggerFactory; ``` + +## Example + + +```typescript +// plugins/my-plugin/server/plugin.ts +// "id: myPlugin" in `plugins/my-plugin/kibana.yaml` + +export class MyPlugin implements Plugin { + constructor(private readonly initContext: PluginInitializerContext) { + this.logger = initContext.logger.get(); + // `logger` context: `plugins.myPlugin` + this.mySubLogger = initContext.logger.get('sub'); // or this.logger.get('sub'); + // `mySubLogger` context: `plugins.myPlugin.sub` + } +} + +``` + diff --git a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md index 18760170afa1f..90a19d53bd5e1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.plugininitializercontext.md @@ -16,8 +16,8 @@ export interface PluginInitializerContext | Property | Type | Description | | --- | --- | --- | -| [config](./kibana-plugin-core-server.plugininitializercontext.config.md) | {
legacy: {
globalConfig$: Observable<SharedGlobalConfig>;
};
create: <T = ConfigSchema>() => Observable<T>;
createIfExists: <T = ConfigSchema>() => Observable<T | undefined>;
} | | +| [config](./kibana-plugin-core-server.plugininitializercontext.config.md) | {
legacy: {
globalConfig$: Observable<SharedGlobalConfig>;
get: () => SharedGlobalConfig;
};
create: <T = ConfigSchema>() => Observable<T>;
get: <T = ConfigSchema>() => T;
} | Accessors for the plugin's configuration | | [env](./kibana-plugin-core-server.plugininitializercontext.env.md) | {
mode: EnvironmentMode;
packageInfo: Readonly<PackageInfo>;
instanceUuid: string;
} | | -| [logger](./kibana-plugin-core-server.plugininitializercontext.logger.md) | LoggerFactory | | +| [logger](./kibana-plugin-core-server.plugininitializercontext.logger.md) | LoggerFactory | instance already bound to the plugin's logging context | | [opaqueId](./kibana-plugin-core-server.plugininitializercontext.opaqueid.md) | PluginOpaqueId | | diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md deleted file mode 100644 index 29a511d57d7bd..0000000000000 --- a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) > [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) > [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md) - -## IKbnUrlStateStorage.cancel property - -cancels any pending url updates - -Signature: - -```typescript -cancel: () => void; -``` diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md deleted file mode 100644 index dfeef1cdce22c..0000000000000 --- a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md +++ /dev/null @@ -1,15 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) > [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) > [flush](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md) - -## IKbnUrlStateStorage.flush property - -Synchronously runs any pending url updates, returned boolean indicates if change occurred. - -Signature: - -```typescript -flush: (opts?: { - replace?: boolean; - }) => boolean; -``` diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md new file mode 100644 index 0000000000000..8e3b9a7bfeb3f --- /dev/null +++ b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-kibana\_utils-public-state\_sync](./kibana-plugin-plugins-kibana_utils-public-state_sync.md) > [IKbnUrlStateStorage](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md) > [kbnUrlControls](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md) + +## IKbnUrlStateStorage.kbnUrlControls property + +Lower level wrapper around history library that handles batching multiple URL updates into one history change + +Signature: + +```typescript +kbnUrlControls: IKbnUrlControls; +``` diff --git a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md index 371f7b7c15362..7fb8717fae003 100644 --- a/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md +++ b/docs/development/plugins/kibana_utils/public/state_sync/kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.md @@ -20,9 +20,8 @@ export interface IKbnUrlStateStorage extends IStateStorage | Property | Type | Description | | --- | --- | --- | -| [cancel](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.cancel.md) | () => void | cancels any pending url updates | | [change$](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.change_.md) | <State = unknown>(key: string) => Observable<State | null> | | -| [flush](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.flush.md) | (opts?: {
replace?: boolean;
}) => boolean | Synchronously runs any pending url updates, returned boolean indicates if change occurred. | | [get](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.get.md) | <State = unknown>(key: string) => State | null | | +| [kbnUrlControls](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.kbnurlcontrols.md) | IKbnUrlControls | Lower level wrapper around history library that handles batching multiple URL updates into one history change | | [set](./kibana-plugin-plugins-kibana_utils-public-state_sync.ikbnurlstatestorage.set.md) | <State>(key: string, state: State, opts?: {
replace: boolean;
}) => Promise<string | undefined> | | diff --git a/docs/maps/maps-aggregations.asciidoc b/docs/maps/maps-aggregations.asciidoc index f09d99250c22d..3c66e187bf59c 100644 --- a/docs/maps/maps-aggregations.asciidoc +++ b/docs/maps/maps-aggregations.asciidoc @@ -98,6 +98,13 @@ Point to point uses an {es} {ref}/search-aggregations-bucket-terms-aggregation.h Then, a nested {ref}/search-aggregations-bucket-geotilegrid-aggregation.html[GeoTile grid aggregation] groups sources for each destination into grids. A line connects each source grid centroid to each destination. +Point-to-point layers are used in several common use cases: + +* Source-destination maps for network traffic +* Origin-destination maps for flight data +* Origin-destination flows for import/export/migration +* Origin-destination for pick-up/drop-off data + image::maps/images/point_to_point.png[] [role="xpack"] diff --git a/docs/redirects.asciidoc b/docs/redirects.asciidoc index 4449b58b4bab3..c7bdff800bb0b 100644 --- a/docs/redirects.asciidoc +++ b/docs/redirects.asciidoc @@ -194,10 +194,89 @@ This page has moved. Refer to <>. This page has moved. Refer to <>. +[float] +[[time-series-visual-builder]] +=== Time Series Visual Builder + +This page was deleted. Refer to <>. + +[float] +[[kibana-keystore-has-moved-from-the-data-folder-to-the-config-folder]] +=== Kibana Keystore has moved from the Data Folder to the Config Folder + +This page has been deleted. Refer to link:https://www.elastic.co/guide/en/kibana/7.9/breaking-changes-7.9.html#user-facing-changes-79[Breaking changes in 7.9]. + +[float] +[[createvis]] +=== Create Visualization + +This page has been deleted. Refer to <>. + +[float] +[[data-table]] +=== Data Table + +This page has been deleted. Refer to <>. + + +[float] +[[xy-chart]] +=== Line, Area, and Bar Chart + +This page has been deleted. Refer to <>. + +[float] +[[add-canvas-events]] +=== Add Canvas Elements + +This page has been moved. Refer to <>. + +[float] +[[vega-lite-tutorial]] +=== Vega-Lite Tutorial + +This page has been moved. Refer to <>. + +[float] +[[heatmap-chart]] +=== Heatmap Chart + +This page has been moved. Refer to <>. + +[float] +[[interface-overview]] +=== Interface Overview + +This page has been moved. Refer to <>. + +[float] +[[time-series-visualizations]] +=== Featured Visualizations + +This page has been moved. Refer to <>. + +[float] +[[timelion-customize]] +=== Customize and format visualizations + +This page has been moved. Refer to <>. + +[float] +[[dashboard-drilldown]] +=== Dashboard Drilldowns + +This page has been moved. Refer to <>. + +[float] +[[development-plugin-localization]] +=== Localization for plugins + +This page has been moved. PRefer to <>. + [role="exclude",id="visualize"] == Visualize -This content has moved. See <>. +This content has moved. Refer to <>. [role="exclude",id="explore-dashboard-data"] -This content has moved. See <>. +This content has moved. Refer to <>. diff --git a/docs/setup/upgrade/upgrade-migrations.asciidoc b/docs/setup/upgrade/upgrade-migrations.asciidoc index 7436536d22781..cc6e363872808 100644 --- a/docs/setup/upgrade/upgrade-migrations.asciidoc +++ b/docs/setup/upgrade/upgrade-migrations.asciidoc @@ -19,17 +19,16 @@ Saved objects are stored in two indices: * `.kibana_{kibana_version}_001`, or if the `kibana.index` configuration setting is set `.{kibana.index}_{kibana_version}_001`. E.g. for Kibana v7.12.0 `.kibana_7.12.0_001`. * `.kibana_task_manager_{kibana_version}_001`, or if the `xpack.tasks.index` configuration setting is set `.{xpack.tasks.index}_{kibana_version}_001` E.g. for Kibana v7.12.0 `.kibana_task_manager_7.12.0_001`. -The index aliases `.kibana` and `.kibana_task_manager` will always point to the most up-to-date version indices. +The index aliases `.kibana` and `.kibana_task_manager` will always point to +the most up-to-date saved object indices. The first time a newer {kib} starts, it will first perform an upgrade migration before starting plugins or serving HTTP traffic. To prevent losing acknowledged writes old nodes should be shutdown before starting the upgrade. To reduce the likelihood of old nodes losing acknowledged writes, {kib} 7.12.0 and later will add a write block to the outdated index. Table 1 lists the saved objects indices used by previous versions of {kib}. .Saved object indices and aliases per {kib} version [options="header"] -[cols="a,a,a"] |======================= -|Upgrading from version | Outdated index (alias) | Upgraded index (alias) -| 6.0.0 through 6.4.x | `.kibana` 1.3+^.^| `.kibana_7.12.0_001` -(`.kibana` alias) +|Upgrading from version | Outdated index (alias) +| 6.0.0 through 6.4.x | `.kibana` `.kibana_task_manager_7.12.0_001` (`.kibana_task_manager` alias) | 6.5.0 through 7.3.x | `.kibana_N` (`.kibana` alias) diff --git a/docs/user/alerting/alert-types.asciidoc b/docs/user/alerting/alert-types.asciidoc index 7c5a957d1cf79..279739e95b522 100644 --- a/docs/user/alerting/alert-types.asciidoc +++ b/docs/user/alerting/alert-types.asciidoc @@ -8,7 +8,7 @@ This section covers stack alerts. For domain-specific alert types, refer to the Users will need `all` access to the *Stack Alerts* feature to be able to create and edit any of the alerts listed below. See <> for more information on configuring roles that provide access to this feature. -Currently {kib} provides one stack alert: the <> type. +Currently {kib} provides two stack alerts: <> and <>. [float] [[alert-type-index-threshold]] @@ -112,6 +112,47 @@ You can interactively change the time window and observe the effect it has on th [role="screenshot"] image::images/alert-types-index-threshold-example-comparison.png[Comparing two time windows] +[float] +[[alert-type-es-query]] +=== ES query + +The ES query alert type is designed to run a user-configured {es} query over indices, compare the number of matches to a configured threshold, and schedule +actions to run when the threshold condition is met. + +[float] +==== Creating the alert + +An ES query alert can be created from the *Create* button in the <>. Fill in the <>, then select *ES query*. + +[role="screenshot"] +image::images/alert-types-es-query-select.png[Choosing an ES query alert type] + +[float] +==== Defining the conditions +The ES query alert has 4 clauses that define the condition to detect. +[role="screenshot"] +image::images/alert-types-es-query-conditions.png[Four clauses define the condition to detect] + +Index:: This clause requires an *index or index pattern* and a *time field* that will be used for the *time window*. +ES query:: This clause specifies the ES DSL query to execute. The number of documents that match this query will be evaulated against the threshold +condition. Aggregations are not supported at this time. +Threshold:: This clause defines a threshold value and a comparison operator (`is above`, `is above or equals`, `is below`, `is below or equals`, or `is between`). The number of documents that match the specified query is compared to this threshold. +Time window:: This clause determines how far back to search for documents, using the *time field* set in the *index* clause. Generally this value should be set to a value higher than the *check every* value in the <>, to avoid gaps in detection. + +[float] +==== Testing your query + +Use the *Test query* feature to verify that your query DSL is valid. +When your query is valid:: Valid queries will be executed against the configured *index* using the configured *time window*. The number of documents that +match the query will be displayed. + +[role="screenshot"] +image::images/alert-types-es-query-valid.png[Test ES query returns number of matches when valid] + +When your query is invalid:: An error message is shown if the query is invalid. + +[role="screenshot"] +image::images/alert-types-es-query-invalid.png[Test ES query shows error when invalid] \ No newline at end of file diff --git a/docs/user/alerting/images/alert-types-es-query-conditions.png b/docs/user/alerting/images/alert-types-es-query-conditions.png new file mode 100644 index 0000000000000..ce2bd6a42a4b5 Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-conditions.png differ diff --git a/docs/user/alerting/images/alert-types-es-query-invalid.png b/docs/user/alerting/images/alert-types-es-query-invalid.png new file mode 100644 index 0000000000000..ce8b8e92181a9 Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-invalid.png differ diff --git a/docs/user/alerting/images/alert-types-es-query-select.png b/docs/user/alerting/images/alert-types-es-query-select.png new file mode 100644 index 0000000000000..61fe724ea1412 Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-select.png differ diff --git a/docs/user/alerting/images/alert-types-es-query-valid.png b/docs/user/alerting/images/alert-types-es-query-valid.png new file mode 100644 index 0000000000000..1894ad2b445f8 Binary files /dev/null and b/docs/user/alerting/images/alert-types-es-query-valid.png differ diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 4a0598cc569cd..2c961dca44474 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -223,7 +223,7 @@ experimental[] Access the Elastic Map Service files via the same mechanism: ---- url: { // "type" defaults to "elasticsearch" otherwise - type: emsfile + %type%: emsfile // Name of the file, exactly as in the Region map visualization name: World Countries } @@ -289,7 +289,7 @@ experimental[] You can use the *Vega* https://vega.github.io/vega/docs/data/[dat ---- url: { // "type" defaults to "elasticsearch" otherwise - type: emsfile + %type%: emsfile // Name of the file, exactly as in the Region map visualization name: World Countries } diff --git a/docs/user/images/alerts-and-actions.png b/docs/user/images/alerts-and-actions.png new file mode 100755 index 0000000000000..227abd9441e15 Binary files /dev/null and b/docs/user/images/alerts-and-actions.png differ diff --git a/docs/user/images/app-navigation-search.png b/docs/user/images/app-navigation-search.png new file mode 100644 index 0000000000000..3b89eed44b28f Binary files /dev/null and b/docs/user/images/app-navigation-search.png differ diff --git a/docs/user/images/features-control.png b/docs/user/images/features-control.png new file mode 100755 index 0000000000000..abe75d5ab6fc1 Binary files /dev/null and b/docs/user/images/features-control.png differ diff --git a/docs/user/images/home-page.png b/docs/user/images/home-page.png new file mode 100755 index 0000000000000..9ca4b7f43f427 Binary files /dev/null and b/docs/user/images/home-page.png differ diff --git a/docs/user/images/kibana-main-menu.png b/docs/user/images/kibana-main-menu.png new file mode 100755 index 0000000000000..79e0a3dca8658 Binary files /dev/null and b/docs/user/images/kibana-main-menu.png differ diff --git a/docs/user/images/login-screen.png b/docs/user/images/login-screen.png new file mode 100755 index 0000000000000..7a97c952e1039 Binary files /dev/null and b/docs/user/images/login-screen.png differ diff --git a/docs/user/images/roles-and-privileges.png b/docs/user/images/roles-and-privileges.png new file mode 100755 index 0000000000000..28bff6d13c871 Binary files /dev/null and b/docs/user/images/roles-and-privileges.png differ diff --git a/docs/user/images/select-your-space.png b/docs/user/images/select-your-space.png new file mode 100755 index 0000000000000..887e8eea27c5c Binary files /dev/null and b/docs/user/images/select-your-space.png differ diff --git a/docs/user/images/tags-search.png b/docs/user/images/tags-search.png new file mode 100755 index 0000000000000..67458200c50d1 Binary files /dev/null and b/docs/user/images/tags-search.png differ diff --git a/docs/user/images/visualization-journey.png b/docs/user/images/visualization-journey.png new file mode 100644 index 0000000000000..ef7634485bccd Binary files /dev/null and b/docs/user/images/visualization-journey.png differ diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index a41b42f259471..fb91f6a6a1c9a 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -1,145 +1,444 @@ [[introduction]] -== {kib} — your window into the Elastic Stack +== {kib}—your window into Elastic ++++ What is Kibana? ++++ -**_Visualize and analyze your data and manage all things Elastic Stack._** +{kib} enables you to give +shape to your data and navigate the Elastic Stack. With {kib}, you can: -Whether you’re an analyst or an admin, {kib} makes your data actionable by providing -three key functions. Kibana is: +* *Visualize and analyze your data.* +Search for hidden insights, visualize what you've found in charts, gauges, +maps and more, and combine them in a dashboard. -* **An open-source analytics and visualization platform.** -Use {kib} to explore your {es} data, and then build beautiful visualizations and dashboards. +* *Search, observe, and protect.* +From discovering documents to analyzing logs to finding security vulnerabilities, +{kib} is your portal for accessing these capabilities and more. -* **A UI for managing the Elastic Stack.** -Manage your security settings, assign user roles, take snapshots, roll up your data, -and more — all from the convenience of a {kib} UI. +* *Manage, monitor, and secure the Elastic Stack.* +Manage your indices and ingest pipelines, monitor the health of your +Elastic Stack cluster, and control which users have access to +which features. -* **A centralized hub for Elastic's solutions.** From log analytics to -document discovery to SIEM, {kib} is the portal for accessing these and other capabilities. -[role="screenshot"] -image::images/intro-kibana.png[Kibana home page] +*{kib} is for administrators, analysts, and business users.* +As an admin, your role is to manage the Elastic Stack, from creating your +deployment to getting {es} data into {kib}, and then +managing the data. As an analyst, your job is to discover insights +in the data, visualize your data on dashboards, and share your findings. As a business user, +you want to view existing dashboards and drill down into details. + +*{kib} works with all types of data.* Your data can be structured or unstructured text, +numerical data, time-series data, geospatial data, logs, metrics, security events, +and more. Kibana is designed to use Elasticsearch as a data store. +No matter your data, {kib} can help you uncover patterns and relationships and visualize the results. [float] -[[get-data-into-kibana]] -=== Ingest data +[[kibana-home-page]] +=== Where to start + +Start with the home page, where you’re guided toward the most common use cases. +For a quick reference of {kib} use cases, refer to <> -{kib} is designed to use {es} as a data source. Think of Elasticsearch as the engine that stores -and processes the data, with {kib} sitting on top. +[role="screenshot"] +image::images/home-page.png[Kibana home page] + +The main menu gets you to where you need to go. Like the home page, +the menu is organized by use case. Want to work with your logs, metrics, APM, or +Uptime data? The apps you need are under *Observability*. The main menu also includes +*Recently viewed*, so you can easily access your previously opened apps. -To start working with your data in Kibana, use one of the many ingest options, -available from the home page. You can collect data from an app or service or upload a file that contains your data. -If you're not ready to use your own data, you can add a sample data set -to give {kib} a test drive. +Hidden by default, you open the main menu by clicking the +hamburger icon. To keep the main menu visible at all times, click the *Dock navigation* item. [role="screenshot"] -image::setup/images/add-data-home.png[Built-in options for adding data to Kibana: Add data, Add Elastic Agent, Upload a file] +image::images/kibana-main-menu.png[Kibana main menu] [float] -[[explore-and-query]] -=== Explore & query +[[kibana-navigation-search]] +=== Search {kib} + +Using the Search field in the global header, you can +search for applications and objects, such as +dashboards and visualizations. + +Search suggestions include deep links into applications, +allowing you to directly navigate to the views you need most. -Ready to dive into your data? With <>, you can explore your data and -search for hidden insights and relationships. Ask your questions, and then -narrow the results to just the data you want. +[role="screenshot"] +image::images/app-navigation-search.png[Example of searching for apps] + +When searching for objects, you can search by type, name, and tag. +Tags are keywords or labels that you assign to {kib} objects, +so you can classify the objects in a way that is meaningful to you. +You can then quickly search for related objects based on shared tags. [role="screenshot"] -image::images/intro-discover.png[Discover UI] +image::images/tags-search.png[Example of searching for tags] + +To get the most from the search feature, follow these tips: + +* Use the keyboard shortcut—Ctrl+/ on Windows and Linux, Command+/ on MacOS—to focus on the input at any time. + +* Use the provided syntax keywords. ++ +[cols=2*] +|=== +|Search by type +|`type:dashboard` + +Available types: `application`, `canvas-workpad`, `dashboard`, `index-pattern`, `lens`, `maps`, `query`, `search`, `visualization` + +|Search by tag +|`tag:mytagname` + +`tag:"tag name with spaces"` + +|Search by type and name +|`type:dashboard my_dashboard_title` + +|Advanced searches +|`tag:(tagname1 or tagname2) my_dashboard_title` + +`type:lens tag:(tagname1 or tagname2)` + +`type:(dashboard or canvas-workpad) logs` + +|=== + [float] [[visualize-and-analyze]] -=== Visualize & analyze +=== Analyze your data -A visualization is worth a thousand log lines, and {kib} provides -many options for showcasing your data. Use <>, -our drag-and-drop interface, -to rapidly build -charts, tables, metrics, and more. If there -is a better visualization for your data, *Lens* suggests it, allowing for quick -switching between visualization types. - -Once your visualizations are just the way you want, -use <> to collect them in one place. A dashboard provides -insights into your data from multiple perspectives. +Data analysis is the core functionality of {kib}. +You can quickly search through large amounts of data, explore fields and values, +and then use {kib}’s drag-and-drop interface to rapidly build charts, tables, metrics, and more. [role="screenshot"] -image::images/intro-dashboard.png[Sample eCommerce data set dashboard] +image::images/visualization-journey.png[User visualization journey] + +[[get-data-into-kibana]] +. *Add data.* The best way to add {es} data to {kib} is to use one of our guided processes, +available from the <>. You can collect data from an app or service, upload a +file, or add a sample data set. -{kib} also offers these visualization features: +. *Explore.* With <>, you can search your data for hidden +insights and relationships. Ask your questions, and then filter the results to just the data you want. +You can also limit your results to the most recent documents added to {es}. -* <> gives you the ability to present your data in a -visually compelling, pixel-perfect report. Give your data the “wow” factor -needed to impress your CEO or to captivate coworkers with a big-screen display. +. *Visualize.* {kib} provides many options to create visualizations of your data, from +aggregation-based data to time series data. +<> is your starting point to create visualizations, +and then pulling them together to show your data from multiple perspectives. -* <> enables you to ask (and answer) meaningful -questions of your location-based data. Maps supports multiple -layers and data sources, mapping of individual geo points and shapes, -and dynamic client-side styling. +. *Present.* With <>, you can display your data on a visually +compelling, pixel-perfect workpad. **Canvas** can give your data +the “wow” factor needed to impress your CEO and captivate coworkers with a big-screen display. -* <> allows you to combine -an infinite number of aggregations to display complex data. -With TSVB, you can customize -every aspect of your visualization. Choose your own date format and color -gradients, and easily switch your data view between time series, metric, -top N, gauge, and markdown. +. *Share.* Ready to <> your findings with a larger audience? {kib} offers many options—embed +a dashboard, share a link, export to PDF, and more. [float] -[[organize-and-secure]] -=== Organize & secure +==== Plot location data on a map +If you’re looking to better understand the “where’’ in your data, your data +analysis journey will also include <>. This app is the right +choice when you’re looking for a spatial pattern, performing ad-hoc location-driven analysis, +or analyzing metrics with a geographic perspective. With *Maps*, you can build +world country maps, administrative region maps, and point-to-point origin-destination maps. +You can also visualize and track movement over space and through time. -Want to share Kibana’s goodness with other people or teams? You can do so with -<>, built for organizing your visualizations, dashboards, and indices. -Think of a space as its own mini {kib} installation — it’s isolated from -all other spaces, so you can tailor it to your specific needs without impacting others. +[float] +==== Model data behavior -You can even choose which features to enable within each space. Don’t need -Machine learning in your “Executive” space? Simply turn it off. +To model the behavior of your data, you'll want to use +<>. +This app can help you extract insights from your data that you might otherwise miss. +You can forecast unusual behavior in your time series data. +You can also perform outlier detection, regression, and classification analysis +on your data and generate annotated results. -[role="screenshot"] -image::images/intro-spaces.png[Space selector screen] +[float] +==== Graph relationships -You can take this all one step further with Kibana’s security features, and -control which users have access to each space. {kib} allows for fine-grained -controls, so you can give a user read-only access to -dashboards in one space, but full access to all of Kibana’s features in another. +Looking to uncover how items in your data are related? +<> is your app. Graphing relationships is useful in a variety of use cases, +from fraud detection to recommendation engines. For example, graph exploration +can help you uncover website vulnerabilities that hackers are targeting, +so you can harden your website. Or, you might provide graph-based +personalized recommendations to your e-commerce customers. + +[float] +[[extend-your-use-case]] +=== Search, observe, and protect + +Being able to search, observe, and protect your data is a requirement for any analyst. +{kib} provides solutions for each of these use cases. + +* https://www.elastic.co/guide/en/enterprise-search/current/index.html[*Enterprise Search*] enables you to create a search experience for your app, workplace, and website. + +* {observability-guide}/observability-introduction.html[*Elastic Observability*] enables you to monitor and apply analytics in real time +to events happening across all your environments. You can analyze log events, monitor the performance metrics for the host or container +that it ran in, trace the transaction, and check the overall service availability. + +* Designed for security analysts, {security-guide}/es-overview.html[*Elastic Security*] provides an overview of +the events and alerts from your environment. Elastic Security helps you defend +your organization from threats before damage and loss occur. ++ +[role="screenshot"] +image::siem/images/detections-ui.png[] [float] [[manage-all-things-stack]] === Manage all things Elastic Stack -<> provides guided processes for managing all -things Elastic Stack — indices, clusters, licenses, UI settings, -and more. Want to update your {es} indices? Set user roles and privileges? -Turn on dark mode? Kibana has UIs for all that. +{kib}'s <> takes you under the hood, +so you can twist the levers and turn the knobs. *Stack Management* provides +guided processes for administering all things Elastic Stack, +including data, indices, clusters, alerts, and security. [role="screenshot"] image::images/intro-management.png[] [float] -[[extend-your-use-case]] -=== Extend your use case — or add a new one +==== Manage your data, indices, and clusters + +{kib} offers these data management tasks—all from the convenience of a UI: + +* Refresh, flush, and clear the cache of your indices. +* Define the lifecycle of an index as it ages. +* Define a policy for taking snapshots of your cluster. +* Roll up data from one or more indices into a new, compact index. +* Replicate indices on a remote cluster and copy them to a local cluster. + +[float] +==== Alert and take action +Detecting and acting on significant shifts and signals in your data is a need +that exists in almost every use case. For example, you might set an alert to notify you when: -As a hub for Elastic's https://www.elastic.co/products/[solutions], {kib} -can help you find security vulnerabilities, -monitor performance, and address your business needs. Get alerted if a key -metric spikes. Detect anomalous behavior or forecast future spikes. Root out -bottlenecks in your application code. Kibana doesn’t limit or dictate how you explore your data. +* A shift occurs in your business critical KPIs. +* System resources, such as memory, CPU and disk space, take a dip. +* An unusually high number of service requests, suspicious processes, and login attempts occurs. + +An alert is triggered when a specified condition is met. For example, +an alert might trigger when the average or max of one of +your metrics exceeds a threshold within a specified time frame. + +When the alert triggers, you can send a notification to a system that is part of +your daily workflow. {kib} integrates with email, Slack, PagerDuty, and ServiceNow, +to name a few. + +A dedicated view for creating, searching, and editing alerts is in <>. [role="screenshot"] -image::siem/images/detections-ui.png[] +image::images/alerts-and-actions.png[Alerts and Actions view] + [float] -[[try-kibana]] -=== Give {kib} a try +[[organize-and-secure]] +=== Organize your work in spaces + +Want to share {kib}’s goodness with other people or teams without overwhelming them? You can do so +with <>, built for organizing your visualizations, dashboards, and indices. +Think of a space as its own mini {kib} installation—it’s isolated from all other spaces, +so you can tailor it to your specific needs without impacting others. + +[role="screenshot"] +image::images/select-your-space.png[Space selector screen] + +Most of {kib}’s entities are space-aware, including dashboards, visualizations, index patterns, +Canvas workpads, Timelion visualizations, graphs, tags, and machine learning jobs. + +In addition: + +* **Elastic Security** is space-aware, so the timelines and investigations +you open in one space will not be available to other spaces. + +* **Observability** is currently partially space-aware, but will be enhanced to become fully space-aware. + +* Most of the **Stack Management** features are not space aware because they +are primarily used to manage features of {es}, which serves as a shared data store for all spaces. + +* Alerts are space-aware and work nicely with the {kib} role-based access control +model to allow you secure access to them, depending on the alert type and your user roles. +For example, roles with no access to an app will not have access to its alerts. + +[float] +==== Control feature visibility + +You can take spaces one step further and control which features are visible +within each space. For example, you might hide **Dev Tools** in your "Executive" +space or show **Stack Monitoring** only in your "Admin" space. + +Controlling feature visibility is not a security feature. To secure access +to specific features on a per-user basis, you must configure +<>. + +[role="screenshot"] +image::images/features-control.png[Features Controls screen] -There is no faster way to try out {kib} than with our hosted {es} Service. -https://www.elastic.co/cloud/elasticsearch-service/signup[Sign up for a free trial] -and start exploring data in minutes. +[float] +[[intro-kibana-Security]] +=== Secure {kib} + +{kib} offers a range of security features for you to control who has access to what. +The security features are automatically turned on when +{ref}/get-started-enable-security.html[security is enabled in +{es}]. For a description of all available configuration options, +see <>. + +[float] +==== Log in +Kibana supports several <>, +allowing you to login using {es}’s built-in realms, or by your own single sign-on provider. + +[role="screenshot"] +image::images/login-screen.png[Login screen] + +[float] +==== Secure access + +{kib} provides roles and privileges for controlling which users can +view and manage {kib} features. Privileges grant permission to view an application +or perform a specific action and are assigned to roles. Roles allow you to describe +a “template” of capabilities that you can grant to many users, +without having to redefine what each user should be able to do. + +When you create a role, you can scope the assigned {kib} privileges to specific spaces. +This makes it possible to grant users different access levels in different spaces, +or even give users their very own private space. For example, power users might +have privileges to create and edit visualizations and dashboards, +while analysts or executives might have *Dashboard* and *Canvas* with read-only privileges. + +{kib}’s role management interface allows you to describe these various access +levels, or you can automate role creation via our <>. + +[role="screenshot"] +image::images/roles-and-privileges.png[{kib privileges}] + +[float] +==== Audit access + +Once you have your users and roles configured, you might want to maintain a +record of who did what, when. The {kib} audit log will record this information for you, +which can then be correlated with {es} audit logs to gain more insights into your +users’ behavior. For more information, see <>. + +[float] +[[whats-the-right-app]] +=== What’s the right app for you? + +{kib} has a wealth of apps, each with its own area of specialty. +Scan this table to quickly find the app that gets you to our goal. + +[cols=2*] +|=== + +2+| *Get started* + +|Get {kib} +|https://www.elastic.co/cloud/elasticsearch-service/signup[Sign up for a free trial] and start exploring data in minutes. + +|Don’t know where to begin +|The home page. If you’re looking to explore and visualize your data, follow +the <>. + +|Add data +|The Add data page, available from the home page. + +|See the full list of {kib} features +|The https://www.elastic.co/kibana/features[{kib} features page on elastic.co] + +2+| *Analyze and visualize your data* + +|Know what’s in your data +|<> + +|Create charts and other visualizations +|<> + +|Show your data from different perspectives +|<> + +|Work with location data +|<> + +|Create a presentation of your data +|<> + +|Generate models for your data’s behavior +|<> + +|Explore connections in your data +|<> + +|Share your data +|<>, <> + +2+|*Build a search experience* + +|Create a search experience for your workplace +|https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html[Workplace Search] + +|Build a search experience for your app +|https://www.elastic.co/guide/en/app-search/current/getting-started.html[App Search] + + +2+|*Monitor, analyze, and react to events* + +|Monitor software services and applications in real-time by collecting performance information +|{observability-guide}/apm.html[APM] + +|Monitor the availability of your sites and services +|{observability-guide}/monitor-uptime.html[Uptime] + +|Search, filter, and tail all your logs +|{observability-guide}/monitor-logs.html[Logs] + +|Analyze metrics from your infrastructure, apps, and services +|{observability-guide}/analyze-metrics.html[Metrics] + +2+|*Prevent, detect, and respond to threats* + +|Create and manage rules for suspicious source events, and view the alerts these rules create. +|{security-guide}/detection-engine-overview.html[Detections] + +|View all hosts and host-related security events. +|{security-guide}/hosts-overview.html[Hosts] + +|View key network activity metrics via an interactive map. +|{security-guide}/network-page-overview.html[Network] + +|Investigate alerts and complex threats, such as lateral movement of malware across hosts in your network. +|{security-guide}/timelines-ui.html[Timelines] + +|Create and track security issues +|{security-guide}/cases-overview.html[Cases] + +|View and manage hosts that are running Endpoint Security +|{security-guide}/admin-page-ov.html[Administration] + +2+|*Administer your Kibana instance* + +|Manage your Elasticsearch data +|< Data>> + +|Set up alerts +|< Alerts and Actions>> + +|Organize your workspace and users +|< Spaces>> + +|Define user roles and privileges +|< Users>> + +|Customize {kib} to suit your needs +|< Advanced Settings>> + +|=== + +[float] +[[try-kibana]] +=== Getting help -You can also <> — no code, no additional -infrastructure required. +Using our in-product guidance can help you get up and running, faster. +Click the help icon image:images/intro-help-icon.png[Help icon in navigation bar] +for help with questions or to provide feedback. -Our <> and in-product guidance can -help you get up and running, faster. Click the help icon image:images/intro-help-icon.png[Help icon in navigation bar] for help with questions or to provide feedback. +To keep up with what’s new and changed in Elastic, click the celebration icon in the global header. diff --git a/jest.config.integration.js b/jest.config.integration.js index 99728c5471dfb..2064abb7e36a1 100644 --- a/jest.config.integration.js +++ b/jest.config.integration.js @@ -17,7 +17,6 @@ module.exports = { testPathIgnorePatterns: preset.testPathIgnorePatterns.filter( (pattern) => !pattern.includes('integration_tests') ), - setupFilesAfterEnv: ['/packages/kbn-test/target/jest/setup/after_env.integration.js'], reporters: [ 'default', [ @@ -25,7 +24,5 @@ module.exports = { { reportName: 'Jest Integration Tests' }, ], ], - coverageReporters: !!process.env.CI - ? [['json', { file: 'jest-integration.json' }]] - : ['html', 'text'], + setupFilesAfterEnv: ['/packages/kbn-test/target/jest/setup/after_env.integration.js'], }; diff --git a/jest.config.js b/jest.config.js index 9ac5e57254e5a..f1833772c82a1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,14 +7,6 @@ */ module.exports = { - preset: '@kbn/test', rootDir: '.', - projects: [ - '/packages/*/jest.config.js', - '/src/*/jest.config.js', - '/src/legacy/*/jest.config.js', - '/src/plugins/*/jest.config.js', - '/test/*/jest.config.js', - '/x-pack/plugins/*/jest.config.js', - ], + projects: [...require('./jest.config.oss').projects, ...require('./x-pack/jest.config').projects], }; diff --git a/jest.config.oss.js b/jest.config.oss.js new file mode 100644 index 0000000000000..1b478aa85bdba --- /dev/null +++ b/jest.config.oss.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '.', + projects: [ + '/packages/*/jest.config.js', + '/src/*/jest.config.js', + '/src/legacy/*/jest.config.js', + '/src/plugins/*/jest.config.js', + '/test/*/jest.config.js', + ], +}; diff --git a/package.json b/package.json index 42949a7014131..920e0c8ba5192 100644 --- a/package.json +++ b/package.json @@ -312,7 +312,7 @@ "tabbable": "1.1.3", "tar": "4.4.13", "tinygradient": "0.4.3", - "tinymath": "1.2.1", + "@kbn/tinymath": "link:packages/kbn-tinymath", "tree-kill": "^1.2.2", "ts-easing": "^0.2.0", "tslib": "^2.0.0", @@ -346,6 +346,7 @@ "@babel/register": "^7.12.10", "@babel/traverse": "^7.12.12", "@babel/types": "^7.12.12", + "@bazel/ibazel": "^0.14.0", "@cypress/snapshot": "^2.1.7", "@cypress/webpack-preprocessor": "^5.5.0", "@elastic/apm-rum": "^5.6.1", @@ -392,6 +393,7 @@ "@storybook/addon-essentials": "^6.0.26", "@storybook/addon-knobs": "^6.0.26", "@storybook/addon-storyshots": "^6.0.26", + "@storybook/addon-docs": "^6.0.26", "@storybook/components": "^6.0.26", "@storybook/core": "^6.0.26", "@storybook/core-events": "^6.0.26", @@ -595,7 +597,7 @@ "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", - "chromedriver": "^87.0.3", + "chromedriver": "^88.0.0", "clean-webpack-plugin": "^3.0.0", "cmd-shim": "^2.1.0", "compare-versions": "3.5.1", @@ -846,4 +848,4 @@ "yargs": "^15.4.1", "zlib": "^1.0.5" } -} +} \ No newline at end of file diff --git a/packages/elastic-eslint-config-kibana/.eslintrc.js b/packages/elastic-eslint-config-kibana/.eslintrc.js index c0f8bf0ecb508..2e978c543cc69 100644 --- a/packages/elastic-eslint-config-kibana/.eslintrc.js +++ b/packages/elastic-eslint-config-kibana/.eslintrc.js @@ -65,6 +65,11 @@ module.exports = { to: false, disallowedMessage: `Don't import monaco directly, use or add exports to @kbn/monaco` }, + { + from: 'tinymath', + to: '@kbn/tinymath', + disallowedMessage: `Don't use 'tinymath', use '@kbn/tinymath'` + }, ], ], }, diff --git a/packages/kbn-config/src/config_service.mock.ts b/packages/kbn-config/src/config_service.mock.ts index cd6f399ddcce2..59f788767004c 100644 --- a/packages/kbn-config/src/config_service.mock.ts +++ b/packages/kbn-config/src/config_service.mock.ts @@ -17,8 +17,8 @@ const createConfigServiceMock = ({ }: { atPath?: Record; getConfig$?: Record } = {}) => { const mocked: jest.Mocked = { atPath: jest.fn(), + atPathSync: jest.fn(), getConfig$: jest.fn(), - optionalAtPath: jest.fn(), getUsedPaths: jest.fn(), getUnusedPaths: jest.fn(), isEnabledAtPath: jest.fn(), @@ -27,6 +27,7 @@ const createConfigServiceMock = ({ validate: jest.fn(), }; mocked.atPath.mockReturnValue(new BehaviorSubject(atPath)); + mocked.atPathSync.mockReturnValue(atPath); mocked.getConfig$.mockReturnValue(new BehaviorSubject(new ObjectToConfigAdapter(getConfig$))); mocked.getUsedPaths.mockResolvedValue([]); mocked.getUnusedPaths.mockResolvedValue([]); diff --git a/packages/kbn-config/src/config_service.test.ts b/packages/kbn-config/src/config_service.test.ts index 96d1f794a691c..e55916d7d348c 100644 --- a/packages/kbn-config/src/config_service.test.ts +++ b/packages/kbn-config/src/config_service.test.ts @@ -105,27 +105,6 @@ test('re-validate config when updated', async () => { `); }); -test("returns undefined if fetching optional config at a path that doesn't exist", async () => { - const rawConfig = getRawConfigProvider({}); - const configService = new ConfigService(rawConfig, defaultEnv, logger); - - const value$ = configService.optionalAtPath('unique-name'); - const value = await value$.pipe(first()).toPromise(); - - expect(value).toBeUndefined(); -}); - -test('returns observable config at optional path if it exists', async () => { - const rawConfig = getRawConfigProvider({ value: 'bar' }); - const configService = new ConfigService(rawConfig, defaultEnv, logger); - await configService.setSchema('value', schema.string()); - - const value$ = configService.optionalAtPath('value'); - const value: any = await value$.pipe(first()).toPromise(); - - expect(value).toBe('bar'); -}); - test("does not push new configs when reloading if config at path hasn't changed", async () => { const rawConfig$ = new BehaviorSubject>({ key: 'value' }); const rawConfigProvider = rawConfigServiceMock.create({ rawConfig$ }); @@ -209,34 +188,38 @@ test('flags schema paths as handled when registering a schema', async () => { test('tracks unhandled paths', async () => { const initialConfig = { - bar: { - deep1: { - key: '123', - }, - deep2: { - key: '321', - }, + service: { + string: 'str', + number: 42, }, - foo: 'value', - quux: { - deep1: { - key: 'hello', - }, - deep2: { - key: 'world', - }, + plugin: { + foo: 'bar', + }, + unknown: { + hello: 'dolly', + number: 9000, }, }; const rawConfigProvider = rawConfigServiceMock.create({ rawConfig: initialConfig }); const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); - - configService.atPath('foo'); - configService.atPath(['bar', 'deep2']); + await configService.setSchema( + 'service', + schema.object({ + string: schema.string(), + number: schema.number(), + }) + ); + await configService.setSchema( + 'plugin', + schema.object({ + foo: schema.string(), + }) + ); const unused = await configService.getUnusedPaths(); - expect(unused).toEqual(['bar.deep1.key', 'quux.deep1.key', 'quux.deep2.key']); + expect(unused).toEqual(['unknown.hello', 'unknown.number']); }); test('correctly passes context', async () => { @@ -339,22 +322,18 @@ test('does not throw if schema does not define "enabled" schema', async () => { const rawConfigProvider = rawConfigServiceMock.create({ rawConfig: initialConfig }); const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); - await expect( + expect( configService.setSchema( 'pid', schema.object({ file: schema.string(), }) ) - ).resolves.toBeUndefined(); + ).toBeUndefined(); const value$ = configService.atPath('pid'); const value: any = await value$.pipe(first()).toPromise(); expect(value.enabled).toBe(undefined); - - const valueOptional$ = configService.optionalAtPath('pid'); - const valueOptional: any = await valueOptional$.pipe(first()).toPromise(); - expect(valueOptional.enabled).toBe(undefined); }); test('treats config as enabled if config path is not present in config', async () => { @@ -457,3 +436,44 @@ test('logs deprecation warning during validation', async () => { ] `); }); + +describe('atPathSync', () => { + test('returns the value at path', async () => { + const rawConfig = getRawConfigProvider({ key: 'foo' }); + const configService = new ConfigService(rawConfig, defaultEnv, logger); + const stringSchema = schema.string(); + await configService.setSchema('key', stringSchema); + + await configService.validate(); + + const value = configService.atPathSync('key'); + expect(value).toBe('foo'); + }); + + test('throws if called before `validate`', async () => { + const rawConfig = getRawConfigProvider({ key: 'foo' }); + const configService = new ConfigService(rawConfig, defaultEnv, logger); + const stringSchema = schema.string(); + await configService.setSchema('key', stringSchema); + + expect(() => configService.atPathSync('key')).toThrowErrorMatchingInlineSnapshot( + `"\`atPathSync\` called before config was validated"` + ); + }); + + test('returns the last config value', async () => { + const rawConfig$ = new BehaviorSubject>({ key: 'value' }); + const rawConfigProvider = rawConfigServiceMock.create({ rawConfig$ }); + + const configService = new ConfigService(rawConfigProvider, defaultEnv, logger); + await configService.setSchema('key', schema.string()); + + await configService.validate(); + + expect(configService.atPathSync('key')).toEqual('value'); + + rawConfig$.next({ key: 'new-value' }); + + expect(configService.atPathSync('key')).toEqual('new-value'); + }); +}); diff --git a/packages/kbn-config/src/config_service.ts b/packages/kbn-config/src/config_service.ts index 9518279f35766..929735ffc15f2 100644 --- a/packages/kbn-config/src/config_service.ts +++ b/packages/kbn-config/src/config_service.ts @@ -10,7 +10,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { Type } from '@kbn/config-schema'; import { isEqual } from 'lodash'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; -import { distinctUntilChanged, first, map, shareReplay, take } from 'rxjs/operators'; +import { distinctUntilChanged, first, map, shareReplay, take, tap } from 'rxjs/operators'; import { Logger, LoggerFactory } from '@kbn/logging'; import { Config, ConfigPath, Env } from '.'; @@ -32,13 +32,15 @@ export class ConfigService { private readonly log: Logger; private readonly deprecationLog: Logger; + private validated = false; private readonly config$: Observable; + private lastConfig?: Config; /** * Whenever a config if read at a path, we mark that path as 'handled'. We can * then list all unhandled config paths when the startup process is completed. */ - private readonly handledPaths: ConfigPath[] = []; + private readonly handledPaths: Set = new Set(); private readonly schemas = new Map>(); private readonly deprecations = new BehaviorSubject([]); @@ -55,6 +57,9 @@ export class ConfigService { const migrated = applyDeprecations(rawConfig, deprecations); return new LegacyObjectToConfigAdapter(migrated); }), + tap((config) => { + this.lastConfig = config; + }), shareReplay(1) ); } @@ -62,7 +67,7 @@ export class ConfigService { /** * Set config schema for a path and performs its validation */ - public async setSchema(path: ConfigPath, schema: Type) { + public setSchema(path: ConfigPath, schema: Type) { const namespace = pathToString(path); if (this.schemas.has(namespace)) { throw new Error(`Validation schema for [${path}] was already registered.`); @@ -94,15 +99,16 @@ export class ConfigService { public async validate() { const namespaces = [...this.schemas.keys()]; for (let i = 0; i < namespaces.length; i++) { - await this.validateConfigAtPath(namespaces[i]).pipe(first()).toPromise(); + await this.getValidatedConfigAtPath$(namespaces[i]).pipe(first()).toPromise(); } await this.logDeprecation(); + this.validated = true; } /** * Returns the full config object observable. This is not intended for - * "normal use", but for features that _need_ access to the full object. + * "normal use", but for internal features that _need_ access to the full object. */ public getConfig$() { return this.config$; @@ -110,27 +116,26 @@ export class ConfigService { /** * Reads the subset of the config at the specified `path` and validates it - * against the static `schema` on the given `ConfigClass`. + * against its registered schema. * * @param path - The path to the desired subset of the config. */ public atPath(path: ConfigPath) { - return this.validateConfigAtPath(path) as Observable; + return this.getValidatedConfigAtPath$(path) as Observable; } /** - * Same as `atPath`, but returns `undefined` if there is no config at the - * specified path. + * Similar to {@link atPath}, but return the last emitted value synchronously instead of an + * observable. * - * {@link ConfigService.atPath} + * @param path - The path to the desired subset of the config. */ - public optionalAtPath(path: ConfigPath) { - return this.getDistinctConfig(path).pipe( - map((config) => { - if (config === undefined) return undefined; - return this.validateAtPath(path, config) as TSchema; - }) - ); + public atPathSync(path: ConfigPath) { + if (!this.validated) { + throw new Error('`atPathSync` called before config was validated'); + } + const configAtPath = this.lastConfig!.get(path); + return this.validateAtPath(path, configAtPath) as TSchema; } public async isEnabledAtPath(path: ConfigPath) { @@ -144,10 +149,7 @@ export class ConfigService { const config = await this.config$.pipe(first()).toPromise(); // if plugin hasn't got a config schema, we try to read "enabled" directly - const isEnabled = - validatedConfig && validatedConfig.enabled !== undefined - ? validatedConfig.enabled - : config.get(enabledPath); + const isEnabled = validatedConfig?.enabled ?? config.get(enabledPath); // not declared. consider that plugin is enabled by default if (isEnabled === undefined) { @@ -170,15 +172,13 @@ export class ConfigService { public async getUnusedPaths() { const config = await this.config$.pipe(first()).toPromise(); - const handledPaths = this.handledPaths.map(pathToString); - + const handledPaths = [...this.handledPaths.values()].map(pathToString); return config.getFlattenedPaths().filter((path) => !isPathHandled(path, handledPaths)); } public async getUsedPaths() { const config = await this.config$.pipe(first()).toPromise(); - const handledPaths = this.handledPaths.map(pathToString); - + const handledPaths = [...this.handledPaths.values()].map(pathToString); return config.getFlattenedPaths().filter((path) => isPathHandled(path, handledPaths)); } @@ -210,22 +210,17 @@ export class ConfigService { ); } - private validateConfigAtPath(path: ConfigPath) { - return this.getDistinctConfig(path).pipe(map((config) => this.validateAtPath(path, config))); - } - - private getDistinctConfig(path: ConfigPath) { - this.markAsHandled(path); - + private getValidatedConfigAtPath$(path: ConfigPath) { return this.config$.pipe( map((config) => config.get(path)), - distinctUntilChanged(isEqual) + distinctUntilChanged(isEqual), + map((config) => this.validateAtPath(path, config)) ); } private markAsHandled(path: ConfigPath) { this.log.debug(`Marking config path as handled: ${path}`); - this.handledPaths.push(path); + this.handledPaths.add(path); } } diff --git a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts index c037c5f0308c8..c12a147fddddc 100644 --- a/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts +++ b/packages/kbn-config/src/legacy/legacy_object_to_config_adapter.ts @@ -84,7 +84,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { }; } - private static transformPlugins(configValue: LegacyVars) { + private static transformPlugins(configValue: LegacyVars = {}) { // These properties are the only ones we use from the existing `plugins` config node // since `scanDirs` isn't respected by new platform plugin discovery. return { diff --git a/packages/kbn-es/package.json b/packages/kbn-es/package.json index c8e25a95594c6..8ea83d744bcb9 100644 --- a/packages/kbn-es/package.json +++ b/packages/kbn-es/package.json @@ -8,6 +8,7 @@ "devOnly": true }, "scripts": { + "build": "node scripts/build", "kbn:bootstrap": "node scripts/build", "kbn:watch": "node scripts/build --watch" }, diff --git a/packages/kbn-es/src/cli_commands/snapshot.js b/packages/kbn-es/src/cli_commands/snapshot.js index d66c352a356aa..711992a5895ed 100644 --- a/packages/kbn-es/src/cli_commands/snapshot.js +++ b/packages/kbn-es/src/cli_commands/snapshot.js @@ -62,8 +62,6 @@ exports.run = async (defaults = {}) => { await cluster.extractDataDirectory(installPath, options.dataArchive); } - options.bundledJDK = true; - await cluster.run(installPath, options); } }; diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 60f8a327594d6..f554dd8a1b8e5 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 1e9e19f4533af..80ff4eb6f83b0 100644 --- a/packages/kbn-es/src/install/archive.js +++ b/packages/kbn-es/src/install/archive.js @@ -34,7 +34,6 @@ exports.installArchive = async function installArchive(archive, options = {}) { basePath = BASE_PATH, installPath = path.resolve(basePath, path.basename(archive, '.tar.gz')), log = defaultLog, - bundledJDK = false, esArgs = [], } = options; @@ -64,7 +63,7 @@ exports.installArchive = async function installArchive(archive, options = {}) { await appendToConfig(installPath, 'xpack.security.enabled', 'true'); await appendToConfig(installPath, 'xpack.license.self_generated.type', license); - await configureKeystore(installPath, log, bundledJDK, [ + await configureKeystore(installPath, log, [ ['bootstrap.password', password], ...parseSettings(esArgs, { filter: SettingsFilter.SecureOnly }), ]); @@ -89,20 +88,11 @@ async function appendToConfig(installPath, key, value) { * * @param {String} installPath * @param {ToolingLog} log - * @param {boolean} bundledJDK * @param {Array<[string, string]>} secureSettings List of custom Elasticsearch secure settings to * add into the keystore. */ -async function configureKeystore( - installPath, - log = defaultLog, - bundledJDK = false, - secureSettings -) { - const env = {}; - if (bundledJDK) { - env.JAVA_HOME = ''; - } +async function configureKeystore(installPath, log = defaultLog, secureSettings) { + const env = { JAVA_HOME: '' }; await execa(ES_KEYSTORE_BIN, ['create'], { cwd: installPath, env }); for (const [secureSettingName, secureSettingValue] of secureSettings) { diff --git a/packages/kbn-es/src/install/snapshot.js b/packages/kbn-es/src/install/snapshot.js index 55c0e41ea9640..b9562f20d81b7 100644 --- a/packages/kbn-es/src/install/snapshot.js +++ b/packages/kbn-es/src/install/snapshot.js @@ -61,7 +61,6 @@ exports.installSnapshot = async function installSnapshot({ basePath = BASE_PATH, installPath = path.resolve(basePath, version), log = defaultLog, - bundledJDK = true, esArgs, }) { const { downloadPath } = await exports.downloadSnapshot({ @@ -78,7 +77,6 @@ exports.installSnapshot = async function installSnapshot({ basePath, installPath, log, - bundledJDK, esArgs, }); }; diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 1a4fb390d0c17..a13976d148738 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 96356e01f8c04..089ff163a692d 100644 --- a/packages/kbn-optimizer/src/worker/webpack.config.ts +++ b/packages/kbn-optimizer/src/worker/webpack.config.ts @@ -216,7 +216,6 @@ export function getWebpackConfig(bundle: Bundle, bundleRefs: BundleRefs, worker: extensions: ['.js', '.ts', '.tsx', '.json'], mainFields: ['browser', 'main'], alias: { - tinymath: require.resolve('tinymath/lib/tinymath.es5.js'), core_app_image_assets: Path.resolve(worker.repoRoot, 'src/core/public/core_app/images'), }, }, diff --git a/packages/kbn-pm/README.md b/packages/kbn-pm/README.md index c169b5c75e178..eb1ac6ffa92aa 100644 --- a/packages/kbn-pm/README.md +++ b/packages/kbn-pm/README.md @@ -150,14 +150,14 @@ e.g. `build` or `test`. Instead of jumping into each package and running `yarn build` you can run: ``` -yarn kbn run build +yarn kbn run build --skip-missing ``` And if needed, you can skip packages in the same way as for bootstrapping, e.g. with `--exclude` and `--skip-kibana-plugins`: ``` -yarn kbn run build --exclude kibana +yarn kbn run build --exclude kibana --skip-missing ``` ### Watching diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 09995a9be30a6..df04965cd8c32 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -94,7 +94,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _cli__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "run", function() { return _cli__WEBPACK_IMPORTED_MODULE_0__["run"]; }); -/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(510); +/* harmony import */ var _production__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(512); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return _production__WEBPACK_IMPORTED_MODULE_1__["buildProductionProjects"]; }); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(248); @@ -106,7 +106,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(251); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "transformDependencies", function() { return _utils_package_json__WEBPACK_IMPORTED_MODULE_4__["transformDependencies"]; }); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(509); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(511); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getProjectPaths", function() { return _config__WEBPACK_IMPORTED_MODULE_5__["getProjectPaths"]; }); /* @@ -139,7 +139,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5); /* harmony import */ var _kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3__); /* harmony import */ var _commands__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(128); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(503); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(505); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(246); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one @@ -179,12 +179,15 @@ function help() { --debug Set log level to debug --quiet Set log level to error --silent Disable log output + + "run" options: + --skip-missing Ignore packages which don't have the requested script ` + '\n'); } async function run(argv) { _utils_log__WEBPACK_IMPORTED_MODULE_6__["log"].setLogLevel(Object(_kbn_dev_utils_tooling_log__WEBPACK_IMPORTED_MODULE_3__["pickLevelFromFlags"])(getopts__WEBPACK_IMPORTED_MODULE_1___default()(argv, { - boolean: ['verbose', 'debug', 'quiet', 'silent'] + boolean: ['verbose', 'debug', 'quiet', 'silent', 'skip-missing'] }))); // We can simplify this setup (and remove this extra handling) once Yarn // starts forwarding the `--` directly to this script, see // https://github.com/yarnpkg/yarn/blob/b2d3e1a8fe45ef376b716d597cc79b38702a9320/src/cli/index.js#L174-L182 @@ -8824,9 +8827,9 @@ exports.ToolingLogCollectingWriter = ToolingLogCollectingWriter; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "commands", function() { return commands; }); /* harmony import */ var _bootstrap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(129); -/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(371); -/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(402); -/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(403); +/* harmony import */ var _clean__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(373); +/* harmony import */ var _run__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(404); +/* harmony import */ var _watch__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(405); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -8862,6 +8865,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_bootstrap_cache_file__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(368); /* harmony import */ var _utils_yarn_lock__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(365); /* harmony import */ var _utils_validate_dependencies__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(369); +/* harmony import */ var _utils_bazel__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(371); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -8878,19 +8882,23 @@ __webpack_require__.r(__webpack_exports__); + const BootstrapCommand = { description: 'Install dependencies and crosslink projects', name: 'bootstrap', async run(projects, projectGraph, { options, - kbn + kbn, + rootPath }) { var _projects$get; const batchedProjects = Object(_utils_projects__WEBPACK_IMPORTED_MODULE_4__["topologicallyBatchProjects"])(projects, projectGraph); const kibanaProjectPath = (_projects$get = projects.get('kibana')) === null || _projects$get === void 0 ? void 0 : _projects$get.path; - const extraArgs = [...(options['frozen-lockfile'] === true ? ['--frozen-lockfile'] : []), ...(options['prefer-offline'] === true ? ['--prefer-offline'] : [])]; + const extraArgs = [...(options['frozen-lockfile'] === true ? ['--frozen-lockfile'] : []), ...(options['prefer-offline'] === true ? ['--prefer-offline'] : [])]; // Install bazel machinery tools if needed + + await Object(_utils_bazel__WEBPACK_IMPORTED_MODULE_9__["installBazelTools"])(rootPath); // Install monorepo npm dependencies for (const batch of batchedProjects) { for (const project of batch) { @@ -47932,12 +47940,90 @@ function addProjectToTree(tree, pathParts, project) { /* 371 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony import */ var _install_tools__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(372); +/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return _install_tools__WEBPACK_IMPORTED_MODULE_0__["installBazelTools"]; }); + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + + +/***/ }), +/* 372 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "installBazelTools", function() { return installBazelTools; }); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4); +/* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); +/* harmony import */ var _child_process__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(319); +/* harmony import */ var _fs__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(131); +/* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(246); +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + + + + + +async function readBazelToolsVersionFile(repoRootPath, versionFilename) { + const version = (await Object(_fs__WEBPACK_IMPORTED_MODULE_2__["readFile"])(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(repoRootPath, versionFilename))).toString().split('\n')[0]; + + if (!version) { + throw new Error(`[bazel_tools] Failed on reading bazel tools versions\n ${versionFilename} file do not contain any version set`); + } + + return version; +} + +async function installBazelTools(repoRootPath) { + _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] reading bazel tools versions from version files`); + const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); + const bazelVersion = await readBazelToolsVersionFile(repoRootPath, '.bazelversion'); // Check what globals are installed + + _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] verify if bazelisk is installed`); + const { + stdout + } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_1__["spawn"])('yarn', ['global', 'list'], { + stdio: 'pipe' + }); // Install bazelisk if not installed + + if (!stdout.includes(`@bazel/bazelisk@${bazeliskVersion}`)) { + _log__WEBPACK_IMPORTED_MODULE_3__["log"].info(`[bazel_tools] installing Bazel tools`); + _log__WEBPACK_IMPORTED_MODULE_3__["log"].debug(`[bazel_tools] bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}`); + await Object(_child_process__WEBPACK_IMPORTED_MODULE_1__["spawn"])('yarn', ['global', 'add', `@bazel/bazelisk@${bazeliskVersion}`], { + env: { + USE_BAZEL_VERSION: bazelVersion + }, + stdio: 'pipe' + }); + } + + _log__WEBPACK_IMPORTED_MODULE_3__["log"].success(`[bazel_tools] all bazel tools are correctly installed`); +} + +/***/ }), +/* 373 */ +/***/ (function(module, __webpack_exports__, __webpack_require__) { + "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CleanCommand", function() { return CleanCommand; }); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(372); +/* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(374); /* harmony import */ var ora__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(ora__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); @@ -48026,20 +48112,20 @@ const CleanCommand = { }; /***/ }), -/* 372 */ +/* 374 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readline = __webpack_require__(373); -const chalk = __webpack_require__(374); -const cliCursor = __webpack_require__(381); -const cliSpinners = __webpack_require__(383); -const logSymbols = __webpack_require__(385); -const stripAnsi = __webpack_require__(394); -const wcwidth = __webpack_require__(396); -const isInteractive = __webpack_require__(400); -const MuteStream = __webpack_require__(401); +const readline = __webpack_require__(375); +const chalk = __webpack_require__(376); +const cliCursor = __webpack_require__(383); +const cliSpinners = __webpack_require__(385); +const logSymbols = __webpack_require__(387); +const stripAnsi = __webpack_require__(396); +const wcwidth = __webpack_require__(398); +const isInteractive = __webpack_require__(402); +const MuteStream = __webpack_require__(403); const TEXT = Symbol('text'); const PREFIX_TEXT = Symbol('prefixText'); @@ -48392,23 +48478,23 @@ module.exports.promise = (action, options) => { /***/ }), -/* 373 */ +/* 375 */ /***/ (function(module, exports) { module.exports = require("readline"); /***/ }), -/* 374 */ +/* 376 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiStyles = __webpack_require__(375); +const ansiStyles = __webpack_require__(377); const {stdout: stdoutColor, stderr: stderrColor} = __webpack_require__(120); const { stringReplaceAll, stringEncaseCRLFWithFirstIndex -} = __webpack_require__(379); +} = __webpack_require__(381); // `supportsColor.level` → `ansiStyles.color[name]` mapping const levelMapping = [ @@ -48609,7 +48695,7 @@ const chalkTag = (chalk, ...strings) => { } if (template === undefined) { - template = __webpack_require__(380); + template = __webpack_require__(382); } return template(chalk, parts.join('')); @@ -48638,7 +48724,7 @@ module.exports = chalk; /***/ }), -/* 375 */ +/* 377 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -48684,7 +48770,7 @@ const setLazyProperty = (object, property, get) => { let colorConvert; const makeDynamicStyles = (wrap, targetSpace, identity, isBackground) => { if (colorConvert === undefined) { - colorConvert = __webpack_require__(376); + colorConvert = __webpack_require__(378); } const offset = isBackground ? 10 : 0; @@ -48809,11 +48895,11 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(115)(module))) /***/ }), -/* 376 */ +/* 378 */ /***/ (function(module, exports, __webpack_require__) { -const conversions = __webpack_require__(377); -const route = __webpack_require__(378); +const conversions = __webpack_require__(379); +const route = __webpack_require__(380); const convert = {}; @@ -48896,7 +48982,7 @@ module.exports = convert; /***/ }), -/* 377 */ +/* 379 */ /***/ (function(module, exports, __webpack_require__) { /* MIT license */ @@ -49741,10 +49827,10 @@ convert.rgb.gray = function (rgb) { /***/ }), -/* 378 */ +/* 380 */ /***/ (function(module, exports, __webpack_require__) { -const conversions = __webpack_require__(377); +const conversions = __webpack_require__(379); /* This function routes a model to all other models. @@ -49844,7 +49930,7 @@ module.exports = function (fromModel) { /***/ }), -/* 379 */ +/* 381 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -49890,7 +49976,7 @@ module.exports = { /***/ }), -/* 380 */ +/* 382 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -50031,12 +50117,12 @@ module.exports = (chalk, temporary) => { /***/ }), -/* 381 */ +/* 383 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const restoreCursor = __webpack_require__(382); +const restoreCursor = __webpack_require__(384); let isHidden = false; @@ -50073,7 +50159,7 @@ exports.toggle = (force, writableStream) => { /***/ }), -/* 382 */ +/* 384 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -50089,13 +50175,13 @@ module.exports = onetime(() => { /***/ }), -/* 383 */ +/* 385 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const spinners = Object.assign({}, __webpack_require__(384)); +const spinners = Object.assign({}, __webpack_require__(386)); const spinnersList = Object.keys(spinners); @@ -50113,18 +50199,18 @@ module.exports.default = spinners; /***/ }), -/* 384 */ +/* 386 */ /***/ (function(module) { module.exports = JSON.parse("{\"dots\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠹\",\"⠸\",\"⠼\",\"⠴\",\"⠦\",\"⠧\",\"⠇\",\"⠏\"]},\"dots2\":{\"interval\":80,\"frames\":[\"⣾\",\"⣽\",\"⣻\",\"⢿\",\"⡿\",\"⣟\",\"⣯\",\"⣷\"]},\"dots3\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠞\",\"⠖\",\"⠦\",\"⠴\",\"⠲\",\"⠳\",\"⠓\"]},\"dots4\":{\"interval\":80,\"frames\":[\"⠄\",\"⠆\",\"⠇\",\"⠋\",\"⠙\",\"⠸\",\"⠰\",\"⠠\",\"⠰\",\"⠸\",\"⠙\",\"⠋\",\"⠇\",\"⠆\"]},\"dots5\":{\"interval\":80,\"frames\":[\"⠋\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\"]},\"dots6\":{\"interval\":80,\"frames\":[\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠴\",\"⠲\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠚\",\"⠙\",\"⠉\",\"⠁\"]},\"dots7\":{\"interval\":80,\"frames\":[\"⠈\",\"⠉\",\"⠋\",\"⠓\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠖\",\"⠦\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\"]},\"dots8\":{\"interval\":80,\"frames\":[\"⠁\",\"⠁\",\"⠉\",\"⠙\",\"⠚\",\"⠒\",\"⠂\",\"⠂\",\"⠒\",\"⠲\",\"⠴\",\"⠤\",\"⠄\",\"⠄\",\"⠤\",\"⠠\",\"⠠\",\"⠤\",\"⠦\",\"⠖\",\"⠒\",\"⠐\",\"⠐\",\"⠒\",\"⠓\",\"⠋\",\"⠉\",\"⠈\",\"⠈\"]},\"dots9\":{\"interval\":80,\"frames\":[\"⢹\",\"⢺\",\"⢼\",\"⣸\",\"⣇\",\"⡧\",\"⡗\",\"⡏\"]},\"dots10\":{\"interval\":80,\"frames\":[\"⢄\",\"⢂\",\"⢁\",\"⡁\",\"⡈\",\"⡐\",\"⡠\"]},\"dots11\":{\"interval\":100,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⡀\",\"⢀\",\"⠠\",\"⠐\",\"⠈\"]},\"dots12\":{\"interval\":80,\"frames\":[\"⢀⠀\",\"⡀⠀\",\"⠄⠀\",\"⢂⠀\",\"⡂⠀\",\"⠅⠀\",\"⢃⠀\",\"⡃⠀\",\"⠍⠀\",\"⢋⠀\",\"⡋⠀\",\"⠍⠁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⢈⠩\",\"⡀⢙\",\"⠄⡙\",\"⢂⠩\",\"⡂⢘\",\"⠅⡘\",\"⢃⠨\",\"⡃⢐\",\"⠍⡐\",\"⢋⠠\",\"⡋⢀\",\"⠍⡁\",\"⢋⠁\",\"⡋⠁\",\"⠍⠉\",\"⠋⠉\",\"⠋⠉\",\"⠉⠙\",\"⠉⠙\",\"⠉⠩\",\"⠈⢙\",\"⠈⡙\",\"⠈⠩\",\"⠀⢙\",\"⠀⡙\",\"⠀⠩\",\"⠀⢘\",\"⠀⡘\",\"⠀⠨\",\"⠀⢐\",\"⠀⡐\",\"⠀⠠\",\"⠀⢀\",\"⠀⡀\"]},\"dots8Bit\":{\"interval\":80,\"frames\":[\"⠀\",\"⠁\",\"⠂\",\"⠃\",\"⠄\",\"⠅\",\"⠆\",\"⠇\",\"⡀\",\"⡁\",\"⡂\",\"⡃\",\"⡄\",\"⡅\",\"⡆\",\"⡇\",\"⠈\",\"⠉\",\"⠊\",\"⠋\",\"⠌\",\"⠍\",\"⠎\",\"⠏\",\"⡈\",\"⡉\",\"⡊\",\"⡋\",\"⡌\",\"⡍\",\"⡎\",\"⡏\",\"⠐\",\"⠑\",\"⠒\",\"⠓\",\"⠔\",\"⠕\",\"⠖\",\"⠗\",\"⡐\",\"⡑\",\"⡒\",\"⡓\",\"⡔\",\"⡕\",\"⡖\",\"⡗\",\"⠘\",\"⠙\",\"⠚\",\"⠛\",\"⠜\",\"⠝\",\"⠞\",\"⠟\",\"⡘\",\"⡙\",\"⡚\",\"⡛\",\"⡜\",\"⡝\",\"⡞\",\"⡟\",\"⠠\",\"⠡\",\"⠢\",\"⠣\",\"⠤\",\"⠥\",\"⠦\",\"⠧\",\"⡠\",\"⡡\",\"⡢\",\"⡣\",\"⡤\",\"⡥\",\"⡦\",\"⡧\",\"⠨\",\"⠩\",\"⠪\",\"⠫\",\"⠬\",\"⠭\",\"⠮\",\"⠯\",\"⡨\",\"⡩\",\"⡪\",\"⡫\",\"⡬\",\"⡭\",\"⡮\",\"⡯\",\"⠰\",\"⠱\",\"⠲\",\"⠳\",\"⠴\",\"⠵\",\"⠶\",\"⠷\",\"⡰\",\"⡱\",\"⡲\",\"⡳\",\"⡴\",\"⡵\",\"⡶\",\"⡷\",\"⠸\",\"⠹\",\"⠺\",\"⠻\",\"⠼\",\"⠽\",\"⠾\",\"⠿\",\"⡸\",\"⡹\",\"⡺\",\"⡻\",\"⡼\",\"⡽\",\"⡾\",\"⡿\",\"⢀\",\"⢁\",\"⢂\",\"⢃\",\"⢄\",\"⢅\",\"⢆\",\"⢇\",\"⣀\",\"⣁\",\"⣂\",\"⣃\",\"⣄\",\"⣅\",\"⣆\",\"⣇\",\"⢈\",\"⢉\",\"⢊\",\"⢋\",\"⢌\",\"⢍\",\"⢎\",\"⢏\",\"⣈\",\"⣉\",\"⣊\",\"⣋\",\"⣌\",\"⣍\",\"⣎\",\"⣏\",\"⢐\",\"⢑\",\"⢒\",\"⢓\",\"⢔\",\"⢕\",\"⢖\",\"⢗\",\"⣐\",\"⣑\",\"⣒\",\"⣓\",\"⣔\",\"⣕\",\"⣖\",\"⣗\",\"⢘\",\"⢙\",\"⢚\",\"⢛\",\"⢜\",\"⢝\",\"⢞\",\"⢟\",\"⣘\",\"⣙\",\"⣚\",\"⣛\",\"⣜\",\"⣝\",\"⣞\",\"⣟\",\"⢠\",\"⢡\",\"⢢\",\"⢣\",\"⢤\",\"⢥\",\"⢦\",\"⢧\",\"⣠\",\"⣡\",\"⣢\",\"⣣\",\"⣤\",\"⣥\",\"⣦\",\"⣧\",\"⢨\",\"⢩\",\"⢪\",\"⢫\",\"⢬\",\"⢭\",\"⢮\",\"⢯\",\"⣨\",\"⣩\",\"⣪\",\"⣫\",\"⣬\",\"⣭\",\"⣮\",\"⣯\",\"⢰\",\"⢱\",\"⢲\",\"⢳\",\"⢴\",\"⢵\",\"⢶\",\"⢷\",\"⣰\",\"⣱\",\"⣲\",\"⣳\",\"⣴\",\"⣵\",\"⣶\",\"⣷\",\"⢸\",\"⢹\",\"⢺\",\"⢻\",\"⢼\",\"⢽\",\"⢾\",\"⢿\",\"⣸\",\"⣹\",\"⣺\",\"⣻\",\"⣼\",\"⣽\",\"⣾\",\"⣿\"]},\"line\":{\"interval\":130,\"frames\":[\"-\",\"\\\\\",\"|\",\"/\"]},\"line2\":{\"interval\":100,\"frames\":[\"⠂\",\"-\",\"–\",\"—\",\"–\",\"-\"]},\"pipe\":{\"interval\":100,\"frames\":[\"┤\",\"┘\",\"┴\",\"└\",\"├\",\"┌\",\"┬\",\"┐\"]},\"simpleDots\":{\"interval\":400,\"frames\":[\". \",\".. \",\"...\",\" \"]},\"simpleDotsScrolling\":{\"interval\":200,\"frames\":[\". \",\".. \",\"...\",\" ..\",\" .\",\" \"]},\"star\":{\"interval\":70,\"frames\":[\"✶\",\"✸\",\"✹\",\"✺\",\"✹\",\"✷\"]},\"star2\":{\"interval\":80,\"frames\":[\"+\",\"x\",\"*\"]},\"flip\":{\"interval\":70,\"frames\":[\"_\",\"_\",\"_\",\"-\",\"`\",\"`\",\"'\",\"´\",\"-\",\"_\",\"_\",\"_\"]},\"hamburger\":{\"interval\":100,\"frames\":[\"☱\",\"☲\",\"☴\"]},\"growVertical\":{\"interval\":120,\"frames\":[\"▁\",\"▃\",\"▄\",\"▅\",\"▆\",\"▇\",\"▆\",\"▅\",\"▄\",\"▃\"]},\"growHorizontal\":{\"interval\":120,\"frames\":[\"▏\",\"▎\",\"▍\",\"▌\",\"▋\",\"▊\",\"▉\",\"▊\",\"▋\",\"▌\",\"▍\",\"▎\"]},\"balloon\":{\"interval\":140,\"frames\":[\" \",\".\",\"o\",\"O\",\"@\",\"*\",\" \"]},\"balloon2\":{\"interval\":120,\"frames\":[\".\",\"o\",\"O\",\"°\",\"O\",\"o\",\".\"]},\"noise\":{\"interval\":100,\"frames\":[\"▓\",\"▒\",\"░\"]},\"bounce\":{\"interval\":120,\"frames\":[\"⠁\",\"⠂\",\"⠄\",\"⠂\"]},\"boxBounce\":{\"interval\":120,\"frames\":[\"▖\",\"▘\",\"▝\",\"▗\"]},\"boxBounce2\":{\"interval\":100,\"frames\":[\"▌\",\"▀\",\"▐\",\"▄\"]},\"triangle\":{\"interval\":50,\"frames\":[\"◢\",\"◣\",\"◤\",\"◥\"]},\"arc\":{\"interval\":100,\"frames\":[\"◜\",\"◠\",\"◝\",\"◞\",\"◡\",\"◟\"]},\"circle\":{\"interval\":120,\"frames\":[\"◡\",\"⊙\",\"◠\"]},\"squareCorners\":{\"interval\":180,\"frames\":[\"◰\",\"◳\",\"◲\",\"◱\"]},\"circleQuarters\":{\"interval\":120,\"frames\":[\"◴\",\"◷\",\"◶\",\"◵\"]},\"circleHalves\":{\"interval\":50,\"frames\":[\"◐\",\"◓\",\"◑\",\"◒\"]},\"squish\":{\"interval\":100,\"frames\":[\"╫\",\"╪\"]},\"toggle\":{\"interval\":250,\"frames\":[\"⊶\",\"⊷\"]},\"toggle2\":{\"interval\":80,\"frames\":[\"▫\",\"▪\"]},\"toggle3\":{\"interval\":120,\"frames\":[\"□\",\"■\"]},\"toggle4\":{\"interval\":100,\"frames\":[\"■\",\"□\",\"▪\",\"▫\"]},\"toggle5\":{\"interval\":100,\"frames\":[\"▮\",\"▯\"]},\"toggle6\":{\"interval\":300,\"frames\":[\"ဝ\",\"၀\"]},\"toggle7\":{\"interval\":80,\"frames\":[\"⦾\",\"⦿\"]},\"toggle8\":{\"interval\":100,\"frames\":[\"◍\",\"◌\"]},\"toggle9\":{\"interval\":100,\"frames\":[\"◉\",\"◎\"]},\"toggle10\":{\"interval\":100,\"frames\":[\"㊂\",\"㊀\",\"㊁\"]},\"toggle11\":{\"interval\":50,\"frames\":[\"⧇\",\"⧆\"]},\"toggle12\":{\"interval\":120,\"frames\":[\"☗\",\"☖\"]},\"toggle13\":{\"interval\":80,\"frames\":[\"=\",\"*\",\"-\"]},\"arrow\":{\"interval\":100,\"frames\":[\"←\",\"↖\",\"↑\",\"↗\",\"→\",\"↘\",\"↓\",\"↙\"]},\"arrow2\":{\"interval\":80,\"frames\":[\"⬆️ \",\"↗️ \",\"➡️ \",\"↘️ \",\"⬇️ \",\"↙️ \",\"⬅️ \",\"↖️ \"]},\"arrow3\":{\"interval\":120,\"frames\":[\"▹▹▹▹▹\",\"▸▹▹▹▹\",\"▹▸▹▹▹\",\"▹▹▸▹▹\",\"▹▹▹▸▹\",\"▹▹▹▹▸\"]},\"bouncingBar\":{\"interval\":80,\"frames\":[\"[ ]\",\"[= ]\",\"[== ]\",\"[=== ]\",\"[ ===]\",\"[ ==]\",\"[ =]\",\"[ ]\",\"[ =]\",\"[ ==]\",\"[ ===]\",\"[====]\",\"[=== ]\",\"[== ]\",\"[= ]\"]},\"bouncingBall\":{\"interval\":80,\"frames\":[\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ●)\",\"( ● )\",\"( ● )\",\"( ● )\",\"( ● )\",\"(● )\"]},\"smiley\":{\"interval\":200,\"frames\":[\"😄 \",\"😝 \"]},\"monkey\":{\"interval\":300,\"frames\":[\"🙈 \",\"🙈 \",\"🙉 \",\"🙊 \"]},\"hearts\":{\"interval\":100,\"frames\":[\"💛 \",\"💙 \",\"💜 \",\"💚 \",\"❤️ \"]},\"clock\":{\"interval\":100,\"frames\":[\"🕛 \",\"🕐 \",\"🕑 \",\"🕒 \",\"🕓 \",\"🕔 \",\"🕕 \",\"🕖 \",\"🕗 \",\"🕘 \",\"🕙 \",\"🕚 \"]},\"earth\":{\"interval\":180,\"frames\":[\"🌍 \",\"🌎 \",\"🌏 \"]},\"material\":{\"interval\":17,\"frames\":[\"█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"███▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"████▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"███████▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"████████▁▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"██████████▁▁▁▁▁▁▁▁▁▁\",\"███████████▁▁▁▁▁▁▁▁▁\",\"█████████████▁▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁▁██████████████▁▁▁▁\",\"▁▁▁██████████████▁▁▁\",\"▁▁▁▁█████████████▁▁▁\",\"▁▁▁▁██████████████▁▁\",\"▁▁▁▁██████████████▁▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁██████████████▁\",\"▁▁▁▁▁▁██████████████\",\"▁▁▁▁▁▁██████████████\",\"▁▁▁▁▁▁▁█████████████\",\"▁▁▁▁▁▁▁█████████████\",\"▁▁▁▁▁▁▁▁████████████\",\"▁▁▁▁▁▁▁▁████████████\",\"▁▁▁▁▁▁▁▁▁███████████\",\"▁▁▁▁▁▁▁▁▁███████████\",\"▁▁▁▁▁▁▁▁▁▁██████████\",\"▁▁▁▁▁▁▁▁▁▁██████████\",\"▁▁▁▁▁▁▁▁▁▁▁▁████████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁██████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"█▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"███▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"████▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"█████▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"██████▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"████████▁▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"█████████▁▁▁▁▁▁▁▁▁▁▁\",\"███████████▁▁▁▁▁▁▁▁▁\",\"████████████▁▁▁▁▁▁▁▁\",\"████████████▁▁▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"██████████████▁▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁██████████████▁▁▁▁▁\",\"▁▁▁█████████████▁▁▁▁\",\"▁▁▁▁▁████████████▁▁▁\",\"▁▁▁▁▁████████████▁▁▁\",\"▁▁▁▁▁▁███████████▁▁▁\",\"▁▁▁▁▁▁▁▁█████████▁▁▁\",\"▁▁▁▁▁▁▁▁█████████▁▁▁\",\"▁▁▁▁▁▁▁▁▁█████████▁▁\",\"▁▁▁▁▁▁▁▁▁█████████▁▁\",\"▁▁▁▁▁▁▁▁▁▁█████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁████████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁███████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁███████▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁███████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁████\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁███\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁██\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁█\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\",\"▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁\"]},\"moon\":{\"interval\":80,\"frames\":[\"🌑 \",\"🌒 \",\"🌓 \",\"🌔 \",\"🌕 \",\"🌖 \",\"🌗 \",\"🌘 \"]},\"runner\":{\"interval\":140,\"frames\":[\"🚶 \",\"🏃 \"]},\"pong\":{\"interval\":80,\"frames\":[\"▐⠂ ▌\",\"▐⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂▌\",\"▐ ⠠▌\",\"▐ ⡀▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐ ⠠ ▌\",\"▐ ⠂ ▌\",\"▐ ⠈ ▌\",\"▐ ⠂ ▌\",\"▐ ⠠ ▌\",\"▐ ⡀ ▌\",\"▐⠠ ▌\"]},\"shark\":{\"interval\":120,\"frames\":[\"▐|\\\\____________▌\",\"▐_|\\\\___________▌\",\"▐__|\\\\__________▌\",\"▐___|\\\\_________▌\",\"▐____|\\\\________▌\",\"▐_____|\\\\_______▌\",\"▐______|\\\\______▌\",\"▐_______|\\\\_____▌\",\"▐________|\\\\____▌\",\"▐_________|\\\\___▌\",\"▐__________|\\\\__▌\",\"▐___________|\\\\_▌\",\"▐____________|\\\\▌\",\"▐____________/|▌\",\"▐___________/|_▌\",\"▐__________/|__▌\",\"▐_________/|___▌\",\"▐________/|____▌\",\"▐_______/|_____▌\",\"▐______/|______▌\",\"▐_____/|_______▌\",\"▐____/|________▌\",\"▐___/|_________▌\",\"▐__/|__________▌\",\"▐_/|___________▌\",\"▐/|____________▌\"]},\"dqpb\":{\"interval\":100,\"frames\":[\"d\",\"q\",\"p\",\"b\"]},\"weather\":{\"interval\":100,\"frames\":[\"☀️ \",\"☀️ \",\"☀️ \",\"🌤 \",\"⛅️ \",\"🌥 \",\"☁️ \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"🌧 \",\"🌨 \",\"⛈ \",\"🌨 \",\"🌧 \",\"🌨 \",\"☁️ \",\"🌥 \",\"⛅️ \",\"🌤 \",\"☀️ \",\"☀️ \"]},\"christmas\":{\"interval\":400,\"frames\":[\"🌲\",\"🎄\"]},\"grenade\":{\"interval\":80,\"frames\":[\"، \",\"′ \",\" ´ \",\" ‾ \",\" ⸌\",\" ⸊\",\" |\",\" ⁎\",\" ⁕\",\" ෴ \",\" ⁓\",\" \",\" \",\" \"]},\"point\":{\"interval\":125,\"frames\":[\"∙∙∙\",\"●∙∙\",\"∙●∙\",\"∙∙●\",\"∙∙∙\"]},\"layer\":{\"interval\":150,\"frames\":[\"-\",\"=\",\"≡\"]},\"betaWave\":{\"interval\":80,\"frames\":[\"ρββββββ\",\"βρβββββ\",\"ββρββββ\",\"βββρβββ\",\"ββββρββ\",\"βββββρβ\",\"ββββββρ\"]},\"aesthetic\":{\"interval\":80,\"frames\":[\"▰▱▱▱▱▱▱\",\"▰▰▱▱▱▱▱\",\"▰▰▰▱▱▱▱\",\"▰▰▰▰▱▱▱\",\"▰▰▰▰▰▱▱\",\"▰▰▰▰▰▰▱\",\"▰▰▰▰▰▰▰\",\"▰▱▱▱▱▱▱\"]}}"); /***/ }), -/* 385 */ +/* 387 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const chalk = __webpack_require__(386); +const chalk = __webpack_require__(388); const isSupported = process.platform !== 'win32' || process.env.CI || process.env.TERM === 'xterm-256color'; @@ -50146,16 +50232,16 @@ module.exports = isSupported ? main : fallbacks; /***/ }), -/* 386 */ +/* 388 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const escapeStringRegexp = __webpack_require__(265); -const ansiStyles = __webpack_require__(387); -const stdoutColor = __webpack_require__(392).stdout; +const ansiStyles = __webpack_require__(389); +const stdoutColor = __webpack_require__(394).stdout; -const template = __webpack_require__(393); +const template = __webpack_require__(395); const isSimpleWindowsTerm = process.platform === 'win32' && !(process.env.TERM || '').toLowerCase().startsWith('xterm'); @@ -50381,12 +50467,12 @@ module.exports.default = module.exports; // For TypeScript /***/ }), -/* 387 */ +/* 389 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; /* WEBPACK VAR INJECTION */(function(module) { -const colorConvert = __webpack_require__(388); +const colorConvert = __webpack_require__(390); const wrapAnsi16 = (fn, offset) => function () { const code = fn.apply(colorConvert, arguments); @@ -50554,11 +50640,11 @@ Object.defineProperty(module, 'exports', { /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(115)(module))) /***/ }), -/* 388 */ +/* 390 */ /***/ (function(module, exports, __webpack_require__) { -var conversions = __webpack_require__(389); -var route = __webpack_require__(391); +var conversions = __webpack_require__(391); +var route = __webpack_require__(393); var convert = {}; @@ -50638,11 +50724,11 @@ module.exports = convert; /***/ }), -/* 389 */ +/* 391 */ /***/ (function(module, exports, __webpack_require__) { /* MIT license */ -var cssKeywords = __webpack_require__(390); +var cssKeywords = __webpack_require__(392); // NOTE: conversions should only return primitive values (i.e. arrays, or // values that give correct `typeof` results). @@ -51512,7 +51598,7 @@ convert.rgb.gray = function (rgb) { /***/ }), -/* 390 */ +/* 392 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -51671,10 +51757,10 @@ module.exports = { /***/ }), -/* 391 */ +/* 393 */ /***/ (function(module, exports, __webpack_require__) { -var conversions = __webpack_require__(389); +var conversions = __webpack_require__(391); /* this function routes a model to all other models. @@ -51774,7 +51860,7 @@ module.exports = function (fromModel) { /***/ }), -/* 392 */ +/* 394 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -51912,7 +51998,7 @@ module.exports = { /***/ }), -/* 393 */ +/* 395 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52047,18 +52133,18 @@ module.exports = (chalk, tmp) => { /***/ }), -/* 394 */ +/* 396 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const ansiRegex = __webpack_require__(395); +const ansiRegex = __webpack_require__(397); module.exports = string => typeof string === 'string' ? string.replace(ansiRegex(), '') : string; /***/ }), -/* 395 */ +/* 397 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52075,14 +52161,14 @@ module.exports = ({onlyFirst = false} = {}) => { /***/ }), -/* 396 */ +/* 398 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var defaults = __webpack_require__(397) -var combining = __webpack_require__(399) +var defaults = __webpack_require__(399) +var combining = __webpack_require__(401) var DEFAULTS = { nul: 0, @@ -52181,10 +52267,10 @@ function bisearch(ucs) { /***/ }), -/* 397 */ +/* 399 */ /***/ (function(module, exports, __webpack_require__) { -var clone = __webpack_require__(398); +var clone = __webpack_require__(400); module.exports = function(options, defaults) { options = options || {}; @@ -52199,7 +52285,7 @@ module.exports = function(options, defaults) { }; /***/ }), -/* 398 */ +/* 400 */ /***/ (function(module, exports, __webpack_require__) { var clone = (function() { @@ -52371,7 +52457,7 @@ if ( true && module.exports) { /***/ }), -/* 399 */ +/* 401 */ /***/ (function(module, exports) { module.exports = [ @@ -52427,7 +52513,7 @@ module.exports = [ /***/ }), -/* 400 */ +/* 402 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -52443,7 +52529,7 @@ module.exports = ({stream = process.stdout} = {}) => { /***/ }), -/* 401 */ +/* 403 */ /***/ (function(module, exports, __webpack_require__) { var Stream = __webpack_require__(138) @@ -52594,7 +52680,7 @@ MuteStream.prototype.close = proxy('close') /***/ }), -/* 402 */ +/* 404 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52620,7 +52706,8 @@ const RunCommand = { name: 'run', async run(projects, projectGraph, { - extraArgs + extraArgs, + options }) { const batchedProjects = Object(_utils_projects__WEBPACK_IMPORTED_MODULE_3__["topologicallyBatchProjects"])(projects, projectGraph); @@ -52631,20 +52718,26 @@ const RunCommand = { const scriptName = extraArgs[0]; const scriptArgs = extraArgs.slice(1); await Object(_utils_parallelize__WEBPACK_IMPORTED_MODULE_2__["parallelizeBatches"])(batchedProjects, async project => { - if (project.hasScript(scriptName)) { - _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].info(`[${project.name}] running "${scriptName}" script`); - await project.runScriptStreaming(scriptName, { - args: scriptArgs - }); - _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].success(`[${project.name}] complete`); + if (!project.hasScript(scriptName)) { + if (!!options['skip-missing']) { + return; + } + + throw new _utils_errors__WEBPACK_IMPORTED_MODULE_0__["CliError"](`[${project.name}] no "${scriptName}" script defined. To skip packages without the "${scriptName}" script pass --skip-missing`); } + + _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].info(`[${project.name}] running "${scriptName}" script`); + await project.runScriptStreaming(scriptName, { + args: scriptArgs + }); + _utils_log__WEBPACK_IMPORTED_MODULE_1__["log"].success(`[${project.name}] complete`); }); } }; /***/ }), -/* 403 */ +/* 405 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -52654,7 +52747,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(246); /* harmony import */ var _utils_parallelize__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(247); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(248); -/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(404); +/* harmony import */ var _utils_watch__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(406); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -52729,14 +52822,14 @@ const WatchCommand = { }; /***/ }), -/* 404 */ +/* 406 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "waitUntilWatchIsReady", function() { return waitUntilWatchIsReady; }); /* harmony import */ var rxjs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8); -/* harmony import */ var rxjs_operators__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(405); +/* harmony import */ var rxjs_operators__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(407); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -52792,141 +52885,141 @@ function waitUntilWatchIsReady(stream, opts = {}) { } /***/ }), -/* 405 */ +/* 407 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(406); +/* harmony import */ var _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(408); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "audit", function() { return _internal_operators_audit__WEBPACK_IMPORTED_MODULE_0__["audit"]; }); -/* harmony import */ var _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(407); +/* harmony import */ var _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(409); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "auditTime", function() { return _internal_operators_auditTime__WEBPACK_IMPORTED_MODULE_1__["auditTime"]; }); -/* harmony import */ var _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(408); +/* harmony import */ var _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(410); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buffer", function() { return _internal_operators_buffer__WEBPACK_IMPORTED_MODULE_2__["buffer"]; }); -/* harmony import */ var _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(409); +/* harmony import */ var _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(411); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferCount", function() { return _internal_operators_bufferCount__WEBPACK_IMPORTED_MODULE_3__["bufferCount"]; }); -/* harmony import */ var _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(410); +/* harmony import */ var _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(412); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferTime", function() { return _internal_operators_bufferTime__WEBPACK_IMPORTED_MODULE_4__["bufferTime"]; }); -/* harmony import */ var _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(411); +/* harmony import */ var _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(413); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferToggle", function() { return _internal_operators_bufferToggle__WEBPACK_IMPORTED_MODULE_5__["bufferToggle"]; }); -/* harmony import */ var _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(412); +/* harmony import */ var _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(414); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "bufferWhen", function() { return _internal_operators_bufferWhen__WEBPACK_IMPORTED_MODULE_6__["bufferWhen"]; }); -/* harmony import */ var _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(413); +/* harmony import */ var _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(415); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "catchError", function() { return _internal_operators_catchError__WEBPACK_IMPORTED_MODULE_7__["catchError"]; }); -/* harmony import */ var _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(414); +/* harmony import */ var _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(416); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "combineAll", function() { return _internal_operators_combineAll__WEBPACK_IMPORTED_MODULE_8__["combineAll"]; }); -/* harmony import */ var _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(415); +/* harmony import */ var _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(417); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "combineLatest", function() { return _internal_operators_combineLatest__WEBPACK_IMPORTED_MODULE_9__["combineLatest"]; }); -/* harmony import */ var _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(416); +/* harmony import */ var _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(418); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concat", function() { return _internal_operators_concat__WEBPACK_IMPORTED_MODULE_10__["concat"]; }); /* harmony import */ var _internal_operators_concatAll__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(80); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatAll", function() { return _internal_operators_concatAll__WEBPACK_IMPORTED_MODULE_11__["concatAll"]; }); -/* harmony import */ var _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(417); +/* harmony import */ var _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(419); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatMap", function() { return _internal_operators_concatMap__WEBPACK_IMPORTED_MODULE_12__["concatMap"]; }); -/* harmony import */ var _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(418); +/* harmony import */ var _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(420); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "concatMapTo", function() { return _internal_operators_concatMapTo__WEBPACK_IMPORTED_MODULE_13__["concatMapTo"]; }); -/* harmony import */ var _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(419); +/* harmony import */ var _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(421); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "count", function() { return _internal_operators_count__WEBPACK_IMPORTED_MODULE_14__["count"]; }); -/* harmony import */ var _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(420); +/* harmony import */ var _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(422); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "debounce", function() { return _internal_operators_debounce__WEBPACK_IMPORTED_MODULE_15__["debounce"]; }); -/* harmony import */ var _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(421); +/* harmony import */ var _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(423); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "debounceTime", function() { return _internal_operators_debounceTime__WEBPACK_IMPORTED_MODULE_16__["debounceTime"]; }); -/* harmony import */ var _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(422); +/* harmony import */ var _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(424); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "defaultIfEmpty", function() { return _internal_operators_defaultIfEmpty__WEBPACK_IMPORTED_MODULE_17__["defaultIfEmpty"]; }); -/* harmony import */ var _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(423); +/* harmony import */ var _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(425); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "delay", function() { return _internal_operators_delay__WEBPACK_IMPORTED_MODULE_18__["delay"]; }); -/* harmony import */ var _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(425); +/* harmony import */ var _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(427); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "delayWhen", function() { return _internal_operators_delayWhen__WEBPACK_IMPORTED_MODULE_19__["delayWhen"]; }); -/* harmony import */ var _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(426); +/* harmony import */ var _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(428); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "dematerialize", function() { return _internal_operators_dematerialize__WEBPACK_IMPORTED_MODULE_20__["dematerialize"]; }); -/* harmony import */ var _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(427); +/* harmony import */ var _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(429); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinct", function() { return _internal_operators_distinct__WEBPACK_IMPORTED_MODULE_21__["distinct"]; }); -/* harmony import */ var _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__ = __webpack_require__(428); +/* harmony import */ var _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__ = __webpack_require__(430); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinctUntilChanged", function() { return _internal_operators_distinctUntilChanged__WEBPACK_IMPORTED_MODULE_22__["distinctUntilChanged"]; }); -/* harmony import */ var _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__ = __webpack_require__(429); +/* harmony import */ var _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__ = __webpack_require__(431); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "distinctUntilKeyChanged", function() { return _internal_operators_distinctUntilKeyChanged__WEBPACK_IMPORTED_MODULE_23__["distinctUntilKeyChanged"]; }); -/* harmony import */ var _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__ = __webpack_require__(430); +/* harmony import */ var _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__ = __webpack_require__(432); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "elementAt", function() { return _internal_operators_elementAt__WEBPACK_IMPORTED_MODULE_24__["elementAt"]; }); -/* harmony import */ var _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(433); +/* harmony import */ var _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(435); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "endWith", function() { return _internal_operators_endWith__WEBPACK_IMPORTED_MODULE_25__["endWith"]; }); -/* harmony import */ var _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(434); +/* harmony import */ var _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(436); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "every", function() { return _internal_operators_every__WEBPACK_IMPORTED_MODULE_26__["every"]; }); -/* harmony import */ var _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(435); +/* harmony import */ var _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(437); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "exhaust", function() { return _internal_operators_exhaust__WEBPACK_IMPORTED_MODULE_27__["exhaust"]; }); -/* harmony import */ var _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__ = __webpack_require__(436); +/* harmony import */ var _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__ = __webpack_require__(438); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "exhaustMap", function() { return _internal_operators_exhaustMap__WEBPACK_IMPORTED_MODULE_28__["exhaustMap"]; }); -/* harmony import */ var _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__ = __webpack_require__(437); +/* harmony import */ var _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__ = __webpack_require__(439); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "expand", function() { return _internal_operators_expand__WEBPACK_IMPORTED_MODULE_29__["expand"]; }); /* harmony import */ var _internal_operators_filter__WEBPACK_IMPORTED_MODULE_30__ = __webpack_require__(105); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "filter", function() { return _internal_operators_filter__WEBPACK_IMPORTED_MODULE_30__["filter"]; }); -/* harmony import */ var _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__ = __webpack_require__(438); +/* harmony import */ var _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__ = __webpack_require__(440); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "finalize", function() { return _internal_operators_finalize__WEBPACK_IMPORTED_MODULE_31__["finalize"]; }); -/* harmony import */ var _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(439); +/* harmony import */ var _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(441); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "find", function() { return _internal_operators_find__WEBPACK_IMPORTED_MODULE_32__["find"]; }); -/* harmony import */ var _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__ = __webpack_require__(440); +/* harmony import */ var _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__ = __webpack_require__(442); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "findIndex", function() { return _internal_operators_findIndex__WEBPACK_IMPORTED_MODULE_33__["findIndex"]; }); -/* harmony import */ var _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(441); +/* harmony import */ var _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(443); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "first", function() { return _internal_operators_first__WEBPACK_IMPORTED_MODULE_34__["first"]; }); /* harmony import */ var _internal_operators_groupBy__WEBPACK_IMPORTED_MODULE_35__ = __webpack_require__(31); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "groupBy", function() { return _internal_operators_groupBy__WEBPACK_IMPORTED_MODULE_35__["groupBy"]; }); -/* harmony import */ var _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(442); +/* harmony import */ var _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(444); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ignoreElements", function() { return _internal_operators_ignoreElements__WEBPACK_IMPORTED_MODULE_36__["ignoreElements"]; }); -/* harmony import */ var _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(443); +/* harmony import */ var _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(445); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "isEmpty", function() { return _internal_operators_isEmpty__WEBPACK_IMPORTED_MODULE_37__["isEmpty"]; }); -/* harmony import */ var _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(444); +/* harmony import */ var _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(446); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "last", function() { return _internal_operators_last__WEBPACK_IMPORTED_MODULE_38__["last"]; }); /* harmony import */ var _internal_operators_map__WEBPACK_IMPORTED_MODULE_39__ = __webpack_require__(66); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "map", function() { return _internal_operators_map__WEBPACK_IMPORTED_MODULE_39__["map"]; }); -/* harmony import */ var _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(446); +/* harmony import */ var _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(448); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mapTo", function() { return _internal_operators_mapTo__WEBPACK_IMPORTED_MODULE_40__["mapTo"]; }); -/* harmony import */ var _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__ = __webpack_require__(447); +/* harmony import */ var _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__ = __webpack_require__(449); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "materialize", function() { return _internal_operators_materialize__WEBPACK_IMPORTED_MODULE_41__["materialize"]; }); -/* harmony import */ var _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(448); +/* harmony import */ var _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(450); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "max", function() { return _internal_operators_max__WEBPACK_IMPORTED_MODULE_42__["max"]; }); -/* harmony import */ var _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(451); +/* harmony import */ var _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(453); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "merge", function() { return _internal_operators_merge__WEBPACK_IMPORTED_MODULE_43__["merge"]; }); /* harmony import */ var _internal_operators_mergeAll__WEBPACK_IMPORTED_MODULE_44__ = __webpack_require__(81); @@ -52937,175 +53030,175 @@ __webpack_require__.r(__webpack_exports__); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "flatMap", function() { return _internal_operators_mergeMap__WEBPACK_IMPORTED_MODULE_45__["flatMap"]; }); -/* harmony import */ var _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(452); +/* harmony import */ var _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(454); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mergeMapTo", function() { return _internal_operators_mergeMapTo__WEBPACK_IMPORTED_MODULE_46__["mergeMapTo"]; }); -/* harmony import */ var _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__ = __webpack_require__(453); +/* harmony import */ var _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__ = __webpack_require__(455); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "mergeScan", function() { return _internal_operators_mergeScan__WEBPACK_IMPORTED_MODULE_47__["mergeScan"]; }); -/* harmony import */ var _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__ = __webpack_require__(454); +/* harmony import */ var _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__ = __webpack_require__(456); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "min", function() { return _internal_operators_min__WEBPACK_IMPORTED_MODULE_48__["min"]; }); -/* harmony import */ var _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(455); +/* harmony import */ var _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(457); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "multicast", function() { return _internal_operators_multicast__WEBPACK_IMPORTED_MODULE_49__["multicast"]; }); /* harmony import */ var _internal_operators_observeOn__WEBPACK_IMPORTED_MODULE_50__ = __webpack_require__(41); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "observeOn", function() { return _internal_operators_observeOn__WEBPACK_IMPORTED_MODULE_50__["observeOn"]; }); -/* harmony import */ var _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__ = __webpack_require__(456); +/* harmony import */ var _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__ = __webpack_require__(458); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "onErrorResumeNext", function() { return _internal_operators_onErrorResumeNext__WEBPACK_IMPORTED_MODULE_51__["onErrorResumeNext"]; }); -/* harmony import */ var _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__ = __webpack_require__(457); +/* harmony import */ var _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__ = __webpack_require__(459); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "pairwise", function() { return _internal_operators_pairwise__WEBPACK_IMPORTED_MODULE_52__["pairwise"]; }); -/* harmony import */ var _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__ = __webpack_require__(458); +/* harmony import */ var _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__ = __webpack_require__(460); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "partition", function() { return _internal_operators_partition__WEBPACK_IMPORTED_MODULE_53__["partition"]; }); -/* harmony import */ var _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__ = __webpack_require__(459); +/* harmony import */ var _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__ = __webpack_require__(461); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "pluck", function() { return _internal_operators_pluck__WEBPACK_IMPORTED_MODULE_54__["pluck"]; }); -/* harmony import */ var _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__ = __webpack_require__(460); +/* harmony import */ var _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__ = __webpack_require__(462); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publish", function() { return _internal_operators_publish__WEBPACK_IMPORTED_MODULE_55__["publish"]; }); -/* harmony import */ var _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__ = __webpack_require__(461); +/* harmony import */ var _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__ = __webpack_require__(463); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishBehavior", function() { return _internal_operators_publishBehavior__WEBPACK_IMPORTED_MODULE_56__["publishBehavior"]; }); -/* harmony import */ var _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__ = __webpack_require__(462); +/* harmony import */ var _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__ = __webpack_require__(464); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishLast", function() { return _internal_operators_publishLast__WEBPACK_IMPORTED_MODULE_57__["publishLast"]; }); -/* harmony import */ var _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__ = __webpack_require__(463); +/* harmony import */ var _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__ = __webpack_require__(465); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "publishReplay", function() { return _internal_operators_publishReplay__WEBPACK_IMPORTED_MODULE_58__["publishReplay"]; }); -/* harmony import */ var _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__ = __webpack_require__(464); +/* harmony import */ var _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__ = __webpack_require__(466); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "race", function() { return _internal_operators_race__WEBPACK_IMPORTED_MODULE_59__["race"]; }); -/* harmony import */ var _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__ = __webpack_require__(449); +/* harmony import */ var _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__ = __webpack_require__(451); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "reduce", function() { return _internal_operators_reduce__WEBPACK_IMPORTED_MODULE_60__["reduce"]; }); -/* harmony import */ var _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__ = __webpack_require__(465); +/* harmony import */ var _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__ = __webpack_require__(467); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "repeat", function() { return _internal_operators_repeat__WEBPACK_IMPORTED_MODULE_61__["repeat"]; }); -/* harmony import */ var _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__ = __webpack_require__(466); +/* harmony import */ var _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__ = __webpack_require__(468); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "repeatWhen", function() { return _internal_operators_repeatWhen__WEBPACK_IMPORTED_MODULE_62__["repeatWhen"]; }); -/* harmony import */ var _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__ = __webpack_require__(467); +/* harmony import */ var _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__ = __webpack_require__(469); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "retry", function() { return _internal_operators_retry__WEBPACK_IMPORTED_MODULE_63__["retry"]; }); -/* harmony import */ var _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__ = __webpack_require__(468); +/* harmony import */ var _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__ = __webpack_require__(470); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "retryWhen", function() { return _internal_operators_retryWhen__WEBPACK_IMPORTED_MODULE_64__["retryWhen"]; }); /* harmony import */ var _internal_operators_refCount__WEBPACK_IMPORTED_MODULE_65__ = __webpack_require__(30); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "refCount", function() { return _internal_operators_refCount__WEBPACK_IMPORTED_MODULE_65__["refCount"]; }); -/* harmony import */ var _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__ = __webpack_require__(469); +/* harmony import */ var _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__ = __webpack_require__(471); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sample", function() { return _internal_operators_sample__WEBPACK_IMPORTED_MODULE_66__["sample"]; }); -/* harmony import */ var _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__ = __webpack_require__(470); +/* harmony import */ var _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__ = __webpack_require__(472); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sampleTime", function() { return _internal_operators_sampleTime__WEBPACK_IMPORTED_MODULE_67__["sampleTime"]; }); -/* harmony import */ var _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__ = __webpack_require__(450); +/* harmony import */ var _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__ = __webpack_require__(452); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "scan", function() { return _internal_operators_scan__WEBPACK_IMPORTED_MODULE_68__["scan"]; }); -/* harmony import */ var _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__ = __webpack_require__(471); +/* harmony import */ var _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__ = __webpack_require__(473); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "sequenceEqual", function() { return _internal_operators_sequenceEqual__WEBPACK_IMPORTED_MODULE_69__["sequenceEqual"]; }); -/* harmony import */ var _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__ = __webpack_require__(472); +/* harmony import */ var _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__ = __webpack_require__(474); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "share", function() { return _internal_operators_share__WEBPACK_IMPORTED_MODULE_70__["share"]; }); -/* harmony import */ var _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__ = __webpack_require__(473); +/* harmony import */ var _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__ = __webpack_require__(475); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "shareReplay", function() { return _internal_operators_shareReplay__WEBPACK_IMPORTED_MODULE_71__["shareReplay"]; }); -/* harmony import */ var _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__ = __webpack_require__(474); +/* harmony import */ var _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__ = __webpack_require__(476); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "single", function() { return _internal_operators_single__WEBPACK_IMPORTED_MODULE_72__["single"]; }); -/* harmony import */ var _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__ = __webpack_require__(475); +/* harmony import */ var _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__ = __webpack_require__(477); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skip", function() { return _internal_operators_skip__WEBPACK_IMPORTED_MODULE_73__["skip"]; }); -/* harmony import */ var _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__ = __webpack_require__(476); +/* harmony import */ var _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__ = __webpack_require__(478); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipLast", function() { return _internal_operators_skipLast__WEBPACK_IMPORTED_MODULE_74__["skipLast"]; }); -/* harmony import */ var _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__ = __webpack_require__(477); +/* harmony import */ var _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__ = __webpack_require__(479); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipUntil", function() { return _internal_operators_skipUntil__WEBPACK_IMPORTED_MODULE_75__["skipUntil"]; }); -/* harmony import */ var _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__ = __webpack_require__(478); +/* harmony import */ var _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__ = __webpack_require__(480); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "skipWhile", function() { return _internal_operators_skipWhile__WEBPACK_IMPORTED_MODULE_76__["skipWhile"]; }); -/* harmony import */ var _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__ = __webpack_require__(479); +/* harmony import */ var _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__ = __webpack_require__(481); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "startWith", function() { return _internal_operators_startWith__WEBPACK_IMPORTED_MODULE_77__["startWith"]; }); -/* harmony import */ var _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__ = __webpack_require__(480); +/* harmony import */ var _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__ = __webpack_require__(482); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "subscribeOn", function() { return _internal_operators_subscribeOn__WEBPACK_IMPORTED_MODULE_78__["subscribeOn"]; }); -/* harmony import */ var _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__ = __webpack_require__(482); +/* harmony import */ var _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__ = __webpack_require__(484); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchAll", function() { return _internal_operators_switchAll__WEBPACK_IMPORTED_MODULE_79__["switchAll"]; }); -/* harmony import */ var _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__ = __webpack_require__(483); +/* harmony import */ var _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__ = __webpack_require__(485); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchMap", function() { return _internal_operators_switchMap__WEBPACK_IMPORTED_MODULE_80__["switchMap"]; }); -/* harmony import */ var _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__ = __webpack_require__(484); +/* harmony import */ var _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__ = __webpack_require__(486); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "switchMapTo", function() { return _internal_operators_switchMapTo__WEBPACK_IMPORTED_MODULE_81__["switchMapTo"]; }); -/* harmony import */ var _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__ = __webpack_require__(432); +/* harmony import */ var _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__ = __webpack_require__(434); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "take", function() { return _internal_operators_take__WEBPACK_IMPORTED_MODULE_82__["take"]; }); -/* harmony import */ var _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__ = __webpack_require__(445); +/* harmony import */ var _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__ = __webpack_require__(447); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeLast", function() { return _internal_operators_takeLast__WEBPACK_IMPORTED_MODULE_83__["takeLast"]; }); -/* harmony import */ var _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__ = __webpack_require__(485); +/* harmony import */ var _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__ = __webpack_require__(487); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeUntil", function() { return _internal_operators_takeUntil__WEBPACK_IMPORTED_MODULE_84__["takeUntil"]; }); -/* harmony import */ var _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__ = __webpack_require__(486); +/* harmony import */ var _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__ = __webpack_require__(488); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "takeWhile", function() { return _internal_operators_takeWhile__WEBPACK_IMPORTED_MODULE_85__["takeWhile"]; }); -/* harmony import */ var _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__ = __webpack_require__(487); +/* harmony import */ var _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__ = __webpack_require__(489); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "tap", function() { return _internal_operators_tap__WEBPACK_IMPORTED_MODULE_86__["tap"]; }); -/* harmony import */ var _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__ = __webpack_require__(488); +/* harmony import */ var _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__ = __webpack_require__(490); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throttle", function() { return _internal_operators_throttle__WEBPACK_IMPORTED_MODULE_87__["throttle"]; }); -/* harmony import */ var _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__ = __webpack_require__(489); +/* harmony import */ var _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__ = __webpack_require__(491); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throttleTime", function() { return _internal_operators_throttleTime__WEBPACK_IMPORTED_MODULE_88__["throttleTime"]; }); -/* harmony import */ var _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__ = __webpack_require__(431); +/* harmony import */ var _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__ = __webpack_require__(433); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "throwIfEmpty", function() { return _internal_operators_throwIfEmpty__WEBPACK_IMPORTED_MODULE_89__["throwIfEmpty"]; }); -/* harmony import */ var _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__ = __webpack_require__(490); +/* harmony import */ var _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__ = __webpack_require__(492); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeInterval", function() { return _internal_operators_timeInterval__WEBPACK_IMPORTED_MODULE_90__["timeInterval"]; }); -/* harmony import */ var _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__ = __webpack_require__(491); +/* harmony import */ var _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__ = __webpack_require__(493); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeout", function() { return _internal_operators_timeout__WEBPACK_IMPORTED_MODULE_91__["timeout"]; }); -/* harmony import */ var _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__ = __webpack_require__(492); +/* harmony import */ var _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__ = __webpack_require__(494); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timeoutWith", function() { return _internal_operators_timeoutWith__WEBPACK_IMPORTED_MODULE_92__["timeoutWith"]; }); -/* harmony import */ var _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__ = __webpack_require__(493); +/* harmony import */ var _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__ = __webpack_require__(495); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "timestamp", function() { return _internal_operators_timestamp__WEBPACK_IMPORTED_MODULE_93__["timestamp"]; }); -/* harmony import */ var _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__ = __webpack_require__(494); +/* harmony import */ var _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__ = __webpack_require__(496); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "toArray", function() { return _internal_operators_toArray__WEBPACK_IMPORTED_MODULE_94__["toArray"]; }); -/* harmony import */ var _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__ = __webpack_require__(495); +/* harmony import */ var _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__ = __webpack_require__(497); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "window", function() { return _internal_operators_window__WEBPACK_IMPORTED_MODULE_95__["window"]; }); -/* harmony import */ var _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__ = __webpack_require__(496); +/* harmony import */ var _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__ = __webpack_require__(498); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowCount", function() { return _internal_operators_windowCount__WEBPACK_IMPORTED_MODULE_96__["windowCount"]; }); -/* harmony import */ var _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__ = __webpack_require__(497); +/* harmony import */ var _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__ = __webpack_require__(499); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowTime", function() { return _internal_operators_windowTime__WEBPACK_IMPORTED_MODULE_97__["windowTime"]; }); -/* harmony import */ var _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__ = __webpack_require__(498); +/* harmony import */ var _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__ = __webpack_require__(500); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowToggle", function() { return _internal_operators_windowToggle__WEBPACK_IMPORTED_MODULE_98__["windowToggle"]; }); -/* harmony import */ var _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__ = __webpack_require__(499); +/* harmony import */ var _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__ = __webpack_require__(501); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "windowWhen", function() { return _internal_operators_windowWhen__WEBPACK_IMPORTED_MODULE_99__["windowWhen"]; }); -/* harmony import */ var _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__ = __webpack_require__(500); +/* harmony import */ var _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__ = __webpack_require__(502); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "withLatestFrom", function() { return _internal_operators_withLatestFrom__WEBPACK_IMPORTED_MODULE_100__["withLatestFrom"]; }); -/* harmony import */ var _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__ = __webpack_require__(501); +/* harmony import */ var _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__ = __webpack_require__(503); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "zip", function() { return _internal_operators_zip__WEBPACK_IMPORTED_MODULE_101__["zip"]; }); -/* harmony import */ var _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__ = __webpack_require__(502); +/* harmony import */ var _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__ = __webpack_require__(504); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "zipAll", function() { return _internal_operators_zipAll__WEBPACK_IMPORTED_MODULE_102__["zipAll"]; }); /** PURE_IMPORTS_START PURE_IMPORTS_END */ @@ -53216,7 +53309,7 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 406 */ +/* 408 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53295,14 +53388,14 @@ var AuditSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 407 */ +/* 409 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "auditTime", function() { return auditTime; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(55); -/* harmony import */ var _audit__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(406); +/* harmony import */ var _audit__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(408); /* harmony import */ var _observable_timer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(108); /** PURE_IMPORTS_START _scheduler_async,_audit,_observable_timer PURE_IMPORTS_END */ @@ -53318,7 +53411,7 @@ function auditTime(duration, scheduler) { /***/ }), -/* 408 */ +/* 410 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53365,7 +53458,7 @@ var BufferSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 409 */ +/* 411 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53466,7 +53559,7 @@ var BufferSkipCountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 410 */ +/* 412 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53627,7 +53720,7 @@ function dispatchBufferClose(arg) { /***/ }), -/* 411 */ +/* 413 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53746,7 +53839,7 @@ var BufferToggleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 412 */ +/* 414 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53839,7 +53932,7 @@ var BufferWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 413 */ +/* 415 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53899,7 +53992,7 @@ var CatchSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 414 */ +/* 416 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53915,7 +54008,7 @@ function combineAll(project) { /***/ }), -/* 415 */ +/* 417 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53947,7 +54040,7 @@ function combineLatest() { /***/ }), -/* 416 */ +/* 418 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53967,7 +54060,7 @@ function concat() { /***/ }), -/* 417 */ +/* 419 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -53983,13 +54076,13 @@ function concatMap(project, resultSelector) { /***/ }), -/* 418 */ +/* 420 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "concatMapTo", function() { return concatMapTo; }); -/* harmony import */ var _concatMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(417); +/* harmony import */ var _concatMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(419); /** PURE_IMPORTS_START _concatMap PURE_IMPORTS_END */ function concatMapTo(innerObservable, resultSelector) { @@ -53999,7 +54092,7 @@ function concatMapTo(innerObservable, resultSelector) { /***/ }), -/* 419 */ +/* 421 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54064,7 +54157,7 @@ var CountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 420 */ +/* 422 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54149,7 +54242,7 @@ var DebounceSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 421 */ +/* 423 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54225,7 +54318,7 @@ function dispatchNext(subscriber) { /***/ }), -/* 422 */ +/* 424 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54275,7 +54368,7 @@ var DefaultIfEmptySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 423 */ +/* 425 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54283,7 +54376,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "delay", function() { return delay; }); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(12); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(55); -/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(424); +/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(426); /* harmony import */ var _Subscriber__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(11); /* harmony import */ var _Notification__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(42); /** PURE_IMPORTS_START tslib,_scheduler_async,_util_isDate,_Subscriber,_Notification PURE_IMPORTS_END */ @@ -54382,7 +54475,7 @@ var DelayMessage = /*@__PURE__*/ (function () { /***/ }), -/* 424 */ +/* 426 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54396,7 +54489,7 @@ function isDate(value) { /***/ }), -/* 425 */ +/* 427 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54542,7 +54635,7 @@ var SubscriptionDelaySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 426 */ +/* 428 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54580,7 +54673,7 @@ var DeMaterializeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 427 */ +/* 429 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54656,7 +54749,7 @@ var DistinctSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 428 */ +/* 430 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54727,13 +54820,13 @@ var DistinctUntilChangedSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 429 */ +/* 431 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "distinctUntilKeyChanged", function() { return distinctUntilKeyChanged; }); -/* harmony import */ var _distinctUntilChanged__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(428); +/* harmony import */ var _distinctUntilChanged__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(430); /** PURE_IMPORTS_START _distinctUntilChanged PURE_IMPORTS_END */ function distinctUntilKeyChanged(key, compare) { @@ -54743,7 +54836,7 @@ function distinctUntilKeyChanged(key, compare) { /***/ }), -/* 430 */ +/* 432 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54751,9 +54844,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "elementAt", function() { return elementAt; }); /* harmony import */ var _util_ArgumentOutOfRangeError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(62); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(105); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(431); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(422); -/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(432); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(433); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(424); +/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(434); /** PURE_IMPORTS_START _util_ArgumentOutOfRangeError,_filter,_throwIfEmpty,_defaultIfEmpty,_take PURE_IMPORTS_END */ @@ -54775,7 +54868,7 @@ function elementAt(index, defaultValue) { /***/ }), -/* 431 */ +/* 433 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54841,7 +54934,7 @@ function defaultErrorFactory() { /***/ }), -/* 432 */ +/* 434 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54903,7 +54996,7 @@ var TakeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 433 */ +/* 435 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54925,7 +55018,7 @@ function endWith() { /***/ }), -/* 434 */ +/* 436 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -54987,7 +55080,7 @@ var EverySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 435 */ +/* 437 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55041,7 +55134,7 @@ var SwitchFirstSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 436 */ +/* 438 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55135,7 +55228,7 @@ var ExhaustMapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 437 */ +/* 439 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55247,7 +55340,7 @@ var ExpandSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 438 */ +/* 440 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55285,7 +55378,7 @@ var FinallySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 439 */ +/* 441 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55357,13 +55450,13 @@ var FindValueSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 440 */ +/* 442 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "findIndex", function() { return findIndex; }); -/* harmony import */ var _operators_find__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(439); +/* harmony import */ var _operators_find__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(441); /** PURE_IMPORTS_START _operators_find PURE_IMPORTS_END */ function findIndex(predicate, thisArg) { @@ -55373,7 +55466,7 @@ function findIndex(predicate, thisArg) { /***/ }), -/* 441 */ +/* 443 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55381,9 +55474,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "first", function() { return first; }); /* harmony import */ var _util_EmptyError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(63); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(105); -/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(432); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(422); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(431); +/* harmony import */ var _take__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(434); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(424); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(433); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(25); /** PURE_IMPORTS_START _util_EmptyError,_filter,_take,_defaultIfEmpty,_throwIfEmpty,_util_identity PURE_IMPORTS_END */ @@ -55400,7 +55493,7 @@ function first(predicate, defaultValue) { /***/ }), -/* 442 */ +/* 444 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55437,7 +55530,7 @@ var IgnoreElementsSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 443 */ +/* 445 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55481,7 +55574,7 @@ var IsEmptySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 444 */ +/* 446 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55489,9 +55582,9 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "last", function() { return last; }); /* harmony import */ var _util_EmptyError__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(63); /* harmony import */ var _filter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(105); -/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(445); -/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(431); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(422); +/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(447); +/* harmony import */ var _throwIfEmpty__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(433); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(424); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(25); /** PURE_IMPORTS_START _util_EmptyError,_filter,_takeLast,_throwIfEmpty,_defaultIfEmpty,_util_identity PURE_IMPORTS_END */ @@ -55508,7 +55601,7 @@ function last(predicate, defaultValue) { /***/ }), -/* 445 */ +/* 447 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55585,7 +55678,7 @@ var TakeLastSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 446 */ +/* 448 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55624,7 +55717,7 @@ var MapToSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 447 */ +/* 449 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55674,13 +55767,13 @@ var MaterializeSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 448 */ +/* 450 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "max", function() { return max; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(449); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(451); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function max(comparer) { @@ -55693,15 +55786,15 @@ function max(comparer) { /***/ }), -/* 449 */ +/* 451 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "reduce", function() { return reduce; }); -/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(450); -/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(445); -/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(422); +/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(452); +/* harmony import */ var _takeLast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(447); +/* harmony import */ var _defaultIfEmpty__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(424); /* harmony import */ var _util_pipe__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(24); /** PURE_IMPORTS_START _scan,_takeLast,_defaultIfEmpty,_util_pipe PURE_IMPORTS_END */ @@ -55722,7 +55815,7 @@ function reduce(accumulator, seed) { /***/ }), -/* 450 */ +/* 452 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55804,7 +55897,7 @@ var ScanSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 451 */ +/* 453 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55824,7 +55917,7 @@ function merge() { /***/ }), -/* 452 */ +/* 454 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55849,7 +55942,7 @@ function mergeMapTo(innerObservable, resultSelector, concurrent) { /***/ }), -/* 453 */ +/* 455 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -55958,13 +56051,13 @@ var MergeScanSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 454 */ +/* 456 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "min", function() { return min; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(449); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(451); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function min(comparer) { @@ -55977,7 +56070,7 @@ function min(comparer) { /***/ }), -/* 455 */ +/* 457 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56026,7 +56119,7 @@ var MulticastOperator = /*@__PURE__*/ (function () { /***/ }), -/* 456 */ +/* 458 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56116,7 +56209,7 @@ var OnErrorResumeNextSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 457 */ +/* 459 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56164,7 +56257,7 @@ var PairwiseSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 458 */ +/* 460 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56187,7 +56280,7 @@ function partition(predicate, thisArg) { /***/ }), -/* 459 */ +/* 461 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56227,14 +56320,14 @@ function plucker(props, length) { /***/ }), -/* 460 */ +/* 462 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publish", function() { return publish; }); /* harmony import */ var _Subject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(27); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(457); /** PURE_IMPORTS_START _Subject,_multicast PURE_IMPORTS_END */ @@ -56247,14 +56340,14 @@ function publish(selector) { /***/ }), -/* 461 */ +/* 463 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishBehavior", function() { return publishBehavior; }); /* harmony import */ var _BehaviorSubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(32); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(457); /** PURE_IMPORTS_START _BehaviorSubject,_multicast PURE_IMPORTS_END */ @@ -56265,14 +56358,14 @@ function publishBehavior(value) { /***/ }), -/* 462 */ +/* 464 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishLast", function() { return publishLast; }); /* harmony import */ var _AsyncSubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(50); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(457); /** PURE_IMPORTS_START _AsyncSubject,_multicast PURE_IMPORTS_END */ @@ -56283,14 +56376,14 @@ function publishLast() { /***/ }), -/* 463 */ +/* 465 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "publishReplay", function() { return publishReplay; }); /* harmony import */ var _ReplaySubject__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(33); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(457); /** PURE_IMPORTS_START _ReplaySubject,_multicast PURE_IMPORTS_END */ @@ -56306,7 +56399,7 @@ function publishReplay(bufferSize, windowTime, selectorOrScheduler, scheduler) { /***/ }), -/* 464 */ +/* 466 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56333,7 +56426,7 @@ function race() { /***/ }), -/* 465 */ +/* 467 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56398,7 +56491,7 @@ var RepeatSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 466 */ +/* 468 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56492,7 +56585,7 @@ var RepeatWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 467 */ +/* 469 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56545,7 +56638,7 @@ var RetrySubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 468 */ +/* 470 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56631,7 +56724,7 @@ var RetryWhenSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 469 */ +/* 471 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56686,7 +56779,7 @@ var SampleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 470 */ +/* 472 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56746,7 +56839,7 @@ function dispatchNotification(state) { /***/ }), -/* 471 */ +/* 473 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56869,13 +56962,13 @@ var SequenceEqualCompareToSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 472 */ +/* 474 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "share", function() { return share; }); -/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(455); +/* harmony import */ var _multicast__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(457); /* harmony import */ var _refCount__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(30); /* harmony import */ var _Subject__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(27); /** PURE_IMPORTS_START _multicast,_refCount,_Subject PURE_IMPORTS_END */ @@ -56892,7 +56985,7 @@ function share() { /***/ }), -/* 473 */ +/* 475 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -56961,7 +57054,7 @@ function shareReplayOperator(_a) { /***/ }), -/* 474 */ +/* 476 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57041,7 +57134,7 @@ var SingleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 475 */ +/* 477 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57083,7 +57176,7 @@ var SkipSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 476 */ +/* 478 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57145,7 +57238,7 @@ var SkipLastSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 477 */ +/* 479 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57202,7 +57295,7 @@ var SkipUntilSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 478 */ +/* 480 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57258,7 +57351,7 @@ var SkipWhileSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 479 */ +/* 481 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57287,13 +57380,13 @@ function startWith() { /***/ }), -/* 480 */ +/* 482 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "subscribeOn", function() { return subscribeOn; }); -/* harmony import */ var _observable_SubscribeOnObservable__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(481); +/* harmony import */ var _observable_SubscribeOnObservable__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(483); /** PURE_IMPORTS_START _observable_SubscribeOnObservable PURE_IMPORTS_END */ function subscribeOn(scheduler, delay) { @@ -57318,7 +57411,7 @@ var SubscribeOnOperator = /*@__PURE__*/ (function () { /***/ }), -/* 481 */ +/* 483 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57382,13 +57475,13 @@ var SubscribeOnObservable = /*@__PURE__*/ (function (_super) { /***/ }), -/* 482 */ +/* 484 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "switchAll", function() { return switchAll; }); -/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(483); +/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(485); /* harmony import */ var _util_identity__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(25); /** PURE_IMPORTS_START _switchMap,_util_identity PURE_IMPORTS_END */ @@ -57400,7 +57493,7 @@ function switchAll() { /***/ }), -/* 483 */ +/* 485 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57488,13 +57581,13 @@ var SwitchMapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 484 */ +/* 486 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "switchMapTo", function() { return switchMapTo; }); -/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(483); +/* harmony import */ var _switchMap__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(485); /** PURE_IMPORTS_START _switchMap PURE_IMPORTS_END */ function switchMapTo(innerObservable, resultSelector) { @@ -57504,7 +57597,7 @@ function switchMapTo(innerObservable, resultSelector) { /***/ }), -/* 485 */ +/* 487 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57552,7 +57645,7 @@ var TakeUntilSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 486 */ +/* 488 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57620,7 +57713,7 @@ var TakeWhileSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 487 */ +/* 489 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57708,7 +57801,7 @@ var TapSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 488 */ +/* 490 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57810,7 +57903,7 @@ var ThrottleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 489 */ +/* 491 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57819,7 +57912,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(12); /* harmony import */ var _Subscriber__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(11); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(55); -/* harmony import */ var _throttle__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(488); +/* harmony import */ var _throttle__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(490); /** PURE_IMPORTS_START tslib,_Subscriber,_scheduler_async,_throttle PURE_IMPORTS_END */ @@ -57908,7 +58001,7 @@ function dispatchNext(arg) { /***/ }), -/* 490 */ +/* 492 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57916,7 +58009,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeInterval", function() { return timeInterval; }); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TimeInterval", function() { return TimeInterval; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(55); -/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(450); +/* harmony import */ var _scan__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(452); /* harmony import */ var _observable_defer__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(91); /* harmony import */ var _map__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(66); /** PURE_IMPORTS_START _scheduler_async,_scan,_observable_defer,_map PURE_IMPORTS_END */ @@ -57952,7 +58045,7 @@ var TimeInterval = /*@__PURE__*/ (function () { /***/ }), -/* 491 */ +/* 493 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57960,7 +58053,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeout", function() { return timeout; }); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(55); /* harmony import */ var _util_TimeoutError__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(64); -/* harmony import */ var _timeoutWith__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(492); +/* harmony import */ var _timeoutWith__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(494); /* harmony import */ var _observable_throwError__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(49); /** PURE_IMPORTS_START _scheduler_async,_util_TimeoutError,_timeoutWith,_observable_throwError PURE_IMPORTS_END */ @@ -57977,7 +58070,7 @@ function timeout(due, scheduler) { /***/ }), -/* 492 */ +/* 494 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -57985,7 +58078,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "timeoutWith", function() { return timeoutWith; }); /* harmony import */ var tslib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(12); /* harmony import */ var _scheduler_async__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(55); -/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(424); +/* harmony import */ var _util_isDate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(426); /* harmony import */ var _innerSubscribe__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(90); /** PURE_IMPORTS_START tslib,_scheduler_async,_util_isDate,_innerSubscribe PURE_IMPORTS_END */ @@ -58056,7 +58149,7 @@ var TimeoutWithSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 493 */ +/* 495 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58086,13 +58179,13 @@ var Timestamp = /*@__PURE__*/ (function () { /***/ }), -/* 494 */ +/* 496 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "toArray", function() { return toArray; }); -/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(449); +/* harmony import */ var _reduce__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(451); /** PURE_IMPORTS_START _reduce PURE_IMPORTS_END */ function toArrayReducer(arr, item, index) { @@ -58109,7 +58202,7 @@ function toArray() { /***/ }), -/* 495 */ +/* 497 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58187,7 +58280,7 @@ var WindowSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 496 */ +/* 498 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58277,7 +58370,7 @@ var WindowCountSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 497 */ +/* 499 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58447,7 +58540,7 @@ function dispatchWindowClose(state) { /***/ }), -/* 498 */ +/* 500 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58590,7 +58683,7 @@ var WindowToggleSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 499 */ +/* 501 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58687,7 +58780,7 @@ var WindowSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 500 */ +/* 502 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58782,7 +58875,7 @@ var WithLatestFromSubscriber = /*@__PURE__*/ (function (_super) { /***/ }), -/* 501 */ +/* 503 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58804,7 +58897,7 @@ function zip() { /***/ }), -/* 502 */ +/* 504 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58820,7 +58913,7 @@ function zipAll(project) { /***/ }), -/* 503 */ +/* 505 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58830,7 +58923,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(246); /* harmony import */ var _utils_projects__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(248); /* harmony import */ var _utils_projects_tree__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(370); -/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(504); +/* harmony import */ var _utils_kibana__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(506); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -58901,7 +58994,7 @@ function toArray(value) { } /***/ }), -/* 504 */ +/* 506 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -58909,13 +59002,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Kibana", function() { return Kibana; }); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_0__); -/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(505); +/* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(507); /* harmony import */ var multimatch__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(multimatch__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(239); /* harmony import */ var is_path_inside__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(is_path_inside__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var _yarn_lock__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(365); /* harmony import */ var _projects__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(248); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(509); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(511); function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } @@ -59066,15 +59159,15 @@ class Kibana { } /***/ }), -/* 505 */ +/* 507 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const minimatch = __webpack_require__(150); -const arrayUnion = __webpack_require__(506); -const arrayDiffer = __webpack_require__(507); -const arrify = __webpack_require__(508); +const arrayUnion = __webpack_require__(508); +const arrayDiffer = __webpack_require__(509); +const arrify = __webpack_require__(510); module.exports = (list, patterns, options = {}) => { list = arrify(list); @@ -59098,7 +59191,7 @@ module.exports = (list, patterns, options = {}) => { /***/ }), -/* 506 */ +/* 508 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59110,7 +59203,7 @@ module.exports = (...arguments_) => { /***/ }), -/* 507 */ +/* 509 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59125,7 +59218,7 @@ module.exports = arrayDiffer; /***/ }), -/* 508 */ +/* 510 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59155,7 +59248,7 @@ module.exports = arrify; /***/ }), -/* 509 */ +/* 511 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; @@ -59214,12 +59307,12 @@ function getProjectPaths({ } /***/ }), -/* 510 */ +/* 512 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); -/* harmony import */ var _build_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(511); +/* harmony import */ var _build_production_projects__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(513); /* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return _build_production_projects__WEBPACK_IMPORTED_MODULE_0__["buildProductionProjects"]; }); /* @@ -59232,19 +59325,19 @@ __webpack_require__.r(__webpack_exports__); /***/ }), -/* 511 */ +/* 513 */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "buildProductionProjects", function() { return buildProductionProjects; }); -/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(512); +/* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(514); /* harmony import */ var cpy__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(cpy__WEBPACK_IMPORTED_MODULE_0__); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(143); /* harmony import */ var del__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(del__WEBPACK_IMPORTED_MODULE_1__); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(4); /* harmony import */ var path__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(path__WEBPACK_IMPORTED_MODULE_2__); -/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(509); +/* harmony import */ var _config__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(511); /* harmony import */ var _utils_fs__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(131); /* harmony import */ var _utils_log__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(246); /* harmony import */ var _utils_package_json__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(251); @@ -59370,7 +59463,7 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { } /***/ }), -/* 512 */ +/* 514 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59378,14 +59471,14 @@ async function copyToBuild(project, kibanaRoot, buildRoot) { const EventEmitter = __webpack_require__(156); const path = __webpack_require__(4); const os = __webpack_require__(121); -const pMap = __webpack_require__(513); -const arrify = __webpack_require__(508); -const globby = __webpack_require__(514); -const hasGlob = __webpack_require__(710); -const cpFile = __webpack_require__(712); -const junk = __webpack_require__(722); -const pFilter = __webpack_require__(723); -const CpyError = __webpack_require__(725); +const pMap = __webpack_require__(515); +const arrify = __webpack_require__(510); +const globby = __webpack_require__(516); +const hasGlob = __webpack_require__(712); +const cpFile = __webpack_require__(714); +const junk = __webpack_require__(724); +const pFilter = __webpack_require__(725); +const CpyError = __webpack_require__(727); const defaultOptions = { ignoreJunk: true @@ -59536,7 +59629,7 @@ module.exports = (source, destination, { /***/ }), -/* 513 */ +/* 515 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59624,17 +59717,17 @@ module.exports = async ( /***/ }), -/* 514 */ +/* 516 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const arrayUnion = __webpack_require__(515); +const arrayUnion = __webpack_require__(517); const glob = __webpack_require__(147); -const fastGlob = __webpack_require__(517); -const dirGlob = __webpack_require__(703); -const gitignore = __webpack_require__(706); +const fastGlob = __webpack_require__(519); +const dirGlob = __webpack_require__(705); +const gitignore = __webpack_require__(708); const DEFAULT_FILTER = () => false; @@ -59779,12 +59872,12 @@ module.exports.gitignore = gitignore; /***/ }), -/* 515 */ +/* 517 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var arrayUniq = __webpack_require__(516); +var arrayUniq = __webpack_require__(518); module.exports = function () { return arrayUniq([].concat.apply([], arguments)); @@ -59792,7 +59885,7 @@ module.exports = function () { /***/ }), -/* 516 */ +/* 518 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59861,10 +59954,10 @@ if ('Set' in global) { /***/ }), -/* 517 */ +/* 519 */ /***/ (function(module, exports, __webpack_require__) { -const pkg = __webpack_require__(518); +const pkg = __webpack_require__(520); module.exports = pkg.async; module.exports.default = pkg.async; @@ -59877,19 +59970,19 @@ module.exports.generateTasks = pkg.generateTasks; /***/ }), -/* 518 */ +/* 520 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var optionsManager = __webpack_require__(519); -var taskManager = __webpack_require__(520); -var reader_async_1 = __webpack_require__(674); -var reader_stream_1 = __webpack_require__(698); -var reader_sync_1 = __webpack_require__(699); -var arrayUtils = __webpack_require__(701); -var streamUtils = __webpack_require__(702); +var optionsManager = __webpack_require__(521); +var taskManager = __webpack_require__(522); +var reader_async_1 = __webpack_require__(676); +var reader_stream_1 = __webpack_require__(700); +var reader_sync_1 = __webpack_require__(701); +var arrayUtils = __webpack_require__(703); +var streamUtils = __webpack_require__(704); /** * Synchronous API. */ @@ -59955,7 +60048,7 @@ function isString(source) { /***/ }), -/* 519 */ +/* 521 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -59993,13 +60086,13 @@ exports.prepare = prepare; /***/ }), -/* 520 */ +/* 522 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var patternUtils = __webpack_require__(521); +var patternUtils = __webpack_require__(523); /** * Generate tasks based on parent directory of each pattern. */ @@ -60090,16 +60183,16 @@ exports.convertPatternGroupToTask = convertPatternGroupToTask; /***/ }), -/* 521 */ +/* 523 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var globParent = __webpack_require__(522); +var globParent = __webpack_require__(524); var isGlob = __webpack_require__(172); -var micromatch = __webpack_require__(525); +var micromatch = __webpack_require__(527); var GLOBSTAR = '**'; /** * Return true for static pattern. @@ -60245,15 +60338,15 @@ exports.matchAny = matchAny; /***/ }), -/* 522 */ +/* 524 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var path = __webpack_require__(4); -var isglob = __webpack_require__(523); -var pathDirname = __webpack_require__(524); +var isglob = __webpack_require__(525); +var pathDirname = __webpack_require__(526); var isWin32 = __webpack_require__(121).platform() === 'win32'; module.exports = function globParent(str) { @@ -60276,7 +60369,7 @@ module.exports = function globParent(str) { /***/ }), -/* 523 */ +/* 525 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -60307,7 +60400,7 @@ module.exports = function isGlob(str) { /***/ }), -/* 524 */ +/* 526 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60457,7 +60550,7 @@ module.exports.win32 = win32; /***/ }), -/* 525 */ +/* 527 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -60468,18 +60561,18 @@ module.exports.win32 = win32; */ var util = __webpack_require__(112); -var braces = __webpack_require__(526); -var toRegex = __webpack_require__(527); -var extend = __webpack_require__(640); +var braces = __webpack_require__(528); +var toRegex = __webpack_require__(529); +var extend = __webpack_require__(642); /** * Local dependencies */ -var compilers = __webpack_require__(642); -var parsers = __webpack_require__(669); -var cache = __webpack_require__(670); -var utils = __webpack_require__(671); +var compilers = __webpack_require__(644); +var parsers = __webpack_require__(671); +var cache = __webpack_require__(672); +var utils = __webpack_require__(673); var MAX_LENGTH = 1024 * 64; /** @@ -61341,7 +61434,7 @@ module.exports = micromatch; /***/ }), -/* 526 */ +/* 528 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -61351,18 +61444,18 @@ module.exports = micromatch; * Module dependencies */ -var toRegex = __webpack_require__(527); -var unique = __webpack_require__(549); -var extend = __webpack_require__(550); +var toRegex = __webpack_require__(529); +var unique = __webpack_require__(551); +var extend = __webpack_require__(552); /** * Local dependencies */ -var compilers = __webpack_require__(552); -var parsers = __webpack_require__(565); -var Braces = __webpack_require__(569); -var utils = __webpack_require__(553); +var compilers = __webpack_require__(554); +var parsers = __webpack_require__(567); +var Braces = __webpack_require__(571); +var utils = __webpack_require__(555); var MAX_LENGTH = 1024 * 64; var cache = {}; @@ -61666,16 +61759,16 @@ module.exports = braces; /***/ }), -/* 527 */ +/* 529 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var safe = __webpack_require__(528); -var define = __webpack_require__(534); -var extend = __webpack_require__(542); -var not = __webpack_require__(546); +var safe = __webpack_require__(530); +var define = __webpack_require__(536); +var extend = __webpack_require__(544); +var not = __webpack_require__(548); var MAX_LENGTH = 1024 * 64; /** @@ -61828,10 +61921,10 @@ module.exports.makeRe = makeRe; /***/ }), -/* 528 */ +/* 530 */ /***/ (function(module, exports, __webpack_require__) { -var parse = __webpack_require__(529); +var parse = __webpack_require__(531); var types = parse.types; module.exports = function (re, opts) { @@ -61877,13 +61970,13 @@ function isRegExp (x) { /***/ }), -/* 529 */ +/* 531 */ /***/ (function(module, exports, __webpack_require__) { -var util = __webpack_require__(530); -var types = __webpack_require__(531); -var sets = __webpack_require__(532); -var positions = __webpack_require__(533); +var util = __webpack_require__(532); +var types = __webpack_require__(533); +var sets = __webpack_require__(534); +var positions = __webpack_require__(535); module.exports = function(regexpStr) { @@ -62165,11 +62258,11 @@ module.exports.types = types; /***/ }), -/* 530 */ +/* 532 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(531); -var sets = __webpack_require__(532); +var types = __webpack_require__(533); +var sets = __webpack_require__(534); // All of these are private and only used by randexp. @@ -62282,7 +62375,7 @@ exports.error = function(regexp, msg) { /***/ }), -/* 531 */ +/* 533 */ /***/ (function(module, exports) { module.exports = { @@ -62298,10 +62391,10 @@ module.exports = { /***/ }), -/* 532 */ +/* 534 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(531); +var types = __webpack_require__(533); var INTS = function() { return [{ type: types.RANGE , from: 48, to: 57 }]; @@ -62386,10 +62479,10 @@ exports.anyChar = function() { /***/ }), -/* 533 */ +/* 535 */ /***/ (function(module, exports, __webpack_require__) { -var types = __webpack_require__(531); +var types = __webpack_require__(533); exports.wordBoundary = function() { return { type: types.POSITION, value: 'b' }; @@ -62409,7 +62502,7 @@ exports.end = function() { /***/ }), -/* 534 */ +/* 536 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62422,8 +62515,8 @@ exports.end = function() { -var isobject = __webpack_require__(535); -var isDescriptor = __webpack_require__(536); +var isobject = __webpack_require__(537); +var isDescriptor = __webpack_require__(538); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -62454,7 +62547,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 535 */ +/* 537 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62473,7 +62566,7 @@ module.exports = function isObject(val) { /***/ }), -/* 536 */ +/* 538 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62486,9 +62579,9 @@ module.exports = function isObject(val) { -var typeOf = __webpack_require__(537); -var isAccessor = __webpack_require__(538); -var isData = __webpack_require__(540); +var typeOf = __webpack_require__(539); +var isAccessor = __webpack_require__(540); +var isData = __webpack_require__(542); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -62502,7 +62595,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 537 */ +/* 539 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -62637,7 +62730,7 @@ function isBuffer(val) { /***/ }), -/* 538 */ +/* 540 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62650,7 +62743,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(539); +var typeOf = __webpack_require__(541); // accessor descriptor properties var accessor = { @@ -62713,7 +62806,7 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 539 */ +/* 541 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -62848,7 +62941,7 @@ function isBuffer(val) { /***/ }), -/* 540 */ +/* 542 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -62861,7 +62954,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(541); +var typeOf = __webpack_require__(543); module.exports = function isDataDescriptor(obj, prop) { // data descriptor properties @@ -62904,7 +62997,7 @@ module.exports = function isDataDescriptor(obj, prop) { /***/ }), -/* 541 */ +/* 543 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -63039,14 +63132,14 @@ function isBuffer(val) { /***/ }), -/* 542 */ +/* 544 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(543); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(545); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -63106,7 +63199,7 @@ function isEnum(obj, key) { /***/ }), -/* 543 */ +/* 545 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63119,7 +63212,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -63127,7 +63220,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 544 */ +/* 546 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63140,7 +63233,7 @@ module.exports = function isExtendable(val) { -var isObject = __webpack_require__(535); +var isObject = __webpack_require__(537); function isObjectObject(o) { return isObject(o) === true @@ -63171,7 +63264,7 @@ module.exports = function isPlainObject(o) { /***/ }), -/* 545 */ +/* 547 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63218,14 +63311,14 @@ module.exports = function(receiver, objects) { /***/ }), -/* 546 */ +/* 548 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(547); -var safe = __webpack_require__(528); +var extend = __webpack_require__(549); +var safe = __webpack_require__(530); /** * The main export is a function that takes a `pattern` string and an `options` object. @@ -63297,14 +63390,14 @@ module.exports = toRegex; /***/ }), -/* 547 */ +/* 549 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(548); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(550); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -63364,7 +63457,7 @@ function isEnum(obj, key) { /***/ }), -/* 548 */ +/* 550 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63377,7 +63470,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -63385,7 +63478,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 549 */ +/* 551 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63435,13 +63528,13 @@ module.exports.immutable = function uniqueImmutable(arr) { /***/ }), -/* 550 */ +/* 552 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(551); +var isObject = __webpack_require__(553); module.exports = function extend(o/*, objects*/) { if (!isObject(o)) { o = {}; } @@ -63475,7 +63568,7 @@ function hasOwn(obj, key) { /***/ }), -/* 551 */ +/* 553 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -63495,13 +63588,13 @@ module.exports = function isExtendable(val) { /***/ }), -/* 552 */ +/* 554 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(553); +var utils = __webpack_require__(555); module.exports = function(braces, options) { braces.compiler @@ -63784,25 +63877,25 @@ function hasQueue(node) { /***/ }), -/* 553 */ +/* 555 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var splitString = __webpack_require__(554); +var splitString = __webpack_require__(556); var utils = module.exports; /** * Module dependencies */ -utils.extend = __webpack_require__(550); -utils.flatten = __webpack_require__(557); -utils.isObject = __webpack_require__(535); -utils.fillRange = __webpack_require__(558); -utils.repeat = __webpack_require__(564); -utils.unique = __webpack_require__(549); +utils.extend = __webpack_require__(552); +utils.flatten = __webpack_require__(559); +utils.isObject = __webpack_require__(537); +utils.fillRange = __webpack_require__(560); +utils.repeat = __webpack_require__(566); +utils.unique = __webpack_require__(551); utils.define = function(obj, key, val) { Object.defineProperty(obj, key, { @@ -64134,7 +64227,7 @@ utils.escapeRegex = function(str) { /***/ }), -/* 554 */ +/* 556 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64147,7 +64240,7 @@ utils.escapeRegex = function(str) { -var extend = __webpack_require__(555); +var extend = __webpack_require__(557); module.exports = function(str, options, fn) { if (typeof str !== 'string') { @@ -64312,14 +64405,14 @@ function keepEscaping(opts, str, idx) { /***/ }), -/* 555 */ +/* 557 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(556); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(558); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -64379,7 +64472,7 @@ function isEnum(obj, key) { /***/ }), -/* 556 */ +/* 558 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64392,7 +64485,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -64400,7 +64493,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 557 */ +/* 559 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64429,7 +64522,7 @@ function flat(arr, res) { /***/ }), -/* 558 */ +/* 560 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64443,10 +64536,10 @@ function flat(arr, res) { var util = __webpack_require__(112); -var isNumber = __webpack_require__(559); -var extend = __webpack_require__(550); -var repeat = __webpack_require__(562); -var toRegex = __webpack_require__(563); +var isNumber = __webpack_require__(561); +var extend = __webpack_require__(552); +var repeat = __webpack_require__(564); +var toRegex = __webpack_require__(565); /** * Return a range of numbers or letters. @@ -64644,7 +64737,7 @@ module.exports = fillRange; /***/ }), -/* 559 */ +/* 561 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64657,7 +64750,7 @@ module.exports = fillRange; -var typeOf = __webpack_require__(560); +var typeOf = __webpack_require__(562); module.exports = function isNumber(num) { var type = typeOf(num); @@ -64673,10 +64766,10 @@ module.exports = function isNumber(num) { /***/ }), -/* 560 */ +/* 562 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(561); +var isBuffer = __webpack_require__(563); var toString = Object.prototype.toString; /** @@ -64795,7 +64888,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 561 */ +/* 563 */ /***/ (function(module, exports) { /*! @@ -64822,7 +64915,7 @@ function isSlowBuffer (obj) { /***/ }), -/* 562 */ +/* 564 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64899,7 +64992,7 @@ function repeat(str, num) { /***/ }), -/* 563 */ +/* 565 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -64912,8 +65005,8 @@ function repeat(str, num) { -var repeat = __webpack_require__(562); -var isNumber = __webpack_require__(559); +var repeat = __webpack_require__(564); +var isNumber = __webpack_require__(561); var cache = {}; function toRegexRange(min, max, options) { @@ -65200,7 +65293,7 @@ module.exports = toRegexRange; /***/ }), -/* 564 */ +/* 566 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -65225,14 +65318,14 @@ module.exports = function repeat(ele, num) { /***/ }), -/* 565 */ +/* 567 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Node = __webpack_require__(566); -var utils = __webpack_require__(553); +var Node = __webpack_require__(568); +var utils = __webpack_require__(555); /** * Braces parsers @@ -65592,15 +65685,15 @@ function concatNodes(pos, node, parent, options) { /***/ }), -/* 566 */ +/* 568 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(535); -var define = __webpack_require__(567); -var utils = __webpack_require__(568); +var isObject = __webpack_require__(537); +var define = __webpack_require__(569); +var utils = __webpack_require__(570); var ownNames; /** @@ -66091,7 +66184,7 @@ exports = module.exports = Node; /***/ }), -/* 567 */ +/* 569 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -66104,7 +66197,7 @@ exports = module.exports = Node; -var isDescriptor = __webpack_require__(536); +var isDescriptor = __webpack_require__(538); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -66129,13 +66222,13 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 568 */ +/* 570 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(560); +var typeOf = __webpack_require__(562); var utils = module.exports; /** @@ -67155,17 +67248,17 @@ function assert(val, message) { /***/ }), -/* 569 */ +/* 571 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extend = __webpack_require__(550); -var Snapdragon = __webpack_require__(570); -var compilers = __webpack_require__(552); -var parsers = __webpack_require__(565); -var utils = __webpack_require__(553); +var extend = __webpack_require__(552); +var Snapdragon = __webpack_require__(572); +var compilers = __webpack_require__(554); +var parsers = __webpack_require__(567); +var utils = __webpack_require__(555); /** * Customize Snapdragon parser and renderer @@ -67266,17 +67359,17 @@ module.exports = Braces; /***/ }), -/* 570 */ +/* 572 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var Base = __webpack_require__(571); -var define = __webpack_require__(598); -var Compiler = __webpack_require__(608); -var Parser = __webpack_require__(637); -var utils = __webpack_require__(617); +var Base = __webpack_require__(573); +var define = __webpack_require__(600); +var Compiler = __webpack_require__(610); +var Parser = __webpack_require__(639); +var utils = __webpack_require__(619); var regexCache = {}; var cache = {}; @@ -67447,20 +67540,20 @@ module.exports.Parser = Parser; /***/ }), -/* 571 */ +/* 573 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var define = __webpack_require__(572); -var CacheBase = __webpack_require__(573); -var Emitter = __webpack_require__(574); -var isObject = __webpack_require__(535); -var merge = __webpack_require__(592); -var pascal = __webpack_require__(595); -var cu = __webpack_require__(596); +var define = __webpack_require__(574); +var CacheBase = __webpack_require__(575); +var Emitter = __webpack_require__(576); +var isObject = __webpack_require__(537); +var merge = __webpack_require__(594); +var pascal = __webpack_require__(597); +var cu = __webpack_require__(598); /** * Optionally define a custom `cache` namespace to use. @@ -67889,7 +67982,7 @@ module.exports.namespace = namespace; /***/ }), -/* 572 */ +/* 574 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -67902,7 +67995,7 @@ module.exports.namespace = namespace; -var isDescriptor = __webpack_require__(536); +var isDescriptor = __webpack_require__(538); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -67927,21 +68020,21 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 573 */ +/* 575 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(535); -var Emitter = __webpack_require__(574); -var visit = __webpack_require__(575); -var toPath = __webpack_require__(578); -var union = __webpack_require__(579); -var del = __webpack_require__(583); -var get = __webpack_require__(581); -var has = __webpack_require__(588); -var set = __webpack_require__(591); +var isObject = __webpack_require__(537); +var Emitter = __webpack_require__(576); +var visit = __webpack_require__(577); +var toPath = __webpack_require__(580); +var union = __webpack_require__(581); +var del = __webpack_require__(585); +var get = __webpack_require__(583); +var has = __webpack_require__(590); +var set = __webpack_require__(593); /** * Create a `Cache` constructor that when instantiated will @@ -68195,7 +68288,7 @@ module.exports.namespace = namespace; /***/ }), -/* 574 */ +/* 576 */ /***/ (function(module, exports, __webpack_require__) { @@ -68364,7 +68457,7 @@ Emitter.prototype.hasListeners = function(event){ /***/ }), -/* 575 */ +/* 577 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68377,8 +68470,8 @@ Emitter.prototype.hasListeners = function(event){ -var visit = __webpack_require__(576); -var mapVisit = __webpack_require__(577); +var visit = __webpack_require__(578); +var mapVisit = __webpack_require__(579); module.exports = function(collection, method, val) { var result; @@ -68401,7 +68494,7 @@ module.exports = function(collection, method, val) { /***/ }), -/* 576 */ +/* 578 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68414,7 +68507,7 @@ module.exports = function(collection, method, val) { -var isObject = __webpack_require__(535); +var isObject = __webpack_require__(537); module.exports = function visit(thisArg, method, target, val) { if (!isObject(thisArg) && typeof thisArg !== 'function') { @@ -68441,14 +68534,14 @@ module.exports = function visit(thisArg, method, target, val) { /***/ }), -/* 577 */ +/* 579 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var visit = __webpack_require__(576); +var visit = __webpack_require__(578); /** * Map `visit` over an array of objects. @@ -68485,7 +68578,7 @@ function isObject(val) { /***/ }), -/* 578 */ +/* 580 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68498,7 +68591,7 @@ function isObject(val) { -var typeOf = __webpack_require__(560); +var typeOf = __webpack_require__(562); module.exports = function toPath(args) { if (typeOf(args) !== 'arguments') { @@ -68525,16 +68618,16 @@ function filter(arr) { /***/ }), -/* 579 */ +/* 581 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isObject = __webpack_require__(551); -var union = __webpack_require__(580); -var get = __webpack_require__(581); -var set = __webpack_require__(582); +var isObject = __webpack_require__(553); +var union = __webpack_require__(582); +var get = __webpack_require__(583); +var set = __webpack_require__(584); module.exports = function unionValue(obj, prop, value) { if (!isObject(obj)) { @@ -68562,7 +68655,7 @@ function arrayify(val) { /***/ }), -/* 580 */ +/* 582 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68598,7 +68691,7 @@ module.exports = function union(init) { /***/ }), -/* 581 */ +/* 583 */ /***/ (function(module, exports) { /*! @@ -68654,7 +68747,7 @@ function toString(val) { /***/ }), -/* 582 */ +/* 584 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68667,10 +68760,10 @@ function toString(val) { -var split = __webpack_require__(554); -var extend = __webpack_require__(550); -var isPlainObject = __webpack_require__(544); -var isObject = __webpack_require__(551); +var split = __webpack_require__(556); +var extend = __webpack_require__(552); +var isPlainObject = __webpack_require__(546); +var isObject = __webpack_require__(553); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -68716,7 +68809,7 @@ function isValidKey(key) { /***/ }), -/* 583 */ +/* 585 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68729,8 +68822,8 @@ function isValidKey(key) { -var isObject = __webpack_require__(535); -var has = __webpack_require__(584); +var isObject = __webpack_require__(537); +var has = __webpack_require__(586); module.exports = function unset(obj, prop) { if (!isObject(obj)) { @@ -68755,7 +68848,7 @@ module.exports = function unset(obj, prop) { /***/ }), -/* 584 */ +/* 586 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68768,9 +68861,9 @@ module.exports = function unset(obj, prop) { -var isObject = __webpack_require__(585); -var hasValues = __webpack_require__(587); -var get = __webpack_require__(581); +var isObject = __webpack_require__(587); +var hasValues = __webpack_require__(589); +var get = __webpack_require__(583); module.exports = function(obj, prop, noZero) { if (isObject(obj)) { @@ -68781,7 +68874,7 @@ module.exports = function(obj, prop, noZero) { /***/ }), -/* 585 */ +/* 587 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68794,7 +68887,7 @@ module.exports = function(obj, prop, noZero) { -var isArray = __webpack_require__(586); +var isArray = __webpack_require__(588); module.exports = function isObject(val) { return val != null && typeof val === 'object' && isArray(val) === false; @@ -68802,7 +68895,7 @@ module.exports = function isObject(val) { /***/ }), -/* 586 */ +/* 588 */ /***/ (function(module, exports) { var toString = {}.toString; @@ -68813,7 +68906,7 @@ module.exports = Array.isArray || function (arr) { /***/ }), -/* 587 */ +/* 589 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68856,7 +68949,7 @@ module.exports = function hasValue(o, noZero) { /***/ }), -/* 588 */ +/* 590 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68869,9 +68962,9 @@ module.exports = function hasValue(o, noZero) { -var isObject = __webpack_require__(535); -var hasValues = __webpack_require__(589); -var get = __webpack_require__(581); +var isObject = __webpack_require__(537); +var hasValues = __webpack_require__(591); +var get = __webpack_require__(583); module.exports = function(val, prop) { return hasValues(isObject(val) && prop ? get(val, prop) : val); @@ -68879,7 +68972,7 @@ module.exports = function(val, prop) { /***/ }), -/* 589 */ +/* 591 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -68892,8 +68985,8 @@ module.exports = function(val, prop) { -var typeOf = __webpack_require__(590); -var isNumber = __webpack_require__(559); +var typeOf = __webpack_require__(592); +var isNumber = __webpack_require__(561); module.exports = function hasValue(val) { // is-number checks for NaN and other edge cases @@ -68946,10 +69039,10 @@ module.exports = function hasValue(val) { /***/ }), -/* 590 */ +/* 592 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(561); +var isBuffer = __webpack_require__(563); var toString = Object.prototype.toString; /** @@ -69071,7 +69164,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 591 */ +/* 593 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69084,10 +69177,10 @@ module.exports = function kindOf(val) { -var split = __webpack_require__(554); -var extend = __webpack_require__(550); -var isPlainObject = __webpack_require__(544); -var isObject = __webpack_require__(551); +var split = __webpack_require__(556); +var extend = __webpack_require__(552); +var isPlainObject = __webpack_require__(546); +var isObject = __webpack_require__(553); module.exports = function(obj, prop, val) { if (!isObject(obj)) { @@ -69133,14 +69226,14 @@ function isValidKey(key) { /***/ }), -/* 592 */ +/* 594 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(593); -var forIn = __webpack_require__(594); +var isExtendable = __webpack_require__(595); +var forIn = __webpack_require__(596); function mixinDeep(target, objects) { var len = arguments.length, i = 0; @@ -69204,7 +69297,7 @@ module.exports = mixinDeep; /***/ }), -/* 593 */ +/* 595 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69217,7 +69310,7 @@ module.exports = mixinDeep; -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -69225,7 +69318,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 594 */ +/* 596 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69248,7 +69341,7 @@ module.exports = function forIn(obj, fn, thisArg) { /***/ }), -/* 595 */ +/* 597 */ /***/ (function(module, exports) { /*! @@ -69275,14 +69368,14 @@ module.exports = pascalcase; /***/ }), -/* 596 */ +/* 598 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; var util = __webpack_require__(112); -var utils = __webpack_require__(597); +var utils = __webpack_require__(599); /** * Expose class utils @@ -69647,7 +69740,7 @@ cu.bubble = function(Parent, events) { /***/ }), -/* 597 */ +/* 599 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69661,10 +69754,10 @@ var utils = {}; * Lazily required module dependencies */ -utils.union = __webpack_require__(580); -utils.define = __webpack_require__(598); -utils.isObj = __webpack_require__(535); -utils.staticExtend = __webpack_require__(605); +utils.union = __webpack_require__(582); +utils.define = __webpack_require__(600); +utils.isObj = __webpack_require__(537); +utils.staticExtend = __webpack_require__(607); /** @@ -69675,7 +69768,7 @@ module.exports = utils; /***/ }), -/* 598 */ +/* 600 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69688,7 +69781,7 @@ module.exports = utils; -var isDescriptor = __webpack_require__(599); +var isDescriptor = __webpack_require__(601); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -69713,7 +69806,7 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 599 */ +/* 601 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69726,9 +69819,9 @@ module.exports = function defineProperty(obj, prop, val) { -var typeOf = __webpack_require__(600); -var isAccessor = __webpack_require__(601); -var isData = __webpack_require__(603); +var typeOf = __webpack_require__(602); +var isAccessor = __webpack_require__(603); +var isData = __webpack_require__(605); module.exports = function isDescriptor(obj, key) { if (typeOf(obj) !== 'object') { @@ -69742,7 +69835,7 @@ module.exports = function isDescriptor(obj, key) { /***/ }), -/* 600 */ +/* 602 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -69895,7 +69988,7 @@ function isBuffer(val) { /***/ }), -/* 601 */ +/* 603 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -69908,7 +70001,7 @@ function isBuffer(val) { -var typeOf = __webpack_require__(602); +var typeOf = __webpack_require__(604); // accessor descriptor properties var accessor = { @@ -69971,10 +70064,10 @@ module.exports = isAccessorDescriptor; /***/ }), -/* 602 */ +/* 604 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(561); +var isBuffer = __webpack_require__(563); var toString = Object.prototype.toString; /** @@ -70093,7 +70186,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 603 */ +/* 605 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70106,7 +70199,7 @@ module.exports = function kindOf(val) { -var typeOf = __webpack_require__(604); +var typeOf = __webpack_require__(606); // data descriptor properties var data = { @@ -70155,10 +70248,10 @@ module.exports = isDataDescriptor; /***/ }), -/* 604 */ +/* 606 */ /***/ (function(module, exports, __webpack_require__) { -var isBuffer = __webpack_require__(561); +var isBuffer = __webpack_require__(563); var toString = Object.prototype.toString; /** @@ -70277,7 +70370,7 @@ module.exports = function kindOf(val) { /***/ }), -/* 605 */ +/* 607 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70290,8 +70383,8 @@ module.exports = function kindOf(val) { -var copy = __webpack_require__(606); -var define = __webpack_require__(598); +var copy = __webpack_require__(608); +var define = __webpack_require__(600); var util = __webpack_require__(112); /** @@ -70374,15 +70467,15 @@ module.exports = extend; /***/ }), -/* 606 */ +/* 608 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var typeOf = __webpack_require__(560); -var copyDescriptor = __webpack_require__(607); -var define = __webpack_require__(598); +var typeOf = __webpack_require__(562); +var copyDescriptor = __webpack_require__(609); +var define = __webpack_require__(600); /** * Copy static properties, prototype properties, and descriptors from one object to another. @@ -70555,7 +70648,7 @@ module.exports.has = has; /***/ }), -/* 607 */ +/* 609 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70643,16 +70736,16 @@ function isObject(val) { /***/ }), -/* 608 */ +/* 610 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(609); -var define = __webpack_require__(598); -var debug = __webpack_require__(611)('snapdragon:compiler'); -var utils = __webpack_require__(617); +var use = __webpack_require__(611); +var define = __webpack_require__(600); +var debug = __webpack_require__(613)('snapdragon:compiler'); +var utils = __webpack_require__(619); /** * Create a new `Compiler` with the given `options`. @@ -70806,7 +70899,7 @@ Compiler.prototype = { // source map support if (opts.sourcemap) { - var sourcemaps = __webpack_require__(636); + var sourcemaps = __webpack_require__(638); sourcemaps(this); this.mapVisit(this.ast.nodes); this.applySourceMaps(); @@ -70827,7 +70920,7 @@ module.exports = Compiler; /***/ }), -/* 609 */ +/* 611 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70840,7 +70933,7 @@ module.exports = Compiler; -var utils = __webpack_require__(610); +var utils = __webpack_require__(612); module.exports = function base(app, opts) { if (!utils.isObject(app) && typeof app !== 'function') { @@ -70955,7 +71048,7 @@ module.exports = function base(app, opts) { /***/ }), -/* 610 */ +/* 612 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -70969,8 +71062,8 @@ var utils = {}; * Lazily required module dependencies */ -utils.define = __webpack_require__(598); -utils.isObject = __webpack_require__(535); +utils.define = __webpack_require__(600); +utils.isObject = __webpack_require__(537); utils.isString = function(val) { @@ -70985,7 +71078,7 @@ module.exports = utils; /***/ }), -/* 611 */ +/* 613 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -70994,14 +71087,14 @@ module.exports = utils; */ if (typeof process !== 'undefined' && process.type === 'renderer') { - module.exports = __webpack_require__(612); + module.exports = __webpack_require__(614); } else { - module.exports = __webpack_require__(615); + module.exports = __webpack_require__(617); } /***/ }), -/* 612 */ +/* 614 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71010,7 +71103,7 @@ if (typeof process !== 'undefined' && process.type === 'renderer') { * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(613); +exports = module.exports = __webpack_require__(615); exports.log = log; exports.formatArgs = formatArgs; exports.save = save; @@ -71192,7 +71285,7 @@ function localstorage() { /***/ }), -/* 613 */ +/* 615 */ /***/ (function(module, exports, __webpack_require__) { @@ -71208,7 +71301,7 @@ exports.coerce = coerce; exports.disable = disable; exports.enable = enable; exports.enabled = enabled; -exports.humanize = __webpack_require__(614); +exports.humanize = __webpack_require__(616); /** * The currently active debug mode names, and names to skip. @@ -71400,7 +71493,7 @@ function coerce(val) { /***/ }), -/* 614 */ +/* 616 */ /***/ (function(module, exports) { /** @@ -71558,7 +71651,7 @@ function plural(ms, n, name) { /***/ }), -/* 615 */ +/* 617 */ /***/ (function(module, exports, __webpack_require__) { /** @@ -71574,7 +71667,7 @@ var util = __webpack_require__(112); * Expose `debug()` as the module. */ -exports = module.exports = __webpack_require__(613); +exports = module.exports = __webpack_require__(615); exports.init = init; exports.log = log; exports.formatArgs = formatArgs; @@ -71753,7 +71846,7 @@ function createWritableStdioStream (fd) { case 'PIPE': case 'TCP': - var net = __webpack_require__(616); + var net = __webpack_require__(618); stream = new net.Socket({ fd: fd, readable: false, @@ -71812,13 +71905,13 @@ exports.enable(load()); /***/ }), -/* 616 */ +/* 618 */ /***/ (function(module, exports) { module.exports = require("net"); /***/ }), -/* 617 */ +/* 619 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -71828,9 +71921,9 @@ module.exports = require("net"); * Module dependencies */ -exports.extend = __webpack_require__(550); -exports.SourceMap = __webpack_require__(618); -exports.sourceMapResolve = __webpack_require__(629); +exports.extend = __webpack_require__(552); +exports.SourceMap = __webpack_require__(620); +exports.sourceMapResolve = __webpack_require__(631); /** * Convert backslash in the given string to forward slashes @@ -71873,7 +71966,7 @@ exports.last = function(arr, n) { /***/ }), -/* 618 */ +/* 620 */ /***/ (function(module, exports, __webpack_require__) { /* @@ -71881,13 +71974,13 @@ exports.last = function(arr, n) { * Licensed under the New BSD license. See LICENSE.txt or: * http://opensource.org/licenses/BSD-3-Clause */ -exports.SourceMapGenerator = __webpack_require__(619).SourceMapGenerator; -exports.SourceMapConsumer = __webpack_require__(625).SourceMapConsumer; -exports.SourceNode = __webpack_require__(628).SourceNode; +exports.SourceMapGenerator = __webpack_require__(621).SourceMapGenerator; +exports.SourceMapConsumer = __webpack_require__(627).SourceMapConsumer; +exports.SourceNode = __webpack_require__(630).SourceNode; /***/ }), -/* 619 */ +/* 621 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -71897,10 +71990,10 @@ exports.SourceNode = __webpack_require__(628).SourceNode; * http://opensource.org/licenses/BSD-3-Clause */ -var base64VLQ = __webpack_require__(620); -var util = __webpack_require__(622); -var ArraySet = __webpack_require__(623).ArraySet; -var MappingList = __webpack_require__(624).MappingList; +var base64VLQ = __webpack_require__(622); +var util = __webpack_require__(624); +var ArraySet = __webpack_require__(625).ArraySet; +var MappingList = __webpack_require__(626).MappingList; /** * An instance of the SourceMapGenerator represents a source map which is @@ -72309,7 +72402,7 @@ exports.SourceMapGenerator = SourceMapGenerator; /***/ }), -/* 620 */ +/* 622 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72349,7 +72442,7 @@ exports.SourceMapGenerator = SourceMapGenerator; * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -var base64 = __webpack_require__(621); +var base64 = __webpack_require__(623); // A single base 64 digit can contain 6 bits of data. For the base 64 variable // length quantities we use in the source map spec, the first bit is the sign, @@ -72455,7 +72548,7 @@ exports.decode = function base64VLQ_decode(aStr, aIndex, aOutParam) { /***/ }), -/* 621 */ +/* 623 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72528,7 +72621,7 @@ exports.decode = function (charCode) { /***/ }), -/* 622 */ +/* 624 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72951,7 +73044,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate /***/ }), -/* 623 */ +/* 625 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -72961,7 +73054,7 @@ exports.compareByGeneratedPositionsInflated = compareByGeneratedPositionsInflate * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(622); +var util = __webpack_require__(624); var has = Object.prototype.hasOwnProperty; var hasNativeMap = typeof Map !== "undefined"; @@ -73078,7 +73171,7 @@ exports.ArraySet = ArraySet; /***/ }), -/* 624 */ +/* 626 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73088,7 +73181,7 @@ exports.ArraySet = ArraySet; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(622); +var util = __webpack_require__(624); /** * Determine whether mappingB is after mappingA with respect to generated @@ -73163,7 +73256,7 @@ exports.MappingList = MappingList; /***/ }), -/* 625 */ +/* 627 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -73173,11 +73266,11 @@ exports.MappingList = MappingList; * http://opensource.org/licenses/BSD-3-Clause */ -var util = __webpack_require__(622); -var binarySearch = __webpack_require__(626); -var ArraySet = __webpack_require__(623).ArraySet; -var base64VLQ = __webpack_require__(620); -var quickSort = __webpack_require__(627).quickSort; +var util = __webpack_require__(624); +var binarySearch = __webpack_require__(628); +var ArraySet = __webpack_require__(625).ArraySet; +var base64VLQ = __webpack_require__(622); +var quickSort = __webpack_require__(629).quickSort; function SourceMapConsumer(aSourceMap) { var sourceMap = aSourceMap; @@ -74251,7 +74344,7 @@ exports.IndexedSourceMapConsumer = IndexedSourceMapConsumer; /***/ }), -/* 626 */ +/* 628 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74368,7 +74461,7 @@ exports.search = function search(aNeedle, aHaystack, aCompare, aBias) { /***/ }), -/* 627 */ +/* 629 */ /***/ (function(module, exports) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74488,7 +74581,7 @@ exports.quickSort = function (ary, comparator) { /***/ }), -/* 628 */ +/* 630 */ /***/ (function(module, exports, __webpack_require__) { /* -*- Mode: js; js-indent-level: 2; -*- */ @@ -74498,8 +74591,8 @@ exports.quickSort = function (ary, comparator) { * http://opensource.org/licenses/BSD-3-Clause */ -var SourceMapGenerator = __webpack_require__(619).SourceMapGenerator; -var util = __webpack_require__(622); +var SourceMapGenerator = __webpack_require__(621).SourceMapGenerator; +var util = __webpack_require__(624); // Matches a Windows-style `\r\n` newline or a `\n` newline used by all other // operating systems these days (capturing the result). @@ -74907,17 +75000,17 @@ exports.SourceNode = SourceNode; /***/ }), -/* 629 */ +/* 631 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014, 2015, 2016, 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var sourceMappingURL = __webpack_require__(630) -var resolveUrl = __webpack_require__(631) -var decodeUriComponent = __webpack_require__(632) -var urix = __webpack_require__(634) -var atob = __webpack_require__(635) +var sourceMappingURL = __webpack_require__(632) +var resolveUrl = __webpack_require__(633) +var decodeUriComponent = __webpack_require__(634) +var urix = __webpack_require__(636) +var atob = __webpack_require__(637) @@ -75215,7 +75308,7 @@ module.exports = { /***/ }), -/* 630 */ +/* 632 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_RESULT__;// Copyright 2014 Simon Lydell @@ -75278,7 +75371,7 @@ void (function(root, factory) { /***/ }), -/* 631 */ +/* 633 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75296,13 +75389,13 @@ module.exports = resolveUrl /***/ }), -/* 632 */ +/* 634 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2017 Simon Lydell // X11 (“MIT”) Licensed. (See LICENSE.) -var decodeUriComponent = __webpack_require__(633) +var decodeUriComponent = __webpack_require__(635) function customDecodeUriComponent(string) { // `decodeUriComponent` turns `+` into ` `, but that's not wanted. @@ -75313,7 +75406,7 @@ module.exports = customDecodeUriComponent /***/ }), -/* 633 */ +/* 635 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75414,7 +75507,7 @@ module.exports = function (encodedURI) { /***/ }), -/* 634 */ +/* 636 */ /***/ (function(module, exports, __webpack_require__) { // Copyright 2014 Simon Lydell @@ -75437,7 +75530,7 @@ module.exports = urix /***/ }), -/* 635 */ +/* 637 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75451,7 +75544,7 @@ module.exports = atob.atob = atob; /***/ }), -/* 636 */ +/* 638 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -75459,8 +75552,8 @@ module.exports = atob.atob = atob; var fs = __webpack_require__(134); var path = __webpack_require__(4); -var define = __webpack_require__(598); -var utils = __webpack_require__(617); +var define = __webpack_require__(600); +var utils = __webpack_require__(619); /** * Expose `mixin()`. @@ -75603,19 +75696,19 @@ exports.comment = function(node) { /***/ }), -/* 637 */ +/* 639 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var use = __webpack_require__(609); +var use = __webpack_require__(611); var util = __webpack_require__(112); -var Cache = __webpack_require__(638); -var define = __webpack_require__(598); -var debug = __webpack_require__(611)('snapdragon:parser'); -var Position = __webpack_require__(639); -var utils = __webpack_require__(617); +var Cache = __webpack_require__(640); +var define = __webpack_require__(600); +var debug = __webpack_require__(613)('snapdragon:parser'); +var Position = __webpack_require__(641); +var utils = __webpack_require__(619); /** * Create a new `Parser` with the given `input` and `options`. @@ -76143,7 +76236,7 @@ module.exports = Parser; /***/ }), -/* 638 */ +/* 640 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76250,13 +76343,13 @@ MapCache.prototype.del = function mapDelete(key) { /***/ }), -/* 639 */ +/* 641 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var define = __webpack_require__(598); +var define = __webpack_require__(600); /** * Store position for a node @@ -76271,14 +76364,14 @@ module.exports = function Position(start, parser) { /***/ }), -/* 640 */ +/* 642 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(641); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(643); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -76338,7 +76431,7 @@ function isEnum(obj, key) { /***/ }), -/* 641 */ +/* 643 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76351,7 +76444,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -76359,14 +76452,14 @@ module.exports = function isExtendable(val) { /***/ }), -/* 642 */ +/* 644 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var nanomatch = __webpack_require__(643); -var extglob = __webpack_require__(658); +var nanomatch = __webpack_require__(645); +var extglob = __webpack_require__(660); module.exports = function(snapdragon) { var compilers = snapdragon.compiler.compilers; @@ -76443,7 +76536,7 @@ function escapeExtglobs(compiler) { /***/ }), -/* 643 */ +/* 645 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -76454,17 +76547,17 @@ function escapeExtglobs(compiler) { */ var util = __webpack_require__(112); -var toRegex = __webpack_require__(527); -var extend = __webpack_require__(644); +var toRegex = __webpack_require__(529); +var extend = __webpack_require__(646); /** * Local dependencies */ -var compilers = __webpack_require__(646); -var parsers = __webpack_require__(647); -var cache = __webpack_require__(650); -var utils = __webpack_require__(652); +var compilers = __webpack_require__(648); +var parsers = __webpack_require__(649); +var cache = __webpack_require__(652); +var utils = __webpack_require__(654); var MAX_LENGTH = 1024 * 64; /** @@ -77288,14 +77381,14 @@ module.exports = nanomatch; /***/ }), -/* 644 */ +/* 646 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var isExtendable = __webpack_require__(645); -var assignSymbols = __webpack_require__(545); +var isExtendable = __webpack_require__(647); +var assignSymbols = __webpack_require__(547); module.exports = Object.assign || function(obj/*, objects*/) { if (obj === null || typeof obj === 'undefined') { @@ -77355,7 +77448,7 @@ function isEnum(obj, key) { /***/ }), -/* 645 */ +/* 647 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77368,7 +77461,7 @@ function isEnum(obj, key) { -var isPlainObject = __webpack_require__(544); +var isPlainObject = __webpack_require__(546); module.exports = function isExtendable(val) { return isPlainObject(val) || typeof val === 'function' || Array.isArray(val); @@ -77376,7 +77469,7 @@ module.exports = function isExtendable(val) { /***/ }), -/* 646 */ +/* 648 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -77722,15 +77815,15 @@ module.exports = function(nanomatch, options) { /***/ }), -/* 647 */ +/* 649 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regexNot = __webpack_require__(546); -var toRegex = __webpack_require__(527); -var isOdd = __webpack_require__(648); +var regexNot = __webpack_require__(548); +var toRegex = __webpack_require__(529); +var isOdd = __webpack_require__(650); /** * Characters to use in negation regex (we want to "not" match @@ -78116,7 +78209,7 @@ module.exports.not = NOT_REGEX; /***/ }), -/* 648 */ +/* 650 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78129,7 +78222,7 @@ module.exports.not = NOT_REGEX; -var isNumber = __webpack_require__(649); +var isNumber = __webpack_require__(651); module.exports = function isOdd(i) { if (!isNumber(i)) { @@ -78143,7 +78236,7 @@ module.exports = function isOdd(i) { /***/ }), -/* 649 */ +/* 651 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78171,14 +78264,14 @@ module.exports = function isNumber(num) { /***/ }), -/* 650 */ +/* 652 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(651))(); +module.exports = new (__webpack_require__(653))(); /***/ }), -/* 651 */ +/* 653 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78191,7 +78284,7 @@ module.exports = new (__webpack_require__(651))(); -var MapCache = __webpack_require__(638); +var MapCache = __webpack_require__(640); /** * Create a new `FragmentCache` with an optional object to use for `caches`. @@ -78313,7 +78406,7 @@ exports = module.exports = FragmentCache; /***/ }), -/* 652 */ +/* 654 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78326,14 +78419,14 @@ var path = __webpack_require__(4); * Module dependencies */ -var isWindows = __webpack_require__(653)(); -var Snapdragon = __webpack_require__(570); -utils.define = __webpack_require__(654); -utils.diff = __webpack_require__(655); -utils.extend = __webpack_require__(644); -utils.pick = __webpack_require__(656); -utils.typeOf = __webpack_require__(657); -utils.unique = __webpack_require__(549); +var isWindows = __webpack_require__(655)(); +var Snapdragon = __webpack_require__(572); +utils.define = __webpack_require__(656); +utils.diff = __webpack_require__(657); +utils.extend = __webpack_require__(646); +utils.pick = __webpack_require__(658); +utils.typeOf = __webpack_require__(659); +utils.unique = __webpack_require__(551); /** * Returns true if the given value is effectively an empty string @@ -78699,7 +78792,7 @@ utils.unixify = function(options) { /***/ }), -/* 653 */ +/* 655 */ /***/ (function(module, exports, __webpack_require__) { var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;/*! @@ -78727,7 +78820,7 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ /***/ }), -/* 654 */ +/* 656 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78740,8 +78833,8 @@ var __WEBPACK_AMD_DEFINE_FACTORY__, __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_ -var isobject = __webpack_require__(535); -var isDescriptor = __webpack_require__(536); +var isobject = __webpack_require__(537); +var isDescriptor = __webpack_require__(538); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -78772,7 +78865,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 655 */ +/* 657 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78826,7 +78919,7 @@ function diffArray(one, two) { /***/ }), -/* 656 */ +/* 658 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -78839,7 +78932,7 @@ function diffArray(one, two) { -var isObject = __webpack_require__(535); +var isObject = __webpack_require__(537); module.exports = function pick(obj, keys) { if (!isObject(obj) && typeof obj !== 'function') { @@ -78868,7 +78961,7 @@ module.exports = function pick(obj, keys) { /***/ }), -/* 657 */ +/* 659 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -79003,7 +79096,7 @@ function isBuffer(val) { /***/ }), -/* 658 */ +/* 660 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79013,18 +79106,18 @@ function isBuffer(val) { * Module dependencies */ -var extend = __webpack_require__(550); -var unique = __webpack_require__(549); -var toRegex = __webpack_require__(527); +var extend = __webpack_require__(552); +var unique = __webpack_require__(551); +var toRegex = __webpack_require__(529); /** * Local dependencies */ -var compilers = __webpack_require__(659); -var parsers = __webpack_require__(665); -var Extglob = __webpack_require__(668); -var utils = __webpack_require__(667); +var compilers = __webpack_require__(661); +var parsers = __webpack_require__(667); +var Extglob = __webpack_require__(670); +var utils = __webpack_require__(669); var MAX_LENGTH = 1024 * 64; /** @@ -79341,13 +79434,13 @@ module.exports = extglob; /***/ }), -/* 659 */ +/* 661 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(660); +var brackets = __webpack_require__(662); /** * Extglob compilers @@ -79517,7 +79610,7 @@ module.exports = function(extglob) { /***/ }), -/* 660 */ +/* 662 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79527,17 +79620,17 @@ module.exports = function(extglob) { * Local dependencies */ -var compilers = __webpack_require__(661); -var parsers = __webpack_require__(663); +var compilers = __webpack_require__(663); +var parsers = __webpack_require__(665); /** * Module dependencies */ -var debug = __webpack_require__(611)('expand-brackets'); -var extend = __webpack_require__(550); -var Snapdragon = __webpack_require__(570); -var toRegex = __webpack_require__(527); +var debug = __webpack_require__(613)('expand-brackets'); +var extend = __webpack_require__(552); +var Snapdragon = __webpack_require__(572); +var toRegex = __webpack_require__(529); /** * Parses the given POSIX character class `pattern` and returns a @@ -79735,13 +79828,13 @@ module.exports = brackets; /***/ }), -/* 661 */ +/* 663 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var posix = __webpack_require__(662); +var posix = __webpack_require__(664); module.exports = function(brackets) { brackets.compiler @@ -79829,7 +79922,7 @@ module.exports = function(brackets) { /***/ }), -/* 662 */ +/* 664 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -79858,14 +79951,14 @@ module.exports = { /***/ }), -/* 663 */ +/* 665 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var utils = __webpack_require__(664); -var define = __webpack_require__(598); +var utils = __webpack_require__(666); +var define = __webpack_require__(600); /** * Text regex @@ -80084,14 +80177,14 @@ module.exports.TEXT_REGEX = TEXT_REGEX; /***/ }), -/* 664 */ +/* 666 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var toRegex = __webpack_require__(527); -var regexNot = __webpack_require__(546); +var toRegex = __webpack_require__(529); +var regexNot = __webpack_require__(548); var cached; /** @@ -80125,15 +80218,15 @@ exports.createRegex = function(pattern, include) { /***/ }), -/* 665 */ +/* 667 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var brackets = __webpack_require__(660); -var define = __webpack_require__(666); -var utils = __webpack_require__(667); +var brackets = __webpack_require__(662); +var define = __webpack_require__(668); +var utils = __webpack_require__(669); /** * Characters to use in text regex (we want to "not" match @@ -80288,7 +80381,7 @@ module.exports = parsers; /***/ }), -/* 666 */ +/* 668 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80301,7 +80394,7 @@ module.exports = parsers; -var isDescriptor = __webpack_require__(536); +var isDescriptor = __webpack_require__(538); module.exports = function defineProperty(obj, prop, val) { if (typeof obj !== 'object' && typeof obj !== 'function') { @@ -80326,14 +80419,14 @@ module.exports = function defineProperty(obj, prop, val) { /***/ }), -/* 667 */ +/* 669 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var regex = __webpack_require__(546); -var Cache = __webpack_require__(651); +var regex = __webpack_require__(548); +var Cache = __webpack_require__(653); /** * Utils @@ -80402,7 +80495,7 @@ utils.createRegex = function(str) { /***/ }), -/* 668 */ +/* 670 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80412,16 +80505,16 @@ utils.createRegex = function(str) { * Module dependencies */ -var Snapdragon = __webpack_require__(570); -var define = __webpack_require__(666); -var extend = __webpack_require__(550); +var Snapdragon = __webpack_require__(572); +var define = __webpack_require__(668); +var extend = __webpack_require__(552); /** * Local dependencies */ -var compilers = __webpack_require__(659); -var parsers = __webpack_require__(665); +var compilers = __webpack_require__(661); +var parsers = __webpack_require__(667); /** * Customize Snapdragon parser and renderer @@ -80487,16 +80580,16 @@ module.exports = Extglob; /***/ }), -/* 669 */ +/* 671 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -var extglob = __webpack_require__(658); -var nanomatch = __webpack_require__(643); -var regexNot = __webpack_require__(546); -var toRegex = __webpack_require__(527); +var extglob = __webpack_require__(660); +var nanomatch = __webpack_require__(645); +var regexNot = __webpack_require__(548); +var toRegex = __webpack_require__(529); var not; /** @@ -80577,14 +80670,14 @@ function textRegex(pattern) { /***/ }), -/* 670 */ +/* 672 */ /***/ (function(module, exports, __webpack_require__) { -module.exports = new (__webpack_require__(651))(); +module.exports = new (__webpack_require__(653))(); /***/ }), -/* 671 */ +/* 673 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80597,13 +80690,13 @@ var path = __webpack_require__(4); * Module dependencies */ -var Snapdragon = __webpack_require__(570); -utils.define = __webpack_require__(672); -utils.diff = __webpack_require__(655); -utils.extend = __webpack_require__(640); -utils.pick = __webpack_require__(656); -utils.typeOf = __webpack_require__(673); -utils.unique = __webpack_require__(549); +var Snapdragon = __webpack_require__(572); +utils.define = __webpack_require__(674); +utils.diff = __webpack_require__(657); +utils.extend = __webpack_require__(642); +utils.pick = __webpack_require__(658); +utils.typeOf = __webpack_require__(675); +utils.unique = __webpack_require__(551); /** * Returns true if the platform is windows, or `path.sep` is `\\`. @@ -80900,7 +80993,7 @@ utils.unixify = function(options) { /***/ }), -/* 672 */ +/* 674 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -80913,8 +81006,8 @@ utils.unixify = function(options) { -var isobject = __webpack_require__(535); -var isDescriptor = __webpack_require__(536); +var isobject = __webpack_require__(537); +var isDescriptor = __webpack_require__(538); var define = (typeof Reflect !== 'undefined' && Reflect.defineProperty) ? Reflect.defineProperty : Object.defineProperty; @@ -80945,7 +81038,7 @@ module.exports = function defineProperty(obj, key, val) { /***/ }), -/* 673 */ +/* 675 */ /***/ (function(module, exports) { var toString = Object.prototype.toString; @@ -81080,7 +81173,7 @@ function isBuffer(val) { /***/ }), -/* 674 */ +/* 676 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81099,9 +81192,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(675); -var reader_1 = __webpack_require__(688); -var fs_stream_1 = __webpack_require__(692); +var readdir = __webpack_require__(677); +var reader_1 = __webpack_require__(690); +var fs_stream_1 = __webpack_require__(694); var ReaderAsync = /** @class */ (function (_super) { __extends(ReaderAsync, _super); function ReaderAsync() { @@ -81162,15 +81255,15 @@ exports.default = ReaderAsync; /***/ }), -/* 675 */ +/* 677 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const readdirSync = __webpack_require__(676); -const readdirAsync = __webpack_require__(684); -const readdirStream = __webpack_require__(687); +const readdirSync = __webpack_require__(678); +const readdirAsync = __webpack_require__(686); +const readdirStream = __webpack_require__(689); module.exports = exports = readdirAsyncPath; exports.readdir = exports.readdirAsync = exports.async = readdirAsyncPath; @@ -81254,7 +81347,7 @@ function readdirStreamStat (dir, options) { /***/ }), -/* 676 */ +/* 678 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81262,11 +81355,11 @@ function readdirStreamStat (dir, options) { module.exports = readdirSync; -const DirectoryReader = __webpack_require__(677); +const DirectoryReader = __webpack_require__(679); let syncFacade = { - fs: __webpack_require__(682), - forEach: __webpack_require__(683), + fs: __webpack_require__(684), + forEach: __webpack_require__(685), sync: true }; @@ -81295,7 +81388,7 @@ function readdirSync (dir, options, internalOptions) { /***/ }), -/* 677 */ +/* 679 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -81304,9 +81397,9 @@ function readdirSync (dir, options, internalOptions) { const Readable = __webpack_require__(138).Readable; const EventEmitter = __webpack_require__(156).EventEmitter; const path = __webpack_require__(4); -const normalizeOptions = __webpack_require__(678); -const stat = __webpack_require__(680); -const call = __webpack_require__(681); +const normalizeOptions = __webpack_require__(680); +const stat = __webpack_require__(682); +const call = __webpack_require__(683); /** * Asynchronously reads the contents of a directory and streams the results @@ -81682,14 +81775,14 @@ module.exports = DirectoryReader; /***/ }), -/* 678 */ +/* 680 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const globToRegExp = __webpack_require__(679); +const globToRegExp = __webpack_require__(681); module.exports = normalizeOptions; @@ -81866,7 +81959,7 @@ function normalizeOptions (options, internalOptions) { /***/ }), -/* 679 */ +/* 681 */ /***/ (function(module, exports) { module.exports = function (glob, opts) { @@ -82003,13 +82096,13 @@ module.exports = function (glob, opts) { /***/ }), -/* 680 */ +/* 682 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const call = __webpack_require__(681); +const call = __webpack_require__(683); module.exports = stat; @@ -82084,7 +82177,7 @@ function symlinkStat (fs, path, lstats, callback) { /***/ }), -/* 681 */ +/* 683 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82145,14 +82238,14 @@ function callOnce (fn) { /***/ }), -/* 682 */ +/* 684 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const call = __webpack_require__(681); +const call = __webpack_require__(683); /** * A facade around {@link fs.readdirSync} that allows it to be called @@ -82216,7 +82309,7 @@ exports.lstat = function (path, callback) { /***/ }), -/* 683 */ +/* 685 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82245,7 +82338,7 @@ function syncForEach (array, iterator, done) { /***/ }), -/* 684 */ +/* 686 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82253,12 +82346,12 @@ function syncForEach (array, iterator, done) { module.exports = readdirAsync; -const maybe = __webpack_require__(685); -const DirectoryReader = __webpack_require__(677); +const maybe = __webpack_require__(687); +const DirectoryReader = __webpack_require__(679); let asyncFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(686), + forEach: __webpack_require__(688), async: true }; @@ -82300,7 +82393,7 @@ function readdirAsync (dir, options, callback, internalOptions) { /***/ }), -/* 685 */ +/* 687 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82327,7 +82420,7 @@ module.exports = function maybe (cb, promise) { /***/ }), -/* 686 */ +/* 688 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82363,7 +82456,7 @@ function asyncForEach (array, iterator, done) { /***/ }), -/* 687 */ +/* 689 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82371,11 +82464,11 @@ function asyncForEach (array, iterator, done) { module.exports = readdirStream; -const DirectoryReader = __webpack_require__(677); +const DirectoryReader = __webpack_require__(679); let streamFacade = { fs: __webpack_require__(134), - forEach: __webpack_require__(686), + forEach: __webpack_require__(688), async: true }; @@ -82395,16 +82488,16 @@ function readdirStream (dir, options, internalOptions) { /***/ }), -/* 688 */ +/* 690 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var path = __webpack_require__(4); -var deep_1 = __webpack_require__(689); -var entry_1 = __webpack_require__(691); -var pathUtil = __webpack_require__(690); +var deep_1 = __webpack_require__(691); +var entry_1 = __webpack_require__(693); +var pathUtil = __webpack_require__(692); var Reader = /** @class */ (function () { function Reader(options) { this.options = options; @@ -82470,14 +82563,14 @@ exports.default = Reader; /***/ }), -/* 689 */ +/* 691 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(690); -var patternUtils = __webpack_require__(521); +var pathUtils = __webpack_require__(692); +var patternUtils = __webpack_require__(523); var DeepFilter = /** @class */ (function () { function DeepFilter(options, micromatchOptions) { this.options = options; @@ -82560,7 +82653,7 @@ exports.default = DeepFilter; /***/ }), -/* 690 */ +/* 692 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82591,14 +82684,14 @@ exports.makeAbsolute = makeAbsolute; /***/ }), -/* 691 */ +/* 693 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -var pathUtils = __webpack_require__(690); -var patternUtils = __webpack_require__(521); +var pathUtils = __webpack_require__(692); +var patternUtils = __webpack_require__(523); var EntryFilter = /** @class */ (function () { function EntryFilter(options, micromatchOptions) { this.options = options; @@ -82683,7 +82776,7 @@ exports.default = EntryFilter; /***/ }), -/* 692 */ +/* 694 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82703,8 +82796,8 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var fsStat = __webpack_require__(693); -var fs_1 = __webpack_require__(697); +var fsStat = __webpack_require__(695); +var fs_1 = __webpack_require__(699); var FileSystemStream = /** @class */ (function (_super) { __extends(FileSystemStream, _super); function FileSystemStream() { @@ -82754,14 +82847,14 @@ exports.default = FileSystemStream; /***/ }), -/* 693 */ +/* 695 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const optionsManager = __webpack_require__(694); -const statProvider = __webpack_require__(696); +const optionsManager = __webpack_require__(696); +const statProvider = __webpack_require__(698); /** * Asynchronous API. */ @@ -82792,13 +82885,13 @@ exports.statSync = statSync; /***/ }), -/* 694 */ +/* 696 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -const fsAdapter = __webpack_require__(695); +const fsAdapter = __webpack_require__(697); function prepare(opts) { const options = Object.assign({ fs: fsAdapter.getFileSystemAdapter(opts ? opts.fs : undefined), @@ -82811,7 +82904,7 @@ exports.prepare = prepare; /***/ }), -/* 695 */ +/* 697 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82834,7 +82927,7 @@ exports.getFileSystemAdapter = getFileSystemAdapter; /***/ }), -/* 696 */ +/* 698 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82886,7 +82979,7 @@ exports.isFollowedSymlink = isFollowedSymlink; /***/ }), -/* 697 */ +/* 699 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82917,7 +83010,7 @@ exports.default = FileSystem; /***/ }), -/* 698 */ +/* 700 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -82937,9 +83030,9 @@ var __extends = (this && this.__extends) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); var stream = __webpack_require__(138); -var readdir = __webpack_require__(675); -var reader_1 = __webpack_require__(688); -var fs_stream_1 = __webpack_require__(692); +var readdir = __webpack_require__(677); +var reader_1 = __webpack_require__(690); +var fs_stream_1 = __webpack_require__(694); var TransformStream = /** @class */ (function (_super) { __extends(TransformStream, _super); function TransformStream(reader) { @@ -83007,7 +83100,7 @@ exports.default = ReaderStream; /***/ }), -/* 699 */ +/* 701 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83026,9 +83119,9 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var readdir = __webpack_require__(675); -var reader_1 = __webpack_require__(688); -var fs_sync_1 = __webpack_require__(700); +var readdir = __webpack_require__(677); +var reader_1 = __webpack_require__(690); +var fs_sync_1 = __webpack_require__(702); var ReaderSync = /** @class */ (function (_super) { __extends(ReaderSync, _super); function ReaderSync() { @@ -83088,7 +83181,7 @@ exports.default = ReaderSync; /***/ }), -/* 700 */ +/* 702 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83107,8 +83200,8 @@ var __extends = (this && this.__extends) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -var fsStat = __webpack_require__(693); -var fs_1 = __webpack_require__(697); +var fsStat = __webpack_require__(695); +var fs_1 = __webpack_require__(699); var FileSystemSync = /** @class */ (function (_super) { __extends(FileSystemSync, _super); function FileSystemSync() { @@ -83154,7 +83247,7 @@ exports.default = FileSystemSync; /***/ }), -/* 701 */ +/* 703 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83170,7 +83263,7 @@ exports.flatten = flatten; /***/ }), -/* 702 */ +/* 704 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83191,13 +83284,13 @@ exports.merge = merge; /***/ }), -/* 703 */ +/* 705 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); -const pathType = __webpack_require__(704); +const pathType = __webpack_require__(706); const getExtensions = extensions => extensions.length > 1 ? `{${extensions.join(',')}}` : extensions[0]; @@ -83263,13 +83356,13 @@ module.exports.sync = (input, opts) => { /***/ }), -/* 704 */ +/* 706 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); -const pify = __webpack_require__(705); +const pify = __webpack_require__(707); function type(fn, fn2, fp) { if (typeof fp !== 'string') { @@ -83312,7 +83405,7 @@ exports.symlinkSync = typeSync.bind(null, 'lstatSync', 'isSymbolicLink'); /***/ }), -/* 705 */ +/* 707 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -83403,17 +83496,17 @@ module.exports = (obj, opts) => { /***/ }), -/* 706 */ +/* 708 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const fs = __webpack_require__(134); const path = __webpack_require__(4); -const fastGlob = __webpack_require__(517); -const gitIgnore = __webpack_require__(707); -const pify = __webpack_require__(708); -const slash = __webpack_require__(709); +const fastGlob = __webpack_require__(519); +const gitIgnore = __webpack_require__(709); +const pify = __webpack_require__(710); +const slash = __webpack_require__(711); const DEFAULT_IGNORE = [ '**/node_modules/**', @@ -83511,7 +83604,7 @@ module.exports.sync = options => { /***/ }), -/* 707 */ +/* 709 */ /***/ (function(module, exports) { // A simple implementation of make-array @@ -83980,7 +84073,7 @@ module.exports = options => new IgnoreBase(options) /***/ }), -/* 708 */ +/* 710 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84055,7 +84148,7 @@ module.exports = (input, options) => { /***/ }), -/* 709 */ +/* 711 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84073,7 +84166,7 @@ module.exports = input => { /***/ }), -/* 710 */ +/* 712 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84086,7 +84179,7 @@ module.exports = input => { -var isGlob = __webpack_require__(711); +var isGlob = __webpack_require__(713); module.exports = function hasGlob(val) { if (val == null) return false; @@ -84106,7 +84199,7 @@ module.exports = function hasGlob(val) { /***/ }), -/* 711 */ +/* 713 */ /***/ (function(module, exports, __webpack_require__) { /*! @@ -84137,17 +84230,17 @@ module.exports = function isGlob(str) { /***/ }), -/* 712 */ +/* 714 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const path = __webpack_require__(4); const {constants: fsConstants} = __webpack_require__(134); -const pEvent = __webpack_require__(713); -const CpFileError = __webpack_require__(716); -const fs = __webpack_require__(718); -const ProgressEmitter = __webpack_require__(721); +const pEvent = __webpack_require__(715); +const CpFileError = __webpack_require__(718); +const fs = __webpack_require__(720); +const ProgressEmitter = __webpack_require__(723); const cpFileAsync = async (source, destination, options, progressEmitter) => { let readError; @@ -84261,12 +84354,12 @@ module.exports.sync = (source, destination, options) => { /***/ }), -/* 713 */ +/* 715 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pTimeout = __webpack_require__(714); +const pTimeout = __webpack_require__(716); const symbolAsyncIterator = Symbol.asyncIterator || '@@asyncIterator'; @@ -84557,12 +84650,12 @@ module.exports.iterator = (emitter, event, options) => { /***/ }), -/* 714 */ +/* 716 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pFinally = __webpack_require__(715); +const pFinally = __webpack_require__(717); class TimeoutError extends Error { constructor(message) { @@ -84608,7 +84701,7 @@ module.exports.TimeoutError = TimeoutError; /***/ }), -/* 715 */ +/* 717 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84630,12 +84723,12 @@ module.exports = (promise, onFinally) => { /***/ }), -/* 716 */ +/* 718 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(717); +const NestedError = __webpack_require__(719); class CpFileError extends NestedError { constructor(message, nested) { @@ -84649,7 +84742,7 @@ module.exports = CpFileError; /***/ }), -/* 717 */ +/* 719 */ /***/ (function(module, exports, __webpack_require__) { var inherits = __webpack_require__(112).inherits; @@ -84705,16 +84798,16 @@ module.exports = NestedError; /***/ }), -/* 718 */ +/* 720 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; const {promisify} = __webpack_require__(112); const fs = __webpack_require__(133); -const makeDir = __webpack_require__(719); -const pEvent = __webpack_require__(713); -const CpFileError = __webpack_require__(716); +const makeDir = __webpack_require__(721); +const pEvent = __webpack_require__(715); +const CpFileError = __webpack_require__(718); const stat = promisify(fs.stat); const lstat = promisify(fs.lstat); @@ -84811,7 +84904,7 @@ exports.copyFileSync = (source, destination, flags) => { /***/ }), -/* 719 */ +/* 721 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -84819,7 +84912,7 @@ exports.copyFileSync = (source, destination, flags) => { const fs = __webpack_require__(134); const path = __webpack_require__(4); const {promisify} = __webpack_require__(112); -const semver = __webpack_require__(720); +const semver = __webpack_require__(722); const useNativeRecursiveOption = semver.satisfies(process.version, '>=10.12.0'); @@ -84974,7 +85067,7 @@ module.exports.sync = (input, options) => { /***/ }), -/* 720 */ +/* 722 */ /***/ (function(module, exports) { exports = module.exports = SemVer @@ -86576,7 +86669,7 @@ function coerce (version, options) { /***/ }), -/* 721 */ +/* 723 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86617,7 +86710,7 @@ module.exports = ProgressEmitter; /***/ }), -/* 722 */ +/* 724 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86663,12 +86756,12 @@ exports.default = module.exports; /***/ }), -/* 723 */ +/* 725 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const pMap = __webpack_require__(724); +const pMap = __webpack_require__(726); const pFilter = async (iterable, filterer, options) => { const values = await pMap( @@ -86685,7 +86778,7 @@ module.exports.default = pFilter; /***/ }), -/* 724 */ +/* 726 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -86764,12 +86857,12 @@ module.exports.default = pMap; /***/ }), -/* 725 */ +/* 727 */ /***/ (function(module, exports, __webpack_require__) { "use strict"; -const NestedError = __webpack_require__(717); +const NestedError = __webpack_require__(719); class CpyError extends NestedError { constructor(message, nested) { diff --git a/packages/kbn-pm/src/cli.ts b/packages/kbn-pm/src/cli.ts index a27587085eab1..e6be8d1821d01 100644 --- a/packages/kbn-pm/src/cli.ts +++ b/packages/kbn-pm/src/cli.ts @@ -41,6 +41,9 @@ function help() { --debug Set log level to debug --quiet Set log level to error --silent Disable log output + + "run" options: + --skip-missing Ignore packages which don't have the requested script ` + '\n' ); } @@ -49,7 +52,7 @@ export async function run(argv: string[]) { log.setLogLevel( pickLevelFromFlags( getopts(argv, { - boolean: ['verbose', 'debug', 'quiet', 'silent'], + boolean: ['verbose', 'debug', 'quiet', 'silent', 'skip-missing'], }) ) ); diff --git a/packages/kbn-pm/src/commands/bootstrap.ts b/packages/kbn-pm/src/commands/bootstrap.ts index 0ad420899870d..8cd346a56f278 100644 --- a/packages/kbn-pm/src/commands/bootstrap.ts +++ b/packages/kbn-pm/src/commands/bootstrap.ts @@ -17,12 +17,13 @@ import { getAllChecksums } from '../utils/project_checksums'; import { BootstrapCacheFile } from '../utils/bootstrap_cache_file'; import { readYarnLock } from '../utils/yarn_lock'; import { validateDependencies } from '../utils/validate_dependencies'; +import { installBazelTools } from '../utils/bazel'; export const BootstrapCommand: ICommand = { description: 'Install dependencies and crosslink projects', name: 'bootstrap', - async run(projects, projectGraph, { options, kbn }) { + async run(projects, projectGraph, { options, kbn, rootPath }) { const batchedProjects = topologicallyBatchProjects(projects, projectGraph); const kibanaProjectPath = projects.get('kibana')?.path; const extraArgs = [ @@ -30,6 +31,10 @@ export const BootstrapCommand: ICommand = { ...(options['prefer-offline'] === true ? ['--prefer-offline'] : []), ]; + // Install bazel machinery tools if needed + await installBazelTools(rootPath); + + // Install monorepo npm dependencies for (const batch of batchedProjects) { for (const project of batch) { const isExternalPlugin = project.path.includes(`${kibanaProjectPath}${sep}plugins`); diff --git a/packages/kbn-pm/src/commands/run.ts b/packages/kbn-pm/src/commands/run.ts index acbafe07b9a84..fb306f37082fe 100644 --- a/packages/kbn-pm/src/commands/run.ts +++ b/packages/kbn-pm/src/commands/run.ts @@ -16,7 +16,7 @@ export const RunCommand: ICommand = { description: 'Run script defined in package.json in each package that contains that script.', name: 'run', - async run(projects, projectGraph, { extraArgs }) { + async run(projects, projectGraph, { extraArgs, options }) { const batchedProjects = topologicallyBatchProjects(projects, projectGraph); if (extraArgs.length === 0) { @@ -27,13 +27,21 @@ export const RunCommand: ICommand = { const scriptArgs = extraArgs.slice(1); await parallelizeBatches(batchedProjects, async (project) => { - if (project.hasScript(scriptName)) { - log.info(`[${project.name}] running "${scriptName}" script`); - await project.runScriptStreaming(scriptName, { - args: scriptArgs, - }); - log.success(`[${project.name}] complete`); + if (!project.hasScript(scriptName)) { + if (!!options['skip-missing']) { + return; + } + + throw new CliError( + `[${project.name}] no "${scriptName}" script defined. To skip packages without the "${scriptName}" script pass --skip-missing` + ); } + + log.info(`[${project.name}] running "${scriptName}" script`); + await project.runScriptStreaming(scriptName, { + args: scriptArgs, + }); + log.success(`[${project.name}] complete`); }); }, }; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts b/packages/kbn-pm/src/utils/bazel/index.ts similarity index 77% rename from src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts rename to packages/kbn-pm/src/utils/bazel/index.ts index f101372f5bbce..957c4bdf7f6aa 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.d.ts +++ b/packages/kbn-pm/src/utils/bazel/index.ts @@ -6,6 +6,4 @@ * Public License, v 1. */ -import { VegaBaseView } from './vega_base_view'; - -export class VegaMapView extends VegaBaseView {} +export * from './install_tools'; diff --git a/packages/kbn-pm/src/utils/bazel/install_tools.ts b/packages/kbn-pm/src/utils/bazel/install_tools.ts new file mode 100644 index 0000000000000..4e19974590e83 --- /dev/null +++ b/packages/kbn-pm/src/utils/bazel/install_tools.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { resolve } from 'path'; +import { spawn } from '../child_process'; +import { readFile } from '../fs'; +import { log } from '../log'; + +async function readBazelToolsVersionFile(repoRootPath: string, versionFilename: string) { + const version = (await readFile(resolve(repoRootPath, versionFilename))) + .toString() + .split('\n')[0]; + + if (!version) { + throw new Error( + `[bazel_tools] Failed on reading bazel tools versions\n ${versionFilename} file do not contain any version set` + ); + } + + return version; +} + +export async function installBazelTools(repoRootPath: string) { + log.debug(`[bazel_tools] reading bazel tools versions from version files`); + const bazeliskVersion = await readBazelToolsVersionFile(repoRootPath, '.bazeliskversion'); + const bazelVersion = await readBazelToolsVersionFile(repoRootPath, '.bazelversion'); + + // Check what globals are installed + log.debug(`[bazel_tools] verify if bazelisk is installed`); + const { stdout } = await spawn('yarn', ['global', 'list'], { stdio: 'pipe' }); + + // Install bazelisk if not installed + if (!stdout.includes(`@bazel/bazelisk@${bazeliskVersion}`)) { + log.info(`[bazel_tools] installing Bazel tools`); + + log.debug( + `[bazel_tools] bazelisk is not installed. Installing @bazel/bazelisk@${bazeliskVersion} and bazel@${bazelVersion}` + ); + await spawn('yarn', ['global', 'add', `@bazel/bazelisk@${bazeliskVersion}`], { + env: { + USE_BAZEL_VERSION: bazelVersion, + }, + stdio: 'pipe', + }); + } + + log.success(`[bazel_tools] all bazel tools are correctly installed`); +} diff --git a/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts b/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts index b6dcd40b53d2e..08bfa5eb404ca 100644 --- a/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts +++ b/packages/kbn-telemetry-tools/src/tools/tasks/check_matching_schemas_task.ts @@ -23,7 +23,7 @@ export function checkMatchingSchemasTask({ roots }: TaskContext, throwOnDiff: bo root.esMappingDiffs = Object.keys(differences); if (root.esMappingDiffs.length && throwOnDiff) { throw Error( - `The following changes must be persisted in ${fullPath} file. Use '--fix' to update.\n${JSON.stringify( + `The following changes must be persisted in ${fullPath} file. Run 'node scripts/telemetry_check --fix' to update.\n${JSON.stringify( differences, null, 2 diff --git a/packages/kbn-test/jest-preset.js b/packages/kbn-test/jest-preset.js index ebedb314f9594..ed88944ed862d 100644 --- a/packages/kbn-test/jest-preset.js +++ b/packages/kbn-test/jest-preset.js @@ -19,7 +19,7 @@ module.exports = { coveragePathIgnorePatterns: ['/node_modules/', '.*\\.d\\.ts'], // A list of reporter names that Jest uses when writing coverage reports - coverageReporters: !!process.env.CI ? [['json', { file: 'jest.json' }]] : ['html', 'text'], + coverageReporters: !!process.env.CODE_COVERAGE ? ['json'] : ['html', 'text'], // An array of file extensions your modules use moduleFileExtensions: ['js', 'mjs', 'json', 'ts', 'tsx', 'node'], diff --git a/packages/kbn-tinymath/README.md b/packages/kbn-tinymath/README.md new file mode 100644 index 0000000000000..1094c4286c851 --- /dev/null +++ b/packages/kbn-tinymath/README.md @@ -0,0 +1,72 @@ +# kbn-tinymath + +kbn-tinymath is a tiny arithmetic and function evaluator for simple numbers and arrays. Named properties can be accessed from an optional scope parameter. +It's available as an expression function called `math` in Canvas, and the grammar/AST structure is available +for use by Kibana plugins that want to use math. + +See [Function Documentation](/docs/functions.md) for details on built-in functions available in Tinymath. + +```javascript +const { evaluate } = require('@kbn/tinymath'); + +// Simple math +evaluate('10 + 20'); // 30 +evaluate('round(3.141592)') // 3 + +// Named properties +evaluate('foo + 20', {foo: 5}); // 25 + +// Arrays +evaluate('bar + 20', {bar: [1, 2, 3]}); // [21, 22, 23] +evaluate('bar + baz', {bar: [1, 2, 3], baz: [4, 5, 6]}); // [5, 7, 9] +evaluate('multiply(bar, baz) / 10', {bar: [1, 2, 3], baz: [4, 5, 6]}); // [0.4, 1, 1.8] +``` + +### Adding Functions + +Functions can be injected, and built in function overwritten, via the 3rd argument to `evaluate`: + +```javascript +const { evaluate } = require('@kbn/tinymath'); + +evaluate('plustwo(foo)', {foo: 5}, { + plustwo: function(a) { + return a + 2; + } +}); // 7 +``` + +### Parsing + +You can get to the parsed AST by importing `parse` + +```javascript +const { parse } = require('@kbn/tinymath'); + +parse('1 + random()') +/* +{ + "name": "add", + "args": [ + 1, + { + "name": "random", + "args": [] + } + ] +} +*/ +``` + +#### Notes + +* Floating point operations have the normal Javascript limitations + +### Building kbn-tinymath + +This package is rebuilt when running `yarn kbn bootstrap`, but can also be build directly +using `yarn build` from the `packages/kbn-tinymath` directory. +### Running tests + +To test `@kbn/tinymath` from Kibana, run `yarn run jest --watch packages/kbn-tinymath` from +the top level of Kibana. diff --git a/packages/kbn-tinymath/babel.config.js b/packages/kbn-tinymath/babel.config.js new file mode 100644 index 0000000000000..c578a02ede1fb --- /dev/null +++ b/packages/kbn-tinymath/babel.config.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +module.exports = { + env: { + web: { + presets: ['@kbn/babel-preset/webpack_preset'], + }, + node: { + presets: ['@kbn/babel-preset/node_preset'], + }, + }, + ignore: ['**/*.test.ts', '**/*.test.tsx'], +}; diff --git a/packages/kbn-tinymath/docs/functions.md b/packages/kbn-tinymath/docs/functions.md new file mode 100644 index 0000000000000..0c7460a8189dd --- /dev/null +++ b/packages/kbn-tinymath/docs/functions.md @@ -0,0 +1,687 @@ +# TinyMath Functions +This document provides detailed information about the functions available in Tinymath and lists what parameters each function accepts, the return value of that function, and examples of how each function behaves. Most of the functions below accept arrays and apply JavaScript Math methods to each element of that array. For the functions that accept multiple arrays as parameters, the function generally does calculation index by index. Any function below can be wrapped by another function as long as the return type of the inner function matches the acceptable parameter type of the outer function. + +## _abs(_ _a_ _)_ +Calculates the absolute value of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The absolute value of `a`. Returns an array with the the absolute values of each element if `a` is an array. +**Example** +```js +abs(-1) // returns 1 +abs(2) // returns 2 +abs([-1 , -2, 3, -4]) // returns [1, 2, 3, 4] +``` +*** +## _add(_ ..._args_ _)_ +Calculates the sum of one or more numbers/arrays passed into the function. If at least one array of numbers is passed into the function, the function will calculate the sum by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The sum of all numbers in `args` if `args` contains only numbers. Returns an array of sums of the elements at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Throws**: + +- `'Array length mismatch'` if `args` contains arrays of different lengths + +**Example** +```js +add(1, 2, 3) // returns 6 +add([10, 20, 30, 40], 10, 20, 30) // returns [70, 80, 90, 100] +add([1, 2], 3, [4, 5], 6) // returns [(1 + 3 + 4 + 6), (2 + 3 + 5 + 6)] = [14, 16] +``` +*** +## _cbrt(_ _a_ _)_ +Calculates the cube root of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The cube root of `a`. Returns an array with the the cube roots of each element if `a` is an array. +**Example** +```js +cbrt(-27) // returns -3 +cbrt(94) // returns 4.546835943776344 +cbrt([27, 64, 125]) // returns [3, 4, 5] +``` +*** +## _ceil(_ _a_ _)_ +Calculates the ceiling of a number, i.e. rounds a number towards positive infinity. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The ceiling of `a`. Returns an array with the the ceilings of each element if `a` is an array. +**Example** +```js +ceil(1.2) // returns 2 +ceil(-1.8) // returns -1 +ceil([1.1, 2.2, 3.3]) // returns [2, 3, 4] +``` +*** +## _clamp(_ ..._a_, _min_, _max_ _)_ +Restricts value to a given range and returns closed available value. If only min is provided, values are restricted to only a lower bound. + + +| Param | Type | Description | +| --- | --- | --- | +| ...a | number \| Array.<number> | one or more numbers or arrays of numbers | +| min | number \| Array.<number> | The minimum value this function will return. | +| max | number \| Array.<number> | The maximum value this function will return. | + +**Returns**: number \| Array.<number> - The closest value between `min` (inclusive) and `max` (inclusive). Returns an array with values greater than or equal to `min` and less than or equal to `max` (if provided) at each index. +**Throws**: + +- `'Array length mismatch'` if `a`, `min`, and/or `max` are arrays of different lengths +- `'Min must be less than max'` if `max` is less than `min` +- `'Missing minimum value. You may want to use the 'max' function instead'` if min is not provided +- `'Missing maximum value. You may want to use the 'min' function instead'` if max is not provided + +**Example** +```js +clamp(1, 2, 3) // returns 2 +clamp([10, 20, 30, 40], 15, 25) // returns [15, 20, 25, 25] +clamp(10, [15, 2, 4, 20], 25) // returns [15, 10, 10, 20] +clamp(35, 10, [20, 30, 40, 50]) // returns [20, 30, 35, 35] +clamp([1, 9], 3, [4, 5]) // returns [clamp([1, 3, 4]), clamp([9, 3, 5])] = [3, 5] +``` +*** +## _cos(_ _a_ _)_ +Calculates the the cosine of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in radians. | + +**Returns**: number \| Array.<number> - The cosine of `a`. Returns an array with the the cosine of each element if `a` is an array. +**Example** +```js +cos(0) // returns 1 +cos(1.5707963267948966) // returns 6.123233995736766e-17 +cos([0, 1.5707963267948966]) // returns [1, 6.123233995736766e-17] +``` +*** +## _count(_ _a_ _)_ +Returns the length of an array. Alias for size + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: number - The length of the array. Returns 1 if `a` is not an array. +**Throws**: + +- `'Must pass an array'` if `a` is not an array + +**Example** +```js +count([]) // returns 0 +count([-1, -2, -3, -4]) // returns 4 +count(100) // returns 1 +``` +*** +## _cube(_ _a_ _)_ +Calculates the cube of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The cube of `a`. Returns an array with the the cubes of each element if `a` is an array. +**Example** +```js +cube(-3) // returns -27 +cube([3, 4, 5]) // returns [27, 64, 125] +``` +*** +## _degtorad(_ _a_ _)_ +Converts degrees to radians for a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in degrees. | + +**Returns**: number \| Array.<number> - The radians of `a`. Returns an array with the the radians of each element if `a` is an array. +**Example** +```js +degtorad(0) // returns 0 +degtorad(90) // returns 1.5707963267948966 +degtorad([0, 90, 180, 360]) // returns [0, 1.5707963267948966, 3.141592653589793, 6.283185307179586] +``` +*** +## _divide(_ _a_, _b_ _)_ +Divides two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | dividend, a number or an array of numbers | +| b | number \| Array.<number> | divisor, a number or an array of numbers, `b` != 0 | + +**Returns**: number \| Array.<number> - The quotient of `a` and `b` if both are numbers. Returns an array with the quotients applied index-wise to each element if `a` or `b` is an array. +**Throws**: + +- `'Array length mismatch'` if `a` and `b` are arrays with different lengths +- `'Cannot divide by 0'` if `b` equals 0 or contains 0 + +**Example** +```js +divide(6, 3) // returns 2 +divide([10, 20, 30, 40], 10) // returns [1, 2, 3, 4] +divide(10, [1, 2, 5, 10]) // returns [10, 5, 2, 1] +divide([14, 42, 65, 108], [2, 7, 5, 12]) // returns [7, 6, 13, 9] +``` +*** +## _exp(_ _a_ _)_ +Calculates _e^x_ where _e_ is Euler's number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - `e^a`. Returns an array with the values of `e^x` evaluated where `x` is each element of `a` if `a` is an array. +**Example** +```js +exp(2) // returns e^2 = 7.3890560989306495 +exp([1, 2, 3]) // returns [e^1, e^2, e^3] = [2.718281828459045, 7.3890560989306495, 20.085536923187668] +``` +*** +## _first(_ _a_ _)_ +Returns the first element of an array. If anything other than an array is passed in, the input is returned. + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: \* - The first element of `a`. Returns `a` if `a` is not an array. +**Example** +```js +first(2) // returns 2 +first([1, 2, 3]) // returns 1 +``` +*** +## _fix(_ _a_ _)_ +Calculates the fix of a number, i.e. rounds a number towards 0. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The fix of `a`. Returns an array with the the fixes for each element if `a` is an array. +**Example** +```js +fix(1.2) // returns 1 +fix(-1.8) // returns -1 +fix([1.8, 2.9, -3.7, -4.6]) // returns [1, 2, -3, -4] +``` +*** +## _floor(_ _a_ _)_ +Calculates the floor of a number, i.e. rounds a number towards negative infinity. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The floor of `a`. Returns an array with the the floor of each element if `a` is an array. +**Example** +```js +floor(1.8) // returns 1 +floor(-1.2) // returns -2 +floor([1.7, 2.8, 3.9]) // returns [1, 2, 3] +``` +*** +## _last(_ _a_ _)_ +Returns the last element of an array. If anything other than an array is passed in, the input is returned. + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: \* - The last element of `a`. Returns `a` if `a` is not an array. +**Example** +```js +last(2) // returns 2 +last([1, 2, 3]) // returns 3 +``` +*** +## _log(_ _a_, _b_ _)_ +Calculates the logarithm of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` must be greater than 0 | +| b | Object | (optional) base for the logarithm. If not provided a value, the default base is e, and the natural log is calculated. | + +**Returns**: number \| Array.<number> - The logarithm of `a`. Returns an array with the the logarithms of each element if `a` is an array. +**Throws**: + +- `'Base out of range'` if `b` <= 0 +- 'Must be greater than 0' if `a` > 0 + +**Example** +```js +log(1) // returns 0 +log(64, 8) // returns 2 +log(42, 5) // returns 2.322344707681546 +log([2, 4, 8, 16, 32], 2) // returns [1, 2, 3, 4, 5] +``` +*** +## _log10(_ _a_ _)_ +Calculates the logarithm base 10 of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` must be greater than 0 | + +**Returns**: number \| Array.<number> - The logarithm of `a`. Returns an array with the the logarithms base 10 of each element if `a` is an array. +**Throws**: + +- `'Must be greater than 0'` if `a` < 0 + +**Example** +```js +log(10) // returns 1 +log(100) // returns 2 +log(80) // returns 1.9030899869919433 +log([10, 100, 1000, 10000, 100000]) // returns [1, 2, 3, 4, 5] +``` +*** +## _max(_ ..._args_ _)_ +Finds the maximum value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the maximum by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The maximum value of all numbers if `args` contains only numbers. Returns an array with the the maximum values at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Throws**: + +- `'Array length mismatch'` if `args` contains arrays of different lengths + +**Example** +```js +max(1, 2, 3) // returns 3 +max([10, 20, 30, 40], 15) // returns [15, 20, 30, 40] +max([1, 9], 4, [3, 5]) // returns [max([1, 4, 3]), max([9, 4, 5])] = [4, 9] +``` +*** +## _mean(_ ..._args_ _)_ +Finds the mean value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the mean by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The mean value of all numbers if `args` contains only numbers. Returns an array with the the mean values of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Example** +```js +mean(1, 2, 3) // returns 2 +mean([10, 20, 30, 40], 20) // returns [15, 20, 25, 30] +mean([1, 9], 5, [3, 4]) // returns [mean([1, 5, 3]), mean([9, 5, 4])] = [3, 6] +``` +*** +## _median(_ ..._args_ _)_ +Finds the median value(s) of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the median by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The median value of all numbers if `args` contains only numbers. Returns an array with the the median values of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Example** +```js +median(1, 1, 2, 3) // returns 1.5 +median(1, 1, 2, 2, 3) // returns 2 +median([10, 20, 30, 40], 10, 20, 30) // returns [15, 20, 25, 25] +median([1, 9], 2, 4, [3, 5]) // returns [median([1, 2, 4, 3]), median([9, 2, 4, 5])] = [2.5, 4.5] +``` +*** +## _min(_ ..._args_ _)_ +Finds the minimum value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the minimum by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The minimum value of all numbers if `args` contains only numbers. Returns an array with the the minimum values of each index, including all scalar numbers in `args` in the calculation at each index if `a` is an array. +**Throws**: + +- `'Array length mismatch'` if `args` contains arrays of different lengths + +**Example** +```js +min(1, 2, 3) // returns 1 +min([10, 20, 30, 40], 25) // returns [10, 20, 25, 25] +min([1, 9], 4, [3, 5]) // returns [min([1, 4, 3]), min([9, 4, 5])] = [1, 4] +``` +*** +## _mod(_ _a_, _b_ _)_ +Remainder after dividing two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | dividend, a number or an array of numbers | +| b | number \| Array.<number> | divisor, a number or an array of numbers, `b` != 0 | + +**Returns**: number \| Array.<number> - The remainder of `a` divided by `b` if both are numbers. Returns an array with the the remainders applied index-wise to each element if `a` or `b` is an array. +**Throws**: + +- `'Array length mismatch'` if `a` and `b` are arrays with different lengths +- `'Cannot divide by 0'` if `b` equals 0 or contains 0 + +**Example** +```js +mod(10, 7) // returns 3 +mod([11, 22, 33, 44], 10) // returns [1, 2, 3, 4] +mod(100, [3, 7, 11, 23]) // returns [1, 2, 1, 8] +mod([14, 42, 65, 108], [5, 4, 14, 2]) // returns [5, 2, 9, 0] +``` +*** +## _mode(_ ..._args_ _)_ +Finds the mode value(s) of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the mode by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: Array.<number> \| Array.<Array.<number>> - An array mode value(s) of all numbers if `args` contains only numbers. Returns an array of arrays with mode value(s) of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Example** +```js +mode(1, 1, 2, 3) // returns [1] +mode(1, 1, 2, 2, 3) // returns [1,2] +mode([10, 20, 30, 40], 10, 20, 30) // returns [[10], [20], [30], [10, 20, 30, 40]] +mode([1, 9], 1, 4, [3, 5]) // returns [mode([1, 1, 4, 3]), mode([9, 1, 4, 5])] = [[1], [4, 5, 9]] +``` +*** +## _multiply(_ _a_, _b_ _)_ +Multiplies two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | +| b | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The product of `a` and `b` if both are numbers. Returns an array with the the products applied index-wise to each element if `a` or `b` is an array. +**Throws**: + +- `'Array length mismatch'` if `a` and `b` are arrays with different lengths + +**Example** +```js +multiply(6, 3) // returns 18 +multiply([10, 20, 30, 40], 10) // returns [100, 200, 300, 400] +multiply(10, [1, 2, 5, 10]) // returns [10, 20, 50, 100] +multiply([1, 2, 3, 4], [2, 7, 5, 12]) // returns [2, 14, 15, 48] +``` +*** +## _pi(__)_ +Returns the mathematical constant PI + +**Returns**: number - The mathematical constant PI +**Example** +```js +pi() // 3.141592653589793 +``` +*** +## _pow(_ _a_, _b_ _)_ +Raises a number to a given exponent. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | +| b | number | the power that `a` is raised to | + +**Returns**: number \| Array.<number> - `a` raised to the power of `b`. Returns an array with the each element raised to the power of `b` if `a` is an array. +**Throws**: + +- `'Missing exponent'` if `b` is not provided + +**Example** +```js +pow(2,3) // returns 8 +pow([1, 2, 3], 4) // returns [1, 16, 81] +``` +*** +## _radtodeg(_ _a_ _)_ +Converts radians to degrees for a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in radians. | + +**Returns**: number \| Array.<number> - The degrees of `a`. Returns an array with the the degrees of each element if `a` is an array. +**Example** +```js +radtodeg(0) // returns 0 +radtodeg(1.5707963267948966) // returns 90 +radtodeg([0, 1.5707963267948966, 3.141592653589793, 6.283185307179586]) // returns [0, 90, 180, 360] +``` +*** +## _random(_ _a_, _b_ _)_ +Generates a random number within the given range where the lower bound is inclusive and the upper bound is exclusive. If no numbers are passed in, it will return a number between 0 and 1. If only one number is passed in, it will return . + + +| Param | Type | Description | +| --- | --- | --- | +| a | number | (optional) must be greater than 0 if `b` is not provided | +| b | number | (optional) must be greater | + +**Returns**: number - A random number between 0 and 1 if no numbers are passed in. Returns a random number between 0 and `a` if only one number is passed in. Returns a random number between `a` and `b` if two numbers are passed in. +**Throws**: + +- `'Min is be greater than max'` if `a` < 0 when only `a` is passed in or if `a` > `b` when both `a` and `b` are passed in + +**Example** +```js +random() // returns a random number between 0 (inclusive) and 1 (exclusive) +random(10) // returns a random number between 0 (inclusive) and 10 (exclusive) +random(-10,10) // returns a random number between -10 (inclusive) and 10 (exclusive) +``` +*** +## _range(_ ..._args_ _)_ +Finds the range of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the range by index. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number \| Array.<number> - The range value of all numbers if `args` contains only numbers. Returns an array with the the range values at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. +**Example** +```js +range(1, 2, 3) // returns 2 +range([10, 20, 30, 40], 15) // returns [5, 5, 15, 25] +range([1, 9], 4, [3, 5]) // returns [range([1, 4, 3]), range([9, 4, 5])] = [3, 5] +``` +*** +## _round(_ _a_, _b_ _)_ +Rounds a number towards the nearest integer by default or decimal place if specified. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | +| b | number | (optional) number of decimal places, default value: 0 | + +**Returns**: number \| Array.<number> - The rounded value of `a`. Returns an array with the the rounded values of each element if `a` is an array. +**Example** +```js +round(1.2) // returns 2 +round(-10.51) // returns -11 +round(-10.1, 2) // returns -10.1 +round(10.93745987, 4) // returns 10.9375 +round([2.9234, 5.1234, 3.5234, 4.49234324], 2) // returns [2.92, 5.12, 3.52, 4.49] +``` +*** +## _sin(_ _a_ _)_ +Calculates the the sine of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in radians. | + +**Returns**: number \| Array.<number> - The sine of `a`. Returns an array with the the sine of each element if `a` is an array. +**Example** +```js +sin(0) // returns 0 +sin(1.5707963267948966) // returns 1 +sin([0, 1.5707963267948966]) // returns [0, 1] +``` +*** +## _size(_ _a_ _)_ +Returns the length of an array. Alias for count + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: number - The length of the array. Returns 1 if `a` is not an array. +**Throws**: + +- `'Must pass an array'` if `a` is not an array + +**Example** +```js +size([]) // returns 0 +size([-1, -2, -3, -4]) // returns 4 +size(100) // returns 1 +``` +*** +## _sqrt(_ _a_ _)_ +Calculates the square root of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The square root of `a`. Returns an array with the the square roots of each element if `a` is an array. +**Throws**: + +- `'Unable find the square root of a negative number'` if `a` < 0 + +**Example** +```js +sqrt(9) // returns 3 +sqrt(30) //5.477225575051661 +sqrt([9, 16, 25]) // returns [3, 4, 5] +``` +*** +## _square(_ _a_ _)_ +Calculates the square of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The square of `a`. Returns an array with the the squares of each element if `a` is an array. +**Example** +```js +square(-3) // returns 9 +square([3, 4, 5]) // returns [9, 16, 25] +``` +*** +## _subtract(_ _a_, _b_ _)_ +Subtracts two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers | +| b | number \| Array.<number> | a number or an array of numbers | + +**Returns**: number \| Array.<number> - The difference of `a` and `b` if both are numbers or an array of differences applied index-wise to each element. +**Throws**: + +- `'Array length mismatch'` if `a` and `b` are arrays with different lengths + +**Example** +```js +subtract(6, 3) // returns 3 +subtract([10, 20, 30, 40], 10) // returns [0, 10, 20, 30] +subtract(10, [1, 2, 5, 10]) // returns [9, 8, 5, 0] +subtract([14, 42, 65, 108], [2, 7, 5, 12]) // returns [12, 35, 52, 96] +``` +*** +## _sum(_ ..._args_ _)_ +Calculates the sum of one or more numbers/arrays passed into the function. If at least one array is passed, the function will sum up one or more numbers/arrays of numbers and distinct values of an array. Sum accepts arrays of different lengths. + + +| Param | Type | Description | +| --- | --- | --- | +| ...args | number \| Array.<number> | one or more numbers or arrays of numbers | + +**Returns**: number - The sum of one or more numbers/arrays of numbers including distinct values in arrays +**Example** +```js +sum(1, 2, 3) // returns 6 +sum([10, 20, 30, 40], 10, 20, 30) // returns 160 +sum([1, 2], 3, [4, 5], 6) // returns sum(1, 2, 3, 4, 5, 6) = 21 +sum([10, 20, 30, 40], 10, [1, 2, 3], 22) // returns sum(10, 20, 30, 40, 10, 1, 2, 3, 22) = 138 +``` +*** +## _tan(_ _a_ _)_ +Calculates the the tangent of a number. For arrays, the function will be applied index-wise to each element. + + +| Param | Type | Description | +| --- | --- | --- | +| a | number \| Array.<number> | a number or an array of numbers, `a` is expected to be given in radians. | + +**Returns**: number \| Array.<number> - The tangent of `a`. Returns an array with the the tangent of each element if `a` is an array. +**Example** +```js +tan(0) // returns 0 +tan(1) // returns 1.5574077246549023 +tan([0, 1, -1]) // returns [0, 1.5574077246549023, -1.5574077246549023] +``` +*** +## _unique(_ _a_ _)_ +Counts the number of unique values in an array + + +| Param | Type | Description | +| --- | --- | --- | +| a | Array.<any> | array of any values | + +**Returns**: number - The number of unique values in the array. Returns 1 if `a` is not an array. +**Example** +```js +unique(100) // returns 1 +unique([]) // returns 0 +unique([1, 2, 3, 4]) // returns 4 +unique([1, 2, 3, 4, 2, 2, 2, 3, 4, 2, 4, 5, 2, 1, 4, 2]) // returns 5 +``` diff --git a/packages/kbn-tinymath/docs/template/functions.hbs b/packages/kbn-tinymath/docs/template/functions.hbs new file mode 100644 index 0000000000000..60f821e6d15bf --- /dev/null +++ b/packages/kbn-tinymath/docs/template/functions.hbs @@ -0,0 +1,15 @@ +# TinyMath Functions +This document provides detailed information about the functions available in Tinymath and lists what parameters each function accepts, the return value of that function, and examples of how each function behaves. Most of the functions below accept arrays and apply JavaScript Math methods to each element of that array. For the functions that accept multiple arrays as parameters, the function generally does calculation index by index. Any function below can be wrapped by another function as long as the return type of the inner function matches the acceptable parameter type of the outer function. + +{{#functions}} +## _{{name}}(_{{#each params}} {{#if variable}}...{{/if}}_{{name}}_{{#unless @last}},{{/unless}} {{/each}}_)_ +{{description}} + +{{>params~}} +{{>returns~}} +{{>throws~}} +{{>examples~}} +{{#unless @last}} +*** +{{/unless}} +{{/functions}} diff --git a/packages/kbn-tinymath/jest.config.js b/packages/kbn-tinymath/jest.config.js new file mode 100644 index 0000000000000..2fb97d8aa416a --- /dev/null +++ b/packages/kbn-tinymath/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-tinymath'], +}; diff --git a/packages/kbn-tinymath/package.json b/packages/kbn-tinymath/package.json new file mode 100644 index 0000000000000..34fd593672b5a --- /dev/null +++ b/packages/kbn-tinymath/package.json @@ -0,0 +1,12 @@ +{ + "name": "@kbn/tinymath", + "version": "2.0.0", + "license": "SSPL-1.0 OR Elastic License", + "private": true, + "main": "src/index.js", + "scripts": { + "kbn:bootstrap": "yarn build", + "build": "../../node_modules/.bin/pegjs -o src/grammar.js src/grammar.pegjs" + }, + "dependencies": {} +} \ No newline at end of file diff --git a/packages/kbn-tinymath/src/functions/abs.js b/packages/kbn-tinymath/src/functions/abs.js new file mode 100644 index 0000000000000..aa9eaba1ce3b2 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/abs.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the absolute value of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The absolute value of `a`. Returns an array with the the absolute values of each element if `a` is an array. + * + * @example + * abs(-1) // returns 1 + * abs(2) // returns 2 + * abs([-1 , -2, 3, -4]) // returns [1, 2, 3, 4] + */ + +module.exports = { abs }; + +function abs(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.abs(a)); + } + return Math.abs(a); +} diff --git a/packages/kbn-tinymath/src/functions/add.js b/packages/kbn-tinymath/src/functions/add.js new file mode 100644 index 0000000000000..5a4d6802a85ea --- /dev/null +++ b/packages/kbn-tinymath/src/functions/add.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the sum of one or more numbers/arrays passed into the function. If at least one array of numbers is passed into the function, the function will calculate the sum by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The sum of all numbers in `args` if `args` contains only numbers. Returns an array of sums of the elements at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * @throws `'Array length mismatch'` if `args` contains arrays of different lengths + * @example + * add(1, 2, 3) // returns 6 + * add([10, 20, 30, 40], 10, 20, 30) // returns [70, 80, 90, 100] + * add([1, 2], 3, [4, 5], 6) // returns [(1 + 3 + 4 + 6), (2 + 3 + 5 + 6)] = [14, 16] + */ + +module.exports = { add }; + +function add(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) return args[0].reduce((result, current) => result + current); + return args[0]; + } + + return args.reduce((result, current) => { + if (Array.isArray(result) && Array.isArray(current)) { + if (current.length !== result.length) throw new Error('Array length mismatch'); + return result.map((val, i) => val + current[i]); + } + if (Array.isArray(result)) return result.map((val) => val + current); + if (Array.isArray(current)) return current.map((val) => val + result); + return result + current; + }); +} diff --git a/packages/kbn-tinymath/src/functions/cbrt.js b/packages/kbn-tinymath/src/functions/cbrt.js new file mode 100644 index 0000000000000..017a661702761 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/cbrt.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the cube root of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The cube root of `a`. Returns an array with the the cube roots of each element if `a` is an array. + * + * @example + * cbrt(-27) // returns -3 + * cbrt(94) // returns 4.546835943776344 + * cbrt([27, 64, 125]) // returns [3, 4, 5] + */ + +module.exports = { cbrt }; + +function cbrt(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.cbrt(a)); + } + return Math.cbrt(a); +} diff --git a/packages/kbn-tinymath/src/functions/ceil.js b/packages/kbn-tinymath/src/functions/ceil.js new file mode 100644 index 0000000000000..7fbbabe481073 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/ceil.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the ceiling of a number, i.e. rounds a number towards positive infinity. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The ceiling of `a`. Returns an array with the the ceilings of each element if `a` is an array. + * + * @example + * ceil(1.2) // returns 2 + * ceil(-1.8) // returns -1 + * ceil([1.1, 2.2, 3.3]) // returns [2, 3, 4] + */ + +module.exports = { ceil }; + +function ceil(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.ceil(a)); + } + return Math.ceil(a); +} diff --git a/packages/kbn-tinymath/src/functions/clamp.js b/packages/kbn-tinymath/src/functions/clamp.js new file mode 100644 index 0000000000000..66b9e9eaf4f0d --- /dev/null +++ b/packages/kbn-tinymath/src/functions/clamp.js @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const findClamp = (a, min, max) => { + if (min > max) throw new Error('Min must be less than max'); + return Math.min(Math.max(a, min), max); +}; + +/** + * Restricts value to a given range and returns closed available value. If only min is provided, values are restricted to only a lower bound. + * @param {...(number|number[])} a one or more numbers or arrays of numbers + * @param {(number|number[])} min The minimum value this function will return. + * @param {(number|number[])} max The maximum value this function will return. + * @return {(number|number[])} The closest value between `min` (inclusive) and `max` (inclusive). Returns an array with values greater than or equal to `min` and less than or equal to `max` (if provided) at each index. + * @throws `'Array length mismatch'` if `a`, `min`, and/or `max` are arrays of different lengths + * @throws `'Min must be less than max'` if `max` is less than `min` + * @throws `'Missing minimum value. You may want to use the 'max' function instead'` if min is not provided + * @throws `'Missing maximum value. You may want to use the 'min' function instead'` if max is not provided + * + * @example + * clamp(1, 2, 3) // returns 2 + * clamp([10, 20, 30, 40], 15, 25) // returns [15, 20, 25, 25] + * clamp(10, [15, 2, 4, 20], 25) // returns [15, 10, 10, 20] + * clamp(35, 10, [20, 30, 40, 50]) // returns [20, 30, 35, 35] + * clamp([1, 9], 3, [4, 5]) // returns [clamp([1, 3, 4]), clamp([9, 3, 5])] = [3, 5] + */ + +module.exports = { clamp }; + +function clamp(a, min, max) { + if (max === null) + throw new Error("Missing maximum value. You may want to use the 'min' function instead"); + if (min === null) + throw new Error("Missing minimum value. You may want to use the 'max' function instead"); + + if (Array.isArray(max)) { + if (Array.isArray(a) && Array.isArray(min)) { + if (a.length !== max.length || a.length !== min.length) + throw new Error('Array length mismatch'); + return max.map((max, i) => findClamp(a[i], min[i], max)); + } + + if (Array.isArray(a)) { + if (a.length !== max.length) throw new Error('Array length mismatch'); + return max.map((max, i) => findClamp(a[i], min, max)); + } + + if (Array.isArray(min)) { + if (min.length !== max.length) throw new Error('Array length mismatch'); + return max.map((max, i) => findClamp(a, min[i], max)); + } + + return max.map((max) => findClamp(a, min, max)); + } + + if (Array.isArray(a) && Array.isArray(min)) { + if (a.length !== min.length) throw new Error('Array length mismatch'); + return a.map((a, i) => findClamp(a, min[i])); + } + + if (Array.isArray(a)) { + return a.map((a) => findClamp(a, min, max)); + } + + if (Array.isArray(min)) { + return min.map((min) => findClamp(a, min, max)); + } + + return findClamp(a, min, max); +} diff --git a/packages/kbn-tinymath/src/functions/cos.js b/packages/kbn-tinymath/src/functions/cos.js new file mode 100644 index 0000000000000..0385f52793c27 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/cos.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the the cosine of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in radians. + * @return {(number|number[])} The cosine of `a`. Returns an array with the the cosine of each element if `a` is an array. + * @example + * cos(0) // returns 1 + * cos(1.5707963267948966) // returns 6.123233995736766e-17 + * cos([0, 1.5707963267948966]) // returns [1, 6.123233995736766e-17] + */ + +module.exports = { cos }; + +function cos(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.cos(a)); + } + return Math.cos(a); +} diff --git a/packages/kbn-tinymath/src/functions/count.js b/packages/kbn-tinymath/src/functions/count.js new file mode 100644 index 0000000000000..b037999b7ac8a --- /dev/null +++ b/packages/kbn-tinymath/src/functions/count.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { size } = require('./size.js'); + +/** + * Returns the length of an array. Alias for size + * @param {any[]} a array of any values + * @return {(number)} The length of the array. Returns 1 if `a` is not an array. + * @throws `'Must pass an array'` if `a` is not an array + * @example + * count([]) // returns 0 + * count([-1, -2, -3, -4]) // returns 4 + * count(100) // returns 1 + */ + +module.exports = { count }; + +function count(a) { + return size(a); +} + +count.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/functions/cube.js b/packages/kbn-tinymath/src/functions/cube.js new file mode 100644 index 0000000000000..de14ac8749ae1 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/cube.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { pow } = require('./pow.js'); + +/** + * Calculates the cube of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The cube of `a`. Returns an array with the the cubes of each element if `a` is an array. + * + * @example + * cube(-3) // returns -27 + * cube([3, 4, 5]) // returns [27, 64, 125] + */ + +module.exports = { cube }; + +function cube(a) { + return pow(a, 3); +} diff --git a/packages/kbn-tinymath/src/functions/degtorad.js b/packages/kbn-tinymath/src/functions/degtorad.js new file mode 100644 index 0000000000000..20fd8ac9e2060 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/degtorad.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Converts degrees to radians for a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in degrees. + * @return {(number|number[])} The radians of `a`. Returns an array with the the radians of each element if `a` is an array. + * @example + * degtorad(0) // returns 0 + * degtorad(90) // returns 1.5707963267948966 + * degtorad([0, 90, 180, 360]) // returns [0, 1.5707963267948966, 3.141592653589793, 6.283185307179586] + */ + +module.exports = { degtorad }; + +function degtorad(a) { + if (Array.isArray(a)) { + return a.map((a) => (a * Math.PI) / 180); + } + return (a * Math.PI) / 180; +} diff --git a/packages/kbn-tinymath/src/functions/divide.js b/packages/kbn-tinymath/src/functions/divide.js new file mode 100644 index 0000000000000..889e2305cbd9e --- /dev/null +++ b/packages/kbn-tinymath/src/functions/divide.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Divides two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + * @param {(number|number[])} a dividend, a number or an array of numbers + * @param {(number|number[])} b divisor, a number or an array of numbers, `b` != 0 + * @return {(number|number[])} The quotient of `a` and `b` if both are numbers. Returns an array with the quotients applied index-wise to each element if `a` or `b` is an array. + * @throws `'Array length mismatch'` if `a` and `b` are arrays with different lengths + * - `'Cannot divide by 0'` if `b` equals 0 or contains 0 + * @example + * divide(6, 3) // returns 2 + * divide([10, 20, 30, 40], 10) // returns [1, 2, 3, 4] + * divide(10, [1, 2, 5, 10]) // returns [10, 5, 2, 1] + * divide([14, 42, 65, 108], [2, 7, 5, 12]) // returns [7, 6, 13, 9] + */ + +module.exports = { divide }; + +function divide(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) throw new Error('Array length mismatch'); + return a.map((val, i) => { + if (b[i] === 0) throw new Error('Cannot divide by 0'); + return val / b[i]; + }); + } + if (Array.isArray(b)) return b.map((b) => a / b); + if (b === 0) throw new Error('Cannot divide by 0'); + if (Array.isArray(a)) return a.map((a) => a / b); + return a / b; +} diff --git a/packages/kbn-tinymath/src/functions/exp.js b/packages/kbn-tinymath/src/functions/exp.js new file mode 100644 index 0000000000000..d7fd3877001c9 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/exp.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates _e^x_ where _e_ is Euler's number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} `e^a`. Returns an array with the values of `e^x` evaluated where `x` is each element of `a` if `a` is an array. + * + * @example + * exp(2) // returns e^2 = 7.3890560989306495 + * exp([1, 2, 3]) // returns [e^1, e^2, e^3] = [2.718281828459045, 7.3890560989306495, 20.085536923187668] + */ + +module.exports = { exp }; + +function exp(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.exp(a)); + } + return Math.exp(a); +} diff --git a/packages/kbn-tinymath/src/functions/first.js b/packages/kbn-tinymath/src/functions/first.js new file mode 100644 index 0000000000000..911482541b1d3 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/first.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Returns the first element of an array. If anything other than an array is passed in, the input is returned. + * @param {any[]} a array of any values + * @return {*} The first element of `a`. Returns `a` if `a` is not an array. + * + * @example + * first(2) // returns 2 + * first([1, 2, 3]) // returns 1 + */ + +module.exports = { first }; + +function first(a) { + if (Array.isArray(a)) { + return a[0]; + } + return a; +} + +first.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/functions/fix.js b/packages/kbn-tinymath/src/functions/fix.js new file mode 100644 index 0000000000000..16ed2d0dcb54f --- /dev/null +++ b/packages/kbn-tinymath/src/functions/fix.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const fixer = (a) => { + if (a > 0) { + return Math.floor(a); + } + return Math.ceil(a); +}; + +/** + * Calculates the fix of a number, i.e. rounds a number towards 0. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The fix of `a`. Returns an array with the the fixes for each element if `a` is an array. + * + * @example + * fix(1.2) // returns 1 + * fix(-1.8) // returns -1 + * fix([1.8, 2.9, -3.7, -4.6]) // returns [1, 2, -3, -4] + */ + +module.exports = { fix }; + +function fix(a) { + if (Array.isArray(a)) { + return a.map((a) => fixer(a)); + } + return fixer(a); +} diff --git a/packages/kbn-tinymath/src/functions/floor.js b/packages/kbn-tinymath/src/functions/floor.js new file mode 100644 index 0000000000000..db90697edc346 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/floor.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the floor of a number, i.e. rounds a number towards negative infinity. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The floor of `a`. Returns an array with the the floor of each element if `a` is an array. + * + * @example + * floor(1.8) // returns 1 + * floor(-1.2) // returns -2 + * floor([1.7, 2.8, 3.9]) // returns [1, 2, 3] + */ + +module.exports = { floor }; + +function floor(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.floor(a)); + } + return Math.floor(a); +} diff --git a/packages/kbn-tinymath/src/functions/index.js b/packages/kbn-tinymath/src/functions/index.js new file mode 100644 index 0000000000000..ab5805cc0a77e --- /dev/null +++ b/packages/kbn-tinymath/src/functions/index.js @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { abs } = require('./abs'); +const { add } = require('./add'); +const { cbrt } = require('./cbrt'); +const { ceil } = require('./ceil'); +const { clamp } = require('./clamp'); +const { cos } = require('./cos'); +const { count } = require('./count'); +const { cube } = require('./cube'); +const { degtorad } = require('./degtorad'); +const { divide } = require('./divide'); +const { exp } = require('./exp'); +const { first } = require('./first'); +const { fix } = require('./fix'); +const { floor } = require('./floor'); +const { last } = require('./last'); +const { log } = require('./log'); +const { log10 } = require('./log10'); +const { max } = require('./max'); +const { mean } = require('./mean'); +const { median } = require('./median'); +const { min } = require('./min'); +const { mod } = require('./mod'); +const { mode } = require('./mode'); +const { multiply } = require('./multiply'); +const { pi } = require('./pi'); +const { pow } = require('./pow'); +const { radtodeg } = require('./radtodeg'); +const { random } = require('./random'); +const { range } = require('./range'); +const { round } = require('./round'); +const { sin } = require('./sin'); +const { size } = require('./size'); +const { sqrt } = require('./sqrt'); +const { square } = require('./square'); +const { subtract } = require('./subtract'); +const { sum } = require('./sum'); +const { tan } = require('./tan'); +const { unique } = require('./unique'); + +module.exports = { + functions: { + abs, + add, + cbrt, + ceil, + clamp, + cos, + count, + cube, + degtorad, + divide, + exp, + first, + fix, + floor, + last, + log, + log10, + max, + mean, + median, + min, + mod, + mode, + multiply, + pi, + pow, + radtodeg, + random, + range, + round, + sin, + size, + sqrt, + square, + subtract, + sum, + tan, + unique, + }, +}; diff --git a/packages/kbn-tinymath/src/functions/last.js b/packages/kbn-tinymath/src/functions/last.js new file mode 100644 index 0000000000000..08964c784ba88 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/last.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Returns the last element of an array. If anything other than an array is passed in, the input is returned. + * @param {any[]} a array of any values + * @return {*} The last element of `a`. Returns `a` if `a` is not an array. + * + * @example + * last(2) // returns 2 + * last([1, 2, 3]) // returns 3 + */ + +module.exports = { last }; + +function last(a) { + if (Array.isArray(a)) { + return a[a.length - 1]; + } + return a; +} + +last.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/functions/lib/transpose.js b/packages/kbn-tinymath/src/functions/lib/transpose.js new file mode 100644 index 0000000000000..6a771f4f54336 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/lib/transpose.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Transposes a 2D array, i.e. turns the rows into columns and vice versa. Scalar values are also included in the transpose. + * @param {any[][]} args an array or an array that contains arrays + * @param {number} index index of the first array element in args + * @return {any[][]} transpose of args + * @throws `'Array length mismatch'` if `args` contains arrays of different lengths + * @example + * transpose([[1,2], [3,4], [5,6]], 0) // returns [[1, 3, 5], [2, 4, 6]] + * transpose([10, 20, [10, 20, 30, 40], 30], 2) // returns [[10, 20, 10, 30], [10, 20, 20, 30], [10, 20, 30, 30], [10, 20, 40, 30]] + * transpose([4, [1, 9], [3, 5]], 1) // returns [[4, 1, 3], [4, 9, 5]] + */ + +module.exports = { transpose }; + +function transpose(args, index) { + const len = args[index].length; + return args[index].map((col, i) => + args.map((row) => { + if (Array.isArray(row)) { + if (row.length !== len) throw new Error('Array length mismatch'); + return row[i]; + } + return row; + }) + ); +} diff --git a/packages/kbn-tinymath/src/functions/log.js b/packages/kbn-tinymath/src/functions/log.js new file mode 100644 index 0000000000000..07fb8376438d6 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/log.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const changeOfBase = (a, b) => Math.log(a) / Math.log(b); + +/** + * Calculates the logarithm of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` must be greater than 0 + * @param {{number}} b (optional) base for the logarithm. If not provided a value, the default base is e, and the natural log is calculated. + * @return {(number|number[])} The logarithm of `a`. Returns an array with the the logarithms of each element if `a` is an array. + * @throws `'Base out of range'` if `b` <= 0 + * - 'Must be greater than 0' if `a` > 0 + * @example + * log(1) // returns 0 + * log(64, 8) // returns 2 + * log(42, 5) // returns 2.322344707681546 + * log([2, 4, 8, 16, 32], 2) // returns [1, 2, 3, 4, 5] + */ + +module.exports = { log }; + +function log(a, b = Math.E) { + if (b <= 0) throw new Error('Base out of range'); + + if (Array.isArray(a)) { + return a.map((a) => { + if (a < 0) throw new Error('Must be greater than 0'); + return changeOfBase(a, b); + }); + } + if (a < 0) throw new Error('Must be greater than 0'); + return changeOfBase(a, b); +} diff --git a/packages/kbn-tinymath/src/functions/log10.js b/packages/kbn-tinymath/src/functions/log10.js new file mode 100644 index 0000000000000..79417031d5ed8 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/log10.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { log } = require('./log.js'); + +/** + * Calculates the logarithm base 10 of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` must be greater than 0 + * @return {(number|number[])} The logarithm of `a`. Returns an array with the the logarithms base 10 of each element if `a` is an array. + * @throws `'Must be greater than 0'` if `a` < 0 + * @example + * log(10) // returns 1 + * log(100) // returns 2 + * log(80) // returns 1.9030899869919433 + * log([10, 100, 1000, 10000, 100000]) // returns [1, 2, 3, 4, 5] + */ + +module.exports = { log10 }; + +function log10(a) { + return log(a, 10); +} diff --git a/packages/kbn-tinymath/src/functions/max.js b/packages/kbn-tinymath/src/functions/max.js new file mode 100644 index 0000000000000..13cebbfdf662a --- /dev/null +++ b/packages/kbn-tinymath/src/functions/max.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Finds the maximum value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the maximum by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The maximum value of all numbers if `args` contains only numbers. Returns an array with the the maximum values at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * @throws `'Array length mismatch'` if `args` contains arrays of different lengths + * @example + * max(1, 2, 3) // returns 3 + * max([10, 20, 30, 40], 15) // returns [15, 20, 30, 40] + * max([1, 9], 4, [3, 5]) // returns [max([1, 4, 3]), max([9, 4, 5])] = [4, 9] + */ + +module.exports = { max }; + +function max(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) + return args[0].reduce((result, current) => Math.max(result, current)); + return args[0]; + } + + return args.reduce((result, current) => { + if (Array.isArray(result) && Array.isArray(current)) { + if (current.length !== result.length) throw new Error('Array length mismatch'); + return result.map((val, i) => Math.max(val, current[i])); + } + if (Array.isArray(result)) return result.map((val) => Math.max(val, current)); + if (Array.isArray(current)) return current.map((val) => Math.max(val, result)); + return Math.max(result, current); + }); +} diff --git a/packages/kbn-tinymath/src/functions/mean.js b/packages/kbn-tinymath/src/functions/mean.js new file mode 100644 index 0000000000000..ee37d77b10e71 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/mean.js @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { add } = require('./add.js'); + +/** + * Finds the mean value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the mean by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The mean value of all numbers if `args` contains only numbers. Returns an array with the the mean values of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * + * @example + * mean(1, 2, 3) // returns 2 + * mean([10, 20, 30, 40], 20) // returns [15, 20, 25, 30] + * mean([1, 9], 5, [3, 4]) // returns [mean([1, 5, 3]), mean([9, 5, 4])] = [3, 6] + */ + +module.exports = { mean }; + +function mean(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) return add(args[0]) / args[0].length; + return args[0]; + } + const sum = add(...args); + + if (Array.isArray(sum)) { + return sum.map((val) => val / args.length); + } + + return sum / args.length; +} diff --git a/packages/kbn-tinymath/src/functions/median.js b/packages/kbn-tinymath/src/functions/median.js new file mode 100644 index 0000000000000..6f1e3cd4972e5 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/median.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { transpose } = require('./lib/transpose'); + +const findMedian = (a) => { + const len = a.length; + const half = Math.floor(len / 2); + + a.sort((a, b) => b - a); + + if (len % 2 === 0) { + return (a[half] + a[half - 1]) / 2; + } + + return a[half]; +}; + +/** + * Finds the median value(s) of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the median by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The median value of all numbers if `args` contains only numbers. Returns an array with the the median values of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * + * @example + * median(1, 1, 2, 3) // returns 1.5 + * median(1, 1, 2, 2, 3) // returns 2 + * median([10, 20, 30, 40], 10, 20, 30) // returns [15, 20, 25, 25] + * median([1, 9], 2, 4, [3, 5]) // returns [median([1, 2, 4, 3]), median([9, 2, 4, 5])] = [2.5, 4.5] + */ + +module.exports = { median }; + +function median(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) return findMedian(args[0]); + return args[0]; + } + + const firstArray = args.findIndex((element) => Array.isArray(element)); + if (firstArray !== -1) { + const result = transpose(args, firstArray); + return result.map((val) => findMedian(val)); + } + return findMedian(args); +} diff --git a/packages/kbn-tinymath/src/functions/min.js b/packages/kbn-tinymath/src/functions/min.js new file mode 100644 index 0000000000000..44509bedfd088 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/min.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Finds the minimum value of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the minimum by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The minimum value of all numbers if `args` contains only numbers. Returns an array with the the minimum values of each index, including all scalar numbers in `args` in the calculation at each index if `a` is an array. + * @throws `'Array length mismatch'` if `args` contains arrays of different lengths + * @example + * min(1, 2, 3) // returns 1 + * min([10, 20, 30, 40], 25) // returns [10, 20, 25, 25] + * min([1, 9], 4, [3, 5]) // returns [min([1, 4, 3]), min([9, 4, 5])] = [1, 4] + */ + +module.exports = { min }; + +function min(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) + return args[0].reduce((result, current) => Math.min(result, current)); + return args[0]; + } + + return args.reduce((result, current) => { + if (Array.isArray(result) && Array.isArray(current)) { + if (current.length !== result.length) throw new Error('Array length mismatch'); + return result.map((val, i) => Math.min(val, current[i])); + } + if (Array.isArray(result)) return result.map((val) => Math.min(val, current)); + if (Array.isArray(current)) return current.map((val) => Math.min(val, result)); + return Math.min(result, current); + }); +} diff --git a/packages/kbn-tinymath/src/functions/mod.js b/packages/kbn-tinymath/src/functions/mod.js new file mode 100644 index 0000000000000..93c23077a9d69 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/mod.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Remainder after dividing two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + * @param {(number|number[])} a dividend, a number or an array of numbers + * @param {(number|number[])} b divisor, a number or an array of numbers, `b` != 0 + * @return {(number|number[])} The remainder of `a` divided by `b` if both are numbers. Returns an array with the the remainders applied index-wise to each element if `a` or `b` is an array. + * @throws `'Array length mismatch'` if `a` and `b` are arrays with different lengths + * - `'Cannot divide by 0'` if `b` equals 0 or contains 0 + * @example + * mod(10, 7) // returns 3 + * mod([11, 22, 33, 44], 10) // returns [1, 2, 3, 4] + * mod(100, [3, 7, 11, 23]) // returns [1, 2, 1, 8] + * mod([14, 42, 65, 108], [5, 4, 14, 2]) // returns [5, 2, 9, 0] + */ + +module.exports = { mod }; + +function mod(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) throw new Error('Array length mismatch'); + return a.map((val, i) => { + if (b[i] === 0) throw new Error('Cannot divide by 0'); + return val % b[i]; + }); + } + if (Array.isArray(b)) return b.map((b) => a % b); + if (b === 0) throw new Error('Cannot divide by 0'); + if (Array.isArray(a)) return a.map((a) => a % b); + return a % b; +} diff --git a/packages/kbn-tinymath/src/functions/mode.js b/packages/kbn-tinymath/src/functions/mode.js new file mode 100644 index 0000000000000..4c7d8414602df --- /dev/null +++ b/packages/kbn-tinymath/src/functions/mode.js @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { transpose } = require('./lib/transpose'); + +const findMode = (a) => { + let maxFreq = 0; + const mapping = {}; + + a.map((val) => { + if (mapping[val] === undefined) { + mapping[val] = 0; + } + mapping[val] += 1; + if (mapping[val] > maxFreq) { + maxFreq = mapping[val]; + } + }); + + return Object.keys(mapping) + .filter((key) => mapping[key] === maxFreq) + .map((val) => parseFloat(val)) + .sort((a, b) => a - b); +}; + +/** + * Finds the mode value(s) of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the mode by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number[]|number[][])} An array mode value(s) of all numbers if `args` contains only numbers. Returns an array of arrays with mode value(s) of each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * + * @example + * mode(1, 1, 2, 3) // returns [1] + * mode(1, 1, 2, 2, 3) // returns [1,2] + * mode([10, 20, 30, 40], 10, 20, 30) // returns [[10], [20], [30], [10, 20, 30, 40]] + * mode([1, 9], 1, 4, [3, 5]) // returns [mode([1, 1, 4, 3]), mode([9, 1, 4, 5])] = [[1], [4, 5, 9]] + */ + +module.exports = { mode }; + +function mode(...args) { + if (args.length === 1) { + if (Array.isArray(args[0])) return findMode(args[0]); + return args[0]; + } + + const firstArray = args.findIndex((element) => Array.isArray(element)); + if (firstArray !== -1) { + const result = transpose(args, firstArray); + return result.map((val) => findMode(val)); + } + return findMode(args); +} diff --git a/packages/kbn-tinymath/src/functions/multiply.js b/packages/kbn-tinymath/src/functions/multiply.js new file mode 100644 index 0000000000000..6334b510e550b --- /dev/null +++ b/packages/kbn-tinymath/src/functions/multiply.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Multiplies two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @param {(number|number[])} b a number or an array of numbers + * @return {(number|number[])} The product of `a` and `b` if both are numbers. Returns an array with the the products applied index-wise to each element if `a` or `b` is an array. + * @throws `'Array length mismatch'` if `a` and `b` are arrays with different lengths + * @example + * multiply(6, 3) // returns 18 + * multiply([10, 20, 30, 40], 10) // returns [100, 200, 300, 400] + * multiply(10, [1, 2, 5, 10]) // returns [10, 20, 50, 100] + * multiply([1, 2, 3, 4], [2, 7, 5, 12]) // returns [2, 14, 15, 48] + */ + +module.exports = { multiply }; + +function multiply(...args) { + return args.reduce((result, current) => { + if (Array.isArray(result) && Array.isArray(current)) { + if (current.length !== result.length) throw new Error('Array length mismatch'); + return result.map((val, i) => val * current[i]); + } + if (Array.isArray(result)) return result.map((val) => val * current); + if (Array.isArray(current)) return current.map((val) => val * result); + return result * current; + }); +} diff --git a/packages/kbn-tinymath/src/functions/pi.js b/packages/kbn-tinymath/src/functions/pi.js new file mode 100644 index 0000000000000..5dd625cf7f0d3 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/pi.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Returns the mathematical constant PI + * @return {(number)} The mathematical constant PI + * + * @example + * pi() // 3.141592653589793 + */ + +module.exports = { pi }; + +function pi() { + return Math.PI; +} diff --git a/packages/kbn-tinymath/src/functions/pow.js b/packages/kbn-tinymath/src/functions/pow.js new file mode 100644 index 0000000000000..b44b9679fc7f8 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/pow.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Raises a number to a given exponent. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @param {(number)} b the power that `a` is raised to + * @return {(number|number[])} `a` raised to the power of `b`. Returns an array with the each element raised to the power of `b` if `a` is an array. + * @throws `'Missing exponent'` if `b` is not provided + * @example + * pow(2,3) // returns 8 + * pow([1, 2, 3], 4) // returns [1, 16, 81] + */ + +module.exports = { pow }; + +function pow(a, b) { + if (b == null) throw new Error('Missing exponent'); + if (Array.isArray(a)) { + return a.map((a) => Math.pow(a, b)); + } + return Math.pow(a, b); +} diff --git a/packages/kbn-tinymath/src/functions/radtodeg.js b/packages/kbn-tinymath/src/functions/radtodeg.js new file mode 100644 index 0000000000000..51f911e2dcad0 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/radtodeg.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Converts radians to degrees for a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in radians. + * @return {(number|number[])} The degrees of `a`. Returns an array with the the degrees of each element if `a` is an array. + * @example + * radtodeg(0) // returns 0 + * radtodeg(1.5707963267948966) // returns 90 + * radtodeg([0, 1.5707963267948966, 3.141592653589793, 6.283185307179586]) // returns [0, 90, 180, 360] + */ + +module.exports = { radtodeg }; + +function radtodeg(a) { + if (Array.isArray(a)) { + return a.map((a) => (a * 180) / Math.PI); + } + return (a * 180) / Math.PI; +} diff --git a/packages/kbn-tinymath/src/functions/random.js b/packages/kbn-tinymath/src/functions/random.js new file mode 100644 index 0000000000000..ffe5c3a9cb8e2 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/random.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Generates a random number within the given range where the lower bound is inclusive and the upper bound is exclusive. If no numbers are passed in, it will return a number between 0 and 1. If only one number is passed in, it will return . + * @param {number} a (optional) must be greater than 0 if `b` is not provided + * @param {number} b (optional) must be greater + * @return {number} A random number between 0 and 1 if no numbers are passed in. Returns a random number between 0 and `a` if only one number is passed in. Returns a random number between `a` and `b` if two numbers are passed in. + * @throws `'Min is be greater than max'` if `a` < 0 when only `a` is passed in or if `a` > `b` when both `a` and `b` are passed in + * @example + * random() // returns a random number between 0 (inclusive) and 1 (exclusive) + * random(10) // returns a random number between 0 (inclusive) and 10 (exclusive) + * random(-10,10) // returns a random number between -10 (inclusive) and 10 (exclusive) + */ + +module.exports = { random }; + +function random(a, b) { + if (a == null) return Math.random(); + + // a: max, generate random number between 0 and a + if (b == null) { + if (a < 0) throw new Error(`Min is greater than max`); + return Math.random() * a; + } + + // a: min, b: max, generate random number between a and b + if (a > b) throw new Error(`Min is greater than max`); + return Math.random() * (b - a) + a; +} diff --git a/packages/kbn-tinymath/src/functions/range.js b/packages/kbn-tinymath/src/functions/range.js new file mode 100644 index 0000000000000..31f9b618bb1db --- /dev/null +++ b/packages/kbn-tinymath/src/functions/range.js @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { max } = require('./max.js'); +const { min } = require('./min.js'); +const { subtract } = require('./subtract.js'); + +/** + * Finds the range of one of more numbers/arrays of numbers into the function. If at least one array of numbers is passed into the function, the function will find the range by index. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {(number|number[])} The range value of all numbers if `args` contains only numbers. Returns an array with the the range values at each index, including all scalar numbers in `args` in the calculation at each index if `args` contains at least one array. + * + * @example + * range(1, 2, 3) // returns 2 + * range([10, 20, 30, 40], 15) // returns [5, 5, 15, 25] + * range([1, 9], 4, [3, 5]) // returns [range([1, 4, 3]), range([9, 4, 5])] = [3, 5] + */ + +module.exports = { range }; + +function range(...args) { + return subtract(max(...args), min(...args)); +} diff --git a/packages/kbn-tinymath/src/functions/round.js b/packages/kbn-tinymath/src/functions/round.js new file mode 100644 index 0000000000000..1e8847e6dfd2b --- /dev/null +++ b/packages/kbn-tinymath/src/functions/round.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const rounder = (a, b) => Math.round(a * Math.pow(10, b)) / Math.pow(10, b); + +/** + * Rounds a number towards the nearest integer by default or decimal place if specified. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @param {(number)} b (optional) number of decimal places, default value: 0 + * @return {(number|number[])} The rounded value of `a`. Returns an array with the the rounded values of each element if `a` is an array. + * + * @example + * round(1.2) // returns 2 + * round(-10.51) // returns -11 + * round(-10.1, 2) // returns -10.1 + * round(10.93745987, 4) // returns 10.9375 + * round([2.9234, 5.1234, 3.5234, 4.49234324], 2) // returns [2.92, 5.12, 3.52, 4.49] + */ + +module.exports = { round }; + +function round(a, b = 0) { + if (Array.isArray(a)) { + return a.map((a) => rounder(a, b)); + } + return rounder(a, b); +} diff --git a/packages/kbn-tinymath/src/functions/sin.js b/packages/kbn-tinymath/src/functions/sin.js new file mode 100644 index 0000000000000..f08ffa8bdc197 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/sin.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the the sine of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in radians. + * @return {(number|number[])} The sine of `a`. Returns an array with the the sine of each element if `a` is an array. + * @example + * sin(0) // returns 0 + * sin(1.5707963267948966) // returns 1 + * sin([0, 1.5707963267948966]) // returns [0, 1] + */ + +module.exports = { sin }; + +function sin(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.sin(a)); + } + return Math.sin(a); +} diff --git a/packages/kbn-tinymath/src/functions/size.js b/packages/kbn-tinymath/src/functions/size.js new file mode 100644 index 0000000000000..5156a70b38d69 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/size.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Returns the length of an array. Alias for count + * @param {any[]} a array of any values + * @return {(number)} The length of the array. Returns 1 if `a` is not an array. + * @throws `'Must pass an array'` if `a` is not an array + * @example + * size([]) // returns 0 + * size([-1, -2, -3, -4]) // returns 4 + * size(100) // returns 1 + */ + +module.exports = { size }; + +function size(a) { + if (Array.isArray(a)) return a.length; + throw new Error('Must pass an array'); +} + +size.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/functions/sqrt.js b/packages/kbn-tinymath/src/functions/sqrt.js new file mode 100644 index 0000000000000..2c55b2256e0f6 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/sqrt.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the square root of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The square root of `a`. Returns an array with the the square roots of each element if `a` is an array. + * @throws `'Unable find the square root of a negative number'` if `a` < 0 + * @example + * sqrt(9) // returns 3 + * sqrt(30) //5.477225575051661 + * sqrt([9, 16, 25]) // returns [3, 4, 5] + */ + +module.exports = { sqrt }; + +function sqrt(a) { + if (Array.isArray(a)) { + return a.map((a) => { + if (a < 0) throw new Error('Unable find the square root of a negative number'); + return Math.sqrt(a); + }); + } + + if (a < 0) throw new Error('Unable find the square root of a negative number'); + return Math.sqrt(a); +} diff --git a/packages/kbn-tinymath/src/functions/square.js b/packages/kbn-tinymath/src/functions/square.js new file mode 100644 index 0000000000000..a5bccdef7661b --- /dev/null +++ b/packages/kbn-tinymath/src/functions/square.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { pow } = require('./pow.js'); + +/** + * Calculates the square of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @return {(number|number[])} The square of `a`. Returns an array with the the squares of each element if `a` is an array. + * + * @example + * square(-3) // returns 9 + * square([3, 4, 5]) // returns [9, 16, 25] + */ + +module.exports = { square }; + +function square(a) { + return pow(a, 2); +} diff --git a/packages/kbn-tinymath/src/functions/subtract.js b/packages/kbn-tinymath/src/functions/subtract.js new file mode 100644 index 0000000000000..8e5fd256bf158 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/subtract.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Subtracts two numbers. If at least one array of numbers is passed into the function, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers + * @param {(number|number[])} b a number or an array of numbers + * @return {(number|number[])} The difference of `a` and `b` if both are numbers or an array of differences applied index-wise to each element. + * @throws `'Array length mismatch'` if `a` and `b` are arrays with different lengths + * @example + * subtract(6, 3) // returns 3 + * subtract([10, 20, 30, 40], 10) // returns [0, 10, 20, 30] + * subtract(10, [1, 2, 5, 10]) // returns [9, 8, 5, 0] + * subtract([14, 42, 65, 108], [2, 7, 5, 12]) // returns [12, 35, 52, 96] + */ + +module.exports = { subtract }; + +function subtract(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) throw new Error('Array length mismatch'); + return a.map((val, i) => val - b[i]); + } + if (Array.isArray(a)) return a.map((a) => a - b); + if (Array.isArray(b)) return b.map((b) => a - b); + return a - b; +} diff --git a/packages/kbn-tinymath/src/functions/sum.js b/packages/kbn-tinymath/src/functions/sum.js new file mode 100644 index 0000000000000..b13a86d5c2122 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/sum.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const findSum = (total, current) => total + current; + +/** + * Calculates the sum of one or more numbers/arrays passed into the function. If at least one array is passed, the function will sum up one or more numbers/arrays of numbers and distinct values of an array. Sum accepts arrays of different lengths. + * @param {...(number|number[])} args one or more numbers or arrays of numbers + * @return {number} The sum of one or more numbers/arrays of numbers including distinct values in arrays + * + * @example + * sum(1, 2, 3) // returns 6 + * sum([10, 20, 30, 40], 10, 20, 30) // returns 160 + * sum([1, 2], 3, [4, 5], 6) // returns sum(1, 2, 3, 4, 5, 6) = 21 + * sum([10, 20, 30, 40], 10, [1, 2, 3], 22) // returns sum(10, 20, 30, 40, 10, 1, 2, 3, 22) = 138 + */ + +module.exports = { sum }; + +function sum(...args) { + return args.reduce((total, current) => { + if (Array.isArray(current)) { + return total + current.reduce(findSum, 0); + } + return total + current; + }, 0); +} diff --git a/packages/kbn-tinymath/src/functions/tan.js b/packages/kbn-tinymath/src/functions/tan.js new file mode 100644 index 0000000000000..56ea4c35f1459 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/tan.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Calculates the the tangent of a number. For arrays, the function will be applied index-wise to each element. + * @param {(number|number[])} a a number or an array of numbers, `a` is expected to be given in radians. + * @return {(number|number[])} The tangent of `a`. Returns an array with the the tangent of each element if `a` is an array. + * @example + * tan(0) // returns 0 + * tan(1) // returns 1.5574077246549023 + * tan([0, 1, -1]) // returns [0, 1.5574077246549023, -1.5574077246549023] + */ + +module.exports = { tan }; + +function tan(a) { + if (Array.isArray(a)) { + return a.map((a) => Math.tan(a)); + } + return Math.tan(a); +} diff --git a/packages/kbn-tinymath/src/functions/unique.js b/packages/kbn-tinymath/src/functions/unique.js new file mode 100644 index 0000000000000..60196e8568855 --- /dev/null +++ b/packages/kbn-tinymath/src/functions/unique.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/** + * Counts the number of unique values in an array + * @param {any[]} a array of any values + * @return {number} The number of unique values in the array. Returns 1 if `a` is not an array. + * + * @example + * unique(100) // returns 1 + * unique([]) // returns 0 + * unique([1, 2, 3, 4]) // returns 4 + * unique([1, 2, 3, 4, 2, 2, 2, 3, 4, 2, 4, 5, 2, 1, 4, 2]) // returns 5 + */ + +module.exports = { unique }; + +function unique(a) { + if (Array.isArray(a)) { + return a.filter((val, i) => a.indexOf(val) === i).length; + } + return 1; +} + +unique.skipNumberValidation = true; diff --git a/packages/kbn-tinymath/src/grammar.js b/packages/kbn-tinymath/src/grammar.js new file mode 100644 index 0000000000000..60dfcf4800631 --- /dev/null +++ b/packages/kbn-tinymath/src/grammar.js @@ -0,0 +1,1385 @@ +/* + * Generated by PEG.js 0.10.0. + * + * http://pegjs.org/ + */ + +"use strict"; + +function peg$subclass(child, parent) { + function ctor() { this.constructor = child; } + ctor.prototype = parent.prototype; + child.prototype = new ctor(); +} + +function peg$SyntaxError(message, expected, found, location) { + this.message = message; + this.expected = expected; + this.found = found; + this.location = location; + this.name = "SyntaxError"; + + if (typeof Error.captureStackTrace === "function") { + Error.captureStackTrace(this, peg$SyntaxError); + } +} + +peg$subclass(peg$SyntaxError, Error); + +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + "class": function(expectation) { + var escapedParts = "", + i; + + for (i = 0; i < expectation.parts.length; i++) { + escapedParts += expectation.parts[i] instanceof Array + ? classEscape(expectation.parts[i][0]) + "-" + classEscape(expectation.parts[i][1]) + : classEscape(expectation.parts[i]); + } + + return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]"; + }, + + any: function(expectation) { + return "any character"; + }, + + end: function(expectation) { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; + } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } + + function literalEscape(s) { + return s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\0/g, '\\0') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, '\\\\') + .replace(/\]/g, '\\]') + .replace(/\^/g, '\\^') + .replace(/-/g, '\\-') + .replace(/\0/g, '\\0') + .replace(/\t/g, '\\t') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/[\x00-\x0F]/g, function(ch) { return '\\x0' + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return '\\x' + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = new Array(expected.length), + i, j; + + for (i = 0; i < expected.length; i++) { + descriptions[i] = describeExpectation(expected[i]); + } + + descriptions.sort(); + + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; + +function peg$parse(input, options) { + options = options !== void 0 ? options : {}; + + var peg$FAILED = {}, + + peg$startRuleFunctions = { start: peg$parsestart }, + peg$startRuleFunction = peg$parsestart, + + peg$c0 = peg$otherExpectation("whitespace"), + peg$c1 = /^[ \t\n\r]/, + peg$c2 = peg$classExpectation([" ", "\t", "\n", "\r"], false, false), + peg$c3 = /^[ ]/, + peg$c4 = peg$classExpectation([" "], false, false), + peg$c5 = /^["']/, + peg$c6 = peg$classExpectation(["\"", "'"], false, false), + peg$c7 = /^[A-Za-z_@.[\]\-]/, + peg$c8 = peg$classExpectation([["A", "Z"], ["a", "z"], "_", "@", ".", "[", "]", "-"], false, false), + peg$c9 = /^[0-9A-Za-z._@[\]\-]/, + peg$c10 = peg$classExpectation([["0", "9"], ["A", "Z"], ["a", "z"], ".", "_", "@", "[", "]", "-"], false, false), + peg$c11 = peg$otherExpectation("literal"), + peg$c12 = function(literal) { + return literal; + }, + peg$c13 = function(first, rest) { // We can open this up later. Strict for now. + return first + rest.join(''); + }, + peg$c14 = function(first, mid) { + return first + mid.map(m => m[0].join('') + m[1].join('')).join('') + }, + peg$c15 = "+", + peg$c16 = peg$literalExpectation("+", false), + peg$c17 = "-", + peg$c18 = peg$literalExpectation("-", false), + peg$c19 = function(left, rest) { + return rest.reduce((acc, curr) => ({ + name: curr[0] === '+' ? 'add' : 'subtract', + args: [acc, curr[1]] + }), left) + }, + peg$c20 = "*", + peg$c21 = peg$literalExpectation("*", false), + peg$c22 = "/", + peg$c23 = peg$literalExpectation("/", false), + peg$c24 = function(left, rest) { + return rest.reduce((acc, curr) => ({ + name: curr[0] === '*' ? 'multiply' : 'divide', + args: [acc, curr[1]] + }), left) + }, + peg$c25 = "(", + peg$c26 = peg$literalExpectation("(", false), + peg$c27 = ")", + peg$c28 = peg$literalExpectation(")", false), + peg$c29 = function(expr) { + return expr + }, + peg$c30 = peg$otherExpectation("arguments"), + peg$c31 = ",", + peg$c32 = peg$literalExpectation(",", false), + peg$c33 = function(first, arg) {return arg}, + peg$c34 = function(first, rest) { + return [first].concat(rest); + }, + peg$c35 = peg$otherExpectation("function"), + peg$c36 = /^[a-z]/, + peg$c37 = peg$classExpectation([["a", "z"]], false, false), + peg$c38 = function(name, args) { + return {name: name.join(''), args: args || []}; + }, + peg$c39 = peg$otherExpectation("number"), + peg$c40 = function() { return parseFloat(text()); }, + peg$c41 = /^[eE]/, + peg$c42 = peg$classExpectation(["e", "E"], false, false), + peg$c43 = peg$otherExpectation("exponent"), + peg$c44 = ".", + peg$c45 = peg$literalExpectation(".", false), + peg$c46 = "0", + peg$c47 = peg$literalExpectation("0", false), + peg$c48 = /^[1-9]/, + peg$c49 = peg$classExpectation([["1", "9"]], false, false), + peg$c50 = /^[0-9]/, + peg$c51 = peg$classExpectation([["0", "9"]], false, false), + + peg$currPos = 0, + peg$savedPos = 0, + peg$posDetailsCache = [{ line: 1, column: 1 }], + peg$maxFailPos = 0, + peg$maxFailExpected = [], + peg$silentFails = 0, + + peg$result; + + if ("startRule" in options) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== void 0 ? location : peg$computeLocation(peg$savedPos, peg$currPos) + + throw peg$buildSimpleError(message, location); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text: text, ignoreCase: ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description: description }; + } + + function peg$computePosDetails(pos) { + var details = peg$posDetailsCache[pos], p; + + if (details) { + return details; + } else { + p = pos - 1; + while (!peg$posDetailsCache[p]) { + p--; + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + return details; + } + } + + function peg$computeLocation(startPos, endPos) { + var startPosDetails = peg$computePosDetails(startPos), + endPosDetails = peg$computePosDetails(endPos); + + return { + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column + } + }; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsestart() { + var s0; + + s0 = peg$parseAddSubtract(); + + return s0; + } + + function peg$parse_() { + var s0, s1; + + peg$silentFails++; + s0 = []; + if (peg$c1.test(input.charAt(peg$currPos))) { + s1 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c2); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + if (peg$c1.test(input.charAt(peg$currPos))) { + s1 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c2); } + } + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c0); } + } + + return s0; + } + + function peg$parseSpace() { + var s0; + + if (peg$c3.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c4); } + } + + return s0; + } + + function peg$parseQuote() { + var s0; + + if (peg$c5.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c6); } + } + + return s0; + } + + function peg$parseStartChar() { + var s0; + + if (peg$c7.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c8); } + } + + return s0; + } + + function peg$parseValidChar() { + var s0; + + if (peg$c9.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c10); } + } + + return s0; + } + + function peg$parseLiteral() { + var s0, s1, s2, s3; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseNumber(); + if (s2 === peg$FAILED) { + s2 = peg$parseVariableWithQuote(); + if (s2 === peg$FAILED) { + s2 = peg$parseVariable(); + } + } + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c12(s2); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c11); } + } + + return s0; + } + + function peg$parseVariable() { + var s0, s1, s2, s3, s4; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseStartChar(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$parseValidChar(); + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$parseValidChar(); + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c13(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseVariableWithQuote() { + var s0, s1, s2, s3, s4, s5, s6, s7, s8; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseQuote(); + if (s2 !== peg$FAILED) { + s3 = peg$parseStartChar(); + if (s3 !== peg$FAILED) { + s4 = []; + s5 = peg$currPos; + s6 = []; + s7 = peg$parseSpace(); + while (s7 !== peg$FAILED) { + s6.push(s7); + s7 = peg$parseSpace(); + } + if (s6 !== peg$FAILED) { + s7 = []; + s8 = peg$parseValidChar(); + if (s8 !== peg$FAILED) { + while (s8 !== peg$FAILED) { + s7.push(s8); + s8 = peg$parseValidChar(); + } + } else { + s7 = peg$FAILED; + } + if (s7 !== peg$FAILED) { + s6 = [s6, s7]; + s5 = s6; + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + while (s5 !== peg$FAILED) { + s4.push(s5); + s5 = peg$currPos; + s6 = []; + s7 = peg$parseSpace(); + while (s7 !== peg$FAILED) { + s6.push(s7); + s7 = peg$parseSpace(); + } + if (s6 !== peg$FAILED) { + s7 = []; + s8 = peg$parseValidChar(); + if (s8 !== peg$FAILED) { + while (s8 !== peg$FAILED) { + s7.push(s8); + s8 = peg$parseValidChar(); + } + } else { + s7 = peg$FAILED; + } + if (s7 !== peg$FAILED) { + s6 = [s6, s7]; + s5 = s6; + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + } else { + peg$currPos = s5; + s5 = peg$FAILED; + } + } + if (s4 !== peg$FAILED) { + s5 = peg$parseQuote(); + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c14(s3, s4); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseAddSubtract() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseMultiplyDivide(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 43) { + s5 = peg$c15; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c16); } + } + if (s5 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 45) { + s5 = peg$c17; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c18); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parseMultiplyDivide(); + if (s6 !== peg$FAILED) { + s5 = [s5, s6]; + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 43) { + s5 = peg$c15; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c16); } + } + if (s5 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 45) { + s5 = peg$c17; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c18); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parseMultiplyDivide(); + if (s6 !== peg$FAILED) { + s5 = [s5, s6]; + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c19(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseMultiplyDivide() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseFactor(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 42) { + s5 = peg$c20; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c21); } + } + if (s5 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 47) { + s5 = peg$c22; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c23); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parseFactor(); + if (s6 !== peg$FAILED) { + s5 = [s5, s6]; + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 42) { + s5 = peg$c20; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c21); } + } + if (s5 === peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 47) { + s5 = peg$c22; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c23); } + } + } + if (s5 !== peg$FAILED) { + s6 = peg$parseFactor(); + if (s6 !== peg$FAILED) { + s5 = [s5, s6]; + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c24(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseFactor() { + var s0; + + s0 = peg$parseGroup(); + if (s0 === peg$FAILED) { + s0 = peg$parseFunction(); + if (s0 === peg$FAILED) { + s0 = peg$parseLiteral(); + } + } + + return s0; + } + + function peg$parseGroup() { + var s0, s1, s2, s3, s4, s5, s6, s7; + + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 40) { + s2 = peg$c25; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c26); } + } + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + if (s3 !== peg$FAILED) { + s4 = peg$parseAddSubtract(); + if (s4 !== peg$FAILED) { + s5 = peg$parse_(); + if (s5 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 41) { + s6 = peg$c27; + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c28); } + } + if (s6 !== peg$FAILED) { + s7 = peg$parse_(); + if (s7 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c29(s4); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseArguments() { + var s0, s1, s2, s3, s4, s5, s6, s7, s8; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = peg$parseAddSubtract(); + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$currPos; + s5 = peg$parse_(); + if (s5 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 44) { + s6 = peg$c31; + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s6 !== peg$FAILED) { + s7 = peg$parse_(); + if (s7 !== peg$FAILED) { + s8 = peg$parseAddSubtract(); + if (s8 !== peg$FAILED) { + peg$savedPos = s4; + s5 = peg$c33(s2, s8); + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$currPos; + s5 = peg$parse_(); + if (s5 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 44) { + s6 = peg$c31; + peg$currPos++; + } else { + s6 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s6 !== peg$FAILED) { + s7 = peg$parse_(); + if (s7 !== peg$FAILED) { + s8 = peg$parseAddSubtract(); + if (s8 !== peg$FAILED) { + peg$savedPos = s4; + s5 = peg$c33(s2, s8); + s4 = s5; + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } else { + peg$currPos = s4; + s4 = peg$FAILED; + } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 44) { + s5 = peg$c31; + peg$currPos++; + } else { + s5 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c32); } + } + if (s5 === peg$FAILED) { + s5 = null; + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + if (s6 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c34(s2, s3); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c30); } + } + + return s0; + } + + function peg$parseFunction() { + var s0, s1, s2, s3, s4, s5, s6, s7, s8; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parse_(); + if (s1 !== peg$FAILED) { + s2 = []; + if (peg$c36.test(input.charAt(peg$currPos))) { + s3 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c37); } + } + if (s3 !== peg$FAILED) { + while (s3 !== peg$FAILED) { + s2.push(s3); + if (peg$c36.test(input.charAt(peg$currPos))) { + s3 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c37); } + } + } + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 40) { + s3 = peg$c25; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c26); } + } + if (s3 !== peg$FAILED) { + s4 = peg$parse_(); + if (s4 !== peg$FAILED) { + s5 = peg$parseArguments(); + if (s5 === peg$FAILED) { + s5 = null; + } + if (s5 !== peg$FAILED) { + s6 = peg$parse_(); + if (s6 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 41) { + s7 = peg$c27; + peg$currPos++; + } else { + s7 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c28); } + } + if (s7 !== peg$FAILED) { + s8 = peg$parse_(); + if (s8 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c38(s2, s5); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c35); } + } + + return s0; + } + + function peg$parseNumber() { + var s0, s1, s2, s3, s4; + + peg$silentFails++; + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 45) { + s1 = peg$c17; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c18); } + } + if (s1 === peg$FAILED) { + s1 = null; + } + if (s1 !== peg$FAILED) { + s2 = peg$parseInteger(); + if (s2 !== peg$FAILED) { + s3 = peg$parseFraction(); + if (s3 === peg$FAILED) { + s3 = null; + } + if (s3 !== peg$FAILED) { + s4 = peg$parseExp(); + if (s4 === peg$FAILED) { + s4 = null; + } + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$c40(); + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c39); } + } + + return s0; + } + + function peg$parseE() { + var s0; + + if (peg$c41.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c42); } + } + + return s0; + } + + function peg$parseExp() { + var s0, s1, s2, s3, s4; + + peg$silentFails++; + s0 = peg$currPos; + s1 = peg$parseE(); + if (s1 !== peg$FAILED) { + if (input.charCodeAt(peg$currPos) === 45) { + s2 = peg$c17; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c18); } + } + if (s2 === peg$FAILED) { + s2 = null; + } + if (s2 !== peg$FAILED) { + s3 = []; + s4 = peg$parseDigit(); + if (s4 !== peg$FAILED) { + while (s4 !== peg$FAILED) { + s3.push(s4); + s4 = peg$parseDigit(); + } + } else { + s3 = peg$FAILED; + } + if (s3 !== peg$FAILED) { + s1 = [s1, s2, s3]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c43); } + } + + return s0; + } + + function peg$parseFraction() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 46) { + s1 = peg$c44; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c45); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parseDigit(); + if (s3 !== peg$FAILED) { + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parseDigit(); + } + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s1 = [s1, s2]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseInteger() { + var s0, s1, s2, s3; + + if (input.charCodeAt(peg$currPos) === 48) { + s0 = peg$c46; + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c47); } + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (peg$c48.test(input.charAt(peg$currPos))) { + s1 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c49); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$parseDigit(); + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$parseDigit(); + } + if (s2 !== peg$FAILED) { + s1 = [s1, s2]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + + return s0; + } + + function peg$parseDigit() { + var s0; + + if (peg$c50.test(input.charAt(peg$currPos))) { + s0 = input.charAt(peg$currPos); + peg$currPos++; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$c51); } + } + + return s0; + } + + peg$result = peg$startRuleFunction(); + + if (peg$result !== peg$FAILED && peg$currPos === input.length) { + return peg$result; + } else { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } +} + +module.exports = { + SyntaxError: peg$SyntaxError, + parse: peg$parse +}; diff --git a/packages/kbn-tinymath/src/grammar.pegjs b/packages/kbn-tinymath/src/grammar.pegjs new file mode 100644 index 0000000000000..cab8e024e60b3 --- /dev/null +++ b/packages/kbn-tinymath/src/grammar.pegjs @@ -0,0 +1,100 @@ +// tinymath parsing grammar + +start + = Expression + +// characters + +_ "whitespace" + = [ \t\n\r]* + +Space + = [ ] + +Quote + = [\"\'] + +StartChar + = [A-Za-z_@.\[\]-] + +ValidChar + = [0-9A-Za-z._@\[\]-] + +// literals and variables + +Literal "literal" + = _ literal:(Number / VariableWithQuote / Variable) _ { + return literal; + } + +Variable + = _ first:StartChar rest:ValidChar* _ { // We can open this up later. Strict for now. + return first + rest.join(''); + } + +VariableWithQuote + = _ Quote first:StartChar mid:(Space* ValidChar+)* Quote _ { + return first + mid.map(m => m[0].join('') + m[1].join('')).join('') + } + +// expressions + +Expression + = AddSubtract + +AddSubtract + = _ left:MultiplyDivide rest:(('+' / '-') MultiplyDivide)* _ { + return rest.reduce((acc, curr) => ({ + name: curr[0] === '+' ? 'add' : 'subtract', + args: [acc, curr[1]] + }), left) + } + +MultiplyDivide + = _ left:Factor rest:(('*' / '/') Factor)* _ { + return rest.reduce((acc, curr) => ({ + name: curr[0] === '*' ? 'multiply' : 'divide', + args: [acc, curr[1]] + }), left) + } + +Factor + = Group + / Function + / Literal + +Group + = _ '(' _ expr:Expression _ ')' _ { + return expr + } + +Arguments "arguments" + = _ first:Expression rest:(_ ',' _ arg:Expression {return arg})* _ ','? _ { + return [first].concat(rest); + } + +Function "function" + = _ name:[a-z]+ '(' _ args:Arguments? _ ')' _ { + return {name: name.join(''), args: args || []}; + } + +// Numbers. Lol. + +Number "number" + = '-'? Integer Fraction? Exp? { return parseFloat(text()); } + +E + = [eE] + +Exp "exponent" + = E '-'? Digit+ + +Fraction + = '.' Digit+ + +Integer + = '0' + / ([1-9] Digit*) + +Digit + = [0-9] diff --git a/packages/kbn-tinymath/src/index.js b/packages/kbn-tinymath/src/index.js new file mode 100644 index 0000000000000..e61956bd63e55 --- /dev/null +++ b/packages/kbn-tinymath/src/index.js @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { get } = require('lodash'); +const { parse: parseFn } = require('./grammar'); +const { functions: includedFunctions } = require('./functions'); + +module.exports = { parse, evaluate, interpret }; + +function parse(input, options) { + if (input == null) { + throw new Error('Missing expression'); + } + + if (typeof input !== 'string') { + throw new Error('Expression must be a string'); + } + + try { + return parseFn(input, options); + } catch (e) { + throw new Error(`Failed to parse expression. ${e.message}`); + } +} + +function evaluate(expression, scope = {}, injectedFunctions = {}) { + scope = scope || {}; + return interpret(parse(expression), scope, injectedFunctions); +} + +function interpret(node, scope, injectedFunctions) { + const functions = Object.assign({}, includedFunctions, injectedFunctions); // eslint-disable-line + return exec(node); + + function exec(node) { + const type = getType(node); + + if (type === 'function') return invoke(node); + + if (type === 'string') { + const val = getValue(scope, node); + if (typeof val === 'undefined') throw new Error(`Unknown variable: ${node}`); + return val; + } + + return node; // Can only be a number at this point + } + + function invoke(node) { + const { name, args } = node; + const fn = functions[name]; + if (!fn) throw new Error(`No such function: ${name}`); + const execOutput = args.map(exec); + if (fn.skipNumberValidation || isOperable(execOutput)) return fn(...execOutput); + return NaN; + } +} + +function getValue(scope, node) { + // attempt to read value from nested object first, check for exact match if value is undefined + const val = get(scope, node); + return typeof val !== 'undefined' ? val : scope[node]; +} + +function getType(x) { + const type = typeof x; + if (type === 'object') { + const keys = Object.keys(x); + if (keys.length !== 2 || !x.name || !x.args) throw new Error('Invalid AST object'); + return 'function'; + } + if (type === 'string' || type === 'number') return type; + throw new Error(`Unknown AST property type: ${type}`); +} + +function isOperable(args) { + return args.every((arg) => { + if (Array.isArray(arg)) return isOperable(arg); + return typeof arg === 'number' && !isNaN(arg); + }); +} diff --git a/packages/kbn-tinymath/test/functions/abs.test.js b/packages/kbn-tinymath/test/functions/abs.test.js new file mode 100644 index 0000000000000..09ae042d23de6 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/abs.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { abs } = require('../../src/functions/abs.js'); + +describe('Abs', () => { + it('numbers', () => { + expect(abs(-10)).toEqual(10); + expect(abs(10)).toEqual(10); + }); + + it('arrays', () => { + expect(abs([-1])).toEqual([1]); + expect(abs([-10, -20, -30, -40])).toEqual([10, 20, 30, 40]); + expect(abs([-13, 30, -90, 200])).toEqual([13, 30, 90, 200]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/add.test.js b/packages/kbn-tinymath/test/functions/add.test.js new file mode 100644 index 0000000000000..56b4fc48a62ad --- /dev/null +++ b/packages/kbn-tinymath/test/functions/add.test.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { add } = require('../../src/functions/add.js'); + +describe('Add', () => { + it('numbers', () => { + expect(add(1)).toEqual(1); + expect(add(10, 2, 5, 8)).toEqual(25); + expect(add(0.1, 0.2, 0.4, 0.3)).toEqual(0.1 + 0.2 + 0.3 + 0.4); + }); + + it('arrays & numbers', () => { + expect(add([10, 20, 30, 40], 10, 20, 30)).toEqual([70, 80, 90, 100]); + expect(add(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([43, 54, 65, 76]); + }); + + it('arrays', () => { + expect(add([1, 2, 3, 4])).toEqual(10); + expect(add([1, 2, 3, 4], [1, 2, 5, 10])).toEqual([2, 4, 8, 14]); + expect(add([1, 2, 3, 4], [1, 2, 5, 10], [10, 20, 30, 40])).toEqual([12, 24, 38, 54]); + expect(add([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([12, 50, 63, 76]); + }); + + it('array length mismatch', () => { + expect(() => add([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/cbrt.test.js b/packages/kbn-tinymath/test/functions/cbrt.test.js new file mode 100644 index 0000000000000..8b8b57c5a1ba1 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/cbrt.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { cbrt } = require('../../src/functions/cbrt.js'); + +describe('Cbrt', () => { + it('numbers', () => { + expect(cbrt(27)).toEqual(3); + expect(cbrt(-1)).toEqual(-1); + expect(cbrt(94)).toEqual(4.546835943776344); + }); + + it('arrays', () => { + expect(cbrt([27, 64, 125])).toEqual([3, 4, 5]); + expect(cbrt([1, 8, 1000])).toEqual([1, 2, 10]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/ceil.test.js b/packages/kbn-tinymath/test/functions/ceil.test.js new file mode 100644 index 0000000000000..0809c9ba1e9d5 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/ceil.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { ceil } = require('../../src/functions/ceil.js'); + +describe('Ceil', () => { + it('numbers', () => { + expect(ceil(-10.5)).toEqual(-10); + expect(ceil(-10.1)).toEqual(-10); + expect(ceil(10.9)).toEqual(11); + }); + + it('arrays', () => { + expect(ceil([-10.5, -20.9, -30.1, -40.2])).toEqual([-10, -20, -30, -40]); + expect(ceil([2.9, 5.1, 3.5, 4.3])).toEqual([3, 6, 4, 5]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/clamp.test.js b/packages/kbn-tinymath/test/functions/clamp.test.js new file mode 100644 index 0000000000000..7e6015bf304cf --- /dev/null +++ b/packages/kbn-tinymath/test/functions/clamp.test.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { clamp } = require('../../src/functions/clamp.js'); + +describe('Clamp', () => { + it('numbers', () => { + expect(clamp(10, 5, 8)).toEqual(8); + expect(clamp(1, 2, 3)).toEqual(2); + expect(clamp(0.5, 0.2, 0.4)).toEqual(0.4); + expect(clamp(3.58, 0, 1)).toEqual(1); + expect(clamp(-0.48, 0, 1)).toEqual(0); + expect(clamp(1.38, -1, 0)).toEqual(0); + }); + + it('arrays & numbers', () => { + expect(clamp([10, 20, 30, 40], 15, 25)).toEqual([15, 20, 25, 25]); + expect(clamp(10, [15, 2, 4, 20], 25)).toEqual([15, 10, 10, 20]); + expect(clamp(5, 10, [20, 30, 40, 50])).toEqual([10, 10, 10, 10]); + expect(clamp(35, 10, [20, 30, 40, 50])).toEqual([20, 30, 35, 35]); + expect(clamp([1, 9], 3, [4, 5])).toEqual([3, 5]); + }); + + it('arrays', () => { + expect(clamp([6, 28, 32, 10], [11, 2, 5, 10], [20, 21, 22, 23])).toEqual([11, 21, 22, 10]); + }); + + it('errors', () => { + expect(() => clamp(1, 4, 3)).toThrow('Min must be less than max'); + expect(() => clamp([1, 2], [3], 3)).toThrow('Array length mismatch'); + expect(() => clamp([1, 2], [3], 3)).toThrow('Array length mismatch'); + expect(() => clamp(10, 20, null)).toThrow( + "Missing maximum value. You may want to use the 'min' function instead" + ); + expect(() => clamp([10, 20, 30, 40], 15, null)).toThrow( + "Missing maximum value. You may want to use the 'min' function instead" + ); + expect(() => clamp(10, null, 30)).toThrow( + "Missing minimum value. You may want to use the 'max' function instead" + ); + expect(() => clamp([11, 28, 60, 10], null, [1, 48, 3, -17])).toThrow( + "Missing minimum value. You may want to use the 'max' function instead" + ); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/cos.test.js b/packages/kbn-tinymath/test/functions/cos.test.js new file mode 100644 index 0000000000000..9e4461512fe06 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/cos.test.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { cos } = require('../../src/functions/cos.js'); + +describe('Cosine', () => { + it('numbers', () => { + expect(cos(0)).toEqual(1); + expect(cos(1.5707963267948966)).toEqual(6.123233995736766e-17); + }); + + it('arrays', () => { + expect(cos([0, 1.5707963267948966])).toEqual([1, 6.123233995736766e-17]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/cube.test.js b/packages/kbn-tinymath/test/functions/cube.test.js new file mode 100644 index 0000000000000..f91cbd3c58059 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/cube.test.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { cube } = require('../../src/functions/cube.js'); + +describe('Cube', () => { + it('numbers', () => { + expect(cube(3)).toEqual(27); + expect(cube(-1)).toEqual(-1); + }); + + it('arrays', () => { + expect(cube([3, 4, 5])).toEqual([27, 64, 125]); + expect(cube([1, 2, 10])).toEqual([1, 8, 1000]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/degtorad.test.js b/packages/kbn-tinymath/test/functions/degtorad.test.js new file mode 100644 index 0000000000000..8ce78851e7844 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/degtorad.test.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { degtorad } = require('../../src/functions/degtorad.js'); + +describe('Degrees to Radians', () => { + it('numbers', () => { + expect(degtorad(0)).toEqual(0); + expect(degtorad(90)).toEqual(1.5707963267948966); + }); + + it('arrays', () => { + expect(degtorad([0, 90, 180, 360])).toEqual([ + 0, + 1.5707963267948966, + 3.141592653589793, + 6.283185307179586, + ]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/divide.test.js b/packages/kbn-tinymath/test/functions/divide.test.js new file mode 100644 index 0000000000000..f3eea83c3fb80 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/divide.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { divide } = require('../../src/functions/divide.js'); + +describe('Divide', () => { + it('number, number', () => { + expect(divide(10, 2)).toEqual(5); + expect(divide(0.1, 0.02)).toEqual(0.1 / 0.02); + }); + + it('array, number', () => { + expect(divide([10, 20, 30, 40], 10)).toEqual([1, 2, 3, 4]); + }); + + it('number, array', () => { + expect(divide(10, [1, 2, 5, 10])).toEqual([10, 5, 2, 1]); + }); + + it('array, array', () => { + expect(divide([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([11, 24, 20, 18]); + }); + + it('array length mismatch', () => { + expect(() => divide([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/exp.test.js b/packages/kbn-tinymath/test/functions/exp.test.js new file mode 100644 index 0000000000000..0bb25d772ae2c --- /dev/null +++ b/packages/kbn-tinymath/test/functions/exp.test.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { exp } = require('../../src/functions/exp.js'); + +describe('Exp', () => { + it('numbers', () => { + expect(exp(3)).toEqual(Math.exp(3)); + expect(exp(0)).toEqual(Math.exp(0)); + expect(exp(5)).toEqual(Math.exp(5)); + }); + + it('arrays', () => { + expect(exp([3, 4, 5])).toEqual([Math.exp(3), Math.exp(4), Math.exp(5)]); + expect(exp([1, 2, 10])).toEqual([Math.exp(1), Math.exp(2), Math.exp(10)]); + expect(exp([10])).toEqual([Math.exp(10)]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/first.test.js b/packages/kbn-tinymath/test/functions/first.test.js new file mode 100644 index 0000000000000..c977f68117724 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/first.test.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { first } = require('../../src/functions/first.js'); + +describe('First', () => { + it('numbers', () => { + expect(first(-10)).toEqual(-10); + expect(first(10)).toEqual(10); + }); + + it('arrays', () => { + expect(first([])).toEqual(undefined); + expect(first([-1])).toEqual(-1); + expect(first([-10, -20, -30, -40])).toEqual(-10); + expect(first([-13, 30, -90, 200])).toEqual(-13); + }); + + it('skips number validation', () => { + expect(first).toHaveProperty('skipNumberValidation', true); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/fix.test.js b/packages/kbn-tinymath/test/functions/fix.test.js new file mode 100644 index 0000000000000..59a71352ac680 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/fix.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { fix } = require('../../src/functions/fix.js'); + +describe('Fix', () => { + it('numbers', () => { + expect(fix(-10.5)).toEqual(-10); + expect(fix(-10.1)).toEqual(-10); + expect(fix(10.9)).toEqual(10); + }); + + it('arrays', () => { + expect(fix([-10.5, -20.9, -30.1, -40.2])).toEqual([-10, -20, -30, -40]); + expect(fix([2.9, 5.1, 3.5, 4.3])).toEqual([2, 5, 3, 4]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/floor.test.js b/packages/kbn-tinymath/test/functions/floor.test.js new file mode 100644 index 0000000000000..19f80e9bb7b06 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/floor.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { floor } = require('../../src/functions/floor.js'); + +describe('Floor', () => { + it('numbers', () => { + expect(floor(-10.5)).toEqual(-11); + expect(floor(-10.1)).toEqual(-11); + expect(floor(10.9)).toEqual(10); + }); + + it('arrays', () => { + expect(floor([-10.5, -20.9, -30.1, -40.2])).toEqual([-11, -21, -31, -41]); + expect(floor([2.9, 5.1, 3.5, 4.3])).toEqual([2, 5, 3, 4]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/last.test.js b/packages/kbn-tinymath/test/functions/last.test.js new file mode 100644 index 0000000000000..a333541b147ea --- /dev/null +++ b/packages/kbn-tinymath/test/functions/last.test.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { last } = require('../../src/functions/last.js'); + +describe('Last', () => { + it('numbers', () => { + expect(last(-10)).toEqual(-10); + expect(last(10)).toEqual(10); + }); + + it('arrays', () => { + expect(last([])).toEqual(undefined); + expect(last([-1])).toEqual(-1); + expect(last([-10, -20, -30, -40])).toEqual(-40); + expect(last([-13, 30, -90, 200])).toEqual(200); + }); + + it('skips number validation', () => { + expect(last).toHaveProperty('skipNumberValidation', true); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/log.test.js b/packages/kbn-tinymath/test/functions/log.test.js new file mode 100644 index 0000000000000..de142b997039b --- /dev/null +++ b/packages/kbn-tinymath/test/functions/log.test.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { log } = require('../../src/functions/log.js'); + +describe('Log', () => { + it('numbers', () => { + expect(log(1)).toEqual(Math.log(1)); + expect(log(3, 2)).toEqual(Math.log(3) / Math.log(2)); + expect(log(11, 3)).toEqual(Math.log(11) / Math.log(3)); + expect(log(42, 5)).toEqual(2.322344707681546); + }); + + it('arrays', () => { + expect(log([3, 4, 5], 3)).toEqual([ + Math.log(3) / Math.log(3), + Math.log(4) / Math.log(3), + Math.log(5) / Math.log(3), + ]); + expect(log([1, 2, 10], 10)).toEqual([ + Math.log(1) / Math.log(10), + Math.log(2) / Math.log(10), + Math.log(10) / Math.log(10), + ]); + }); + + it('number less than 1', () => { + expect(() => log(-1)).toThrow('Must be greater than 0'); + }); + + it('base out of range', () => { + expect(() => log(1, -1)).toThrow('Base out of range'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/log10.test.js b/packages/kbn-tinymath/test/functions/log10.test.js new file mode 100644 index 0000000000000..e0edfaa8388f0 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/log10.test.js @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { log10 } = require('../../src/functions/log10.js'); + +describe('Log10', () => { + it('numbers', () => { + expect(log10(1)).toEqual(Math.log(1) / Math.log(10)); + expect(log10(3)).toEqual(Math.log(3) / Math.log(10)); + expect(log10(11)).toEqual(Math.log(11) / Math.log(10)); + expect(log10(80)).toEqual(1.9030899869919433); + }); + + it('arrays', () => { + expect(log10([3, 4, 5], 3)).toEqual([ + Math.log(3) / Math.log(10), + Math.log(4) / Math.log(10), + Math.log(5) / Math.log(10), + ]); + expect(log10([1, 2, 10], 10)).toEqual([ + Math.log(1) / Math.log(10), + Math.log(2) / Math.log(10), + Math.log(10) / Math.log(10), + ]); + }); + + it('number less than 1', () => { + expect(() => log10(-1)).toThrow('Must be greater than 0'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/max.test.js b/packages/kbn-tinymath/test/functions/max.test.js new file mode 100644 index 0000000000000..ab4de7b958e68 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/max.test.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { max } = require('../../src/functions/max.js'); + +describe('Max', () => { + it('numbers', () => { + expect(max(1)).toEqual(1); + expect(max(10, 2, 5, 8)).toEqual(10); + expect(max(0.1, 0.2, 0.4, 0.3)).toEqual(0.4); + }); + + it('arrays & numbers', () => { + expect(max([88, 20, 30, 40], 60, [30, 10, 70, 90])).toEqual([88, 60, 70, 90]); + expect(max(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([22, 22, 30, 40]); + }); + + it('arrays', () => { + expect(max([1, 2, 3, 4])).toEqual(4); + expect(max([6, 2, 3, 10], [11, 2, 5, 10])).toEqual([11, 2, 5, 10]); + expect(max([30, 55, 9, 4], [72, 24, 48, 10], [10, 20, 30, 40])).toEqual([72, 55, 48, 40]); + expect(max([11, 28, 60, 10], [1, 48, 3, -17])).toEqual([11, 48, 60, 10]); + }); + + it('array length mismatch', () => { + expect(() => max([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/mean.test.js b/packages/kbn-tinymath/test/functions/mean.test.js new file mode 100644 index 0000000000000..6fb1c1fa18b98 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/mean.test.js @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { mean } = require('../../src/functions/mean.js'); + +describe('Mean', () => { + it('numbers', () => { + expect(mean(1)).toEqual(1); + expect(mean(10, 2, 5, 8)).toEqual(25 / 4); + expect(mean(0.1, 0.2, 0.4, 0.3)).toEqual((0.1 + 0.2 + 0.3 + 0.4) / 4); + }); + + it('arrays & numbers', () => { + expect(mean([10, 20, 30, 40], 10, 20, 30)).toEqual([70 / 4, 80 / 4, 90 / 4, 100 / 4]); + expect(mean(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([43 / 4, 54 / 4, 65 / 4, 76 / 4]); + }); + + it('arrays', () => { + expect(mean([1, 2, 3, 4])).toEqual(10 / 4); + expect(mean([1, 2, 3, 4], [1, 2, 5, 10])).toEqual([2 / 2, 4 / 2, 8 / 2, 14 / 2]); + expect(mean([1, 2, 3, 4], [1, 2, 5, 10], [10, 20, 30, 40])).toEqual([ + 12 / 3, + 24 / 3, + 38 / 3, + 54 / 3, + ]); + expect(mean([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([12 / 2, 50 / 2, 63 / 2, 76 / 2]); + }); + + it('array length mismatch', () => { + expect(() => mean([1, 2], [3])).toThrow(); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/median.test.js b/packages/kbn-tinymath/test/functions/median.test.js new file mode 100644 index 0000000000000..e7dd56b4c6fc4 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/median.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { median } = require('../../src/functions/median.js'); + +describe('Median', () => { + it('numbers', () => { + expect(median(1)).toEqual(1); + expect(median(10, 2, 5, 8)).toEqual((8 + 5) / 2); + expect(median(0.1, 0.2, 0.4, 0.3)).toEqual((0.2 + 0.3) / 2); + }); + + it('arrays & numbers', () => { + expect(median([10, 20, 30, 40], 10, 20, 30)).toEqual([15, 20, 25, 25]); + expect(median(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([10, 15, 16, 16]); + }); + + it('arrays', () => { + expect(median([1, 2, 3, 4], [1, 2, 5, 10])).toEqual([1, 2, 4, 7]); + expect(median([1, 2, 3, 4], [1, 2, 5, 10], [10, 20, 30, 40])).toEqual([1, 2, 5, 10]); + expect(median([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([12 / 2, 50 / 2, 63 / 2, 76 / 2]); + }); + + it('array length mismatch', () => { + expect(() => median([1, 2], [3])).toThrow(); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/min.test.js b/packages/kbn-tinymath/test/functions/min.test.js new file mode 100644 index 0000000000000..9612ce4274d11 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/min.test.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { min } = require('../../src/functions/min.js'); + +describe('Min', () => { + it('numbers', () => { + expect(min(1)).toEqual(1); + expect(min(10, 2, 5, 8)).toEqual(2); + expect(min(0.1, 0.2, 0.4, 0.3)).toEqual(0.1); + }); + + it('arrays & numbers', () => { + expect(min([88, 20, 30, 100], 60, [30, 10, 70, 90])).toEqual([30, 10, 30, 60]); + expect(min([50, 20, 3, 40], 10, [13, 2, 34, 4], 22)).toEqual([10, 2, 3, 4]); + expect(min(10, [50, 20, 3, 40], [13, 2, 34, 4], 22)).toEqual([10, 2, 3, 4]); + }); + + it('arrays', () => { + expect(min([1, 2, 3, 4])).toEqual(1); + expect(min([6, 2, 30, 10], [11, 2, 5, 15])).toEqual([6, 2, 5, 10]); + expect(min([30, 55, 9, 4], [72, 24, 48, 10], [10, 20, 30, 40])).toEqual([10, 20, 9, 4]); + expect(min([11, 28, 60, 10], [1, 48, 3, -17])).toEqual([1, 28, 3, -17]); + }); + + it('array length mismatch', () => { + expect(() => min([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/mod.test.js b/packages/kbn-tinymath/test/functions/mod.test.js new file mode 100644 index 0000000000000..ba3fc35b7e70c --- /dev/null +++ b/packages/kbn-tinymath/test/functions/mod.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { mod } = require('../../src/functions/mod.js'); + +describe('Mod', () => { + it('number, number', () => { + expect(mod(13, 8)).toEqual(5); + expect(mod(0.1, 0.02)).toEqual(0.1 % 0.02); + }); + + it('array, number', () => { + expect(mod([13, 26, 34, 42], 10)).toEqual([3, 6, 4, 2]); + }); + + it('number, array', () => { + expect(mod(10, [3, 7, 2, 4])).toEqual([1, 3, 0, 2]); + }); + + it('array, array', () => { + expect(mod([11, 48, 60, 72], [4, 13, 9, 5])).toEqual([3, 9, 6, 2]); + }); + + it('array length mismatch', () => { + expect(() => mod([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/mode.test.js b/packages/kbn-tinymath/test/functions/mode.test.js new file mode 100644 index 0000000000000..6f33140d41ef0 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/mode.test.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { mode } = require('../../src/functions/mode.js'); + +describe('Mode', () => { + it('numbers', () => { + expect(mode(1)).toEqual(1); + expect(mode(10, 2, 5, 8)).toEqual([2, 5, 8, 10]); + expect(mode(0.1, 0.2, 0.4, 0.3)).toEqual([0.1, 0.2, 0.3, 0.4]); + expect(mode(1, 1, 2, 3, 1, 4, 3, 2, 4)).toEqual([1]); + }); + + it('arrays & numbers', () => { + expect(mode([10, 20, 30, 40], 10, 20, 30)).toEqual([[10], [20], [30], [10, 20, 30, 40]]); + expect(mode([1, 2, 3, 4], 2, 3, [3, 2, 4, 3])).toEqual([[3], [2], [3], [3]]); + }); + + it('arrays', () => { + expect(mode([1, 2, 3, 4], [1, 2, 5, 10])).toEqual([[1], [2], [3, 5], [4, 10]]); + expect(mode([1, 2, 3, 4], [1, 2, 1, 2], [2, 3, 2, 3], [4, 3, 2, 3])).toEqual([ + [1], + [2, 3], + [2], + [3], + ]); + }); + + it('array length mismatch', () => { + expect(() => mode([1, 2], [3])).toThrow(); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/multiply.test.js b/packages/kbn-tinymath/test/functions/multiply.test.js new file mode 100644 index 0000000000000..f3a35d1f45695 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/multiply.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { multiply } = require('../../src/functions/multiply.js'); + +describe('Multiply', () => { + it('number, number', () => { + expect(multiply(10, 2)).toEqual(20); + expect(multiply(0.1, 0.2)).toEqual(0.1 * 0.2); + }); + + it('array, number', () => { + expect(multiply([10, 20, 30, 40], 10)).toEqual([100, 200, 300, 400]); + }); + + it('number, array', () => { + expect(multiply(10, [1, 2, 5, 10])).toEqual([10, 20, 50, 100]); + }); + + it('array, array', () => { + expect(multiply([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([11, 96, 180, 288]); + }); + + it('array length mismatch', () => { + expect(() => multiply([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/pi.test.js b/packages/kbn-tinymath/test/functions/pi.test.js new file mode 100644 index 0000000000000..7f1cdd019401d --- /dev/null +++ b/packages/kbn-tinymath/test/functions/pi.test.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { pi } = require('../../src/functions/pi.js'); + +describe('PI', () => { + it('constant', () => { + expect(pi()).toEqual(Math.PI); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/pow.test.js b/packages/kbn-tinymath/test/functions/pow.test.js new file mode 100644 index 0000000000000..05193aa2177a6 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/pow.test.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { pow } = require('../../src/functions/pow.js'); + +describe('Pow', () => { + it('numbers', () => { + expect(pow(3, 2)).toEqual(9); + expect(pow(-1, -1)).toEqual(-1); + expect(pow(5, 0)).toEqual(1); + }); + + it('arrays', () => { + expect(pow([3, 4, 5], 3)).toEqual([Math.pow(3, 3), Math.pow(4, 3), Math.pow(5, 3)]); + expect(pow([1, 2, 10], 10)).toEqual([Math.pow(1, 10), Math.pow(2, 10), Math.pow(10, 10)]); + }); + + it('missing exponent', () => { + expect(() => pow(1)).toThrow('Missing exponent'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/radtodeg.test.js b/packages/kbn-tinymath/test/functions/radtodeg.test.js new file mode 100644 index 0000000000000..0b97d3d2695be --- /dev/null +++ b/packages/kbn-tinymath/test/functions/radtodeg.test.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { radtodeg } = require('../../src/functions/radtodeg.js'); + +describe('Radians to Degrees', () => { + it('numbers', () => { + expect(radtodeg(0)).toEqual(0); + expect(radtodeg(1.5707963267948966)).toEqual(90); + }); + + it('arrays', () => { + expect(radtodeg([0, 1.5707963267948966, 3.141592653589793, 6.283185307179586])).toEqual([ + 0, + 90, + 180, + 360, + ]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/random.test.js b/packages/kbn-tinymath/test/functions/random.test.js new file mode 100644 index 0000000000000..2b259f2b84771 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/random.test.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { random } = require('../../src/functions/random.js'); + +describe('Random', () => { + it('numbers', () => { + const random1 = random(); + expect(random1).toBeGreaterThanOrEqual(0); + expect(random1).toBeLessThan(1); + expect(random(0)).toEqual(0); + const random3 = random(3); + expect(random3).toBeGreaterThanOrEqual(0); + expect(random3).toBeLessThan(3); + const random100 = random(-100, 100); + expect(random100).toBeGreaterThanOrEqual(-100); + expect(random100).toBeLessThan(100); + expect(random(1, 1)).toEqual(1); + expect(random(100, 100)).toEqual(100); + }); + + it('min greater than max', () => { + expect(() => random(-1)).toThrow('Min is greater than max'); + expect(() => random(3, 1)).toThrow('Min is greater than max'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/range.test.js b/packages/kbn-tinymath/test/functions/range.test.js new file mode 100644 index 0000000000000..920986d5e1368 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/range.test.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { range } = require('../../src/functions/range.js'); + +describe('Range', () => { + it('numbers', () => { + expect(range(1)).toEqual(0); + expect(range(10, 2, 5, 8)).toEqual(8); + expect(range(0.1, 0.2, 0.4, 0.3)).toEqual(0.4 - 0.1); + }); + + it('arrays & numbers', () => { + expect(range([88, 20, 30, 40], 60, [30, 10, 70, 90])).toEqual([58, 50, 40, 50]); + expect(range(10, [10, 20, 30, 40], [1, 2, 3, 4], 22)).toEqual([21, 20, 27, 36]); + }); + + it('arrays', () => { + expect(range([1, 2, 3, 4])).toEqual(3); + expect(range([6, 2, 3, 10], [11, 2, 5, 10])).toEqual([5, 0, 2, 0]); + expect(range([30, 55, 9, 4], [72, 24, 48, 10], [10, 20, 30, 40])).toEqual([62, 35, 39, 36]); + expect(range([11, 28, 60, 10], [1, 48, 3, -17])).toEqual([10, 20, 57, 27]); + }); + + it('array length mismatch', () => { + expect(() => range([1, 2], [3])).toThrow(); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/round.test.js b/packages/kbn-tinymath/test/functions/round.test.js new file mode 100644 index 0000000000000..bea0a9a8377d7 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/round.test.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { round } = require('../../src/functions/round.js'); + +describe('Round', () => { + it('numbers', () => { + expect(round(-10.51)).toEqual(-11); + expect(round(-10.1, 2)).toEqual(-10.1); + expect(round(10.93745987, 4)).toEqual(10.9375); + }); + + it('arrays', () => { + expect(round([-10.51, -20.9, -30.1, -40.2])).toEqual([-11, -21, -30, -40]); + expect(round([2.9234, 5.1234, 3.5234, 4.49234324], 2)).toEqual([2.92, 5.12, 3.52, 4.49]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/sin.test.js b/packages/kbn-tinymath/test/functions/sin.test.js new file mode 100644 index 0000000000000..35a37abe35a68 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/sin.test.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { sin } = require('../../src/functions/sin.js'); + +describe('Sine', () => { + it('numbers', () => { + expect(sin(0)).toEqual(0); + expect(sin(1.5707963267948966)).toEqual(1); + }); + + it('arrays', () => { + expect(sin([0, 1.5707963267948966])).toEqual([0, 1]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/size.test.js b/packages/kbn-tinymath/test/functions/size.test.js new file mode 100644 index 0000000000000..b4db587f30230 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/size.test.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { size } = require('../../src/functions/size.js'); + +describe('Size (also Count)', () => { + it('array', () => { + expect(size([])).toEqual(0); + expect(size([10, 20, 30, 40])).toEqual(4); + }); + + it('not an array', () => { + expect(() => size(null)).toThrow('Must pass an array'); + expect(() => size(undefined)).toThrow('Must pass an array'); + expect(() => size('string')).toThrow('Must pass an array'); + expect(() => size(10)).toThrow('Must pass an array'); + expect(() => size(true)).toThrow('Must pass an array'); + expect(() => size({})).toThrow('Must pass an array'); + expect(() => size(function () {})).toThrow('Must pass an array'); + }); + + it('skips number validation', () => { + expect(size).toHaveProperty('skipNumberValidation', true); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/sqrt.test.js b/packages/kbn-tinymath/test/functions/sqrt.test.js new file mode 100644 index 0000000000000..a170140598d06 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/sqrt.test.js @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { sqrt } = require('../../src/functions/sqrt.js'); + +describe('Sqrt', () => { + it('numbers', () => { + expect(sqrt(9)).toEqual(3); + expect(sqrt(0)).toEqual(0); + expect(sqrt(30)).toEqual(5.477225575051661); + }); + + it('arrays', () => { + expect(sqrt([49, 64, 81])).toEqual([7, 8, 9]); + expect(sqrt([1, 4, 100])).toEqual([1, 2, 10]); + }); + + it('Invalid negative number', () => { + expect(() => sqrt(-1)).toThrow('Unable find the square root of a negative number'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/square.test.js b/packages/kbn-tinymath/test/functions/square.test.js new file mode 100644 index 0000000000000..3b91a5f79c8d3 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/square.test.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { square } = require('../../src/functions/square.js'); + +describe('Square', () => { + it('numbers', () => { + expect(square(3)).toEqual(9); + expect(square(-1)).toEqual(1); + }); + + it('arrays', () => { + expect(square([3, 4, 5])).toEqual([9, 16, 25]); + expect(square([1, 2, 10])).toEqual([1, 4, 100]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/subtract.test.js b/packages/kbn-tinymath/test/functions/subtract.test.js new file mode 100644 index 0000000000000..9cdc1fb85a562 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/subtract.test.js @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { subtract } = require('../../src/functions/subtract.js'); + +describe('Subtract', () => { + it('number, number', () => { + expect(subtract(10, 2)).toEqual(8); + expect(subtract(0.1, 0.2)).toEqual(0.1 - 0.2); + }); + + it('array, number', () => { + expect(subtract([10, 20, 30, 40], 10)).toEqual([0, 10, 20, 30]); + }); + + it('number, array', () => { + expect(subtract(10, [1, 2, 5, 10])).toEqual([9, 8, 5, 0]); + }); + + it('array, array', () => { + expect(subtract([11, 48, 60, 72], [1, 2, 3, 4])).toEqual([10, 46, 57, 68]); + }); + + it('array length mismatch', () => { + expect(() => subtract([1, 2], [3])).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/sum.test.js b/packages/kbn-tinymath/test/functions/sum.test.js new file mode 100644 index 0000000000000..a7d8c3d135253 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/sum.test.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { sum } = require('../../src/functions/sum.js'); + +describe('Sum', () => { + it('numbers', () => { + expect(sum(10, 2, 5, 8)).toEqual(25); + expect(sum(0.1, 0.2, 0.4, 0.3)).toEqual(0.1 + 0.2 + 0.3 + 0.4); + }); + + it('arrays & numbers', () => { + expect(sum([10, 20, 30, 40], 10, 20, 30)).toEqual(160); + expect(sum([10, 20, 30, 40], 10, [1, 2, 3], 22)).toEqual(138); + }); + + it('arrays', () => { + expect(sum([1, 2, 3, 4], [1, 2, 5, 10])).toEqual(28); + expect(sum([1, 2, 3, 4], [1, 2, 5, 10], [10, 20, 30, 40])).toEqual(128); + expect(sum([11, 48, 60, 72], [1, 2, 3, 4])).toEqual(201); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/tan.test.js b/packages/kbn-tinymath/test/functions/tan.test.js new file mode 100644 index 0000000000000..ba6960c0c1d8a --- /dev/null +++ b/packages/kbn-tinymath/test/functions/tan.test.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { tan } = require('../../src/functions/tan.js'); + +describe('Tangent', () => { + it('numbers', () => { + expect(tan(0)).toEqual(0); + expect(tan(1)).toEqual(1.5574077246549023); + }); + + it('arrays', () => { + expect(tan([0, 1])).toEqual([0, 1.5574077246549023]); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/transpose.test.js b/packages/kbn-tinymath/test/functions/transpose.test.js new file mode 100644 index 0000000000000..eb0b8d0c7a0e2 --- /dev/null +++ b/packages/kbn-tinymath/test/functions/transpose.test.js @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { transpose } = require('../../src/functions/lib/transpose'); + +describe('transpose', () => { + it('2D arrays', () => { + expect( + transpose( + [ + [1, 2], + [3, 4], + [5, 6], + ], + 0 + ) + ).toEqual([ + [1, 3, 5], + [2, 4, 6], + ]); + expect(transpose([10, 20, [10, 20, 30, 40], 30], 2)).toEqual([ + [10, 20, 10, 30], + [10, 20, 20, 30], + [10, 20, 30, 30], + [10, 20, 40, 30], + ]); + expect(transpose([4, [1, 9], [3, 5]], 1)).toEqual([ + [4, 1, 3], + [4, 9, 5], + ]); + }); + + it('array length mismatch', () => { + expect(() => transpose([[1], [2, 3]], 0)).toThrow('Array length mismatch'); + }); +}); diff --git a/packages/kbn-tinymath/test/functions/unique.test.js b/packages/kbn-tinymath/test/functions/unique.test.js new file mode 100644 index 0000000000000..d58c190876e2c --- /dev/null +++ b/packages/kbn-tinymath/test/functions/unique.test.js @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +const { unique } = require('../../src/functions/unique.js'); + +describe('Unique', () => { + it('numbers', () => { + expect(unique(1)).toEqual(1); + expect(unique(10000)).toEqual(1); + }); + + it('arrays', () => { + expect(unique([])).toEqual(0); + expect(unique([-10, -20, -30, -40])).toEqual(4); + expect(unique([-13, 30, -90, 200])).toEqual(4); + expect(unique([1, 2, 3, 4, 2, 2, 2, 3, 4, 2, 4, 5, 2, 1, 4, 2])).toEqual(5); + }); + + it('skips number validation', () => { + expect(unique).toHaveProperty('skipNumberValidation', true); + }); +}); diff --git a/packages/kbn-tinymath/test/library.test.js b/packages/kbn-tinymath/test/library.test.js new file mode 100644 index 0000000000000..7569cf90b2e35 --- /dev/null +++ b/packages/kbn-tinymath/test/library.test.js @@ -0,0 +1,273 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +/* + TODO: These tests are wildly imcomplete + Need tests for spacing, etc +*/ + +const { evaluate, parse } = require('..'); + +describe('Parser', () => { + describe('Numbers', () => { + it('integers', () => { + expect(parse('10')).toEqual(10); + }); + + it('floats', () => { + expect(parse('10.5')).toEqual(10.5); + }); + + it('negatives', () => { + expect(parse('-10')).toEqual(-10); + expect(parse('-10.5')).toEqual(-10.5); + }); + }); + + describe('Variables', () => { + it('strings', () => { + expect(parse('f')).toEqual('f'); + expect(parse('foo')).toEqual('foo'); + }); + + it('allowed characters', () => { + expect(parse('_foo')).toEqual('_foo'); + expect(parse('@foo')).toEqual('@foo'); + expect(parse('.foo')).toEqual('.foo'); + expect(parse('-foo')).toEqual('-foo'); + expect(parse('_foo0')).toEqual('_foo0'); + expect(parse('@foo0')).toEqual('@foo0'); + expect(parse('.foo0')).toEqual('.foo0'); + expect(parse('-foo0')).toEqual('-foo0'); + }); + }); + + describe('quoted variables', () => { + it('strings with double quotes', () => { + expect(parse('"foo"')).toEqual('foo'); + expect(parse('"f b"')).toEqual('f b'); + expect(parse('"foo bar"')).toEqual('foo bar'); + expect(parse('"foo bar fizz buzz"')).toEqual('foo bar fizz buzz'); + expect(parse('"foo bar baby"')).toEqual('foo bar baby'); + }); + + it('strings with single quotes', () => { + /* eslint-disable prettier/prettier */ + expect(parse("'foo'")).toEqual('foo'); + expect(parse("'f b'")).toEqual('f b'); + expect(parse("'foo bar'")).toEqual('foo bar'); + expect(parse("'foo bar fizz buzz'")).toEqual('foo bar fizz buzz'); + expect(parse("'foo bar baby'")).toEqual('foo bar baby'); + /* eslint-enable prettier/prettier */ + }); + + it('allowed characters', () => { + expect(parse('"_foo bar"')).toEqual('_foo bar'); + expect(parse('"@foo bar"')).toEqual('@foo bar'); + expect(parse('".foo bar"')).toEqual('.foo bar'); + expect(parse('"-foo bar"')).toEqual('-foo bar'); + expect(parse('"_foo0 bar1"')).toEqual('_foo0 bar1'); + expect(parse('"@foo0 bar1"')).toEqual('@foo0 bar1'); + expect(parse('".foo0 bar1"')).toEqual('.foo0 bar1'); + expect(parse('"-foo0 bar1"')).toEqual('-foo0 bar1'); + }); + + it('invalid characters in double quotes', () => { + const check = (str) => () => parse(str); + expect(check('" foo bar"')).toThrow('but "\\"" found'); + expect(check('"foo bar "')).toThrow('but "\\"" found'); + expect(check('"0foo"')).toThrow('but "\\"" found'); + expect(check('" foo bar"')).toThrow('but "\\"" found'); + expect(check('"foo bar "')).toThrow('but "\\"" found'); + expect(check('"0foo"')).toThrow('but "\\"" found'); + }); + + it('invalid characters in single quotes', () => { + const check = (str) => () => parse(str); + /* eslint-disable prettier/prettier */ + expect(check("' foo bar'")).toThrow('but "\'" found'); + expect(check("'foo bar '")).toThrow('but "\'" found'); + expect(check("'0foo'")).toThrow('but "\'" found'); + expect(check("' foo bar'")).toThrow('but "\'" found'); + expect(check("'foo bar '")).toThrow('but "\'" found'); + expect(check("'0foo'")).toThrow('but "\'" found'); + /* eslint-enable prettier/prettier */ + }); + }); + + describe('Functions', () => { + it('no arguments', () => { + expect(parse('foo()')).toEqual({ name: 'foo', args: [] }); + }); + + it('arguments', () => { + expect(parse('foo(5,10)')).toEqual({ name: 'foo', args: [5, 10] }); + }); + + it('arguments with strings', () => { + expect(parse('foo("string with spaces")')).toEqual({ + name: 'foo', + args: ['string with spaces'], + }); + + /* eslint-disable prettier/prettier */ + expect(parse("foo('string with spaces')")).toEqual({ + name: 'foo', + args: ['string with spaces'], + }); + /* eslint-enable prettier/prettier */ + }); + }); + + it('Missing expression', () => { + expect(() => parse(undefined)).toThrow('Missing expression'); + expect(() => parse(null)).toThrow('Missing expression'); + }); + + it('Failed parse', () => { + expect(() => parse('')).toThrow('Failed to parse expression'); + }); + + it('Not a string', () => { + expect(() => parse(3)).toThrow('Expression must be a string'); + }); +}); + +describe('Evaluate', () => { + it('numbers', () => { + expect(evaluate('10')).toEqual(10); + }); + + it('variables', () => { + expect(evaluate('foo', { foo: 10 })).toEqual(10); + expect(evaluate('bar', { bar: [1, 2] })).toEqual([1, 2]); + }); + + it('variables with spaces', () => { + expect(evaluate('"foo bar"', { 'foo bar': 10 })).toEqual(10); + expect(evaluate('"key with many spaces in it"', { 'key with many spaces in it': 10 })).toEqual( + 10 + ); + }); + + it('valiables with dots', () => { + expect(evaluate('foo.bar', { 'foo.bar': 20 })).toEqual(20); + expect(evaluate('"is.null"', { 'is.null': null })).toEqual(null); + expect(evaluate('"is.false"', { 'is.null': null, 'is.false': false })).toEqual(false); + expect(evaluate('"with space.val"', { 'with space.val': 42 })).toEqual(42); + }); + + it('variables with dot notation', () => { + expect(evaluate('foo.bar', { foo: { bar: 20 } })).toEqual(20); + expect(evaluate('foo.bar[0].baz', { foo: { bar: [{ baz: 30 }, { beer: 40 }] } })).toEqual(30); + expect(evaluate('"is.false"', { is: { null: null, false: false } })).toEqual(false); + }); + + it('equations', () => { + expect(evaluate('3 + 4')).toEqual(7); + expect(evaluate('10 - 2')).toEqual(8); + expect(evaluate('8 + 6 / 3')).toEqual(10); + expect(evaluate('10 * (1 + 2)')).toEqual(30); + expect(evaluate('(3 - 4) * 10')).toEqual(-10); + expect(evaluate('-1 - -12')).toEqual(11); + expect(evaluate('5/20')).toEqual(0.25); + expect(evaluate('1 + 1 + 2 + 3 + 12')).toEqual(19); + expect(evaluate('100 / 10 / 10')).toEqual(1); + }); + + it('equations with functions', () => { + expect(evaluate('3 + multiply(10, 4)')).toEqual(43); + expect(evaluate('3 + multiply(10, 4, 5)')).toEqual(203); + }); + + it('equations with trigonometry', () => { + expect(evaluate('pi()')).toEqual(Math.PI); + expect(evaluate('sin(degtorad(0))')).toEqual(0); + expect(evaluate('sin(degtorad(180))')).toEqual(1.2246467991473532e-16); + expect(evaluate('cos(degtorad(0))')).toEqual(1); + expect(evaluate('cos(degtorad(180))')).toEqual(-1); + expect(evaluate('tan(degtorad(0))')).toEqual(0); + expect(evaluate('tan(degtorad(180))')).toEqual(-1.2246467991473532e-16); + }); + + it('equations with variables', () => { + expect(evaluate('3 + foo', { foo: 5 })).toEqual(8); + expect(evaluate('3 + foo', { foo: [5, 10] })).toEqual([8, 13]); + expect(evaluate('3 + foo', { foo: 5 })).toEqual(8); + expect(evaluate('sum(foo)', { foo: [5, 10, 15] })).toEqual(30); + expect(evaluate('90 / sum(foo)', { foo: [5, 10, 15] })).toEqual(3); + expect(evaluate('multiply(foo, bar)', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([4, 10, 18]); + }); + + it('equations with quoted variables', () => { + expect(evaluate('"b" * 7', { b: 3 })).toEqual(21); + expect(evaluate('"space name" * 2', { 'space name': [1, 2, 21] })).toEqual([2, 4, 42]); + expect(evaluate('sum("space name")', { 'space name': [1, 2, 21] })).toEqual(24); + }); + + it('equations with injected functions', () => { + expect( + evaluate( + 'plustwo(foo)', + { foo: 5 }, + { + plustwo: function (a) { + return a + 2; + }, + } + ) + ).toEqual(7); + expect( + evaluate('negate(1)', null, { + negate: function (a) { + return -a; + }, + }) + ).toEqual(-1); + expect( + evaluate('stringify(2)', null, { + stringify: function (a) { + return '' + a; + }, + }) + ).toEqual('2'); + }); + + it('equations with arrays using special operator functions', () => { + expect(evaluate('foo + bar', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([5, 7, 9]); + expect(evaluate('foo - bar', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([-3, -3, -3]); + expect(evaluate('foo * bar', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([4, 10, 18]); + expect(evaluate('foo / bar', { foo: [1, 2, 3], bar: [4, 5, 6] })).toEqual([ + 1 / 4, + 2 / 5, + 3 / 6, + ]); + }); + + it('missing expression', () => { + expect(() => evaluate('')).toThrow('Failed to parse expression'); + }); + + it('missing referenced scope when used in injected function', () => { + expect(() => + evaluate('increment(foo)', null, { + increment: function (a) { + return a + 1; + }, + }) + ).toThrow('Unknown variable: foo'); + }); + + it('invalid context datatypes', () => { + expect(evaluate('mean(foo)', { foo: [true, true, false] })).toBeNaN(); + expect(evaluate('mean(foo + bar)', { foo: [true, true, false], bar: [1, 2, 3] })).toBeNaN(); + expect(evaluate('mean(foo)', { foo: ['dog', 'cat', 'mouse'] })).toBeNaN(); + expect(evaluate('mean(foo + 2)', { foo: ['dog', 'cat', 'mouse'] })).toBeNaN(); + expect(evaluate('foo + bar', { foo: NaN, bar: [4, 5, 6] })).toBeNaN(); + }); +}); diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 2d053300273fb..b86e2e4c6dedb 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 0000000000000..fd8234d72bd17 --- /dev/null +++ b/src/core/server/plugins/legacy_config.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { take } from 'rxjs/operators'; +import { ConfigService, Env } from '@kbn/config'; +import { getEnvOptions, rawConfigServiceMock } from '../config/mocks'; +import { getGlobalConfig, getGlobalConfig$ } from './legacy_config'; +import { REPO_ROOT } from '@kbn/utils'; +import { loggingSystemMock } from '../logging/logging_system.mock'; +import { duration } from 'moment'; +import { fromRoot } from '../utils'; +import { ByteSizeValue } from '@kbn/config-schema'; +import { Server } from '../server'; + +describe('Legacy config', () => { + let env: Env; + let logger: ReturnType; + + beforeEach(() => { + env = Env.createDefault(REPO_ROOT, getEnvOptions()); + logger = loggingSystemMock.create(); + }); + + const createConfigService = (rawConfig: Record = {}): ConfigService => { + const rawConfigService = rawConfigServiceMock.create({ rawConfig }); + const server = new Server(rawConfigService, env, logger); + server.setupCoreConfig(); + return server.configService; + }; + + describe('getGlobalConfig', () => { + it('should return the global config', async () => { + const configService = createConfigService(); + await configService.validate(); + + const legacyConfig = getGlobalConfig(configService); + + expect(legacyConfig).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); + }); + }); + + describe('getGlobalConfig$', () => { + it('should return an observable for the global config', async () => { + const configService = createConfigService(); + + const legacyConfig = await getGlobalConfig$(configService).pipe(take(1)).toPromise(); + + expect(legacyConfig).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); + }); + }); +}); diff --git a/src/core/server/plugins/legacy_config.ts b/src/core/server/plugins/legacy_config.ts new file mode 100644 index 0000000000000..748a1e3190640 --- /dev/null +++ b/src/core/server/plugins/legacy_config.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { map, shareReplay } from 'rxjs/operators'; +import { combineLatest, Observable } from 'rxjs'; +import { PathConfigType, config as pathConfig } from '@kbn/utils'; +import { pick, deepFreeze } from '@kbn/std'; +import { IConfigService } from '@kbn/config'; + +import { SharedGlobalConfig, SharedGlobalConfigKeys } from './types'; +import { KibanaConfigType, config as kibanaConfig } from '../kibana_config'; +import { + ElasticsearchConfigType, + config as elasticsearchConfig, +} from '../elasticsearch/elasticsearch_config'; +import { SavedObjectsConfigType, savedObjectsConfig } from '../saved_objects/saved_objects_config'; + +const createGlobalConfig = ({ + kibana, + elasticsearch, + path, + savedObjects, +}: { + kibana: KibanaConfigType; + elasticsearch: ElasticsearchConfigType; + path: PathConfigType; + savedObjects: SavedObjectsConfigType; +}): SharedGlobalConfig => { + return deepFreeze({ + kibana: pick(kibana, SharedGlobalConfigKeys.kibana), + elasticsearch: pick(elasticsearch, SharedGlobalConfigKeys.elasticsearch), + path: pick(path, SharedGlobalConfigKeys.path), + savedObjects: pick(savedObjects, SharedGlobalConfigKeys.savedObjects), + }); +}; + +export const getGlobalConfig = (configService: IConfigService): SharedGlobalConfig => { + return createGlobalConfig({ + kibana: configService.atPathSync(kibanaConfig.path), + elasticsearch: configService.atPathSync(elasticsearchConfig.path), + path: configService.atPathSync(pathConfig.path), + savedObjects: configService.atPathSync(savedObjectsConfig.path), + }); +}; + +export const getGlobalConfig$ = (configService: IConfigService): Observable => { + return combineLatest([ + configService.atPath(kibanaConfig.path), + configService.atPath(elasticsearchConfig.path), + configService.atPath(pathConfig.path), + configService.atPath(savedObjectsConfig.path), + ]).pipe( + map( + ([kibana, elasticsearch, path, savedObjects]) => + createGlobalConfig({ + kibana, + elasticsearch, + path, + savedObjects, + }), + shareReplay(1) + ) + ); +}; diff --git a/src/core/server/plugins/plugin_context.test.ts b/src/core/server/plugins/plugin_context.test.ts index 3d212bc555828..c71102df9929b 100644 --- a/src/core/server/plugins/plugin_context.test.ts +++ b/src/core/server/plugins/plugin_context.test.ts @@ -17,15 +17,8 @@ import { rawConfigServiceMock, getEnvOptions } from '../config/mocks'; import { PluginManifest } from './types'; import { Server } from '../server'; import { fromRoot } from '../utils'; -import { ByteSizeValue } from '@kbn/config-schema'; - -const logger = loggingSystemMock.create(); - -let coreId: symbol; -let env: Env; -let coreContext: CoreContext; -let server: Server; -let instanceInfo: InstanceInfo; +import { schema, ByteSizeValue } from '@kbn/config-schema'; +import { ConfigService } from '@kbn/config'; function createPluginManifest(manifestProps: Partial = {}): PluginManifest { return { @@ -43,61 +36,112 @@ function createPluginManifest(manifestProps: Partial = {}): Plug } describe('createPluginInitializerContext', () => { + let logger: ReturnType; + let coreId: symbol; + let opaqueId: symbol; + let env: Env; + let coreContext: CoreContext; + let server: Server; + let instanceInfo: InstanceInfo; + beforeEach(async () => { + logger = loggingSystemMock.create(); coreId = Symbol('core'); + opaqueId = Symbol(); instanceInfo = { uuid: 'instance-uuid', }; env = Env.createDefault(REPO_ROOT, getEnvOptions()); const config$ = rawConfigServiceMock.create({ rawConfig: {} }); server = new Server(config$, env, logger); - await server.setupCoreConfig(); + server.setupCoreConfig(); coreContext = { coreId, env, logger, configService: server.configService }; }); - it('should return a globalConfig handler in the context', async () => { - const manifest = createPluginManifest(); - const opaqueId = Symbol(); - const pluginInitializerContext = createPluginInitializerContext( - coreContext, - opaqueId, - manifest, - instanceInfo - ); + describe('context.config', () => { + it('config.get() should return the plugin config synchronously', async () => { + const config$ = rawConfigServiceMock.create({ + rawConfig: { + plugin: { + foo: 'bar', + answer: 42, + }, + }, + }); + + const configService = new ConfigService(config$, env, logger); + configService.setSchema( + 'plugin', + schema.object({ + foo: schema.string(), + answer: schema.number(), + }) + ); + await configService.validate(); - expect(pluginInitializerContext.config.legacy.globalConfig$).toBeDefined(); + coreContext = { coreId, env, logger, configService }; - const configObject = await pluginInitializerContext.config.legacy.globalConfig$ - .pipe(first()) - .toPromise(); - expect(configObject).toStrictEqual({ - kibana: { - index: '.kibana', - autocompleteTerminateAfter: duration(100000), - autocompleteTimeout: duration(1000), - }, - elasticsearch: { - shardTimeout: duration(30, 's'), - requestTimeout: duration(30, 's'), - pingTimeout: duration(30, 's'), - }, - path: { data: fromRoot('data') }, - savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + const manifest = createPluginManifest({ + configPath: 'plugin', + }); + + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + + expect(pluginInitializerContext.config.get()).toEqual({ + foo: 'bar', + answer: 42, + }); + }); + + it('config.globalConfig$ should be an observable for the global config', async () => { + const manifest = createPluginManifest(); + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + + expect(pluginInitializerContext.config.legacy.globalConfig$).toBeDefined(); + + const configObject = await pluginInitializerContext.config.legacy.globalConfig$ + .pipe(first()) + .toPromise(); + expect(configObject).toStrictEqual({ + kibana: { + index: '.kibana', + autocompleteTerminateAfter: duration(100000), + autocompleteTimeout: duration(1000), + }, + elasticsearch: { + shardTimeout: duration(30, 's'), + requestTimeout: duration(30, 's'), + pingTimeout: duration(30, 's'), + }, + path: { data: fromRoot('data') }, + savedObjects: { maxImportPayloadBytes: new ByteSizeValue(26214400) }, + }); }); }); - it('allow to access the provided instance uuid', () => { - const manifest = createPluginManifest(); - const opaqueId = Symbol(); - instanceInfo = { - uuid: 'kibana-uuid', - }; - const pluginInitializerContext = createPluginInitializerContext( - coreContext, - opaqueId, - manifest, - instanceInfo - ); - expect(pluginInitializerContext.env.instanceUuid).toBe('kibana-uuid'); + describe('context.env', () => { + it('should expose the correct instance uuid', () => { + const manifest = createPluginManifest(); + instanceInfo = { + uuid: 'kibana-uuid', + }; + const pluginInitializerContext = createPluginInitializerContext( + coreContext, + opaqueId, + manifest, + instanceInfo + ); + expect(pluginInitializerContext.env.instanceUuid).toBe('kibana-uuid'); + }); }); }); diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index 5b0e2ee21a887..3b7dc70b9c054 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -6,27 +6,14 @@ * Public License, v 1. */ -import { map, shareReplay } from 'rxjs/operators'; -import { combineLatest } from 'rxjs'; -import { PathConfigType, config as pathConfig } from '@kbn/utils'; -import { pick, deepFreeze } from '@kbn/std'; +import { shareReplay } from 'rxjs/operators'; import type { RequestHandlerContext } from 'src/core/server'; import { CoreContext } from '../core_context'; import { PluginWrapper } from './plugin'; import { PluginsServiceSetupDeps, PluginsServiceStartDeps } from './plugins_service'; -import { - PluginInitializerContext, - PluginManifest, - PluginOpaqueId, - SharedGlobalConfigKeys, -} from './types'; -import { KibanaConfigType, config as kibanaConfig } from '../kibana_config'; -import { - ElasticsearchConfigType, - config as elasticsearchConfig, -} from '../elasticsearch/elasticsearch_config'; +import { PluginInitializerContext, PluginManifest, PluginOpaqueId } from './types'; import { IRouter, RequestHandlerContextProvider } from '../http'; -import { SavedObjectsConfigType, savedObjectsConfig } from '../saved_objects/saved_objects_config'; +import { getGlobalConfig, getGlobalConfig$ } from './legacy_config'; import { CoreSetup, CoreStart } from '..'; export interface InstanceInfo { @@ -78,40 +65,19 @@ export function createPluginInitializerContext( */ config: { legacy: { - /** - * Global configuration - * Note: naming not final here, it will be renamed in a near future (https://github.com/elastic/kibana/issues/46240) - * @deprecated - */ - globalConfig$: combineLatest([ - coreContext.configService.atPath(kibanaConfig.path), - coreContext.configService.atPath(elasticsearchConfig.path), - coreContext.configService.atPath(pathConfig.path), - coreContext.configService.atPath(savedObjectsConfig.path), - ]).pipe( - map(([kibana, elasticsearch, path, savedObjects]) => - deepFreeze({ - kibana: pick(kibana, SharedGlobalConfigKeys.kibana), - elasticsearch: pick(elasticsearch, SharedGlobalConfigKeys.elasticsearch), - path: pick(path, SharedGlobalConfigKeys.path), - savedObjects: pick(savedObjects, SharedGlobalConfigKeys.savedObjects), - }) - ) - ), + globalConfig$: getGlobalConfig$(coreContext.configService), + get: () => getGlobalConfig(coreContext.configService), }, /** * Reads the subset of the config at the `configPath` defined in the plugin - * manifest and validates it against the schema in the static `schema` on - * the given `ConfigClass`. - * @param ConfigClass A class (not an instance of a class) that contains a - * static `schema` that we validate the config at the given `path` against. + * manifest. */ create() { return coreContext.configService.atPath(pluginManifest.configPath).pipe(shareReplay(1)); }, - createIfExists() { - return coreContext.configService.optionalAtPath(pluginManifest.configPath); + get() { + return coreContext.configService.atPathSync(pluginManifest.configPath); }, }, }; diff --git a/src/core/server/plugins/plugins_service.ts b/src/core/server/plugins/plugins_service.ts index 9a1403bda3bca..dd2831f77f537 100644 --- a/src/core/server/plugins/plugins_service.ts +++ b/src/core/server/plugins/plugins_service.ts @@ -219,10 +219,7 @@ export class PluginsService implements CoreService { packageInfo: Readonly; instanceUuid: string; }; + /** + * {@link LoggerFactory | logger factory} instance already bound to the plugin's logging context + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * // "id: myPlugin" in `plugins/my-plugin/kibana.yaml` + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) { + * this.logger = initContext.logger.get(); + * // `logger` context: `plugins.myPlugin` + * this.mySubLogger = initContext.logger.get('sub'); // or this.logger.get('sub'); + * // `mySubLogger` context: `plugins.myPlugin.sub` + * } + * } + * ``` + */ logger: LoggerFactory; + /** + * Accessors for the plugin's configuration + */ config: { - legacy: { globalConfig$: Observable }; + /** + * Provide access to Kibana legacy configuration values. + * + * @remarks Naming not final here, it may be renamed in a near future + * @deprecated Accessing configuration values outside of the plugin's config scope is highly discouraged + */ + legacy: { + globalConfig$: Observable; + get: () => SharedGlobalConfig; + }; + /** + * Return an observable of the plugin's configuration + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * setup(core) { + * this.configSub = this.initContext.config.create().subscribe((config) => { + * this.myService.reconfigure(config); + * }); + * } + * stop() { + * this.configSub.unsubscribe(); + * } + * ``` + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * async setup(core) { + * this.config = await this.initContext.config.create().pipe(take(1)).toPromise(); + * } + * stop() { + * this.configSub.unsubscribe(); + * } + * ``` + * + * @remarks The underlying observable has a replay effect, meaning that awaiting for the first emission + * will be resolved at next tick, without risks to delay any asynchronous code's workflow. + */ create: () => Observable; - createIfExists: () => Observable; + /** + * Return the current value of the plugin's configuration synchronously. + * + * @example + * ```typescript + * // plugins/my-plugin/server/plugin.ts + * + * export class MyPlugin implements Plugin { + * constructor(private readonly initContext: PluginInitializerContext) {} + * setup(core) { + * const config = this.initContext.config.get(); + * // do something with the config + * } + * } + * ``` + * + * @remarks This should only be used when synchronous access is an absolute necessity, such + * as during the plugin's setup or start lifecycle. For all other usages, + * {@link create} should be used instead. + */ + get: () => T; }; } diff --git a/src/core/server/root/index.ts b/src/core/server/root/index.ts index 17cb209897c25..a918580392caa 100644 --- a/src/core/server/root/index.ts +++ b/src/core/server/root/index.ts @@ -36,7 +36,7 @@ export class Root { public async setup() { try { - await this.server.setupCoreConfig(); + this.server.setupCoreConfig(); await this.setupLogging(); this.log.debug('setting up root'); return await this.server.setup(); diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 6ba652abda3d5..741f715ba6ebe 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -206,20 +206,6 @@ describe('DocumentMigrator', () => { ); }); - it('coerces the current Kibana version if it has a hyphen', () => { - const validDefinition = { - kibanaVersion: '3.2.0-SNAPSHOT', - typeRegistry: createRegistry({ - name: 'foo', - convertToMultiNamespaceTypeVersion: '3.2.0', - namespaceType: 'multiple', - }), - minimumConvertVersion: '0.0.0', - log: mockLogger, - }; - expect(() => new DocumentMigrator(validDefinition)).not.toThrowError(); - }); - it('validates convertToMultiNamespaceTypeVersion is not used on a patch version', () => { const invalidDefinition = { kibanaVersion: '3.2.3', diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.ts b/src/core/server/saved_objects/migrations/core/document_migrator.ts index e93586ec7ce4c..e4b89a949d3cf 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.ts @@ -159,11 +159,10 @@ export class DocumentMigrator implements VersionedTransformer { */ constructor({ typeRegistry, - kibanaVersion: rawKibanaVersion, + kibanaVersion, minimumConvertVersion = DEFAULT_MINIMUM_CONVERT_VERSION, log, }: DocumentMigratorOptions) { - const kibanaVersion = rawKibanaVersion.split('-')[0]; // coerce a semver-like string (x.y.z-SNAPSHOT) or prerelease version (x.y.z-alpha) to a regular semver (x.y.z) validateMigrationDefinition(typeRegistry, kibanaVersion, minimumConvertVersion); this.documentMigratorOptions = { typeRegistry, kibanaVersion, log }; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 281d2ce8d03cf..dac93ff29b68f 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -31,6 +31,14 @@ const createRegistry = (types: Array>) => { }; describe('KibanaMigrator', () => { + describe('constructor', () => { + it('coerces the current Kibana version if it has a hyphen', () => { + const options = mockOptions(); + options.kibanaVersion = '3.2.1-SNAPSHOT'; + const migrator = new KibanaMigrator(options); + expect(migrator.kibanaVersion).toEqual('3.2.1'); + }); + }); describe('getActiveMappings', () => { it('returns full index mappings w/ core properties', () => { const options = mockOptions(); diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index c8bc4b2e14123..ecef84a6e297c 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -90,13 +90,12 @@ export class KibanaMigrator { }: KibanaMigratorOptions) { this.client = client; this.kibanaConfig = kibanaConfig; - this.kibanaVersion = kibanaVersion; this.savedObjectsConfig = savedObjectsConfig; this.typeRegistry = typeRegistry; this.serializer = new SavedObjectsSerializer(this.typeRegistry); this.mappingProperties = mergeTypes(this.typeRegistry.getAllTypes()); this.log = logger; - this.kibanaVersion = kibanaVersion; + this.kibanaVersion = kibanaVersion.split('-')[0]; // coerce a semver-like string (x.y.z-SNAPSHOT) or prerelease version (x.y.z-alpha) to a regular semver (x.y.z); this.documentMigrator = new DocumentMigrator({ kibanaVersion, typeRegistry, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index fc90284ffe5b2..aadd16bde0ee6 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 60f3f90428d40..cc1087a422e39 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -300,7 +300,7 @@ export class Server { ); } - public async setupCoreConfig() { + public setupCoreConfig() { const configDescriptors: Array> = [ pathConfig, cspConfig, @@ -325,7 +325,7 @@ export class Server { if (descriptor.deprecations) { this.configService.addDeprecationProvider(descriptor.path, descriptor.deprecations); } - await this.configService.setSchema(descriptor.path, descriptor.schema); + this.configService.setSchema(descriptor.path, descriptor.schema); } } } diff --git a/src/dev/build/args.test.ts b/src/dev/build/args.test.ts index 584f0dfe54b74..745b9d0b910c8 100644 --- a/src/dev/build/args.test.ts +++ b/src/dev/build/args.test.ts @@ -30,8 +30,9 @@ it('build default and oss dist for current platform, without packages, by defaul "buildOssDist": true, "createArchives": true, "createDebPackage": false, - "createDockerPackage": false, - "createDockerUbiPackage": false, + "createDockerCentOS": false, + "createDockerContexts": false, + "createDockerUBI": false, "createRpmPackage": false, "downloadFreshNode": true, "isRelease": false, @@ -53,8 +54,9 @@ it('builds packages if --all-platforms is passed', () => { "buildOssDist": true, "createArchives": true, "createDebPackage": true, - "createDockerPackage": true, - "createDockerUbiPackage": true, + "createDockerCentOS": true, + "createDockerContexts": true, + "createDockerUBI": true, "createRpmPackage": true, "downloadFreshNode": true, "isRelease": false, @@ -76,8 +78,9 @@ it('limits packages if --rpm passed with --all-platforms', () => { "buildOssDist": true, "createArchives": true, "createDebPackage": false, - "createDockerPackage": false, - "createDockerUbiPackage": false, + "createDockerCentOS": false, + "createDockerContexts": false, + "createDockerUBI": false, "createRpmPackage": true, "downloadFreshNode": true, "isRelease": false, @@ -99,8 +102,9 @@ it('limits packages if --deb passed with --all-platforms', () => { "buildOssDist": true, "createArchives": true, "createDebPackage": true, - "createDockerPackage": false, - "createDockerUbiPackage": false, + "createDockerCentOS": false, + "createDockerContexts": false, + "createDockerUBI": false, "createRpmPackage": false, "downloadFreshNode": true, "isRelease": false, @@ -115,7 +119,7 @@ it('limits packages if --deb passed with --all-platforms', () => { }); it('limits packages if --docker passed with --all-platforms', () => { - expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--docker'])) + expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--docker-images'])) .toMatchInlineSnapshot(` Object { "buildOptions": Object { @@ -123,8 +127,9 @@ it('limits packages if --docker passed with --all-platforms', () => { "buildOssDist": true, "createArchives": true, "createDebPackage": false, - "createDockerPackage": true, - "createDockerUbiPackage": true, + "createDockerCentOS": true, + "createDockerContexts": false, + "createDockerUBI": true, "createRpmPackage": false, "downloadFreshNode": true, "isRelease": false, @@ -139,16 +144,24 @@ it('limits packages if --docker passed with --all-platforms', () => { }); it('limits packages if --docker passed with --skip-docker-ubi and --all-platforms', () => { - expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--docker', '--skip-docker-ubi'])) - .toMatchInlineSnapshot(` + expect( + readCliArgs([ + 'node', + 'scripts/build', + '--all-platforms', + '--docker-images', + '--skip-docker-ubi', + ]) + ).toMatchInlineSnapshot(` Object { "buildOptions": Object { "buildDefaultDist": true, "buildOssDist": true, "createArchives": true, "createDebPackage": false, - "createDockerPackage": true, - "createDockerUbiPackage": false, + "createDockerCentOS": true, + "createDockerContexts": false, + "createDockerUBI": false, "createRpmPackage": false, "downloadFreshNode": true, "isRelease": false, @@ -161,3 +174,28 @@ it('limits packages if --docker passed with --skip-docker-ubi and --all-platform } `); }); + +it('limits packages if --all-platforms passed with --skip-docker-centos', () => { + expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--skip-docker-centos'])) + .toMatchInlineSnapshot(` + Object { + "buildOptions": Object { + "buildDefaultDist": true, + "buildOssDist": true, + "createArchives": true, + "createDebPackage": true, + "createDockerCentOS": false, + "createDockerContexts": true, + "createDockerUBI": true, + "createRpmPackage": true, + "downloadFreshNode": true, + "isRelease": false, + "targetAllPlatforms": true, + "versionQualifier": "", + }, + "log": , + "showHelp": false, + "unknownFlags": Array [], + } + `); +}); diff --git a/src/dev/build/args.ts b/src/dev/build/args.ts index 5070e325f40a4..2d26d7db3a5e3 100644 --- a/src/dev/build/args.ts +++ b/src/dev/build/args.ts @@ -21,8 +21,10 @@ export function readCliArgs(argv: string[]) { 'skip-os-packages', 'rpm', 'deb', - 'docker', + 'docker-images', + 'docker-contexts', 'skip-docker-ubi', + 'skip-docker-centos', 'release', 'skip-node-download', 'verbose', @@ -42,7 +44,8 @@ export function readCliArgs(argv: string[]) { debug: true, rpm: null, deb: null, - docker: null, + 'docker-images': null, + 'docker-contexts': null, oss: null, 'version-qualifier': '', }, @@ -69,7 +72,7 @@ export function readCliArgs(argv: string[]) { // In order to build a docker image we always need // to generate all the platforms - if (flags.docker) { + if (flags['docker-images'] || flags['docker-contexts']) { flags['all-platforms'] = true; } @@ -79,7 +82,12 @@ export function readCliArgs(argv: string[]) { } // build all if no flags specified - if (flags.rpm === null && flags.deb === null && flags.docker === null) { + if ( + flags.rpm === null && + flags.deb === null && + flags['docker-images'] === null && + flags['docker-contexts'] === null + ) { return true; } @@ -95,8 +103,10 @@ export function readCliArgs(argv: string[]) { createArchives: !Boolean(flags['skip-archives']), createRpmPackage: isOsPackageDesired('rpm'), createDebPackage: isOsPackageDesired('deb'), - createDockerPackage: isOsPackageDesired('docker'), - createDockerUbiPackage: isOsPackageDesired('docker') && !Boolean(flags['skip-docker-ubi']), + createDockerCentOS: + isOsPackageDesired('docker-images') && !Boolean(flags['skip-docker-centos']), + createDockerUBI: isOsPackageDesired('docker-images') && !Boolean(flags['skip-docker-ubi']), + createDockerContexts: isOsPackageDesired('docker-contexts'), targetAllPlatforms: Boolean(flags['all-platforms']), }; diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts index 6620673829711..df4ba45517cc1 100644 --- a/src/dev/build/build_distributables.ts +++ b/src/dev/build/build_distributables.ts @@ -19,8 +19,9 @@ export interface BuildOptions { createArchives: boolean; createRpmPackage: boolean; createDebPackage: boolean; - createDockerPackage: boolean; - createDockerUbiPackage: boolean; + createDockerUBI: boolean; + createDockerCentOS: boolean; + createDockerContexts: boolean; versionQualifier: string | undefined; targetAllPlatforms: boolean; } @@ -95,12 +96,19 @@ export async function buildDistributables(log: ToolingLog, options: BuildOptions // control w/ --rpm or --skip-os-packages await run(Tasks.CreateRpmPackage); } - if (options.createDockerPackage) { - // control w/ --docker or --skip-docker-ubi or --skip-os-packages - await run(Tasks.CreateDockerPackage); - if (options.createDockerUbiPackage) { - await run(Tasks.CreateDockerUbiPackage); - } + if (options.createDockerUBI) { + // control w/ --docker-images or --skip-docker-ubi or --skip-os-packages + await run(Tasks.CreateDockerUBI); + } + + if (options.createDockerCentOS) { + // control w/ --docker-images or --skip-docker-centos or --skip-os-packages + await run(Tasks.CreateDockerCentOS); + } + + if (options.createDockerContexts) { + // control w/ --docker-contexts or --skip-os-packages + await run(Tasks.CreateDockerContexts); } /** diff --git a/src/dev/build/cli.ts b/src/dev/build/cli.ts index ca9debfdc1ae1..3e3a0a493f2d1 100644 --- a/src/dev/build/cli.ts +++ b/src/dev/build/cli.ts @@ -38,10 +38,12 @@ if (showHelp) { --skip-archives {dim Don't produce tar/zip archives} --skip-os-packages {dim Don't produce rpm/deb/docker packages} --all-platforms {dim Produce archives for all platforms, not just this one} - --rpm {dim Only build the rpm package} - --deb {dim Only build the deb package} - --docker {dim Only build the docker image} + --rpm {dim Only build the rpm packages} + --deb {dim Only build the deb packages} + --docker-images {dim Only build the Docker images} + --docker-contexts {dim Only build the Docker build contexts} --skip-docker-ubi {dim Don't build the docker ubi image} + --skip-docker-centos {dim Don't build the docker centos image} --release {dim Produce a release-ready distributable} --version-qualifier {dim Suffix version with a qualifier} --skip-node-download {dim Reuse existing downloads of node.js} diff --git a/src/dev/build/lib/version_info.test.ts b/src/dev/build/lib/integration_tests/version_info.test.ts similarity index 92% rename from src/dev/build/lib/version_info.test.ts rename to src/dev/build/lib/integration_tests/version_info.test.ts index dc0bf4ce6a833..36d052ebad937 100644 --- a/src/dev/build/lib/version_info.test.ts +++ b/src/dev/build/lib/integration_tests/version_info.test.ts @@ -6,10 +6,11 @@ * Public License, v 1. */ -import pkg from '../../../../package.json'; -import { getVersionInfo } from './version_info'; +import { kibanaPackageJSON as pkg } from '@kbn/dev-utils'; -jest.mock('./get_build_number'); +import { getVersionInfo } from '../version_info'; + +jest.mock('../get_build_number'); describe('isRelease = true', () => { it('returns unchanged package.version, build sha, and build number', async () => { diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index ba57a5f3dbfc9..fd0224d3de13b 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -8,7 +8,7 @@ import { Task } from '../../lib'; import { runFpm } from './run_fpm'; -import { runDockerGenerator, runDockerGeneratorForUBI } from './docker_generator'; +import { runDockerGenerator } from './docker_generator'; export const CreateDebPackage: Task = { description: 'Creating deb package', @@ -49,20 +49,56 @@ export const CreateRpmPackage: Task = { }, }; -export const CreateDockerPackage: Task = { - description: 'Creating docker package', +export const CreateDockerCentOS: Task = { + description: 'Creating Docker CentOS image', async run(config, log, build) { - // Builds Docker targets for default and oss - await runDockerGenerator(config, log, build); + await runDockerGenerator(config, log, build, { + ubi: false, + context: false, + architecture: 'x64', + image: true, + }); + await runDockerGenerator(config, log, build, { + ubi: false, + context: false, + architecture: 'aarch64', + image: true, + }); }, }; -export const CreateDockerUbiPackage: Task = { - description: 'Creating docker ubi package', +export const CreateDockerUBI: Task = { + description: 'Creating Docker UBI image', async run(config, log, build) { - // Builds Docker target default with ubi7 base image - await runDockerGeneratorForUBI(config, log, build); + if (!build.isOss()) { + await runDockerGenerator(config, log, build, { + ubi: true, + context: false, + architecture: 'x64', + image: true, + }); + } + }, +}; + +export const CreateDockerContexts: Task = { + description: 'Creating Docker build contexts', + + async run(config, log, build) { + await runDockerGenerator(config, log, build, { + ubi: false, + context: true, + image: false, + }); + + if (!build.isOss()) { + await runDockerGenerator(config, log, build, { + ubi: true, + context: true, + image: false, + }); + } }, }; diff --git a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts index 07a86927d5a35..4780457fe8054 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.ts @@ -18,7 +18,6 @@ export async function bundleDockerFiles(config: Config, log: ToolingLog, scope: log.info( `Generating kibana${scope.imageFlavor}${scope.ubiImageFlavor} docker build context bundle` ); - const dockerFilesDirName = `kibana${scope.imageFlavor}${scope.ubiImageFlavor}-${scope.version}-docker-build-context`; const dockerFilesBuildDir = resolve(scope.dockerBuildDir, dockerFilesDirName); const dockerFilesOutputDir = config.resolveFromTarget(`${dockerFilesDirName}.tar.gz`); diff --git a/src/dev/build/tasks/os_packages/docker_generator/index.ts b/src/dev/build/tasks/os_packages/docker_generator/index.ts index 1e6e6156c3ed9..229bd5242228c 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/index.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/index.ts @@ -6,4 +6,4 @@ * Public License, v 1. */ -export { runDockerGenerator, runDockerGeneratorForUBI } from './run'; +export { runDockerGenerator } from './run'; diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 30e3b60dcee83..1598f00354bf8 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -15,11 +15,17 @@ # --elasticsearch.logQueries=true kibana_vars=( + apm_oss.apmAgentConfigurationIndex + apm_oss.errorIndices + apm_oss.indexPattern + apm_oss.metricsIndices + apm_oss.onboardingIndices + apm_oss.sourcemapIndices + apm_oss.spanIndices + apm_oss.transactionIndices console.enabled console.proxyConfig console.proxyFilter - ops.cGroupOverrides.cpuPath - ops.cGroupOverrides.cpuAcctPath cpu.cgroup.path.override cpuacct.cgroup.path.override csp.rules @@ -41,10 +47,10 @@ kibana_vars=( elasticsearch.ssl.certificateAuthorities elasticsearch.ssl.key elasticsearch.ssl.keyPassphrase - elasticsearch.ssl.keystore.path elasticsearch.ssl.keystore.password - elasticsearch.ssl.truststore.path + elasticsearch.ssl.keystore.path elasticsearch.ssl.truststore.password + elasticsearch.ssl.truststore.path elasticsearch.ssl.verificationMode elasticsearch.username enterpriseSearch.accessCheckTimeout @@ -76,34 +82,42 @@ kibana_vars=( map.tilemap.options.minZoom map.tilemap.options.subdomains map.tilemap.url + migrations.batchSize + migrations.enableV2 + migrations.pollInterval + migrations.scrollDuration + migrations.skip monitoring.cluster_alerts.email_notifications.email_address monitoring.enabled monitoring.kibana.collection.enabled monitoring.kibana.collection.interval monitoring.ui.container.elasticsearch.enabled monitoring.ui.container.logstash.enabled - monitoring.ui.elasticsearch.password - monitoring.ui.elasticsearch.pingTimeout monitoring.ui.elasticsearch.hosts - monitoring.ui.elasticsearch.username monitoring.ui.elasticsearch.logFetchCount + monitoring.ui.elasticsearch.password + monitoring.ui.elasticsearch.pingTimeout monitoring.ui.elasticsearch.ssl.certificateAuthorities monitoring.ui.elasticsearch.ssl.verificationMode + monitoring.ui.elasticsearch.username monitoring.ui.enabled monitoring.ui.max_bucket_size monitoring.ui.min_interval_seconds newsfeed.enabled + ops.cGroupOverrides.cpuAcctPath + ops.cGroupOverrides.cpuPath ops.interval path.data pid.file regionmap security.showInsecureClusterWarning server.basePath - server.customResponseHeaders server.compression.enabled server.compression.referrerWhitelist server.cors server.cors.origin + server.customResponseHeaders + server.customResponseHeaders server.defaultRoute server.host server.keepAliveTimeout @@ -117,20 +131,24 @@ kibana_vars=( server.ssl.certificateAuthorities server.ssl.cipherSuites server.ssl.clientAuthentication - server.customResponseHeaders server.ssl.enabled server.ssl.key server.ssl.keyPassphrase - server.ssl.keystore.path server.ssl.keystore.password - server.ssl.truststore.path - server.ssl.truststore.password + server.ssl.keystore.path server.ssl.redirectHttpFromPort server.ssl.supportedProtocols + server.ssl.truststore.password + server.ssl.truststore.path server.xsrf.disableProtection server.xsrf.whitelist status.allowAnonymous status.v6ApiFormat + telemetry.allowChangingOptInStatus + telemetry.enabled + telemetry.optIn + telemetry.optInStatusUrl + telemetry.sendUsageFrom tilemap.options.attribution tilemap.options.maxZoom tilemap.options.minZoom @@ -142,9 +160,9 @@ kibana_vars=( xpack.actions.enabled xpack.actions.enabledActionTypes xpack.actions.preconfigured - xpack.actions.proxyUrl xpack.actions.proxyHeaders xpack.actions.proxyRejectUnauthorizedCertificates + xpack.actions.proxyUrl xpack.actions.rejectUnauthorized xpack.alerts.healthCheck.interval xpack.alerts.invalidateApiKeysTask.interval @@ -154,37 +172,29 @@ kibana_vars=( xpack.apm.ui.enabled xpack.apm.ui.maxTraceItems xpack.apm.ui.transactionGroupBucketSize - apm_oss.apmAgentConfigurationIndex - apm_oss.indexPattern - apm_oss.errorIndices - apm_oss.onboardingIndices - apm_oss.spanIndices - apm_oss.sourcemapIndices - apm_oss.transactionIndices - apm_oss.metricsIndices xpack.canvas.enabled - xpack.code.ui.enabled xpack.code.disk.thresholdEnabled xpack.code.disk.watermarkLow - xpack.code.maxWorkspace xpack.code.indexRepoFrequencyMs - xpack.code.updateRepoFrequencyMs xpack.code.lsp.verbose - xpack.code.verbose + xpack.code.maxWorkspace xpack.code.security.enableGitCertCheck xpack.code.security.gitHostWhitelist xpack.code.security.gitProtocolWhitelist + xpack.code.ui.enabled + xpack.code.updateRepoFrequencyMs + xpack.code.verbose xpack.encryptedSavedObjects.encryptionKey xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys xpack.event_log.enabled - xpack.event_log.logEntries xpack.event_log.indexEntries + xpack.event_log.logEntries xpack.fleet.agents.elasticsearch.host xpack.fleet.agents.kibana.host xpack.fleet.agents.tlsCheckDisabled xpack.fleet.registryUrl - xpack.graph.enabled xpack.graph.canEditDrillDownUrls + xpack.graph.enabled xpack.graph.savePolicy xpack.grokdebugger.enabled xpack.infra.enabled @@ -208,28 +218,28 @@ kibana_vars=( xpack.reporting.capture.browser.chromium.disableSandbox xpack.reporting.capture.browser.chromium.inspect xpack.reporting.capture.browser.chromium.maxScreenshotDimension + xpack.reporting.capture.browser.chromium.proxy.bypass xpack.reporting.capture.browser.chromium.proxy.enabled xpack.reporting.capture.browser.chromium.proxy.server - xpack.reporting.capture.browser.chromium.proxy.bypass xpack.reporting.capture.browser.type xpack.reporting.capture.concurrency xpack.reporting.capture.loadDelay + xpack.reporting.capture.maxAttempts xpack.reporting.capture.settleTime xpack.reporting.capture.timeout + xpack.reporting.capture.timeouts.openUrl + xpack.reporting.capture.timeouts.renderComplete + xpack.reporting.capture.timeouts.waitForElements xpack.reporting.capture.viewport.height xpack.reporting.capture.viewport.width xpack.reporting.capture.zoom xpack.reporting.csv.checkForFormulas - xpack.reporting.csv.escapeFormulaValues xpack.reporting.csv.enablePanelActionDownload - xpack.reporting.csv.useByteOrderMarkEncoding + xpack.reporting.csv.escapeFormulaValues xpack.reporting.csv.maxSizeBytes xpack.reporting.csv.scroll.duration xpack.reporting.csv.scroll.size - xpack.reporting.capture.maxAttempts - xpack.reporting.capture.timeouts.openUrl - xpack.reporting.capture.timeouts.waitForElements - xpack.reporting.capture.timeouts.renderComplete + xpack.reporting.csv.useByteOrderMarkEncoding xpack.reporting.enabled xpack.reporting.encryptionKey xpack.reporting.index @@ -248,43 +258,38 @@ kibana_vars=( xpack.reporting.queue.timeout xpack.reporting.roles.allow xpack.rollup.enabled - xpack.security.audit.enabled xpack.searchprofiler.enabled - xpack.security.authc.providers + xpack.security.audit.enabled xpack.security.authc.oidc.realm - xpack.security.authc.saml.realm + xpack.security.authc.providers xpack.security.authc.saml.maxRedirectURLSize + xpack.security.authc.saml.realm xpack.security.authc.selector.enabled xpack.security.cookieName xpack.security.enabled xpack.security.encryptionKey + xpack.security.loginAssistanceMessage + xpack.security.loginHelp xpack.security.sameSiteCookies xpack.security.secureCookies - xpack.security.sessionTimeout + xpack.security.session.cleanupInterval xpack.security.session.idleTimeout xpack.security.session.lifespan - xpack.security.session.cleanupInterval - xpack.security.loginAssistanceMessage - xpack.security.loginHelp + xpack.security.sessionTimeout xpack.spaces.enabled xpack.spaces.maxSpaces xpack.task_manager.enabled + xpack.task_manager.index xpack.task_manager.max_attempts - xpack.task_manager.poll_interval xpack.task_manager.max_poll_inactivity_cycles - xpack.task_manager.request_capacity - xpack.task_manager.index xpack.task_manager.max_workers - xpack.task_manager.monitored_stats_required_freshness xpack.task_manager.monitored_aggregated_stats_refresh_rate + xpack.task_manager.monitored_stats_required_freshness xpack.task_manager.monitored_stats_running_average_window xpack.task_manager.monitored_task_execution_thresholds + xpack.task_manager.poll_interval + xpack.task_manager.request_capacity xpack.task_manager.version_conflict_threshold - telemetry.allowChangingOptInStatus - telemetry.enabled - telemetry.optIn - telemetry.optInStatusUrl - telemetry.sendUsageFrom ) longopts='' diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 26a6a9d6e4a03..c92de567cb446 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -26,19 +26,26 @@ export async function runDockerGenerator( config: Config, log: ToolingLog, build: Build, - ubi: boolean = false + flags: { + architecture?: string; + context: boolean; + image: boolean; + ubi: boolean; + } ) { // UBI var config - const baseOSImage = ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; + const baseOSImage = flags.ubi ? 'docker.elastic.co/ubi8/ubi-minimal:latest' : 'centos:8'; const ubiVersionTag = 'ubi8'; - const ubiImageFlavor = ubi ? `-${ubiVersionTag}` : ''; + const ubiImageFlavor = flags.ubi ? `-${ubiVersionTag}` : ''; // General docker var config const license = build.isOss() ? 'ASL 2.0' : 'Elastic License'; const imageFlavor = build.isOss() ? '-oss' : ''; const imageTag = 'docker.elastic.co/kibana/kibana'; const version = config.getBuildVersion(); - const artifactTarball = `kibana${imageFlavor}-${version}-linux-x86_64.tar.gz`; + const artifactArchitecture = flags.architecture === 'aarch64' ? 'aarch64' : 'x86_64'; + const artifactPrefix = `kibana${imageFlavor}-${version}-linux`; + const artifactTarball = `${artifactPrefix}-${artifactArchitecture}.tar.gz`; const artifactsDir = config.resolveFromTarget('.'); const dockerBuildDate = new Date().toISOString(); // That would produce oss, default and default-ubi7 @@ -47,10 +54,12 @@ export async function runDockerGenerator( 'kibana-docker', build.isOss() ? `oss` : `default${ubiImageFlavor}` ); + const imageArchitecture = flags.architecture === 'aarch64' ? '-aarch64' : ''; const dockerTargetFilename = config.resolveFromTarget( - `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker-image.tar.gz` + `kibana${imageFlavor}${ubiImageFlavor}-${version}-docker-image${imageArchitecture}.tar.gz` ); const scope: TemplateContext = { + artifactPrefix, artifactTarball, imageFlavor, version, @@ -62,7 +71,8 @@ export async function runDockerGenerator( baseOSImage, ubiImageFlavor, dockerBuildDate, - ubi, + ubi: flags.ubi, + architecture: flags.architecture, revision: config.getBuildSha(), }; @@ -106,20 +116,23 @@ export async function runDockerGenerator( // created from the templates/build_docker_sh.template.js // and we just run that bash script await chmodAsync(`${resolve(dockerBuildDir, 'build_docker.sh')}`, '755'); - await exec(log, `./build_docker.sh`, [], { - cwd: dockerBuildDir, - level: 'info', - }); - - // Pack Dockerfiles and create a target for them - await bundleDockerFiles(config, log, scope); -} -export async function runDockerGeneratorForUBI(config: Config, log: ToolingLog, build: Build) { - // Only run ubi docker image build for default distribution - if (build.isOss()) { - return; + // Only build images on native targets + type HostArchitectureToDocker = Record; + const hostTarget: HostArchitectureToDocker = { + x64: 'x64', + arm64: 'aarch64', + }; + const buildImage = hostTarget[process.arch] === flags.architecture && flags.image; + if (buildImage) { + await exec(log, `./build_docker.sh`, [], { + cwd: dockerBuildDir, + level: 'info', + }); } - await runDockerGenerator(config, log, build, true); + // Pack Dockerfiles and create a target for them + if (flags.context) { + await bundleDockerFiles(config, log, scope); + } } diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index 4805ba0ba9bc0..8de2b5e9361e5 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -7,6 +7,7 @@ */ export interface TemplateContext { + artifactPrefix: string; artifactTarball: string; imageFlavor: string; version: string; @@ -21,4 +22,5 @@ export interface TemplateContext { usePublicArtifact?: boolean; ubi: boolean; revision: string; + architecture?: string; } diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile index 1a0e325a2486a..eb4708b6ac555 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/Dockerfile @@ -17,17 +17,19 @@ RUN {{packageManager}} install -y findutils tar gzip {{#usePublicArtifact}} RUN cd /opt && \ - curl --retry 8 -s -L -O https://artifacts.elastic.co/downloads/kibana/{{artifactTarball}} && \ + curl --retry 8 -s -L \ + --output kibana.tar.gz \ + https://artifacts.elastic.co/downloads/kibana/{{artifactPrefix}}-$(arch).tar.gz && \ cd - {{/usePublicArtifact}} {{^usePublicArtifact}} -COPY {{artifactTarball}} /opt +COPY {{artifactTarball}} /opt/kibana.tar.gz {{/usePublicArtifact}} RUN mkdir /usr/share/kibana WORKDIR /usr/share/kibana -RUN tar --strip-components=1 -zxf /opt/{{artifactTarball}} +RUN tar --strip-components=1 -zxf /opt/kibana.tar.gz # Ensure that group permissions are the same as user permissions. # This will help when relying on GID-0 to run Kibana, rather than UID-1000. # OpenShift does this, for example. @@ -51,7 +53,7 @@ EXPOSE 5601 RUN for iter in {1..10}; do \ {{packageManager}} update --setopt=tsflags=nodocs -y && \ {{packageManager}} install --setopt=tsflags=nodocs -y \ - fontconfig freetype shadow-utils libnss3.so {{#ubi}}findutils{{/ubi}} && \ + fontconfig freetype shadow-utils nss {{#ubi}}findutils{{/ubi}} && \ {{packageManager}} clean all && exit_code=0 && break || exit_code=$? && echo "{{packageManager}} error: retry $iter in 10s" && \ sleep 10; \ done; \ @@ -59,8 +61,17 @@ RUN for iter in {1..10}; do \ # Add an init process, check the checksum to make sure it's a match RUN set -e ; \ + TINI_BIN="" ; \ + case "$(arch)" in \ + aarch64) \ + TINI_BIN='tini-arm64' ; \ + ;; \ + x86_64) \ + TINI_BIN='tini-amd64' ; \ + ;; \ + *) echo >&2 "Unsupported architecture $(arch)" ; exit 1 ;; \ + esac ; \ TINI_VERSION='v0.19.0' ; \ - TINI_BIN='tini-amd64' ; \ curl --retry 8 -S -L -O "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${TINI_BIN}" ; \ curl --retry 8 -S -L -O "https://github.com/krallin/tini/releases/download/${TINI_VERSION}/${TINI_BIN}.sha256sum" ; \ sha256sum -c "${TINI_BIN}.sha256sum" ; \ diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index 5e2a0b72769fe..d896e9cfa671c 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -17,6 +17,7 @@ function generator({ dockerTargetFilename, baseOSImage, ubiImageFlavor, + architecture, }: TemplateContext) { return dedent(` #!/usr/bin/env bash diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts index a5cc2d9527cbf..f4e9d11ca9c21 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts @@ -16,6 +16,7 @@ function generator(options: TemplateContext) { const template = readFileSync(resolve(__dirname, './Dockerfile')); return Mustache.render(template.toString(), { packageManager: options.ubiImageFlavor ? 'microdnf' : 'yum', + tiniBin: options.architecture === 'aarch64' ? 'tini-arm64' : 'tini-amd64', ...options, }); } diff --git a/src/dev/ci_setup/.bazelrc-ci b/src/dev/ci_setup/.bazelrc-ci new file mode 100644 index 0000000000000..5b345d3c9e207 --- /dev/null +++ b/src/dev/ci_setup/.bazelrc-ci @@ -0,0 +1,10 @@ +# Inspired on https://github.com/angular/angular/blob/master/.circleci/bazel.linux.rc +# These options are only enabled when running on CI +# That is done by copying this file into "$HOME/.bazelrc" which loads after the .bazelrc into the workspace + +# Import and load bazelrc common settings for ci env +try-import %workspace%/src/dev/ci_setup/.bazelrc-ci.common + +# Remote cache settings for ci env +# build --google_default_credentials +# build --remote_upload_local_results=true diff --git a/src/dev/ci_setup/.bazelrc-ci.common b/src/dev/ci_setup/.bazelrc-ci.common new file mode 100644 index 0000000000000..3f58e4e03a178 --- /dev/null +++ b/src/dev/ci_setup/.bazelrc-ci.common @@ -0,0 +1,11 @@ +# Inspired on https://github.com/angular/angular/blob/master/.circleci/bazel.common.rc +# Settings in this file should be OS agnostic + +# Don't be spammy in the logs +build --noshow_progress + +# Print all the options that apply to the build. +build --announce_rc + +# More details on failures +build --verbose_failures=true diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index 61f578ba33971..e5e21e312b0dd 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -65,3 +65,8 @@ if [ "$GIT_CHANGES" ]; then echo -e "$GIT_CHANGES\n" exit 1 fi + +### +### copy .bazelrc-ci into $HOME/.bazelrc +### +cp "src/dev/ci_setup/.bazelrc-ci" "$HOME/.bazelrc"; diff --git a/src/dev/code_coverage/shell_scripts/extract_archives.sh b/src/dev/code_coverage/shell_scripts/extract_archives.sh index 14b35f8786d02..376467f9f2e55 100644 --- a/src/dev/code_coverage/shell_scripts/extract_archives.sh +++ b/src/dev/code_coverage/shell_scripts/extract_archives.sh @@ -6,7 +6,7 @@ EXTRACT_DIR=/tmp/extracted_coverage mkdir -p $EXTRACT_DIR echo "### Extracting downloaded artifacts" -for x in kibana-intake kibana-oss-tests kibana-xpack-tests; do +for x in kibana-intake x-pack-intake kibana-oss-tests kibana-xpack-tests; do tar -xzf $DOWNLOAD_DIR/coverage/${x}/kibana-coverage.tar.gz -C $EXTRACT_DIR || echo "### Error 'tarring': ${x}" done diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 1a870e3c878f7..d18e2e49d6b83 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -65,6 +65,10 @@ export const IGNORE_FILE_GLOBS = [ 'x-pack/test/fleet_api_integration/apis/fixtures/test_packages/**/*', '.teamcity/**/*', + + // Bazel default files + '**/WORKSPACE.bazel', + '**/BUILD.bazel', ]; /** diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index d1ebcfa1e8399..675b5a682f272 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,4 +18,5 @@ export const storybookAliases = { security_solution: 'x-pack/plugins/security_solution/.storybook', ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', observability: 'x-pack/plugins/observability/.storybook', + presentation: 'src/plugins/presentation_util/storybook', }; diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index e1e2a49439de3..7ea181715717b 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 90706a11b8ce2..c52bd1b4d47b8 100644 --- a/src/plugins/dashboard/public/application/dashboard_state_manager.ts +++ b/src/plugins/dashboard/public/application/dashboard_state_manager.ts @@ -72,7 +72,7 @@ export class DashboardStateManager { >; private readonly stateContainerChangeSub: Subscription; private readonly STATE_STORAGE_KEY = '_a'; - private readonly kbnUrlStateStorage: IKbnUrlStateStorage; + public readonly kbnUrlStateStorage: IKbnUrlStateStorage; private readonly stateSyncRef: ISyncStateRef; private readonly history: History; private readonly usageCollection: UsageCollectionSetup | undefined; @@ -596,7 +596,7 @@ export class DashboardStateManager { this.toUrlState(this.stateContainer.get()) ); // immediately forces scheduled updates and changes location - return this.kbnUrlStateStorage.flush({ replace }); + return !!this.kbnUrlStateStorage.kbnUrlControls.flush(replace); } // TODO: find nicer solution for this diff --git a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap index bce8a661634f6..faec6b4f6f24b 100644 --- a/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -4,10 +4,16 @@ exports[`after fetch When given a title that matches multiple dashboards, filter - getTableColumns((id) => redirectTo({ destination: 'dashboard', id }), savedObjectsTagging), - [savedObjectsTagging, redirectTo] + getTableColumns( + core.application, + kbnUrlStateStorage, + core.uiSettings.get('state:storeInSessionStorage'), + savedObjectsTagging + ), + [core.application, core.uiSettings, kbnUrlStateStorage, savedObjectsTagging] ); const noItemsFragment = useMemo( @@ -99,7 +103,6 @@ export const DashboardListing = ({ (filter: string) => { let searchTerm = filter; let references: SavedObjectsFindOptionsReference[] | undefined; - if (savedObjectsTagging) { const parsed = savedObjectsTagging.ui.parseSearchQuery(filter, { useName: true, @@ -164,7 +167,9 @@ export const DashboardListing = ({ }; const getTableColumns = ( - redirectTo: (id?: string) => void, + application: ApplicationStart, + kbnUrlStateStorage: IKbnUrlStateStorage, + useHash: boolean, savedObjectsTagging?: SavedObjectsTaggingApi ) => { return [ @@ -172,9 +177,15 @@ const getTableColumns = ( field: 'title', name: dashboardListingTable.getTitleColumnName(), sortable: true, - render: (field: string, record: { id: string; title: string }) => ( + render: (field: string, record: { id: string; title: string; timeRestore: boolean }) => ( redirectTo(record.id)} + href={getDashboardListItemLink( + application, + kbnUrlStateStorage, + useHash, + record.id, + record.timeRestore + )} data-test-subj={`dashboardListingTitleLink-${record.title.split(' ').join('-')}`} > {field} diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts new file mode 100644 index 0000000000000..6dbc76803af90 --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { getDashboardListItemLink } from './get_dashboard_list_item_link'; +import { ApplicationStart } from 'kibana/public'; +import { esFilters } from '../../../../data/public'; +import { createHashHistory } from 'history'; +import { createKbnUrlStateStorage } from '../../../../kibana_utils/public'; +import { GLOBAL_STATE_STORAGE_KEY } from '../../url_generator'; + +const DASHBOARD_ID = '13823000-99b9-11ea-9eb6-d9e8adceb647'; + +const application = ({ + getUrlForApp: jest.fn((appId: string, options?: { path?: string; absolute?: boolean }) => { + return `/app/${appId}${options?.path}`; + }), +} as unknown) as ApplicationStart; + +const history = createHashHistory(); +const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + useHash: false, +}); +kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { time: { from: 'now-7d', to: 'now' } }); + +describe('listing dashboard link', () => { + test('creates a link to a dashboard without the timerange query if time is saved on the dashboard', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + true + ); + expect(url).toMatchInlineSnapshot(`"/app/dashboards#/view/${DASHBOARD_ID}?_g=()"`); + }); + + test('creates a link to a dashboard with the timerange query if time is not saved on the dashboard', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + false + ); + expect(url).toMatchInlineSnapshot( + `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(time:(from:now-7d,to:now))"` + ); + }); +}); + +describe('when global time changes', () => { + beforeEach(() => { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + time: { + from: '2021-01-05T11:45:53.375Z', + to: '2021-01-21T11:46:00.990Z', + }, + }); + }); + + test('propagates the correct time on the query', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + false + ); + expect(url).toMatchInlineSnapshot( + `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(time:(from:'2021-01-05T11:45:53.375Z',to:'2021-01-21T11:46:00.990Z'))"` + ); + }); +}); + +describe('when global refreshInterval changes', () => { + beforeEach(() => { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + refreshInterval: { pause: false, value: 300 }, + }); + }); + + test('propagates the refreshInterval on the query', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + false + ); + expect(url).toMatchInlineSnapshot( + `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(refreshInterval:(pause:!f,value:300))"` + ); + }); +}); + +describe('when global filters change', () => { + beforeEach(() => { + const filters = [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + $state: { + store: esFilters.FilterStateStore.GLOBAL_STATE, + }, + }, + ]; + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + filters, + }); + }); + + test('propagates the filters on the query', async () => { + const url = getDashboardListItemLink( + application, + kbnUrlStateStorage, + false, + DASHBOARD_ID, + false + ); + expect(url).toMatchInlineSnapshot( + `"/app/dashboards#/view/${DASHBOARD_ID}?_g=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1)),('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))))"` + ); + }); +}); diff --git a/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts new file mode 100644 index 0000000000000..d14638b9e231f --- /dev/null +++ b/src/plugins/dashboard/public/application/listing/get_dashboard_list_item_link.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { ApplicationStart } from 'kibana/public'; +import { QueryState } from '../../../../data/public'; +import { setStateToKbnUrl } from '../../../../kibana_utils/public'; +import { createDashboardEditUrl, DashboardConstants } from '../../dashboard_constants'; +import { GLOBAL_STATE_STORAGE_KEY } from '../../url_generator'; +import { IKbnUrlStateStorage } from '../../services/kibana_utils'; + +export const getDashboardListItemLink = ( + application: ApplicationStart, + kbnUrlStateStorage: IKbnUrlStateStorage, + useHash: boolean, + id: string, + timeRestore: boolean +) => { + let url = application.getUrlForApp(DashboardConstants.DASHBOARDS_ID, { + path: `#${createDashboardEditUrl(id)}`, + }); + const globalStateInUrl = kbnUrlStateStorage.get(GLOBAL_STATE_STORAGE_KEY) || {}; + + if (timeRestore) { + delete globalStateInUrl.time; + delete globalStateInUrl.refreshInterval; + } + url = setStateToKbnUrl(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url); + return url; +}; diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx new file mode 100644 index 0000000000000..d4703d14627a4 --- /dev/null +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Capabilities } from 'src/core/public'; +import { showPublicUrlSwitch } from './show_share_modal'; + +describe('showPublicUrlSwitch', () => { + test('returns false if "dashboard" app is not available', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns false if "dashboard" app is not accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + dashboard: { + show: false, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns true if "dashboard" app is not available an accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + dashboard: { + show: true, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(true); + }); +}); diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx index 660e7635eb99d..fe4f8ea411289 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.tsx @@ -6,6 +6,7 @@ * Public License, v 1. */ +import { Capabilities } from 'src/core/public'; import { EuiCheckboxGroup } from '@elastic/eui'; import React from 'react'; import { ReactElement, useState } from 'react'; @@ -27,6 +28,14 @@ interface ShowShareModalProps { dashboardStateManager: DashboardStateManager; } +export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { + if (!anonymousUserCapabilities.dashboard) return false; + + const dashboard = (anonymousUserCapabilities.dashboard as unknown) as DashboardCapabilities; + + return !!dashboard.show; +}; + export function ShowShareModal({ share, anchorElement, @@ -94,7 +103,7 @@ export function ShowShareModal({ share.toggleShareContextMenu({ anchorElement, allowEmbed: true, - allowShortUrl: !dashboardCapabilities.hideWriteControls || dashboardCapabilities.createShortUrl, + allowShortUrl: dashboardCapabilities.createShortUrl, shareableUrl: setStateToKbnUrl( '_a', dashboardStateManager.getAppState(), @@ -113,5 +122,6 @@ export function ShowShareModal({ component: EmbedUrlParamExtension, }, ], + showPublicUrlSwitch, }); } diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts index 19035eb8a9328..991f3063cac0a 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts @@ -90,7 +90,7 @@ describe('sync_query_state_with_url', () => { test('url is actually changed when data in services changes', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); filterManager.setFilters([gF, aF]); - kbnUrlStateStorage.flush(); // sync force location change + kbnUrlStateStorage.kbnUrlControls.flush(); // sync force location change expect(history.location.hash).toMatchInlineSnapshot( `"#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!t,index:'logstash-*',key:query,negate:!t,type:custom,value:'%7B%22match%22:%7B%22key1%22:%22value1%22%7D%7D'),query:(match:(key1:value1)))),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))"` ); @@ -126,7 +126,7 @@ describe('sync_query_state_with_url', () => { test('when url is changed, filters synced back to filterManager', () => { const { stop } = syncQueryStateWithUrl(queryServiceStart, kbnUrlStateStorage); - kbnUrlStateStorage.cancel(); // stop initial syncing pending update + kbnUrlStateStorage.kbnUrlControls.cancel(); // stop initial syncing pending update history.push(pathWithFilter); expect(filterManager.getGlobalFilters()).toHaveLength(1); stop(); diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx index 426302689c8f0..b7d9be485a303 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.test.tsx @@ -84,12 +84,15 @@ function wrapQueryStringInputInContext(testProps: any, storage?: any) { ); } -describe('QueryStringInput', () => { +// FAILING: https://github.com/elastic/kibana/issues/85715 +// FAILING: https://github.com/elastic/kibana/issues/89603 +// FAILING: https://github.com/elastic/kibana/issues/89641 +describe.skip('QueryStringInput', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('Should render the given query', async () => { + it.skip('Should render the given query', async () => { const { getByText } = render( wrapQueryStringInputInContext({ query: kqlQuery, diff --git a/src/plugins/discover/public/application/angular/context_state.ts b/src/plugins/discover/public/application/angular/context_state.ts index 73523b218df7c..e8c2f1d397ba5 100644 --- a/src/plugins/discover/public/application/angular/context_state.ts +++ b/src/plugins/discover/public/application/angular/context_state.ts @@ -206,7 +206,7 @@ export function getState({ } }, // helper function just needed for testing - flushToUrl: (replace?: boolean) => stateStorage.flush({ replace }), + flushToUrl: (replace?: boolean) => stateStorage.kbnUrlControls.flush(replace), }; } diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 5c26680c7cc45..dcf86babaa5e1 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -24,7 +24,6 @@ import { import { getSortArray } from './doc_table'; import * as columnActions from './doc_table/actions/columns'; import indexTemplateLegacy from './discover_legacy.html'; -import indexTemplateGrid from './discover_datagrid.html'; import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util'; import { discoverResponseHandler } from './response_handler'; import { @@ -48,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, @@ -63,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(); @@ -87,9 +85,6 @@ const fetchStatuses = { ERROR: 'error', }; -const getSearchSessionIdFromURL = (history) => - getQueryParams(history.location)[SEARCH_SESSION_ID_QUERY_PARAM]; - const app = getAngularModule(); app.config(($routeProvider) => { @@ -116,9 +111,7 @@ app.config(($routeProvider) => { }; const discoverRoute = { ...defaults, - template: getServices().uiSettings.get('doc_table:legacy', true) - ? indexTemplateLegacy - : indexTemplateGrid, + template: indexTemplateLegacy, reloadOnSearch: false, resolve: { savedObjects: function ($route, Promise) { @@ -180,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( @@ -198,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, @@ -258,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; } @@ -354,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(); @@ -478,7 +474,8 @@ function discoverController($route, $scope, Promise) { return ( config.get(SEARCH_ON_PAGE_LOAD_SETTING) || savedSearch.id !== undefined || - timefilter.getRefreshInterval().pause === false + timefilter.getRefreshInterval().pause === false || + searchSessionManager.hasSearchSessionIdInURL() ); }; @@ -489,7 +486,8 @@ function discoverController($route, $scope, Promise) { filterManager.getFetches$(), timefilter.getFetch$(), timefilter.getAutoRefreshFetch$(), - data.query.queryString.getUpdates$() + data.query.queryString.getUpdates$(), + searchSessionManager.newSearchSessionIdFromURL$ ).pipe(debounceTime(100)); subscriptions.add( @@ -515,6 +513,13 @@ function discoverController($route, $scope, Promise) { ) ); + subscriptions.add( + data.search.session.onRefresh$.subscribe(() => { + searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); + refetch$.next(); + }) + ); + $scope.changeInterval = (interval) => { if (interval) { setAppState({ interval }); @@ -594,20 +599,7 @@ function discoverController($route, $scope, Promise) { if (abortController) abortController.abort(); abortController = new AbortController(); - const searchSessionId = (() => { - const searchSessionIdFromURL = getSearchSessionIdFromURL(history); - if (searchSessionIdFromURL) { - if (isInitialSearch) { - data.search.session.restore(searchSessionIdFromURL); - isInitialSearch = false; - return searchSessionIdFromURL; - } else { - // navigating away from background search - removeQueryParam(history, SEARCH_SESSION_ID_QUERY_PARAM); - } - } - return data.search.session.start(); - })(); + const searchSessionId = searchSessionManager.getNextSearchSessionId(); $scope .updateDataSource() @@ -634,6 +626,7 @@ function discoverController($route, $scope, Promise) { $scope.handleRefresh = function (_payload, isUpdate) { if (isUpdate === false) { + searchSessionManager.removeSearchSessionIdFromURL({ replace: false }); refetch$.next(); } }; diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover/public/application/angular/discover_legacy.html index 9383980fd9fd6..76e5c568ffde6 100644 --- a/src/plugins/discover/public/application/angular/discover_legacy.html +++ b/src/plugins/discover/public/application/angular/discover_legacy.html @@ -1,5 +1,5 @@ - - + diff --git a/src/plugins/discover/public/application/angular/discover_search_session.test.ts b/src/plugins/discover/public/application/angular/discover_search_session.test.ts new file mode 100644 index 0000000000000..abec6aedeaf5c --- /dev/null +++ b/src/plugins/discover/public/application/angular/discover_search_session.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { DiscoverSearchSessionManager } from './discover_search_session'; +import { createMemoryHistory } from 'history'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { DataPublicPluginStart } from '../../../../data/public'; + +describe('DiscoverSearchSessionManager', () => { + const history = createMemoryHistory(); + const session = dataPluginMock.createStartContract().search.session as jest.Mocked< + DataPublicPluginStart['search']['session'] + >; + const searchSessionManager = new DiscoverSearchSessionManager({ + history, + session, + }); + + beforeEach(() => { + history.push('/'); + session.start.mockReset(); + session.restore.mockReset(); + session.getSessionId.mockReset(); + session.isCurrentSession.mockReset(); + session.isRestore.mockReset(); + }); + + describe('getNextSearchSessionId', () => { + test('starts a new session', () => { + const nextId = 'id'; + session.start.mockImplementationOnce(() => nextId); + + const id = searchSessionManager.getNextSearchSessionId(); + expect(id).toEqual(nextId); + expect(session.start).toBeCalled(); + }); + + test('restores a session using query param from the URL', () => { + const nextId = 'id_from_url'; + history.push(`/?searchSessionId=${nextId}`); + + const id = searchSessionManager.getNextSearchSessionId(); + expect(id).toEqual(nextId); + expect(session.restore).toBeCalled(); + }); + + test('removes query param from the URL when navigating away from a restored session', () => { + const idFromUrl = 'id_from_url'; + history.push(`/?searchSessionId=${idFromUrl}`); + + const nextId = 'id'; + session.start.mockImplementationOnce(() => nextId); + session.isCurrentSession.mockImplementationOnce(() => true); + session.isRestore.mockImplementationOnce(() => true); + + const id = searchSessionManager.getNextSearchSessionId(); + expect(id).toEqual(nextId); + expect(session.start).toBeCalled(); + expect(history.location.search).toMatchInlineSnapshot(`""`); + }); + }); + + describe('newSearchSessionIdFromURL$', () => { + test('notifies about searchSessionId changes in the URL', () => { + const emits: Array = []; + + const sub = searchSessionManager.newSearchSessionIdFromURL$.subscribe((newId) => { + emits.push(newId); + }); + + history.push(`/?searchSessionId=id1`); + history.push(`/?searchSessionId=id1`); + session.isCurrentSession.mockImplementationOnce(() => true); + history.replace(`/?searchSessionId=id2`); // should skip current this + history.replace(`/`); + history.push(`/?searchSessionId=id1`); + history.push(`/`); + + expect(emits).toMatchInlineSnapshot(` + Array [ + "id1", + null, + "id1", + null, + ] + `); + + sub.unsubscribe(); + }); + }); +}); diff --git a/src/plugins/discover/public/application/angular/discover_search_session.ts b/src/plugins/discover/public/application/angular/discover_search_session.ts new file mode 100644 index 0000000000000..a53d7d6d2c333 --- /dev/null +++ b/src/plugins/discover/public/application/angular/discover_search_session.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { History } from 'history'; +import { filter } from 'rxjs/operators'; +import { DataPublicPluginStart } from '../../../../data/public'; +import { + createQueryParamObservable, + getQueryParams, + removeQueryParam, +} from '../../../../kibana_utils/public'; +import { SEARCH_SESSION_ID_QUERY_PARAM } from '../../url_generator'; + +export interface DiscoverSearchSessionManagerDeps { + history: History; + session: DataPublicPluginStart['search']['session']; +} + +/** + * Helps with state management of search session and {@link SEARCH_SESSION_ID_QUERY_PARAM} in the URL + */ +export class DiscoverSearchSessionManager { + /** + * Notifies about `searchSessionId` changes in the URL, + * skips if `searchSessionId` matches current search session id + */ + readonly newSearchSessionIdFromURL$ = createQueryParamObservable( + this.deps.history, + SEARCH_SESSION_ID_QUERY_PARAM + ).pipe( + filter((searchSessionId) => { + if (!searchSessionId) return true; + return !this.deps.session.isCurrentSession(searchSessionId); + }) + ); + + constructor(private readonly deps: DiscoverSearchSessionManagerDeps) {} + + /** + * Get next session id by either starting or restoring a session. + * When navigating away from the restored session {@link SEARCH_SESSION_ID_QUERY_PARAM} is removed from the URL using history.replace + */ + getNextSearchSessionId() { + let searchSessionIdFromURL = this.getSearchSessionIdFromURL(); + if (searchSessionIdFromURL) { + if ( + this.deps.session.isRestore() && + this.deps.session.isCurrentSession(searchSessionIdFromURL) + ) { + // navigating away from a restored session + this.removeSearchSessionIdFromURL({ replace: true }); + searchSessionIdFromURL = undefined; + } else { + this.deps.session.restore(searchSessionIdFromURL); + } + } + + return searchSessionIdFromURL ?? this.deps.session.start(); + } + + /** + * Removes Discovers {@link SEARCH_SESSION_ID_QUERY_PARAM} from the URL + * @param replace - methods to change the URL + */ + removeSearchSessionIdFromURL({ replace = true }: { replace?: boolean } = { replace: true }) { + if (this.hasSearchSessionIdInURL()) { + removeQueryParam(this.deps.history, SEARCH_SESSION_ID_QUERY_PARAM, replace); + } + } + + /** + * If there is a {@link SEARCH_SESSION_ID_QUERY_PARAM} currently in the URL + */ + hasSearchSessionIdInURL(): boolean { + return !!this.getSearchSessionIdFromURL(); + } + + private getSearchSessionIdFromURL = () => + getQueryParams(this.deps.history.location)[SEARCH_SESSION_ID_QUERY_PARAM] as string | undefined; +} diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover/public/application/angular/discover_state.ts index c769e263655ab..65a8dded11092 100644 --- a/src/plugins/discover/public/application/angular/discover_state.ts +++ b/src/plugins/discover/public/application/angular/discover_state.ts @@ -200,7 +200,7 @@ export function getState({ setState(appStateContainerModified, defaultState); }, getPreviousAppState: () => previousAppState, - flushToUrl: () => stateStorage.flush(), + flushToUrl: () => stateStorage.kbnUrlControls.flush(), isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()), }; } diff --git a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx index 06b6e504832e4..cbd93feb835a0 100644 --- a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx @@ -9,8 +9,11 @@ import angular, { auto, ICompileService, IScope } from 'angular'; import { render } from 'react-dom'; import React, { useRef, useEffect } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { getServices, IIndexPattern } from '../../../kibana_services'; import { IndexPatternField } from '../../../../../data/common/index_patterns'; + export type AngularScope = IScope; export interface AngularDirective { @@ -83,9 +86,11 @@ export interface DocTableLegacyProps { indexPattern: IIndexPattern; minimumVisibleRows: number; onAddColumn?: (column: string) => void; + onBackToTop: () => void; onSort?: (sort: string[][]) => void; onMoveColumn?: (columns: string, newIdx: number) => void; onRemoveColumn?: (column: string) => void; + sampleSize: number; sort?: string[][]; useNewFieldsApi?: boolean; } @@ -120,5 +125,31 @@ export function DocTableLegacy(renderProps: DocTableLegacyProps) { return renderFn(ref.current, renderProps); } }, [renderFn, renderProps]); - return
; + return ( +
+
+ {renderProps.rows.length === renderProps.sampleSize ? ( +
+ + + + +
+ ) : ( + + ​ + + )} +
+ ); } diff --git a/src/plugins/discover/public/application/components/create_discover_directive.ts b/src/plugins/discover/public/application/components/create_discover_directive.ts index 42b99b635a791..10439488f4bc7 100644 --- a/src/plugins/discover/public/application/components/create_discover_directive.ts +++ b/src/plugins/discover/public/application/components/create_discover_directive.ts @@ -17,18 +17,21 @@ export function createDiscoverDirective(reactDirective: any) { ['histogramData', { watchDepth: 'reference' }], ['hits', { watchDepth: 'reference' }], ['indexPattern', { watchDepth: 'reference' }], + ['minimumVisibleRows', { watchDepth: 'reference' }], ['onAddColumn', { watchDepth: 'reference' }], ['onAddFilter', { watchDepth: 'reference' }], ['onChangeInterval', { watchDepth: 'reference' }], + ['onMoveColumn', { watchDepth: 'reference' }], ['onRemoveColumn', { watchDepth: 'reference' }], ['onSetColumns', { watchDepth: 'reference' }], + ['onSkipBottomButtonClick', { watchDepth: 'reference' }], ['onSort', { watchDepth: 'reference' }], ['opts', { watchDepth: 'reference' }], ['resetQuery', { watchDepth: 'reference' }], ['resultState', { watchDepth: 'reference' }], ['rows', { watchDepth: 'reference' }], + ['savedSearch', { watchDepth: 'reference' }], ['searchSource', { watchDepth: 'reference' }], - ['setColumns', { watchDepth: 'reference' }], ['setIndexPattern', { watchDepth: 'reference' }], ['showSaveQuery', { watchDepth: 'reference' }], ['state', { watchDepth: 'reference' }], diff --git a/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts b/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts deleted file mode 100644 index b2b9fd38f73b1..0000000000000 --- a/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts +++ /dev/null @@ -1,45 +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 { DiscoverLegacy } from './discover_legacy'; - -export function createDiscoverLegacyDirective(reactDirective: any) { - return reactDirective(DiscoverLegacy, [ - ['fetch', { watchDepth: 'reference' }], - ['fetchCounter', { watchDepth: 'reference' }], - ['fetchError', { watchDepth: 'reference' }], - ['fieldCounts', { watchDepth: 'reference' }], - ['histogramData', { watchDepth: 'reference' }], - ['hits', { watchDepth: 'reference' }], - ['indexPattern', { watchDepth: 'reference' }], - ['minimumVisibleRows', { watchDepth: 'reference' }], - ['onAddColumn', { watchDepth: 'reference' }], - ['onAddFilter', { watchDepth: 'reference' }], - ['onChangeInterval', { watchDepth: 'reference' }], - ['onMoveColumn', { watchDepth: 'reference' }], - ['onRemoveColumn', { watchDepth: 'reference' }], - ['onSetColumns', { watchDepth: 'reference' }], - ['onSkipBottomButtonClick', { watchDepth: 'reference' }], - ['onSort', { watchDepth: 'reference' }], - ['opts', { watchDepth: 'reference' }], - ['resetQuery', { watchDepth: 'reference' }], - ['resultState', { watchDepth: 'reference' }], - ['rows', { watchDepth: 'reference' }], - ['savedSearch', { watchDepth: 'reference' }], - ['searchSource', { watchDepth: 'reference' }], - ['setIndexPattern', { watchDepth: 'reference' }], - ['showSaveQuery', { watchDepth: 'reference' }], - ['state', { watchDepth: 'reference' }], - ['timefilterUpdateHandler', { watchDepth: 'reference' }], - ['timeRange', { watchDepth: 'reference' }], - ['topNavMenu', { watchDepth: 'reference' }], - ['updateQuery', { watchDepth: 'reference' }], - ['updateSavedQueryId', { watchDepth: 'reference' }], - ['useNewFieldsApi', { watchDepth: 'reference' }], - ]); -} diff --git a/src/plugins/discover/public/application/components/discover_legacy.test.tsx b/src/plugins/discover/public/application/components/discover.test.tsx similarity index 89% rename from src/plugins/discover/public/application/components/discover_legacy.test.tsx rename to src/plugins/discover/public/application/components/discover.test.tsx index 04f294912d49e..3088ca45f7941 100644 --- a/src/plugins/discover/public/application/components/discover_legacy.test.tsx +++ b/src/plugins/discover/public/application/components/discover.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallowWithIntl } from '@kbn/test/jest'; -import { DiscoverLegacy } from './discover_legacy'; +import { Discover } from './discover'; import { inspectorPluginMock } from '../../../../inspector/public/mocks'; import { esHits } from '../../__mocks__/es_hits'; import { indexPatternMock } from '../../__mocks__/index_pattern'; @@ -19,7 +19,7 @@ import { savedSearchMock } from '../../__mocks__/saved_search'; import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; import { dataPluginMock } from '../../../../data/public/mocks'; import { createFilterManagerMock } from '../../../../data/public/query/filter_manager/filter_manager.mock'; -import { uiSettingsMock } from '../../__mocks__/ui_settings'; +import { uiSettingsMock as mockUiSettings } from '../../__mocks__/ui_settings'; import { IndexPattern, IndexPatternAttributes } from '../../../../data/common/index_patterns'; import { SavedObject } from '../../../../../core/types'; import { navigationPluginMock } from '../../../../navigation/public/mocks'; @@ -40,6 +40,7 @@ jest.mock('../../kibana_services', () => { }, }, navigation: mockNavigation, + uiSettings: mockUiSettings, }), }; }); @@ -53,6 +54,7 @@ function getProps(indexPattern: IndexPattern) { save: true, }, }, + uiSettings: mockUiSettings, } as unknown) as DiscoverServices; return { @@ -72,7 +74,7 @@ function getProps(indexPattern: IndexPattern) { onSkipBottomButtonClick: jest.fn(), onSort: jest.fn(), opts: { - config: uiSettingsMock, + config: mockUiSettings, data: dataPluginMock.createStartContract(), fixedScroll: jest.fn(), filterManager: createFilterManagerMock(), @@ -105,15 +107,13 @@ function getProps(indexPattern: IndexPattern) { }; } -describe('Descover legacy component', () => { +describe('Discover component', () => { test('selected index pattern without time field displays no chart toggle', () => { - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component.find('[data-test-subj="discoverChartToggle"]').length).toBe(0); }); test('selected index pattern with time field displays chart toggle', () => { - const component = shallowWithIntl( - - ); + const component = shallowWithIntl(); expect(component.find('[data-test-subj="discoverChartToggle"]').length).toBe(1); }); }); diff --git a/src/plugins/discover/public/application/components/discover.tsx b/src/plugins/discover/public/application/components/discover.tsx index 704e7a9c02e1b..5653ef4f57435 100644 --- a/src/plugins/discover/public/application/components/discover.tsx +++ b/src/plugins/discover/public/application/components/discover.tsx @@ -26,25 +26,30 @@ import classNames from 'classnames'; import { HitsCounter } from './hits_counter'; import { TimechartHeader } from './timechart_header'; import { getServices } from '../../kibana_services'; -import { DiscoverUninitialized, DiscoverHistogram } from '../angular/directives'; +import { DiscoverHistogram, DiscoverUninitialized } from '../angular/directives'; import { DiscoverNoResults } from './no_results'; import { LoadingSpinner } from './loading_spinner/loading_spinner'; +import { DocTableLegacy, DocTableLegacyProps } from '../angular/doc_table/create_doc_table_react'; +import { SkipBottomButton } from './skip_bottom_button'; import { search } from '../../../../data/public'; import { DiscoverSidebarResponsive, DiscoverSidebarResponsiveProps, } from './sidebar/discover_sidebar_responsive'; -import { DiscoverProps } from './discover_legacy'; +import { DiscoverProps } from './types'; +import { getDisplayedColumns } from '../helpers/columns'; import { SortPairArr } from '../angular/doc_table/lib/get_sort'; import { DiscoverGrid, DiscoverGridProps } from './discover_grid/discover_grid'; +import { SEARCH_FIELDS_FROM_SOURCE } from '../../../common'; -export const SidebarMemoized = React.memo((props: DiscoverSidebarResponsiveProps) => ( +const DocTableLegacyMemoized = React.memo((props: DocTableLegacyProps) => ( + +)); +const SidebarMemoized = React.memo((props: DiscoverSidebarResponsiveProps) => ( )); -export const DataGridMemoized = React.memo((props: DiscoverGridProps) => ( - -)); +const DataGridMemoized = React.memo((props: DiscoverGridProps) => ); export function Discover({ fetch, @@ -54,11 +59,14 @@ export function Discover({ histogramData, hits, indexPattern, + minimumVisibleRows, onAddColumn, onAddFilter, onChangeInterval, + onMoveColumn, onRemoveColumn, onSetColumns, + onSkipBottomButtonClick, onSort, opts, resetQuery, @@ -66,7 +74,6 @@ export function Discover({ rows, searchSource, setIndexPattern, - showSaveQuery, state, timefilterUpdateHandler, timeRange, @@ -76,6 +83,11 @@ export function Discover({ }: DiscoverProps) { const scrollableDesktop = useRef(null); const collapseIcon = useRef(null); + const isMobile = () => { + // collapse icon isn't displayed in mobile view, use it to detect which view is displayed + return collapseIcon && !collapseIcon.current; + }; + const [toggleOn, toggleChart] = useState(true); const [isSidebarClosed, setIsSidebarClosed] = useState(false); const services = getServices(); @@ -88,18 +100,8 @@ export function Discover({ ? bucketAggConfig.buckets?.getInterval() : undefined; const contentCentered = resultState === 'uninitialized'; - const showTimeCol = !config.get('doc_table:hideTimeColumn', false) && indexPattern.timeFieldName; - const columns = - state.columns && - state.columns.length > 0 && - // check if all columns where removed except the configured timeField (this can't be removed) - !(state.columns.length === 1 && state.columns[0] === indexPattern.timeFieldName) - ? state.columns - : ['_source']; - // if columns include _source this is considered as default view, so you can't remove columns - // until you add a column using Discover's sidebar - const defaultColumns = columns.includes('_source'); - + const isLegacy = services.uiSettings.get('doc_table:legacy'); + const useNewFieldsApi = !services.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); return ( @@ -114,7 +116,7 @@ export function Discover({ savedQueryId={state.savedQuery} screenTitle={savedSearch.title} showDatePicker={indexPattern.isTimeBased()} - showSaveQuery={showSaveQuery} + showSaveQuery={!!services.capabilities.discover.saveQuery} showSearchBar={true} useDefaultBehaviors={true} /> @@ -137,6 +139,7 @@ export function Discover({ setIndexPattern={setIndexPattern} isClosed={isSidebarClosed} trackUiMetric={trackUiMetric} + useNewFieldsApi={useNewFieldsApi} /> @@ -207,24 +210,28 @@ export function Discover({ /> )} - - { - toggleChart(!toggleOn); - }} - > - {toggleOn - ? i18n.translate('discover.hideChart', { - defaultMessage: 'Hide chart', - }) - : i18n.translate('discover.showChart', { - defaultMessage: 'Show chart', - })} - - + {opts.timefield && ( + + { + toggleChart(!toggleOn); + }} + data-test-subj="discoverChartToggle" + > + {toggleOn + ? i18n.translate('discover.hideChart', { + defaultMessage: 'Hide chart', + }) + : i18n.translate('discover.showChart', { + defaultMessage: 'Show chart', + })} + + + )} + {isLegacy && } {toggleOn && opts.timefield && ( @@ -238,7 +245,10 @@ export function Discover({ className="dscTimechart" > {opts.chartAggConfigs && histogramData && rows.length !== 0 && ( -
+
- {rows && rows.length && ( + {isLegacy && rows && rows.length && ( + { + if (scrollableDesktop && scrollableDesktop.current) { + scrollableDesktop.current.focus(); + } + // Only the desktop one needs to target a specific container + if (!isMobile() && scrollableDesktop.current) { + scrollableDesktop.current.scrollTo(0, 0); + } else if (window) { + window.scrollTo(0, 0); + } + }} + onFilter={onAddFilter} + onMoveColumn={onMoveColumn} + onRemoveColumn={onRemoveColumn} + onSort={onSort} + sampleSize={opts.sampleSize} + useNewFieldsApi={useNewFieldsApi} + /> + )} + {!isLegacy && rows && rows.length && (
{ export const DiscoverGrid = ({ ariaLabelledBy, columns, - defaultColumns, indexPattern, onAddColumn, onFilter, @@ -144,6 +137,7 @@ export const DiscoverGrid = ({ sort, }: DiscoverGridProps) => { const [expanded, setExpanded] = useState(undefined); + const defaultColumns = columns.includes('_source'); /** * Pagination diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx index 481d308cf88a9..6a247ad951c9b 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_columns.tsx @@ -48,7 +48,12 @@ export function buildEuiGridColumn( id: columnName, schema: getSchemaByKbnType(indexPatternField?.type), isSortable: indexPatternField?.sortable, - display: indexPatternField?.displayName, + display: + columnName === '_source' + ? i18n.translate('discover.grid.documentHeader', { + defaultMessage: 'Document', + }) + : indexPatternField?.displayName, actions: { showHide: defaultColumns || columnName === indexPattern.timeFieldName diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx new file mode 100644 index 0000000000000..f9428e30569f7 --- /dev/null +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { mountWithIntl } from '@kbn/test/jest'; +import { DiscoverGridFlyout } from './discover_grid_flyout'; +import { esHits } from '../../../__mocks__/es_hits'; +import { createFilterManagerMock } from '../../../../../data/public/query/filter_manager/filter_manager.mock'; +import { indexPatternMock } from '../../../__mocks__/index_pattern'; +import { DiscoverServices } from '../../../build_services'; +import { DocViewsRegistry } from '../../doc_views/doc_views_registry'; +import { setDocViewsRegistry } from '../../../kibana_services'; +import { indexPatternWithTimefieldMock } from '../../../__mocks__/index_pattern_with_timefield'; + +describe('Discover flyout', function () { + setDocViewsRegistry(new DocViewsRegistry()); + + it('should be rendered correctly using an index pattern without timefield', async () => { + const onClose = jest.fn(); + const component = mountWithIntl( + + ); + + const url = findTestSubject(component, 'docTableRowAction').prop('href'); + expect(url).toMatchInlineSnapshot(`"#/doc/the-index-pattern-id/i?id=1"`); + findTestSubject(component, 'euiFlyoutCloseButton').simulate('click'); + expect(onClose).toHaveBeenCalled(); + }); + + it('should be rendered correctly using an index pattern with timefield', async () => { + const onClose = jest.fn(); + const component = mountWithIntl( + + ); + + const actions = findTestSubject(component, 'docTableRowAction'); + expect(actions.length).toBe(2); + expect(actions.first().prop('href')).toMatchInlineSnapshot( + `"#/doc/index-pattern-with-timefield-id/i?id=1"` + ); + expect(actions.last().prop('href')).toMatchInlineSnapshot( + `"#/context/index-pattern-with-timefield-id/1?_g=(filters:!())&_a=(columns:!(date),filters:!())"` + ); + findTestSubject(component, 'euiFlyoutCloseButton').simulate('click'); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover/public/application/components/discover_legacy.tsx b/src/plugins/discover/public/application/components/discover_legacy.tsx deleted file mode 100644 index 1b90b845a8fff..0000000000000 --- a/src/plugins/discover/public/application/components/discover_legacy.tsx +++ /dev/null @@ -1,517 +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 './discover.scss'; - -import React, { useState, useRef } from 'react'; -import { - EuiButtonEmpty, - EuiButtonIcon, - EuiFlexGroup, - EuiFlexItem, - EuiHideFor, - EuiPage, - EuiPageBody, - EuiPageContent, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; -import { IUiSettingsClient, MountPoint } from 'kibana/public'; -import classNames from 'classnames'; -import { HitsCounter } from './hits_counter'; -import { TimechartHeader } from './timechart_header'; -import { getServices, IndexPattern } from '../../kibana_services'; -import { DiscoverUninitialized, DiscoverHistogram } from '../angular/directives'; -import { DiscoverNoResults } from './no_results'; -import { LoadingSpinner } from './loading_spinner/loading_spinner'; -import { DocTableLegacy, DocTableLegacyProps } from '../angular/doc_table/create_doc_table_react'; -import { SkipBottomButton } from './skip_bottom_button'; -import { - search, - ISearchSource, - TimeRange, - Query, - IndexPatternAttributes, - DataPublicPluginStart, - AggConfigs, - FilterManager, -} from '../../../../data/public'; -import { Chart } from '../angular/helpers/point_series'; -import { AppState } from '../angular/discover_state'; -import { SavedSearch } from '../../saved_searches'; -import { SavedObject } from '../../../../../core/types'; -import { TopNavMenuData } from '../../../../navigation/public'; -import { - DiscoverSidebarResponsive, - DiscoverSidebarResponsiveProps, -} from './sidebar/discover_sidebar_responsive'; -import { DocViewFilterFn, ElasticSearchHit } from '../doc_views/doc_views_types'; - -export interface DiscoverProps { - /** - * Function to fetch documents from Elasticsearch - */ - fetch: () => void; - /** - * Counter how often data was fetched (used for testing) - */ - fetchCounter: number; - /** - * Error in case of a failing document fetch - */ - fetchError?: Error; - /** - * Statistics by fields calculated using the fetched documents - */ - fieldCounts: Record; - /** - * Histogram aggregation data - */ - histogramData?: Chart; - /** - * Number of documents found by recent fetch - */ - hits: number; - /** - * Current IndexPattern - */ - indexPattern: IndexPattern; - /** - * Value needed for legacy "infinite" loading functionality - * Determins how much records are rendered using the legacy table - * Increased when scrolling down - */ - minimumVisibleRows: number; - /** - * Function to add a column to state - */ - onAddColumn: (column: string) => void; - /** - * Function to add a filter to state - */ - onAddFilter: DocViewFilterFn; - /** - * Function to change the used time interval of the date histogram - */ - onChangeInterval: (interval: string) => void; - /** - * Function to move a given column to a given index, used in legacy table - */ - onMoveColumn: (columns: string, newIdx: number) => void; - /** - * Function to remove a given column from state - */ - onRemoveColumn: (column: string) => void; - /** - * Function to replace columns in state - */ - onSetColumns: (columns: string[]) => void; - /** - * Function to scroll down the legacy table to the bottom - */ - onSkipBottomButtonClick: () => void; - /** - * Function to change sorting of the table, triggers a fetch - */ - onSort: (sort: string[][]) => void; - opts: { - /** - * Date histogram aggregation config - */ - chartAggConfigs?: AggConfigs; - /** - * Client of uiSettings - */ - config: IUiSettingsClient; - /** - * Data plugin - */ - data: DataPublicPluginStart; - /** - * Data plugin filter manager - */ - filterManager: FilterManager; - /** - * List of available index patterns - */ - indexPatternList: Array>; - /** - * The number of documents that can be displayed in the table/grid - */ - sampleSize: number; - /** - * Current instance of SavedSearch - */ - savedSearch: SavedSearch; - /** - * Function to set the header menu - */ - setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; - /** - * Timefield of the currently used index pattern - */ - timefield: string; - /** - * Function to set the current state - */ - setAppState: (state: Partial) => void; - }; - /** - * Function to reset the current query - */ - resetQuery: () => void; - /** - * Current state of the actual query, one of 'uninitialized', 'loading' ,'ready', 'none' - */ - resultState: string; - /** - * Array of document of the recent successful search request - */ - rows: ElasticSearchHit[]; - /** - * Instance of SearchSource, the high level search API - */ - searchSource: ISearchSource; - /** - * Function to change the current index pattern - */ - setIndexPattern: (id: string) => void; - /** - * Determines whether the user should be able to use the save query feature - */ - showSaveQuery: boolean; - /** - * Current app state of URL - */ - state: AppState; - /** - * Function to update the time filter - */ - timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; - /** - * Currently selected time range - */ - timeRange?: { from: string; to: string }; - /** - * Menu data of top navigation (New, save ...) - */ - topNavMenu: TopNavMenuData[]; - /** - * Function to update the actual query - */ - updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; - /** - * Function to update the actual savedQuery id - */ - updateSavedQueryId: (savedQueryId?: string) => void; - useNewFieldsApi?: boolean; -} - -export const DocTableLegacyMemoized = React.memo((props: DocTableLegacyProps) => ( - -)); -export const SidebarMemoized = React.memo((props: DiscoverSidebarResponsiveProps) => ( - -)); - -export function DiscoverLegacy({ - fetch, - fetchCounter, - fieldCounts, - fetchError, - histogramData, - hits, - indexPattern, - minimumVisibleRows, - onAddColumn, - onAddFilter, - onChangeInterval, - onMoveColumn, - onRemoveColumn, - onSkipBottomButtonClick, - onSort, - opts, - resetQuery, - resultState, - rows, - searchSource, - setIndexPattern, - showSaveQuery, - state, - timefilterUpdateHandler, - timeRange, - topNavMenu, - updateQuery, - updateSavedQueryId, - useNewFieldsApi, -}: DiscoverProps) { - const scrollableDesktop = useRef(null); - const collapseIcon = useRef(null); - const isMobile = () => { - // collapse icon isn't displayed in mobile view, use it to detect which view is displayed - return collapseIcon && !collapseIcon.current; - }; - - const [toggleOn, toggleChart] = useState(true); - const [isSidebarClosed, setIsSidebarClosed] = useState(false); - const services = getServices(); - const { TopNavMenu } = services.navigation.ui; - const { trackUiMetric } = services; - const { savedSearch, indexPatternList } = opts; - const bucketAggConfig = opts.chartAggConfigs?.aggs[1]; - const bucketInterval = - bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig) - ? bucketAggConfig.buckets?.getInterval() - : undefined; - const contentCentered = resultState === 'uninitialized'; - - const getDisplayColumns = () => { - if (!state.columns) { - return []; - } - const columns = [...state.columns]; - if (useNewFieldsApi) { - return columns.filter((column) => column !== '_source'); - } - return columns.length === 0 ? ['_source'] : columns; - }; - - return ( - - - - -

- {savedSearch.title} -

- - - - - - - setIsSidebarClosed(!isSidebarClosed)} - data-test-subj="collapseSideBarButton" - aria-controls="discover-sidebar" - aria-expanded={isSidebarClosed ? 'false' : 'true'} - aria-label="Toggle sidebar" - buttonRef={collapseIcon} - /> - - - - - {resultState === 'none' && ( - - )} - {resultState === 'uninitialized' && } - {resultState === 'loading' && } - {resultState === 'ready' && ( - - - - - 0 ? hits : 0} - showResetButton={!!(savedSearch && savedSearch.id)} - onResetQuery={resetQuery} - /> - - {toggleOn && ( - - - - )} - {opts.timefield && ( - - { - toggleChart(!toggleOn); - }} - data-test-subj="discoverChartToggle" - > - {toggleOn - ? i18n.translate('discover.hideChart', { - defaultMessage: 'Hide chart', - }) - : i18n.translate('discover.showChart', { - defaultMessage: 'Show chart', - })} - - - )} - - - - {toggleOn && opts.timefield && ( - -
- {opts.chartAggConfigs && rows.length !== 0 && histogramData && ( -
- -
- )} -
-
- )} - - -
-

- -

- {rows && rows.length && ( -
- - {rows.length === opts.sampleSize ? ( -
- - - { - if (scrollableDesktop && scrollableDesktop.current) { - scrollableDesktop.current.focus(); - } - // Only the desktop one needs to target a specific container - if (!isMobile() && scrollableDesktop.current) { - scrollableDesktop.current.scrollTo(0, 0); - } else if (window) { - window.scrollTo(0, 0); - } - }} - > - - -
- ) : ( - - ​ - - )} -
- )} -
-
-
- )} -
-
-
-
-
-
- ); -} diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss index 5bae3d64a6b69..95a50b54b5364 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss @@ -19,6 +19,14 @@ padding-top: $euiSizeS; } + .kbnDocViewer__multifield_row:hover td { + background-color: transparent; + } + + .kbnDocViewer__multifield_title { + font-family: $euiFontFamily; + } + .dscFieldName { color: $euiColorDarkShade; } diff --git a/src/plugins/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index 1ffc0e5af95ac..ac074cf229c77 100644 --- a/src/plugins/discover/public/application/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -167,30 +167,6 @@ describe('DocViewTable at Discover', () => { }); }); -describe('DocViewTable at Discover Doc', () => { - const hit = { - _index: 'logstash-2014.09.09', - _score: 1, - _type: 'doc', - _id: 'id123', - _source: { - extension: 'html', - not_mapped: 'yes', - }, - }; - // here no action buttons are rendered - const props = { - hit, - indexPattern, - }; - const component = mount(); - const foundLength = findTestSubject(component, 'addInclusiveFilterButton').length; - - it(`renders no action buttons`, () => { - expect(foundLength).toBe(0); - }); -}); - describe('DocViewTable at Discover Context', () => { // here no toggleColumnButtons are rendered const hit = { @@ -243,3 +219,172 @@ describe('DocViewTable at Discover Context', () => { expect(component.html() !== html).toBeTruthy(); }); }); + +describe('DocViewTable at Discover Doc', () => { + const hit = { + _index: 'logstash-2014.09.09', + _score: 1, + _type: 'doc', + _id: 'id123', + _source: { + extension: 'html', + not_mapped: 'yes', + }, + }; + // here no action buttons are rendered + const props = { + hit, + indexPattern, + }; + const component = mount(); + const foundLength = findTestSubject(component, 'addInclusiveFilterButton').length; + + it(`renders no action buttons`, () => { + expect(foundLength).toBe(0); + }); +}); + +describe('DocViewTable at Discover Doc with Fields API', () => { + const indexPatterneCommerce = ({ + fields: { + getAll: () => [ + { + name: '_index', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'category', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'category.keyword', + displayName: 'category.keyword', + type: 'string', + scripted: false, + filterable: true, + spec: { + subType: { + multi: { + parent: 'category', + }, + }, + }, + }, + { + name: 'customer_first_name', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'customer_first_name.keyword', + displayName: 'customer_first_name.keyword', + type: 'string', + scripted: false, + filterable: false, + spec: { + subType: { + multi: { + parent: 'customer_first_name', + }, + }, + }, + }, + { + name: 'customer_first_name.nickname', + displayName: 'customer_first_name.nickname', + type: 'string', + scripted: false, + filterable: false, + spec: { + subType: { + multi: { + parent: 'customer_first_name', + }, + }, + }, + }, + ], + }, + metaFields: ['_index', '_type', '_score', '_id'], + flattenHit: jest.fn((hit) => { + const result = {} as Record; + Object.keys(hit).forEach((key) => { + if (key !== 'fields') { + result[key] = hit[key]; + } else { + Object.keys(hit.fields).forEach((field) => { + result[field] = hit.fields[field]; + }); + } + }); + return result; + }), + formatHit: jest.fn((hit) => { + const result = {} as Record; + Object.keys(hit).forEach((key) => { + if (key !== 'fields') { + result[key] = hit[key]; + } else { + Object.keys(hit.fields).forEach((field) => { + result[field] = hit.fields[field]; + }); + } + }); + return result; + }), + } as unknown) as IndexPattern; + + indexPatterneCommerce.fields.getByName = (name: string) => { + return indexPatterneCommerce.fields.getAll().find((field) => field.name === name); + }; + + const fieldsHit = { + _index: 'logstash-2014.09.09', + _type: 'doc', + _id: 'id123', + _score: null, + fields: { + category: "Women's Clothing", + 'category.keyword': "Women's Clothing", + customer_first_name: 'Betty', + 'customer_first_name.keyword': 'Betty', + 'customer_first_name.nickname': 'Betsy', + }, + }; + const props = { + hit: fieldsHit, + columns: ['Document'], + indexPattern: indexPatterneCommerce, + filter: jest.fn(), + onAddColumn: jest.fn(), + onRemoveColumn: jest.fn(), + }; + // @ts-ignore + const component = mount(); + it('renders multifield rows', () => { + const categoryMultifieldRow = findTestSubject( + component, + 'tableDocViewRow-multifieldsTitle-category' + ); + expect(categoryMultifieldRow.length).toBe(1); + const categoryKeywordRow = findTestSubject(component, 'tableDocViewRow-category.keyword'); + expect(categoryKeywordRow.length).toBe(1); + + const customerNameMultiFieldRow = findTestSubject( + component, + 'tableDocViewRow-multifieldsTitle-customer_first_name' + ); + expect(customerNameMultiFieldRow.length).toBe(1); + expect(findTestSubject(component, 'tableDocViewRow-customer_first_name.keyword').length).toBe( + 1 + ); + expect(findTestSubject(component, 'tableDocViewRow-customer_first_name.nickname').length).toBe( + 1 + ); + }); +}); diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index 090df8baba409..7528828a06f97 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -5,8 +5,8 @@ * compliance with, at your election, the Elastic License or the Server Side * Public License, v 1. */ - -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; import { DocViewTableRow } from './table_row'; import { trimAngularSpan } from './table_helper'; import { isNestedFieldParent } from '../../helpers/nested_fields'; @@ -23,6 +23,36 @@ export function DocViewTable({ onRemoveColumn, }: DocViewRenderProps) { const [fieldRowOpen, setFieldRowOpen] = useState({} as Record); + const [multiFields, setMultiFields] = useState({} as Record); + const [fieldsWithParents, setFieldsWithParents] = useState([] as string[]); + + useEffect(() => { + if (!indexPattern) { + return; + } + const mapping = indexPattern.fields.getByName; + const flattened = indexPattern.flattenHit(hit); + const map: Record = {}; + const arr: string[] = []; + + Object.keys(flattened).forEach((key) => { + const field = mapping(key); + + if (field && field.spec?.subType?.multi?.parent) { + const parent = field.spec.subType.multi.parent; + if (!map[parent]) { + map[parent] = [] as string[]; + } + const value = map[parent]; + value.push(key); + map[parent] = value; + arr.push(key); + } + }); + setMultiFields(map); + setFieldsWithParents(arr); + }, [indexPattern, hit]); + if (!indexPattern) { return null; } @@ -34,11 +64,13 @@ export function DocViewTable({ fieldRowOpen[field] = !fieldRowOpen[field]; setFieldRowOpen({ ...fieldRowOpen }); } - return ( {Object.keys(flattened) + .filter((field) => { + return !fieldsWithParents.includes(field); + }) .sort((fieldA, fieldB) => { const mappingA = mapping(fieldA); const mappingB = mapping(fieldB); @@ -67,23 +99,60 @@ export function DocViewTable({ const fieldType = isNestedFieldParent(field, indexPattern) ? 'nested' : indexPattern.fields.getByName(field)?.type; - return ( - toggleValueCollapse(field)} - onToggleColumn={toggleColumn} - value={value} - valueRaw={valueRaw} - /> + + toggleValueCollapse(field)} + onToggleColumn={toggleColumn} + value={value} + valueRaw={valueRaw} + /> + {multiFields[field] ? ( + + + + + ) : null} + {multiFields[field] + ? multiFields[field].map((multiField) => { + return ( + toggleValueCollapse(field)} + onToggleColumn={toggleColumn} + value={value} + valueRaw={valueRaw} + /> + ); + }) + : null} + ); })} diff --git a/src/plugins/discover/public/application/components/table/table_row.tsx b/src/plugins/discover/public/application/components/table/table_row.tsx index 61f9fa50091c1..2b91a757e9bc2 100644 --- a/src/plugins/discover/public/application/components/table/table_row.tsx +++ b/src/plugins/discover/public/application/components/table/table_row.tsx @@ -18,7 +18,7 @@ import { DocViewTableRowIconUnderscore } from './table_row_icon_underscore'; import { FieldName } from '../field_name/field_name'; export interface Props { - field: string; + field?: string; fieldMapping?: FieldMapping; fieldType: string; displayUnderscoreWarning: boolean; @@ -51,25 +51,30 @@ export function DocViewTableRow({ kbnDocViewer__value: true, 'truncate-by-height': isCollapsible && isCollapsed, }); - + const key = field ? field : fieldMapping?.displayName; return ( - +
  + + {i18n.translate('discover.fieldChooser.discoverField.multiFields', { + defaultMessage: 'Multi fields', + })} + +
- + {field ? ( + + ) : ( +   + )} {isCollapsible && ( )} {displayUnderscoreWarning && } + {field ? null :
{key}: 
}
void; + /** + * Counter how often data was fetched (used for testing) + */ + fetchCounter: number; + /** + * Error in case of a failing document fetch + */ + fetchError?: Error; + /** + * Statistics by fields calculated using the fetched documents + */ + fieldCounts: Record; + /** + * Histogram aggregation data + */ + histogramData?: Chart; + /** + * Number of documents found by recent fetch + */ + hits: number; + /** + * Current IndexPattern + */ + indexPattern: IndexPattern; + /** + * Value needed for legacy "infinite" loading functionality + * Determins how much records are rendered using the legacy table + * Increased when scrolling down + */ + minimumVisibleRows: number; + /** + * Function to add a column to state + */ + onAddColumn: (column: string) => void; + /** + * Function to add a filter to state + */ + onAddFilter: DocViewFilterFn; + /** + * Function to change the used time interval of the date histogram + */ + onChangeInterval: (interval: string) => void; + /** + * Function to move a given column to a given index, used in legacy table + */ + onMoveColumn: (columns: string, newIdx: number) => void; + /** + * Function to remove a given column from state + */ + onRemoveColumn: (column: string) => void; + /** + * Function to replace columns in state + */ + onSetColumns: (columns: string[]) => void; + /** + * Function to scroll down the legacy table to the bottom + */ + onSkipBottomButtonClick: () => void; + /** + * Function to change sorting of the table, triggers a fetch + */ + onSort: (sort: string[][]) => void; + opts: { + /** + * Date histogram aggregation config + */ + chartAggConfigs?: AggConfigs; + /** + * Client of uiSettings + */ + config: IUiSettingsClient; + /** + * Data plugin + */ + data: DataPublicPluginStart; + /** + * Data plugin filter manager + */ + filterManager: FilterManager; + /** + * List of available index patterns + */ + indexPatternList: Array>; + /** + * The number of documents that can be displayed in the table/grid + */ + sampleSize: number; + /** + * Current instance of SavedSearch + */ + savedSearch: SavedSearch; + /** + * Function to set the header menu + */ + setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + /** + * Timefield of the currently used index pattern + */ + timefield: string; + /** + * Function to set the current state + */ + setAppState: (state: Partial) => void; + }; + /** + * Function to reset the current query + */ + resetQuery: () => void; + /** + * Current state of the actual query, one of 'uninitialized', 'loading' ,'ready', 'none' + */ + resultState: string; + /** + * Array of document of the recent successful search request + */ + rows: ElasticSearchHit[]; + /** + * Instance of SearchSource, the high level search API + */ + searchSource: ISearchSource; + /** + * Function to change the current index pattern + */ + setIndexPattern: (id: string) => void; + /** + * Current app state of URL + */ + state: AppState; + /** + * Function to update the time filter + */ + timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; + /** + * Currently selected time range + */ + timeRange?: { from: string; to: string }; + /** + * Menu data of top navigation (New, save ...) + */ + topNavMenu: TopNavMenuData[]; + /** + * Function to update the actual query + */ + updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; + /** + * Function to update the actual savedQuery id + */ + updateSavedQueryId: (savedQueryId?: string) => void; +} diff --git a/src/plugins/discover/public/application/helpers/columns.test.ts b/src/plugins/discover/public/application/helpers/columns.test.ts new file mode 100644 index 0000000000000..d455fd1f42c6d --- /dev/null +++ b/src/plugins/discover/public/application/helpers/columns.test.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 + * and the Server Side Public License, v 1; 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 { getDisplayedColumns } from './columns'; +import { indexPatternWithTimefieldMock } from '../../__mocks__/index_pattern_with_timefield'; +import { indexPatternMock } from '../../__mocks__/index_pattern'; + +describe('getDisplayedColumns', () => { + test('returns default columns given a index pattern without timefield', async () => { + const result = getDisplayedColumns([], indexPatternMock); + expect(result).toMatchInlineSnapshot(` + Array [ + "_source", + ] + `); + }); + test('returns default columns given a index pattern with timefield', async () => { + const result = getDisplayedColumns([], indexPatternWithTimefieldMock); + expect(result).toMatchInlineSnapshot(` + Array [ + "_source", + ] + `); + }); + test('returns default columns when just timefield is in state', async () => { + const result = getDisplayedColumns(['timestamp'], indexPatternWithTimefieldMock); + expect(result).toMatchInlineSnapshot(` + Array [ + "_source", + ] + `); + }); + test('returns columns given by argument, no fallback ', async () => { + const result = getDisplayedColumns(['test'], indexPatternWithTimefieldMock); + expect(result).toMatchInlineSnapshot(` + Array [ + "test", + ] + `); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/columns.ts b/src/plugins/discover/public/application/helpers/columns.ts new file mode 100644 index 0000000000000..d2d47c932b7bd --- /dev/null +++ b/src/plugins/discover/public/application/helpers/columns.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 { IndexPattern } from '../../../../data/common'; + +/** + * Function to provide fallback when + * 1) no columns are given + * 2) Just one column is given, which is the configured timefields + */ +export function getDisplayedColumns(stateColumns: string[] = [], indexPattern: IndexPattern) { + return stateColumns && + stateColumns.length > 0 && + // check if all columns where removed except the configured timeField (this can't be removed) + !(stateColumns.length === 1 && stateColumns[0] === indexPattern.timeFieldName) + ? stateColumns + : ['_source']; +} diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts index 1394ceab1dd18..ea16b81615e42 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.test.ts @@ -6,7 +6,8 @@ * Public License, v 1. */ -import { getSharingData } from './get_sharing_data'; +import { Capabilities } from 'kibana/public'; +import { getSharingData, showPublicUrlSwitch } from './get_sharing_data'; import { IUiSettingsClient } from 'kibana/public'; import { createSearchSourceMock } from '../../../../data/common/search/search_source/mocks'; import { indexPatternMock } from '../../__mocks__/index_pattern'; @@ -68,3 +69,44 @@ describe('getSharingData', () => { `); }); }); + +describe('showPublicUrlSwitch', () => { + test('returns false if "discover" app is not available', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns false if "discover" app is not accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + discover: { + show: false, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns true if "discover" app is not available an accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + discover: { + show: true, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(true); + }); +}); diff --git a/src/plugins/discover/public/application/helpers/get_sharing_data.ts b/src/plugins/discover/public/application/helpers/get_sharing_data.ts index 62478f1d2830f..1d780a5573e2a 100644 --- a/src/plugins/discover/public/application/helpers/get_sharing_data.ts +++ b/src/plugins/discover/public/application/helpers/get_sharing_data.ts @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { IUiSettingsClient } from 'kibana/public'; +import { Capabilities, IUiSettingsClient } from 'kibana/public'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; import { getSortForSearchSource } from '../angular/doc_table'; import { SearchSource } from '../../../../data/common'; @@ -76,3 +76,19 @@ export async function getSharingData( indexPatternId: index.id, }; } + +export interface DiscoverCapabilities { + createShortUrl?: boolean; + save?: boolean; + saveQuery?: boolean; + show?: boolean; + storeSearchSession?: boolean; +} + +export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { + if (!anonymousUserCapabilities.discover) return false; + + const discover = (anonymousUserCapabilities.discover as unknown) as DiscoverCapabilities; + + return !!discover.show; +}; diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index b27426a6c0621..4eda742d967f4 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -42,7 +42,6 @@ import { } from '../../kibana_legacy/public'; import { DiscoverStartPlugins } from './plugin'; import { getScopedHistory } from './kibana_services'; -import { createDiscoverLegacyDirective } from './application/components/create_discover_legacy_directive'; import { createDiscoverDirective } from './application/components/create_discover_directive'; /** @@ -124,7 +123,6 @@ export function initializeInnerAngularModule( .config(watchMultiDecorator) .run(registerListenEventListener) .directive('renderComplete', createRenderCompleteDirective) - .directive('discoverLegacy', createDiscoverLegacyDirective) .directive('discover', createDiscoverDirective); } diff --git a/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md index ec27895eed666..36c7d7119ffe5 100644 --- a/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md +++ b/src/plugins/kibana_utils/docs/state_sync/storages/kbn_url_storage.md @@ -96,11 +96,11 @@ setTimeout(() => { }, 0); ``` -For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` provides these advanced apis: +For cases, where granular control over URL updates is needed, `kbnUrlStateStorage` exposes `kbnUrlStateStorage.kbnUrlControls` that exposes these advanced apis: -- `kbnUrlStateStorage.flush({replace: boolean})` - allows to synchronously apply any pending updates. - `replace` option allows to use `history.replace()` instead of `history.push()`. Returned boolean indicates if any update happened -- `kbnUrlStateStorage.cancel()` - cancels any pending updates +- `kbnUrlStateStorage.kbnUrlControls.flush({replace: boolean})` - allows to synchronously apply any pending updates. + `replace` option allows using `history.replace()` instead of `history.push()`. +- `kbnUrlStateStorage.kbnUrlControls.cancel()` - cancels any pending updates. ### Sharing one `kbnUrlStateStorage` instance diff --git a/src/plugins/kibana_utils/public/history/history_observable.test.ts b/src/plugins/kibana_utils/public/history/history_observable.test.ts new file mode 100644 index 0000000000000..818c0d7739283 --- /dev/null +++ b/src/plugins/kibana_utils/public/history/history_observable.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { + createHistoryObservable, + createQueryParamObservable, + createQueryParamsObservable, +} from './history_observable'; +import { createMemoryHistory, History } from 'history'; +import { ParsedQuery } from 'query-string'; + +let history: History; + +beforeEach(() => { + history = createMemoryHistory(); +}); + +test('createHistoryObservable', () => { + const obs$ = createHistoryObservable(history); + const emits: string[] = []; + obs$.subscribe(({ location }) => { + emits.push(location.pathname + location.search); + }); + + history.push('/test'); + history.push('/'); + + expect(emits.length).toEqual(2); + expect(emits).toMatchInlineSnapshot(` + Array [ + "/test", + "/", + ] + `); +}); + +test('createQueryParamsObservable', () => { + const obs$ = createQueryParamsObservable(history); + const emits: ParsedQuery[] = []; + obs$.subscribe((params) => { + emits.push(params); + }); + + history.push('/test'); + history.push('/test?foo=bar'); + history.push('/?foo=bar'); + history.push('/test?foo=bar&foo1=bar1'); + + expect(emits.length).toEqual(2); + expect(emits).toMatchInlineSnapshot(` + Array [ + Object { + "foo": "bar", + }, + Object { + "foo": "bar", + "foo1": "bar1", + }, + ] + `); +}); + +test('createQueryParamObservable', () => { + const obs$ = createQueryParamObservable(history, 'foo'); + const emits: unknown[] = []; + obs$.subscribe((param) => { + emits.push(param); + }); + + history.push('/test'); + history.push('/test?foo=bar'); + history.push('/?foo=bar'); + history.push('/test?foo=baaaar&foo1=bar1'); + history.push('/test?foo1=bar1'); + + expect(emits.length).toEqual(3); + expect(emits).toMatchInlineSnapshot(` + Array [ + "bar", + "baaaar", + null, + ] + `); +}); diff --git a/src/plugins/kibana_utils/public/history/history_observable.ts b/src/plugins/kibana_utils/public/history/history_observable.ts new file mode 100644 index 0000000000000..f02a5e340b1a0 --- /dev/null +++ b/src/plugins/kibana_utils/public/history/history_observable.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Action, History, Location } from 'history'; +import { Observable } from 'rxjs'; +import { ParsedQuery } from 'query-string'; +import deepEqual from 'fast-deep-equal'; +import { map } from 'rxjs/operators'; +import { getQueryParams } from './get_query_params'; +import { distinctUntilChangedWithInitialValue } from '../../common'; + +/** + * Convert history.listen into an observable + * @param history - {@link History} instance + */ +export function createHistoryObservable( + history: History +): Observable<{ location: Location; action: Action }> { + return new Observable((observer) => { + const unlisten = history.listen((location, action) => observer.next({ location, action })); + return () => { + unlisten(); + }; + }); +} + +/** + * Create an observable that emits every time any of query params change. + * Uses deepEqual check. + * @param history - {@link History} instance + */ +export function createQueryParamsObservable(history: History): Observable { + return createHistoryObservable(history).pipe( + map(({ location }) => ({ ...getQueryParams(location) })), + distinctUntilChangedWithInitialValue({ ...getQueryParams(history.location) }, deepEqual) + ); +} + +/** + * Create an observable that emits every time _paramKey_ changes + * @param history - {@link History} instance + * @param paramKey - query param key to observe + */ +export function createQueryParamObservable( + history: History, + paramKey: string +): Observable { + return createQueryParamsObservable(history).pipe( + map((params) => (params[paramKey] ?? null) as Param | null), + distinctUntilChangedWithInitialValue( + (getQueryParams(history.location)[paramKey] ?? null) as Param | null, + deepEqual + ) + ); +} diff --git a/src/plugins/kibana_utils/public/history/index.ts b/src/plugins/kibana_utils/public/history/index.ts index 4b1b610d560e2..b2ac9ed6c739e 100644 --- a/src/plugins/kibana_utils/public/history/index.ts +++ b/src/plugins/kibana_utils/public/history/index.ts @@ -9,3 +9,4 @@ export { removeQueryParam } from './remove_query_param'; export { redirectWhenMissing } from './redirect_when_missing'; export { getQueryParams } from './get_query_params'; +export * from './history_observable'; diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index fa9cf5a52371d..29936da0117c1 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -68,7 +68,14 @@ export { StopSyncStateFnType, } from './state_sync'; export { Configurable, CollectConfigProps } from './ui'; -export { removeQueryParam, redirectWhenMissing, getQueryParams } from './history'; +export { + removeQueryParam, + redirectWhenMissing, + getQueryParams, + createQueryParamsObservable, + createHistoryObservable, + createQueryParamObservable, +} from './history'; export { applyDiff } from './state_management/utils/diff_object'; export { createStartServicesGetter, StartServicesGetter } from './core/create_start_service_getter'; diff --git a/src/plugins/kibana_utils/public/state_sync/public.api.md b/src/plugins/kibana_utils/public/state_sync/public.api.md index a4dfea82cdb59..5524563c034a8 100644 --- a/src/plugins/kibana_utils/public/state_sync/public.api.md +++ b/src/plugins/kibana_utils/public/state_sync/public.api.md @@ -22,14 +22,12 @@ export const createSessionStorageStateStorage: (storage?: Storage) => ISessionSt // @public export interface IKbnUrlStateStorage extends IStateStorage { - cancel: () => void; // (undocumented) change$: (key: string) => Observable; - flush: (opts?: { - replace?: boolean; - }) => boolean; // (undocumented) get: (key: string) => State | null; + // Warning: (ae-forgotten-export) The symbol "IKbnUrlControls" needs to be exported by the entry point index.d.ts + kbnUrlControls: IKbnUrlControls; // (undocumented) set: (key: string, state: State, opts?: { replace: boolean; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts index c7f04bc9cdbe3..890de8f6ed6a1 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -255,7 +255,7 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); - urlSyncStrategy.flush(); + urlSyncStrategy.kbnUrlControls.flush(); expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( @@ -290,7 +290,7 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); - urlSyncStrategy.cancel(); + urlSyncStrategy.kbnUrlControls.cancel(); expect(history.length).toBe(startHistoryLength); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts index fbd3c3f933791..037c6f9fc666d 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.test.ts @@ -39,11 +39,11 @@ describe('KbnUrlStateStorage', () => { const key = '_s'; urlStateStorage.set(key, state); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); - expect(urlStateStorage.flush()).toBe(true); + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_s=(ok:1,test:test)"`); expect(urlStateStorage.get(key)).toEqual(state); - expect(urlStateStorage.flush()).toBe(false); // nothing to flush, not update + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update }); it('should cancel url updates', async () => { @@ -51,7 +51,7 @@ describe('KbnUrlStateStorage', () => { const key = '_s'; const pr = urlStateStorage.set(key, state); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); - urlStateStorage.cancel(); + urlStateStorage.kbnUrlControls.cancel(); await pr; expect(getCurrentUrl()).toMatchInlineSnapshot(`"/"`); expect(urlStateStorage.get(key)).toEqual(null); @@ -215,11 +215,11 @@ describe('KbnUrlStateStorage', () => { const key = '_s'; urlStateStorage.set(key, state); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`); - expect(urlStateStorage.flush()).toBe(true); + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(true); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/#?_s=(ok:1,test:test)"`); expect(urlStateStorage.get(key)).toEqual(state); - expect(urlStateStorage.flush()).toBe(false); // nothing to flush, not update + expect(!!urlStateStorage.kbnUrlControls.flush()).toBe(false); // nothing to flush, not update }); it('should cancel url updates', async () => { @@ -227,7 +227,7 @@ describe('KbnUrlStateStorage', () => { const key = '_s'; const pr = urlStateStorage.set(key, state); expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`); - urlStateStorage.cancel(); + urlStateStorage.kbnUrlControls.cancel(); await pr; expect(getCurrentUrl()).toMatchInlineSnapshot(`"/kibana/app/"`); expect(urlStateStorage.get(key)).toEqual(null); diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts index 700420447bf4f..0935ecd20111f 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync_state_storage/create_kbn_url_state_storage.ts @@ -13,6 +13,7 @@ import { IStateStorage } from './types'; import { createKbnUrlControls, getStateFromKbnUrl, + IKbnUrlControls, setStateToKbnUrl, } from '../../state_management/url'; @@ -39,16 +40,9 @@ export interface IKbnUrlStateStorage extends IStateStorage { change$: (key: string) => Observable; /** - * cancels any pending url updates + * Lower level wrapper around history library that handles batching multiple URL updates into one history change */ - cancel: () => void; - - /** - * Synchronously runs any pending url updates, returned boolean indicates if change occurred. - * @param opts: {replace? boolean} - allows to specify if push or replace should be used for flushing update - * @returns boolean - indicates if there was an update to flush - */ - flush: (opts?: { replace?: boolean }) => boolean; + kbnUrlControls: IKbnUrlControls; } /** @@ -114,11 +108,6 @@ export const createKbnUrlStateStorage = ( }), share() ), - flush: ({ replace = false }: { replace?: boolean } = {}) => { - return !!url.flush(replace); - }, - cancel() { - url.cancel(); - }, + kbnUrlControls: url, }; }; diff --git a/src/plugins/presentation_util/README.md b/src/plugins/presentation_util/README.md deleted file mode 100755 index 047423a0a9036..0000000000000 --- a/src/plugins/presentation_util/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# presentationUtil - -Utilities and components used by the presentation-related plugins \ No newline at end of file diff --git a/src/plugins/presentation_util/README.mdx b/src/plugins/presentation_util/README.mdx new file mode 100755 index 0000000000000..35b80e3634534 --- /dev/null +++ b/src/plugins/presentation_util/README.mdx @@ -0,0 +1,211 @@ +--- +id: presentationUtilPlugin +slug: /kibana-dev-docs/presentationPlugin +title: Presentation Utility Plugin +summary: Introduction to the Presentation Utility Plugin. +date: 2020-01-12 +tags: ['kibana', 'presentation', 'services'] +related: [] +--- + +## Introduction + +The Presentation Utility Plugin is a set of common, shared components and toolkits for solutions within the Presentation space, (e.g. Dashboards, Canvas). + +## Plugin Services Toolkit + +While Kibana provides a `useKibana` hook for use in a plugin, the number of services it provides is very large. This presents a set of difficulties: + +- a direct dependency upon the Kibana environment; +- a requirement to mock the full Kibana environment when testing or using Storybook; +- a lack of knowledge as to what services are being consumed at any given time. + +To mitigate these difficulties, the Presentation Team creates services within the plugin that then consume Kibana-provided (or other) services. This is a toolkit for creating simple services within a plugin. + +### Overview + +- A `PluginServiceFactory` is a function that will return a set of functions-- which comprise a `Service`-- given a set of parameters. +- A `PluginServiceProvider` is an object that use a factory to start, stop or provide a `Service`. +- A `PluginServiceRegistry` is a collection of providers for a given environment, (e.g. Kibana, Jest, Storybook, stub, etc). +- A `PluginServices` object uses a registry to provide services throughout the plugin. + +### Defining Services + +To start, a plugin should define a set of services it wants to provide to itself or other plugins. + + +```ts +export interface PresentationDashboardsService { + findDashboards: ( + query: string, + fields: string[] + ) => Promise>>; + findDashboardsByTitle: (title: string) => Promise>>; +} + +export interface PresentationFooService { + getFoo: () => string; + setFoo: (bar: string) => void; +} + +export interface PresentationUtilServices { + dashboards: PresentationDashboardsService; + foo: PresentationFooService; +} +``` + + +This definition will be used in the toolkit to ensure services are complete and as expected. + +### Plugin Services + +The `PluginServices` class hosts a registry of service providers from which a plugin can access its services. It uses the service definition as a generic. + +```ts +export const pluginServices = new PluginServices(); +``` + +This can be placed in the `index.ts` file of a `services` directory within your plugin. + +Once created, it simply requires a `PluginServiceRegistry` to be started and set. + +### Service Provider Registry + +Each environment in which components are used requires a `PluginServiceRegistry` to specify how the providers are started. For example, simple stubs of services require no parameters to start, (so the `StartParameters` generic remains unspecified) + + +```ts +export const providers: PluginServiceProviders = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + foo: new PluginServiceProvider(fooServiceFactory), +}; + +export const serviceRegistry = new PluginServiceRegistry(providers); +``` + + +By contrast, a registry that uses Kibana can provide `KibanaPluginServiceParams` to determine how to start its providers, so the `StartParameters` generic is given: + + +```ts +export const providers: PluginServiceProviders< + PresentationUtilServices, + KibanaPluginServiceParams +> = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + foo: new PluginServiceProvider(fooServiceFactory), +}; + +export const serviceRegistry = new PluginServiceRegistry< + PresentationUtilServices, + KibanaPluginServiceParams +>(providers); +``` + + +### Service Provider + +A `PluginServiceProvider` is a container for a Service Factory that is responsible for starting, stopping and providing a service implementation. A Service Provider doesn't change, rather the factory and the relevant `StartParameters` change. + +### Service Factories + +A Service Factory is nothing more than a function that uses `StartParameters` to return a set of functions that conforms to a portion of the `Services` specification. For each service, a factory is provided for each environment. + +Given a service definition: + +```ts +export interface PresentationFooService { + getFoo: () => string; + setFoo: (bar: string) => void; +} +``` + +a factory for a stubbed version might look like this: + +```ts +type FooServiceFactory = PluginServiceFactory; + +export const fooServiceFactory: FooServiceFactory = () => ({ + getFoo: () => 'bar', + setFoo: (bar) => { console.log(`${bar} set!`)}, +}); +``` + +and a factory for a Kibana version might look like this: + +```ts +export type FooServiceFactory = KibanaPluginServiceFactory< + PresentationFooService, + PresentationUtilPluginStart +>; + +export const fooServiceFactory: FooServiceFactory = ({ + coreStart, + startPlugins, +}) => { + // ...do something with Kibana services... + + return { + getFoo: //... + setFoo: //... + } +} +``` + +### Using Services + +Once your services and providers are defined, and you have at least one set of factories, you can use `PluginServices` to provide the services to your React components: + + +```ts +// plugin.ts +import { pluginServices } from './services'; +import { registry } from './services/kibana'; + + public async start( + coreStart: CoreStart, + startPlugins: StartDeps + ): Promise { + pluginServices.setRegistry(registry.start({ coreStart, startPlugins })); + return {}; + } +``` + + +and wrap your root React component with the `PluginServices` context: + + +```ts +import { pluginServices } from './services'; + +const ContextProvider = pluginServices.getContextProvider(), + +return( + + + {application} + + +) +``` + + +and then, consume your services using provided hooks in a component: + + +```ts +// component.ts + +import { pluginServices } from '../services'; + +export function MyComponent() { + // Retrieve all context hooks from `PluginServices`, destructuring for the one we're using + const { foo } = pluginServices.getHooks(); + + // Use the `useContext` hook to access the API. + const { getFoo } = foo.useService(); + + // ... +} +``` + diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx new file mode 100644 index 0000000000000..cb9991e216019 --- /dev/null +++ b/src/plugins/presentation_util/public/components/dashboard_picker.stories.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; +import { action } from '@storybook/addon-actions'; + +import { DashboardPicker } from './dashboard_picker'; + +export default { + component: DashboardPicker, + title: 'Dashboard Picker', + argTypes: { + isDisabled: { + control: 'boolean', + defaultValue: false, + }, + }, +}; + +export const Example = ({ isDisabled }: { isDisabled: boolean }) => ( + +); diff --git a/src/plugins/presentation_util/public/components/dashboard_picker.tsx b/src/plugins/presentation_util/public/components/dashboard_picker.tsx index 8aaf9be6ef5c6..b156ef4ae764c 100644 --- a/src/plugins/presentation_util/public/components/dashboard_picker.tsx +++ b/src/plugins/presentation_util/public/components/dashboard_picker.tsx @@ -6,18 +6,16 @@ * Public License, v 1. */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiComboBox } from '@elastic/eui'; -import { SavedObjectsClientContract } from '../../../../core/public'; -import { DashboardSavedObject } from '../../../../plugins/dashboard/public'; +import { pluginServices } from '../services'; export interface DashboardPickerProps { onChange: (dashboard: { name: string; id: string } | null) => void; isDisabled: boolean; - savedObjectsClient: SavedObjectsClientContract; } interface DashboardOption { @@ -26,34 +24,43 @@ interface DashboardOption { } export function DashboardPicker(props: DashboardPickerProps) { - const [dashboards, setDashboards] = useState([]); + const [dashboardOptions, setDashboardOptions] = useState([]); const [isLoadingDashboards, setIsLoadingDashboards] = useState(true); const [selectedDashboard, setSelectedDashboard] = useState(null); + const [query, setQuery] = useState(''); - const { savedObjectsClient, isDisabled, onChange } = props; + const { isDisabled, onChange } = props; + const { dashboards } = pluginServices.getHooks(); + const { findDashboardsByTitle } = dashboards.useService(); - const fetchDashboards = useCallback( - async (query) => { + useEffect(() => { + // We don't want to manipulate the React state if the component has been unmounted + // while we wait for the saved objects to return. + let cleanedUp = false; + + const fetchDashboards = async () => { setIsLoadingDashboards(true); - setDashboards([]); - - const { savedObjects } = await savedObjectsClient.find({ - type: 'dashboard', - search: query ? `${query}*` : '', - searchFields: ['title'], - }); - if (savedObjects) { - setDashboards(savedObjects.map((d) => ({ value: d.id, label: d.attributes.title }))); + setDashboardOptions([]); + + const objects = await findDashboardsByTitle(query ? `${query}*` : ''); + + if (cleanedUp) { + return; + } + + if (objects) { + setDashboardOptions(objects.map((d) => ({ value: d.id, label: d.attributes.title }))); } + setIsLoadingDashboards(false); - }, - [savedObjectsClient] - ); + }; - // Initial dashboard load - useEffect(() => { - fetchDashboards(''); - }, [fetchDashboards]); + fetchDashboards(); + + return () => { + cleanedUp = true; + }; + }, [findDashboardsByTitle, query]); return ( { if (e.length) { @@ -72,7 +79,7 @@ export function DashboardPicker(props: DashboardPickerProps) { onChange(null); } }} - onSearchChange={fetchDashboards} + onSearchChange={setQuery} isDisabled={isDisabled} isLoading={isLoadingDashboards} compressed={true} diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx index 58a70c9db7dd5..7c7b12f52ab5f 100644 --- a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard.tsx @@ -9,18 +9,6 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiRadio, - EuiIconTip, - EuiPanel, - EuiSpacer, -} from '@elastic/eui'; -import { SavedObjectsClientContract } from '../../../../core/public'; import { OnSaveProps, @@ -28,9 +16,9 @@ import { SavedObjectSaveModal, } from '../../../../plugins/saved_objects/public'; -import { DashboardPicker } from './dashboard_picker'; - import './saved_object_save_modal_dashboard.scss'; +import { pluginServices } from '../services'; +import { SaveModalDashboardSelector } from './saved_object_save_modal_dashboard_selector'; interface SaveModalDocumentInfo { id?: string; @@ -38,116 +26,50 @@ interface SaveModalDocumentInfo { description?: string; } -export interface DashboardSaveModalProps { +export interface SaveModalDashboardProps { documentInfo: SaveModalDocumentInfo; objectType: string; onClose: () => void; onSave: (props: OnSaveProps & { dashboardId: string | null }) => void; - savedObjectsClient: SavedObjectsClientContract; tagOptions?: React.ReactNode | ((state: SaveModalState) => React.ReactNode); } -export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) { - const { documentInfo, savedObjectsClient, tagOptions } = props; - const initialCopyOnSave = !Boolean(documentInfo.id); +export function SavedObjectSaveModalDashboard(props: SaveModalDashboardProps) { + const { documentInfo, tagOptions, objectType, onClose } = props; + const { id: documentId } = documentInfo; + const initialCopyOnSave = !Boolean(documentId); + + const { capabilities } = pluginServices.getHooks(); + const { + canAccessDashboards, + canCreateNewDashboards, + canEditDashboards, + } = capabilities.useService(); + + const disableDashboardOptions = + !canAccessDashboards() || (!canCreateNewDashboards && !canEditDashboards); const [dashboardOption, setDashboardOption] = useState<'new' | 'existing' | null>( - documentInfo.id ? null : 'existing' + documentId || disableDashboardOptions ? null : 'existing' ); const [selectedDashboard, setSelectedDashboard] = useState<{ id: string; name: string } | null>( null ); const [copyOnSave, setCopyOnSave] = useState(initialCopyOnSave); - const renderDashboardSelect = (state: SaveModalState) => { - const isDisabled = Boolean(!state.copyOnSave && documentInfo.id); - - return ( - <> - - - - - - - } - /> - - - } - hasChildLabel={false} - > - -
- setDashboardOption('existing')} - disabled={isDisabled} - /> - -
- { - setSelectedDashboard(dash); - }} - /> -
- - - - setDashboardOption('new')} - disabled={isDisabled} - /> - - - - setDashboardOption(null)} - disabled={isDisabled} - /> -
-
-
- - ); - }; + const rightOptions = !disableDashboardOptions + ? () => ( + { + setSelectedDashboard(dash); + }} + onChange={(option) => { + setDashboardOption(option); + }} + {...{ copyOnSave, documentId, dashboardOption }} + /> + ) + : null; const onCopyOnSaveChange = (newCopyOnSave: boolean) => { setDashboardOption(null); @@ -159,7 +81,7 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) { // Don't save with a dashboard ID if we're // just updating an existing visualization - if (!(!onSaveProps.newCopyOnSave && documentInfo.id)) { + if (!(!onSaveProps.newCopyOnSave && documentId)) { if (dashboardOption === 'existing') { dashboardId = selectedDashboard?.id || null; } else { @@ -171,13 +93,14 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) { }; const saveLibraryLabel = - !copyOnSave && documentInfo.id + !copyOnSave && documentId ? i18n.translate('presentationUtil.saveModalDashboard.saveLabel', { defaultMessage: 'Save', }) : i18n.translate('presentationUtil.saveModalDashboard.saveToLibraryLabel', { defaultMessage: 'Save and add to library', }); + const saveDashboardLabel = i18n.translate( 'presentationUtil.saveModalDashboard.saveAndGoToDashboardLabel', { @@ -192,18 +115,20 @@ export function SavedObjectSaveModalDashboard(props: DashboardSaveModalProps) { return ( ); } diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx new file mode 100644 index 0000000000000..2044ecdd713e1 --- /dev/null +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.stories.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React, { useState } from 'react'; +import { action } from '@storybook/addon-actions'; + +import { StorybookParams } from '../services/storybook'; +import { SaveModalDashboardSelector } from './saved_object_save_modal_dashboard_selector'; + +export default { + component: SaveModalDashboardSelector, + title: 'Save Modal Dashboard Selector', + description: 'A selector for determining where an object will be saved after it is created.', + argTypes: { + hasDocumentId: { + control: 'boolean', + defaultValue: false, + }, + copyOnSave: { + control: 'boolean', + defaultValue: false, + }, + canCreateNewDashboards: { + control: 'boolean', + defaultValue: true, + }, + canEditDashboards: { + control: 'boolean', + defaultValue: true, + }, + }, +}; + +export function Example({ + copyOnSave, + hasDocumentId, +}: { + copyOnSave: boolean; + hasDocumentId: boolean; +} & StorybookParams) { + const [dashboardOption, setDashboardOption] = useState<'new' | 'existing' | null>('existing'); + + return ( + + ); +} diff --git a/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx new file mode 100644 index 0000000000000..b1bf9ed695842 --- /dev/null +++ b/src/plugins/presentation_util/public/components/saved_object_save_modal_dashboard_selector.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiRadio, + EuiIconTip, + EuiPanel, + EuiSpacer, +} from '@elastic/eui'; + +import { pluginServices } from '../services'; +import { DashboardPicker, DashboardPickerProps } from './dashboard_picker'; + +import './saved_object_save_modal_dashboard.scss'; + +export interface SaveModalDashboardSelectorProps { + copyOnSave: boolean; + documentId?: string; + onSelectDashboard: DashboardPickerProps['onChange']; + + dashboardOption: 'new' | 'existing' | null; + onChange: (dashboardOption: 'new' | 'existing' | null) => void; +} + +export function SaveModalDashboardSelector(props: SaveModalDashboardSelectorProps) { + const { documentId, onSelectDashboard, dashboardOption, onChange, copyOnSave } = props; + const { capabilities } = pluginServices.getHooks(); + const { canCreateNewDashboards, canEditDashboards } = capabilities.useService(); + + const isDisabled = !copyOnSave && !!documentId; + + return ( + <> + + + + + + + } + /> + + + } + hasChildLabel={false} + > + +
+ {canEditDashboards() && ( + <> + {' '} + onChange('existing')} + disabled={isDisabled} + /> +
+ +
+ + + )} + {canCreateNewDashboards() && ( + <> + {' '} + onChange('new')} + disabled={isDisabled} + /> + + + )} + onChange(null)} + disabled={isDisabled} + /> +
+
+
+ + ); +} diff --git a/src/plugins/presentation_util/public/index.ts b/src/plugins/presentation_util/public/index.ts index baf40a1ea0ae4..586ddd1320641 100644 --- a/src/plugins/presentation_util/public/index.ts +++ b/src/plugins/presentation_util/public/index.ts @@ -10,9 +10,11 @@ import { PresentationUtilPlugin } from './plugin'; export { SavedObjectSaveModalDashboard, - DashboardSaveModalProps, + SaveModalDashboardProps, } from './components/saved_object_save_modal_dashboard'; +export { DashboardPicker } from './components/dashboard_picker'; + export function plugin() { return new PresentationUtilPlugin(); } diff --git a/src/plugins/presentation_util/public/plugin.ts b/src/plugins/presentation_util/public/plugin.ts index cbc1d0eb04e27..5d3618b034656 100644 --- a/src/plugins/presentation_util/public/plugin.ts +++ b/src/plugins/presentation_util/public/plugin.ts @@ -7,16 +7,39 @@ */ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; -import { PresentationUtilPluginSetup, PresentationUtilPluginStart } from './types'; +import { pluginServices } from './services'; +import { registry } from './services/kibana'; +import { + PresentationUtilPluginSetup, + PresentationUtilPluginStart, + PresentationUtilPluginSetupDeps, + PresentationUtilPluginStartDeps, +} from './types'; export class PresentationUtilPlugin - implements Plugin { - public setup(core: CoreSetup): PresentationUtilPluginSetup { + implements + Plugin< + PresentationUtilPluginSetup, + PresentationUtilPluginStart, + PresentationUtilPluginSetupDeps, + PresentationUtilPluginStartDeps + > { + public setup( + _coreSetup: CoreSetup, + _setupPlugins: PresentationUtilPluginSetupDeps + ): PresentationUtilPluginSetup { return {}; } - public start(core: CoreStart): PresentationUtilPluginStart { - return {}; + public async start( + coreStart: CoreStart, + startPlugins: PresentationUtilPluginStartDeps + ): Promise { + pluginServices.setRegistry(registry.start({ coreStart, startPlugins })); + + return { + ContextProvider: pluginServices.getContextProvider(), + }; } public stop() {} diff --git a/src/plugins/presentation_util/public/services/create/factory.ts b/src/plugins/presentation_util/public/services/create/factory.ts new file mode 100644 index 0000000000000..01b143e612461 --- /dev/null +++ b/src/plugins/presentation_util/public/services/create/factory.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { BehaviorSubject } from 'rxjs'; +import { CoreStart, AppUpdater } from 'src/core/public'; + +/** + * A factory function for creating a service. + * + * The `Service` generic determines the shape of the API being produced. + * The `StartParameters` generic determines what parameters are expected to + * create the service. + */ +export type PluginServiceFactory = (params: Parameters) => Service; + +/** + * Parameters necessary to create a Kibana-based service, (e.g. during Plugin + * startup or setup). + * + * The `Start` generic refers to the specific Plugin `TPluginsStart`. + */ +export interface KibanaPluginServiceParams { + coreStart: CoreStart; + startPlugins: Start; + appUpdater?: BehaviorSubject; +} + +/** + * A factory function for creating a Kibana-based service. + * + * The `Service` generic determines the shape of the API being produced. + * The `Setup` generic refers to the specific Plugin `TPluginsSetup`. + * The `Start` generic refers to the specific Plugin `TPluginsStart`. + */ +export type KibanaPluginServiceFactory = ( + params: KibanaPluginServiceParams +) => Service; diff --git a/src/plugins/presentation_util/public/services/create/index.ts b/src/plugins/presentation_util/public/services/create/index.ts new file mode 100644 index 0000000000000..59f1f9fd7a43b --- /dev/null +++ b/src/plugins/presentation_util/public/services/create/index.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { mapValues } from 'lodash'; + +import { PluginServiceRegistry } from './registry'; + +export { PluginServiceRegistry } from './registry'; +export { PluginServiceProvider, PluginServiceProviders } from './provider'; +export { + PluginServiceFactory, + KibanaPluginServiceFactory, + KibanaPluginServiceParams, +} from './factory'; + +/** + * `PluginServices` is a top-level class for specifying and accessing services within a plugin. + * + * A `PluginServices` object can be provided with a `PluginServiceRegistry` at any time, which will + * then be used to provide services to any component that accesses it. + * + * The `Services` generic determines the shape of all service APIs being produced. + */ +export class PluginServices { + private registry: PluginServiceRegistry | null = null; + + /** + * Supply a `PluginServiceRegistry` for the class to use to provide services and context. + * + * @param registry A setup and started `PluginServiceRegistry`. + */ + setRegistry(registry: PluginServiceRegistry | null) { + if (registry && !registry.isStarted()) { + throw new Error('Registry has not been started.'); + } + + this.registry = registry; + } + + /** + * Returns true if a registry has been provided, false otherwise. + */ + hasRegistry() { + return !!this.registry; + } + + /** + * Private getter that will enforce proper setup throughout the class. + */ + private getRegistry() { + if (!this.registry) { + throw new Error('No registry has been provided.'); + } + + return this.registry; + } + + /** + * Return the React Context Provider that will supply services. + */ + getContextProvider() { + return this.getRegistry().getContextProvider(); + } + + /** + * Return a map of React Hooks that can be used in React components. + */ + getHooks(): { [K in keyof Services]: { useService: () => Services[K] } } { + const registry = this.getRegistry(); + const providers = registry.getServiceProviders(); + + // @ts-expect-error Need to fix this; the type isn't fully understood when inferred. + return mapValues(providers, (provider) => ({ + useService: provider.getUseServiceHook(), + })); + } +} diff --git a/src/plugins/presentation_util/public/services/create/provider.tsx b/src/plugins/presentation_util/public/services/create/provider.tsx new file mode 100644 index 0000000000000..981ff1527f981 --- /dev/null +++ b/src/plugins/presentation_util/public/services/create/provider.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React, { createContext, useContext } from 'react'; +import { PluginServiceFactory } from './factory'; + +/** + * A collection of `PluginServiceProvider` objects, keyed by the `Services` API generic. + * + * The `Services` generic determines the shape of all service APIs being produced. + * The `StartParameters` generic determines what parameters are expected to + * start the service. + */ +export type PluginServiceProviders = { + [K in keyof Services]: PluginServiceProvider; +}; + +/** + * An object which uses a given factory to start, stop or provide a service. + * + * The `Service` generic determines the shape of the API being produced. + * The `StartParameters` generic determines what parameters are expected to + * start the service. + */ +export class PluginServiceProvider { + private factory: PluginServiceFactory; + private context = createContext(null); + private pluginService: Service | null = null; + public readonly Provider: React.FC = ({ children }) => { + return {children}; + }; + + constructor(factory: PluginServiceFactory) { + this.factory = factory; + this.context.displayName = 'PluginServiceContext'; + } + + /** + * Private getter that will enforce proper setup throughout the class. + */ + private getService() { + if (!this.pluginService) { + throw new Error('Service not started'); + } + return this.pluginService; + } + + /** + * Start the service. + * + * @param params Parameters used to start the service. + */ + start(params: StartParameters) { + this.pluginService = this.factory(params); + } + + /** + * Returns a function for providing a Context hook for the service. + */ + getUseServiceHook() { + return () => { + const service = useContext(this.context); + + if (!service) { + throw new Error('Provider is not set up correctly'); + } + + return service; + }; + } + + /** + * Stop the service. + */ + stop() { + this.pluginService = null; + } +} diff --git a/src/plugins/presentation_util/public/services/create/registry.tsx b/src/plugins/presentation_util/public/services/create/registry.tsx new file mode 100644 index 0000000000000..5165380780fa9 --- /dev/null +++ b/src/plugins/presentation_util/public/services/create/registry.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; +import { values } from 'lodash'; +import { PluginServiceProvider, PluginServiceProviders } from './provider'; + +/** + * A `PluginServiceRegistry` maintains a set of service providers which can be collectively + * started, stopped or retreived. + * + * The `Services` generic determines the shape of all service APIs being produced. + * The `StartParameters` generic determines what parameters are expected to + * start the service. + */ +export class PluginServiceRegistry { + private providers: PluginServiceProviders; + private _isStarted = false; + + constructor(providers: PluginServiceProviders) { + this.providers = providers; + } + + /** + * Returns true if the registry has been started, false otherwise. + */ + isStarted() { + return this._isStarted; + } + + /** + * Returns a map of `PluginServiceProvider` objects. + */ + getServiceProviders() { + if (!this._isStarted) { + throw new Error('Registry not started'); + } + return this.providers; + } + + /** + * Returns a React Context Provider for use in consuming applications. + */ + getContextProvider() { + // Collect and combine Context.Provider elements from each Service Provider into a single + // Functional Component. + const provider: React.FC = ({ children }) => ( + <> + {values>(this.getServiceProviders()).reduceRight( + (acc, serviceProvider) => { + return {acc}; + }, + children + )} + + ); + + return provider; + } + + /** + * Start the registry. + * + * @param params Parameters used to start the registry. + */ + start(params: StartParameters) { + values>(this.providers).map((serviceProvider) => + serviceProvider.start(params) + ); + this._isStarted = true; + return this; + } + + /** + * Stop the registry. + */ + stop() { + values>(this.providers).map((serviceProvider) => + serviceProvider.stop() + ); + this._isStarted = false; + return this; + } +} diff --git a/src/plugins/presentation_util/public/services/index.ts b/src/plugins/presentation_util/public/services/index.ts new file mode 100644 index 0000000000000..732cc19e14763 --- /dev/null +++ b/src/plugins/presentation_util/public/services/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { SimpleSavedObject } from 'src/core/public'; +import { DashboardSavedObject } from 'src/plugins/dashboard/public'; +import { PluginServices } from './create'; +export interface PresentationDashboardsService { + findDashboards: ( + query: string, + fields: string[] + ) => Promise>>; + findDashboardsByTitle: (title: string) => Promise>>; +} + +export interface PresentationCapabilitiesService { + canAccessDashboards: () => boolean; + canCreateNewDashboards: () => boolean; + canEditDashboards: () => boolean; +} + +export interface PresentationUtilServices { + dashboards: PresentationDashboardsService; + capabilities: PresentationCapabilitiesService; +} + +export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/kibana/capabilities.ts b/src/plugins/presentation_util/public/services/kibana/capabilities.ts new file mode 100644 index 0000000000000..f36b277979358 --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/capabilities.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PresentationUtilPluginStartDeps } from '../../types'; +import { KibanaPluginServiceFactory } from '../create'; +import { PresentationCapabilitiesService } from '..'; + +export type CapabilitiesServiceFactory = KibanaPluginServiceFactory< + PresentationCapabilitiesService, + PresentationUtilPluginStartDeps +>; + +export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ coreStart }) => { + const { dashboard } = coreStart.application.capabilities; + + return { + canAccessDashboards: () => Boolean(dashboard.show), + canCreateNewDashboards: () => Boolean(dashboard.createNew), + canEditDashboards: () => !Boolean(dashboard.hideWriteControls), + }; +}; diff --git a/src/plugins/presentation_util/public/services/kibana/dashboards.ts b/src/plugins/presentation_util/public/services/kibana/dashboards.ts new file mode 100644 index 0000000000000..acfe4bd33e26a --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/dashboards.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { DashboardSavedObject } from 'src/plugins/dashboard/public'; + +import { PresentationUtilPluginStartDeps } from '../../types'; +import { KibanaPluginServiceFactory } from '../create'; +import { PresentationDashboardsService } from '..'; + +export type DashboardsServiceFactory = KibanaPluginServiceFactory< + PresentationDashboardsService, + PresentationUtilPluginStartDeps +>; + +export const dashboardsServiceFactory: DashboardsServiceFactory = ({ coreStart }) => { + const findDashboards = async (query: string = '', fields: string[] = []) => { + const { find } = coreStart.savedObjects.client; + + const { savedObjects } = await find({ + type: 'dashboard', + search: `${query}*`, + searchFields: fields, + }); + + return savedObjects; + }; + + const findDashboardsByTitle = async (title: string = '') => findDashboards(title, ['title']); + + return { + findDashboards, + findDashboardsByTitle, + }; +}; diff --git a/src/plugins/presentation_util/public/services/kibana/index.ts b/src/plugins/presentation_util/public/services/kibana/index.ts new file mode 100644 index 0000000000000..a129b0d94479f --- /dev/null +++ b/src/plugins/presentation_util/public/services/kibana/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { dashboardsServiceFactory } from './dashboards'; +import { capabilitiesServiceFactory } from './capabilities'; +import { + PluginServiceProviders, + KibanaPluginServiceParams, + PluginServiceProvider, + PluginServiceRegistry, +} from '../create'; +import { PresentationUtilPluginStartDeps } from '../../types'; +import { PresentationUtilServices } from '..'; + +export { dashboardsServiceFactory } from './dashboards'; +export { capabilitiesServiceFactory } from './capabilities'; + +export const providers: PluginServiceProviders< + PresentationUtilServices, + KibanaPluginServiceParams +> = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + capabilities: new PluginServiceProvider(capabilitiesServiceFactory), +}; + +export const registry = new PluginServiceRegistry< + PresentationUtilServices, + KibanaPluginServiceParams +>(providers); diff --git a/src/plugins/presentation_util/public/services/storybook/capabilities.ts b/src/plugins/presentation_util/public/services/storybook/capabilities.ts new file mode 100644 index 0000000000000..5048fe50cc025 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/capabilities.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PluginServiceFactory } from '../create'; +import { StorybookParams } from '.'; +import { PresentationCapabilitiesService } from '..'; + +type CapabilitiesServiceFactory = PluginServiceFactory< + PresentationCapabilitiesService, + StorybookParams +>; + +export const capabilitiesServiceFactory: CapabilitiesServiceFactory = ({ + canAccessDashboards, + canCreateNewDashboards, + canEditDashboards, +}) => { + const check = (value: boolean = true) => value; + return { + canAccessDashboards: () => check(canAccessDashboards), + canCreateNewDashboards: () => check(canCreateNewDashboards), + canEditDashboards: () => check(canEditDashboards), + }; +}; diff --git a/src/plugins/presentation_util/public/services/storybook/index.ts b/src/plugins/presentation_util/public/services/storybook/index.ts new file mode 100644 index 0000000000000..536cad3a9d131 --- /dev/null +++ b/src/plugins/presentation_util/public/services/storybook/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PluginServices, PluginServiceProviders, PluginServiceProvider } from '../create'; +import { dashboardsServiceFactory } from '../stub/dashboards'; +import { capabilitiesServiceFactory } from './capabilities'; +import { PresentationUtilServices } from '..'; + +export { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; +export { PresentationUtilServices } from '..'; + +export interface StorybookParams { + canAccessDashboards?: boolean; + canCreateNewDashboards?: boolean; + canEditDashboards?: boolean; +} + +export const providers: PluginServiceProviders = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + capabilities: new PluginServiceProvider(capabilitiesServiceFactory), +}; + +export const pluginServices = new PluginServices(); diff --git a/src/plugins/presentation_util/public/services/stub/capabilities.ts b/src/plugins/presentation_util/public/services/stub/capabilities.ts new file mode 100644 index 0000000000000..33c091022421c --- /dev/null +++ b/src/plugins/presentation_util/public/services/stub/capabilities.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PluginServiceFactory } from '../create'; +import { PresentationCapabilitiesService } from '..'; + +type CapabilitiesServiceFactory = PluginServiceFactory; + +export const capabilitiesServiceFactory: CapabilitiesServiceFactory = () => ({ + canAccessDashboards: () => true, + canCreateNewDashboards: () => true, + canEditDashboards: () => true, +}); diff --git a/src/plugins/presentation_util/public/services/stub/dashboards.ts b/src/plugins/presentation_util/public/services/stub/dashboards.ts new file mode 100644 index 0000000000000..862fa4f952c1e --- /dev/null +++ b/src/plugins/presentation_util/public/services/stub/dashboards.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { PluginServiceFactory } from '../create'; +import { PresentationDashboardsService } from '..'; + +// TODO (clint): Create set of dashboards to stub and return. + +type DashboardsServiceFactory = PluginServiceFactory; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const dashboardsServiceFactory: DashboardsServiceFactory = () => ({ + findDashboards: async (query: string = '', _fields: string[] = []) => { + if (!query) { + return []; + } + + await sleep(2000); + return []; + }, + findDashboardsByTitle: async (title: string) => { + if (!title) { + return []; + } + + await sleep(2000); + return []; + }, +}); diff --git a/src/plugins/presentation_util/public/services/stub/index.ts b/src/plugins/presentation_util/public/services/stub/index.ts new file mode 100644 index 0000000000000..a2bde357fd4c0 --- /dev/null +++ b/src/plugins/presentation_util/public/services/stub/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { dashboardsServiceFactory } from './dashboards'; +import { capabilitiesServiceFactory } from './capabilities'; +import { PluginServiceProviders, PluginServiceProvider, PluginServiceRegistry } from '../create'; +import { PresentationUtilServices } from '..'; + +export { dashboardsServiceFactory } from './dashboards'; +export { capabilitiesServiceFactory } from './capabilities'; + +export const providers: PluginServiceProviders = { + dashboards: new PluginServiceProvider(dashboardsServiceFactory), + capabilities: new PluginServiceProvider(capabilitiesServiceFactory), +}; + +export const registry = new PluginServiceRegistry(providers); diff --git a/src/plugins/presentation_util/public/types.ts b/src/plugins/presentation_util/public/types.ts index ae5646bd9bbae..7371ebc6f736e 100644 --- a/src/plugins/presentation_util/public/types.ts +++ b/src/plugins/presentation_util/public/types.ts @@ -8,5 +8,12 @@ // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PresentationUtilPluginSetup {} + +export interface PresentationUtilPluginStart { + ContextProvider: React.FC; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PresentationUtilPluginSetupDeps {} // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PresentationUtilPluginStart {} +export interface PresentationUtilPluginStartDeps {} diff --git a/src/plugins/presentation_util/storybook/decorator.tsx b/src/plugins/presentation_util/storybook/decorator.tsx new file mode 100644 index 0000000000000..5f56c70a2f849 --- /dev/null +++ b/src/plugins/presentation_util/storybook/decorator.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; + +import { DecoratorFn } from '@storybook/react'; +import { I18nProvider } from '@kbn/i18n/react'; +import { pluginServices } from '../public/services'; +import { PresentationUtilServices } from '../public/services'; +import { providers, StorybookParams } from '../public/services/storybook'; +import { PluginServiceRegistry } from '../public/services/create'; + +export const servicesContextDecorator: DecoratorFn = (story: Function, storybook) => { + const registry = new PluginServiceRegistry(providers); + pluginServices.setRegistry(registry.start(storybook.args)); + const ContextProvider = pluginServices.getContextProvider(); + + return ( + + {story()} + + ); +}; diff --git a/src/plugins/presentation_util/storybook/main.ts b/src/plugins/presentation_util/storybook/main.ts new file mode 100644 index 0000000000000..d12b98f38a03f --- /dev/null +++ b/src/plugins/presentation_util/storybook/main.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Configuration } from 'webpack'; +import { defaultConfig } from '@kbn/storybook'; +import webpackConfig from '@kbn/storybook/target/webpack.config'; + +module.exports = { + ...defaultConfig, + addons: ['@storybook/addon-essentials'], + webpackFinal: (config: Configuration) => { + return webpackConfig({ config }); + }, +}; diff --git a/src/plugins/presentation_util/storybook/manager.ts b/src/plugins/presentation_util/storybook/manager.ts new file mode 100644 index 0000000000000..e9b6a11242036 --- /dev/null +++ b/src/plugins/presentation_util/storybook/manager.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID } from '@storybook/addon-actions'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: 'Kibana Presentation Utility Storybook', + brandUrl: 'https://github.com/elastic/kibana/tree/master/src/plugins/presentation_util', + }), + showPanel: true.valueOf, + selectedPanel: PANEL_ID, +}); diff --git a/src/plugins/presentation_util/storybook/preview.tsx b/src/plugins/presentation_util/storybook/preview.tsx new file mode 100644 index 0000000000000..dfa8ad3be04e7 --- /dev/null +++ b/src/plugins/presentation_util/storybook/preview.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import React from 'react'; +import { addDecorator } from '@storybook/react'; +import { Title, Subtitle, Description, Primary, Stories } from '@storybook/addon-docs/blocks'; + +import { servicesContextDecorator } from './decorator'; + +addDecorator(servicesContextDecorator); + +export const parameters = { + docs: { + page: () => ( + <> + + <Subtitle /> + <Description /> + <Primary /> + <Stories /> + </> + ), + }, +}; diff --git a/src/plugins/presentation_util/tsconfig.json b/src/plugins/presentation_util/tsconfig.json index 1e3756f45e953..a9657db288848 100644 --- a/src/plugins/presentation_util/tsconfig.json +++ b/src/plugins/presentation_util/tsconfig.json @@ -7,7 +7,7 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*"], + "include": ["common/**/*", "public/**/*", "storybook/**/*", "../../../typings/**/*"], "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../dashboard/tsconfig.json" }, diff --git a/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx b/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx index 6702255ee2e2c..f87169d4b828a 100644 --- a/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx +++ b/src/plugins/saved_objects/public/save_modal/show_saved_object_save_modal.tsx @@ -31,7 +31,8 @@ interface MinimalSaveModalProps { export function showSaveModal( saveModal: React.ReactElement<MinimalSaveModalProps>, - I18nContext: I18nStart['Context'] + I18nContext: I18nStart['Context'], + Wrapper?: React.FC ) { const container = document.createElement('div'); const closeModal = () => { @@ -55,5 +56,13 @@ export function showSaveModal( onClose: closeModal, }); - ReactDOM.render(<I18nContext>{element}</I18nContext>, container); + const wrappedElement = Wrapper ? ( + <I18nContext> + <Wrapper>{element}</Wrapper> + </I18nContext> + ) : ( + <I18nContext>{element}</I18nContext> + ); + + ReactDOM.render(wrappedElement, container); } diff --git a/src/plugins/security_oss/public/plugin.mock.ts b/src/plugins/security_oss/public/plugin.mock.ts index 53d96b9c7a303..8fe8d56ea6576 100644 --- a/src/plugins/security_oss/public/plugin.mock.ts +++ b/src/plugins/security_oss/public/plugin.mock.ts @@ -7,6 +7,7 @@ */ import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { InsecureClusterServiceStart } from './insecure_cluster_service'; import { mockInsecureClusterService } from './insecure_cluster_service/insecure_cluster_service.mock'; import { SecurityOssPluginSetup, SecurityOssPluginStart } from './plugin'; @@ -18,7 +19,11 @@ export const mockSecurityOssPlugin = { }, createStart: () => { return { - insecureCluster: mockInsecureClusterService.createStart(), + insecureCluster: mockInsecureClusterService.createStart() as jest.Mocked<InsecureClusterServiceStart>, + anonymousAccess: { + getAccessURLParameters: jest.fn().mockResolvedValue(null), + getCapabilities: jest.fn().mockResolvedValue({}), + }, } as DeeplyMockedKeys<SecurityOssPluginStart>; }, }; diff --git a/src/plugins/share/kibana.json b/src/plugins/share/kibana.json index 7760ea321992d..8b1d28b1606d4 100644 --- a/src/plugins/share/kibana.json +++ b/src/plugins/share/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": true, "ui": true, - "requiredBundles": ["kibanaUtils"] + "requiredBundles": ["kibanaUtils"], + "optionalPlugins": ["securityOss"] } diff --git a/src/plugins/share/public/components/__snapshots__/url_panel_content.test.tsx.snap b/src/plugins/share/public/components/__snapshots__/url_panel_content.test.tsx.snap index 9a7191519131c..e883b550fde04 100644 --- a/src/plugins/share/public/components/__snapshots__/url_panel_content.test.tsx.snap +++ b/src/plugins/share/public/components/__snapshots__/url_panel_content.test.tsx.snap @@ -115,49 +115,68 @@ exports[`share url panel content render 1`] = ` /> </EuiFormRow> <EuiFormRow - data-test-subj="createShortUrl" describedByIds={Array []} display="row" fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} + label={ + <FormattedMessage + defaultMessage="URL" + id="share.urlPanel.urlGroupTitle" + values={Object {}} + /> + } labelType="label" > - <EuiFlexGroup - gutterSize="none" - responsive={false} + <EuiSpacer + size="s" + /> + <EuiFormRow + data-test-subj="createShortUrl" + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + labelType="label" > - <EuiFlexItem - grow={false} + <EuiFlexGroup + gutterSize="none" + responsive={false} > - <EuiSwitch - checked={false} - data-test-subj="useShortUrl" - label={ - <FormattedMessage - defaultMessage="Short URL" - id="share.urlPanel.shortUrlLabel" - values={Object {}} - /> - } - onChange={[Function]} - /> - </EuiFlexItem> - <EuiFlexItem - grow={false} - > - <EuiIconTip - content={ - <FormattedMessage - defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." - id="share.urlPanel.shortUrlHelpText" - values={Object {}} - /> - } - position="bottom" - /> - </EuiFlexItem> - </EuiFlexGroup> + <EuiFlexItem + grow={false} + > + <EuiSwitch + checked={false} + data-test-subj="useShortUrl" + label={ + <FormattedMessage + defaultMessage="Short URL" + id="share.urlPanel.shortUrlLabel" + values={Object {}} + /> + } + onChange={[Function]} + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <EuiIconTip + content={ + <FormattedMessage + defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." + id="share.urlPanel.shortUrlHelpText" + values={Object {}} + /> + } + position="bottom" + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> </EuiFormRow> <EuiSpacer size="m" @@ -277,49 +296,68 @@ exports[`share url panel content should enable saved object export option when o /> </EuiFormRow> <EuiFormRow - data-test-subj="createShortUrl" describedByIds={Array []} display="row" fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} + label={ + <FormattedMessage + defaultMessage="URL" + id="share.urlPanel.urlGroupTitle" + values={Object {}} + /> + } labelType="label" > - <EuiFlexGroup - gutterSize="none" - responsive={false} + <EuiSpacer + size="s" + /> + <EuiFormRow + data-test-subj="createShortUrl" + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + labelType="label" > - <EuiFlexItem - grow={false} - > - <EuiSwitch - checked={false} - data-test-subj="useShortUrl" - label={ - <FormattedMessage - defaultMessage="Short URL" - id="share.urlPanel.shortUrlLabel" - values={Object {}} - /> - } - onChange={[Function]} - /> - </EuiFlexItem> - <EuiFlexItem - grow={false} + <EuiFlexGroup + gutterSize="none" + responsive={false} > - <EuiIconTip - content={ - <FormattedMessage - defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." - id="share.urlPanel.shortUrlHelpText" - values={Object {}} - /> - } - position="bottom" - /> - </EuiFlexItem> - </EuiFlexGroup> + <EuiFlexItem + grow={false} + > + <EuiSwitch + checked={false} + data-test-subj="useShortUrl" + label={ + <FormattedMessage + defaultMessage="Short URL" + id="share.urlPanel.shortUrlLabel" + values={Object {}} + /> + } + onChange={[Function]} + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <EuiIconTip + content={ + <FormattedMessage + defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." + id="share.urlPanel.shortUrlHelpText" + values={Object {}} + /> + } + position="bottom" + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> </EuiFormRow> <EuiSpacer size="m" @@ -438,6 +476,25 @@ exports[`share url panel content should hide short url section when allowShortUr } /> </EuiFormRow> + <EuiFormRow + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + label={ + <FormattedMessage + defaultMessage="URL" + id="share.urlPanel.urlGroupTitle" + values={Object {}} + /> + } + labelType="label" + > + <EuiSpacer + size="s" + /> + </EuiFormRow> <EuiSpacer size="m" /> @@ -569,49 +626,68 @@ exports[`should show url param extensions 1`] = ` /> </EuiFormRow> <EuiFormRow - data-test-subj="createShortUrl" describedByIds={Array []} display="row" fullWidth={false} hasChildLabel={true} hasEmptyLabelSpace={false} + label={ + <FormattedMessage + defaultMessage="URL" + id="share.urlPanel.urlGroupTitle" + values={Object {}} + /> + } labelType="label" > - <EuiFlexGroup - gutterSize="none" - responsive={false} + <EuiSpacer + size="s" + /> + <EuiFormRow + data-test-subj="createShortUrl" + describedByIds={Array []} + display="row" + fullWidth={false} + hasChildLabel={true} + hasEmptyLabelSpace={false} + labelType="label" > - <EuiFlexItem - grow={false} + <EuiFlexGroup + gutterSize="none" + responsive={false} > - <EuiSwitch - checked={false} - data-test-subj="useShortUrl" - label={ - <FormattedMessage - defaultMessage="Short URL" - id="share.urlPanel.shortUrlLabel" - values={Object {}} - /> - } - onChange={[Function]} - /> - </EuiFlexItem> - <EuiFlexItem - grow={false} - > - <EuiIconTip - content={ - <FormattedMessage - defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." - id="share.urlPanel.shortUrlHelpText" - values={Object {}} - /> - } - position="bottom" - /> - </EuiFlexItem> - </EuiFlexGroup> + <EuiFlexItem + grow={false} + > + <EuiSwitch + checked={false} + data-test-subj="useShortUrl" + label={ + <FormattedMessage + defaultMessage="Short URL" + id="share.urlPanel.shortUrlLabel" + values={Object {}} + /> + } + onChange={[Function]} + /> + </EuiFlexItem> + <EuiFlexItem + grow={false} + > + <EuiIconTip + content={ + <FormattedMessage + defaultMessage="We recommend sharing shortened snapshot URLs for maximum compatibility. Internet Explorer has URL length restrictions, and some wiki and markup parsers don't do well with the full-length version of the snapshot URL, but the short URL should work great." + id="share.urlPanel.shortUrlHelpText" + values={Object {}} + /> + } + position="bottom" + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> </EuiFormRow> <EuiSpacer size="m" diff --git a/src/plugins/share/public/components/share_context_menu.tsx b/src/plugins/share/public/components/share_context_menu.tsx index 5b21c449b6a9f..ff2b411d63809 100644 --- a/src/plugins/share/public/components/share_context_menu.tsx +++ b/src/plugins/share/public/components/share_context_menu.tsx @@ -13,9 +13,11 @@ import { i18n } from '@kbn/i18n'; import { EuiContextMenu, EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { HttpStart } from 'kibana/public'; +import type { Capabilities } from 'src/core/public'; import { UrlPanelContent } from './url_panel_content'; import { ShareMenuItem, ShareContextMenuPanelItem, UrlParamExtension } from '../types'; +import type { SecurityOssPluginStart } from '../../../security_oss/public'; interface Props { allowEmbed: boolean; @@ -29,6 +31,8 @@ interface Props { basePath: string; post: HttpStart['post']; embedUrlParamExtensions?: UrlParamExtension[]; + anonymousAccess?: SecurityOssPluginStart['anonymousAccess']; + showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; } export class ShareContextMenu extends Component<Props> { @@ -62,6 +66,8 @@ export class ShareContextMenu extends Component<Props> { basePath={this.props.basePath} post={this.props.post} shareableUrl={this.props.shareableUrl} + anonymousAccess={this.props.anonymousAccess} + showPublicUrlSwitch={this.props.showPublicUrlSwitch} /> ), }; @@ -91,6 +97,8 @@ export class ShareContextMenu extends Component<Props> { post={this.props.post} shareableUrl={this.props.shareableUrl} urlParamExtensions={this.props.embedUrlParamExtensions} + anonymousAccess={this.props.anonymousAccess} + showPublicUrlSwitch={this.props.showPublicUrlSwitch} /> ), }; diff --git a/src/plugins/share/public/components/url_panel_content.tsx b/src/plugins/share/public/components/url_panel_content.tsx index 5901d2452e9aa..ca9025f242b78 100644 --- a/src/plugins/share/public/components/url_panel_content.tsx +++ b/src/plugins/share/public/components/url_panel_content.tsx @@ -28,9 +28,11 @@ import { format as formatUrl, parse as parseUrl } from 'url'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { HttpStart } from 'kibana/public'; import { i18n } from '@kbn/i18n'; +import type { Capabilities } from 'src/core/public'; import { shortenUrl } from '../lib/url_shortener'; import { UrlParamExtension } from '../types'; +import type { SecurityOssPluginStart } from '../../../security_oss/public'; interface Props { allowShortUrl: boolean; @@ -41,6 +43,8 @@ interface Props { basePath: string; post: HttpStart['post']; urlParamExtensions?: UrlParamExtension[]; + anonymousAccess?: SecurityOssPluginStart['anonymousAccess']; + showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; } export enum ExportUrlAsType { @@ -57,10 +61,13 @@ interface UrlParams { interface State { exportUrlAs: ExportUrlAsType; useShortUrl: boolean; + usePublicUrl: boolean; isCreatingShortUrl: boolean; url?: string; shortUrlErrorMsg?: string; urlParams?: UrlParams; + anonymousAccessParameters: Record<string, string> | null; + showPublicUrlSwitch: boolean; } export class UrlPanelContent extends Component<Props, State> { @@ -75,8 +82,11 @@ export class UrlPanelContent extends Component<Props, State> { this.state = { exportUrlAs: ExportUrlAsType.EXPORT_URL_AS_SNAPSHOT, useShortUrl: false, + usePublicUrl: false, isCreatingShortUrl: false, url: '', + anonymousAccessParameters: null, + showPublicUrlSwitch: false, }; } @@ -91,6 +101,41 @@ export class UrlPanelContent extends Component<Props, State> { this.setUrl(); window.addEventListener('hashchange', this.resetUrl, false); + + if (this.props.anonymousAccess) { + (async () => { + const anonymousAccessParameters = await this.props.anonymousAccess!.getAccessURLParameters(); + + if (!this.mounted) { + return; + } + + if (!anonymousAccessParameters) { + return; + } + + let showPublicUrlSwitch: boolean = false; + + if (this.props.showPublicUrlSwitch) { + const anonymousUserCapabilities = await this.props.anonymousAccess!.getCapabilities(); + + if (!this.mounted) { + return; + } + + try { + showPublicUrlSwitch = this.props.showPublicUrlSwitch!(anonymousUserCapabilities); + } catch { + showPublicUrlSwitch = false; + } + } + + this.setState({ + anonymousAccessParameters, + showPublicUrlSwitch, + }); + })(); + } } public render() { @@ -99,7 +144,16 @@ export class UrlPanelContent extends Component<Props, State> { <EuiForm className="kbnShareContextMenu__finalPanel" data-test-subj="shareUrlForm"> {this.renderExportAsRadioGroup()} {this.renderUrlParamExtensions()} - {this.renderShortUrlSwitch()} + + <EuiFormRow + label={<FormattedMessage id="share.urlPanel.urlGroupTitle" defaultMessage="URL" />} + > + <> + <EuiSpacer size={'s'} /> + {this.renderShortUrlSwitch()} + {this.renderPublicUrlSwitch()} + </> + </EuiFormRow> <EuiSpacer size="m" /> @@ -150,10 +204,10 @@ export class UrlPanelContent extends Component<Props, State> { }; private updateUrlParams = (url: string) => { - const embedUrl = this.props.isEmbedded ? this.makeUrlEmbeddable(url) : url; - const extendUrl = this.state.urlParams ? this.getUrlParamExtensions(embedUrl) : embedUrl; + url = this.props.isEmbedded ? this.makeUrlEmbeddable(url) : url; + url = this.state.urlParams ? this.getUrlParamExtensions(url) : url; - return extendUrl; + return url; }; private getSavedObjectUrl = () => { @@ -206,6 +260,20 @@ export class UrlPanelContent extends Component<Props, State> { return `${url}${embedParam}`; }; + private addUrlAnonymousAccessParameters = (url: string): string => { + if (!this.state.anonymousAccessParameters || !this.state.usePublicUrl) { + return url; + } + + const parsedUrl = new URL(url); + + for (const [name, value] of Object.entries(this.state.anonymousAccessParameters)) { + parsedUrl.searchParams.set(name, value); + } + + return parsedUrl.toString(); + }; + private getUrlParamExtensions = (url: string): string => { const { urlParams } = this.state; return urlParams @@ -232,7 +300,8 @@ export class UrlPanelContent extends Component<Props, State> { }; private setUrl = () => { - let url; + let url: string | undefined; + if (this.state.exportUrlAs === ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT) { url = this.getSavedObjectUrl(); } else if (this.state.useShortUrl) { @@ -241,6 +310,10 @@ export class UrlPanelContent extends Component<Props, State> { url = this.getSnapshotUrl(); } + if (url) { + url = this.addUrlAnonymousAccessParameters(url); + } + if (this.props.isEmbedded) { url = this.makeIframeTag(url); } @@ -269,6 +342,14 @@ export class UrlPanelContent extends Component<Props, State> { this.createShortUrl(); }; + private handlePublicUrlChange = () => { + this.setState(({ usePublicUrl }) => { + return { + usePublicUrl: !usePublicUrl, + }; + }, this.setUrl); + }; + private createShortUrl = async () => { this.setState({ isCreatingShortUrl: true, @@ -280,33 +361,38 @@ export class UrlPanelContent extends Component<Props, State> { basePath: this.props.basePath, post: this.props.post, }); - if (this.mounted) { - this.shortUrlCache = shortUrl; - this.setState( - { - isCreatingShortUrl: false, - useShortUrl: true, - }, - this.setUrl - ); + + if (!this.mounted) { + return; } + + this.shortUrlCache = shortUrl; + this.setState( + { + isCreatingShortUrl: false, + useShortUrl: true, + }, + this.setUrl + ); } catch (fetchError) { - if (this.mounted) { - this.shortUrlCache = undefined; - this.setState( - { - useShortUrl: false, - isCreatingShortUrl: false, - shortUrlErrorMsg: i18n.translate('share.urlPanel.unableCreateShortUrlErrorMessage', { - defaultMessage: 'Unable to create short URL. Error: {errorMessage}', - values: { - errorMessage: fetchError.message, - }, - }), - }, - this.setUrl - ); + if (!this.mounted) { + return; } + + this.shortUrlCache = undefined; + this.setState( + { + useShortUrl: false, + isCreatingShortUrl: false, + shortUrlErrorMsg: i18n.translate('share.urlPanel.unableCreateShortUrlErrorMessage', { + defaultMessage: 'Unable to create short URL. Error: {errorMessage}', + values: { + errorMessage: fetchError.message, + }, + }), + }, + this.setUrl + ); } }; @@ -421,6 +507,36 @@ export class UrlPanelContent extends Component<Props, State> { ); }; + private renderPublicUrlSwitch = () => { + if (!this.state.anonymousAccessParameters || !this.state.showPublicUrlSwitch) { + return null; + } + + const switchLabel = ( + <FormattedMessage id="share.urlPanel.publicUrlLabel" defaultMessage="Public URL" /> + ); + const switchComponent = ( + <EuiSwitch + label={switchLabel} + checked={this.state.usePublicUrl} + onChange={this.handlePublicUrlChange} + data-test-subj="usePublicUrl" + /> + ); + const tipContent = ( + <FormattedMessage + id="share.urlPanel.publicUrlHelpText" + defaultMessage="Use public URL to share with anyone. It enables one-step anonymous access by removing the login prompt." + /> + ); + + return ( + <EuiFormRow data-test-subj="createPublicUrl"> + {this.renderWithIconTip(switchComponent, tipContent)} + </EuiFormRow> + ); + }; + private renderUrlParamExtensions = (): ReactElement | void => { if (!this.props.urlParamExtensions) { return; diff --git a/src/plugins/share/public/plugin.test.ts b/src/plugins/share/public/plugin.test.ts index 2b85564ee9ef9..5a3b335115e0d 100644 --- a/src/plugins/share/public/plugin.test.ts +++ b/src/plugins/share/public/plugin.test.ts @@ -10,6 +10,7 @@ import { registryMock, managerMock } from './plugin.test.mocks'; import { SharePlugin } from './plugin'; import { CoreStart } from 'kibana/public'; import { coreMock } from '../../../core/public/mocks'; +import { mockSecurityOssPlugin } from '../../security_oss/public/mocks'; describe('SharePlugin', () => { beforeEach(() => { @@ -21,14 +22,20 @@ describe('SharePlugin', () => { describe('setup', () => { test('wires up and returns registry', async () => { const coreSetup = coreMock.createSetup(); - const setup = await new SharePlugin().setup(coreSetup); + const plugins = { + securityOss: mockSecurityOssPlugin.createSetup(), + }; + const setup = await new SharePlugin().setup(coreSetup, plugins); expect(registryMock.setup).toHaveBeenCalledWith(); expect(setup.register).toBeDefined(); }); test('registers redirect app', async () => { const coreSetup = coreMock.createSetup(); - await new SharePlugin().setup(coreSetup); + const plugins = { + securityOss: mockSecurityOssPlugin.createSetup(), + }; + await new SharePlugin().setup(coreSetup, plugins); expect(coreSetup.application.register).toHaveBeenCalledWith( expect.objectContaining({ id: 'short_url_redirect', @@ -40,13 +47,22 @@ describe('SharePlugin', () => { describe('start', () => { test('wires up and returns show function, but not registry', async () => { const coreSetup = coreMock.createSetup(); + const pluginsSetup = { + securityOss: mockSecurityOssPlugin.createSetup(), + }; const service = new SharePlugin(); - await service.setup(coreSetup); - const start = await service.start({} as CoreStart); + await service.setup(coreSetup, pluginsSetup); + const pluginsStart = { + securityOss: mockSecurityOssPlugin.createStart(), + }; + const start = await service.start({} as CoreStart, pluginsStart); expect(registryMock.start).toHaveBeenCalled(); expect(managerMock.start).toHaveBeenCalledWith( expect.anything(), - expect.objectContaining({ getShareMenuItems: expect.any(Function) }) + expect.objectContaining({ + getShareMenuItems: expect.any(Function), + }), + expect.anything() ); expect(start.toggleShareContextMenu).toBeDefined(); }); diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 55baf72cc4520..26fa1c9113f21 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -10,6 +10,7 @@ import './index.scss'; import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { ShareMenuManager, ShareMenuManagerStart } from './services'; +import type { SecurityOssPluginSetup, SecurityOssPluginStart } from '../../security_oss/public'; import { ShareMenuRegistry, ShareMenuRegistrySetup } from './services'; import { createShortUrlRedirectApp } from './services/short_url_redirect_app'; import { @@ -18,12 +19,20 @@ import { UrlGeneratorsStart, } from './url_generators/url_generator_service'; +export interface ShareSetupDependencies { + securityOss?: SecurityOssPluginSetup; +} + +export interface ShareStartDependencies { + securityOss?: SecurityOssPluginStart; +} + export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> { private readonly shareMenuRegistry = new ShareMenuRegistry(); private readonly shareContextMenu = new ShareMenuManager(); private readonly urlGeneratorsService = new UrlGeneratorsService(); - public setup(core: CoreSetup): SharePluginSetup { + public setup(core: CoreSetup, plugins: ShareSetupDependencies): SharePluginSetup { core.application.register(createShortUrlRedirectApp(core, window.location)); return { ...this.shareMenuRegistry.setup(), @@ -31,9 +40,13 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> { }; } - public start(core: CoreStart): SharePluginStart { + public start(core: CoreStart, plugins: ShareStartDependencies): SharePluginStart { return { - ...this.shareContextMenu.start(core, this.shareMenuRegistry.start()), + ...this.shareContextMenu.start( + core, + this.shareMenuRegistry.start(), + plugins.securityOss?.anonymousAccess + ), urlGenerators: this.urlGeneratorsService.start(core), }; } diff --git a/src/plugins/share/public/services/share_menu_manager.tsx b/src/plugins/share/public/services/share_menu_manager.tsx index cc3649d33d876..7284be6a8719c 100644 --- a/src/plugins/share/public/services/share_menu_manager.tsx +++ b/src/plugins/share/public/services/share_menu_manager.tsx @@ -15,13 +15,18 @@ import { CoreStart, HttpStart } from 'kibana/public'; import { ShareContextMenu } from '../components/share_context_menu'; import { ShareMenuItem, ShowShareMenuOptions } from '../types'; import { ShareMenuRegistryStart } from './share_menu_registry'; +import type { SecurityOssPluginStart } from '../../../security_oss/public'; export class ShareMenuManager { private isOpen = false; private container = document.createElement('div'); - start(core: CoreStart, shareRegistry: ShareMenuRegistryStart) { + start( + core: CoreStart, + shareRegistry: ShareMenuRegistryStart, + anonymousAccess?: SecurityOssPluginStart['anonymousAccess'] + ) { return { /** * Collects share menu items from registered providers and mounts the share context menu under @@ -35,6 +40,7 @@ export class ShareMenuManager { menuItems, post: core.http.post, basePath: core.http.basePath.get(), + anonymousAccess, }); }, }; @@ -57,10 +63,13 @@ export class ShareMenuManager { post, basePath, embedUrlParamExtensions, + anonymousAccess, + showPublicUrlSwitch, }: ShowShareMenuOptions & { menuItems: ShareMenuItem[]; post: HttpStart['post']; basePath: string; + anonymousAccess?: SecurityOssPluginStart['anonymousAccess']; }) { if (this.isOpen) { this.onClose(); @@ -92,6 +101,8 @@ export class ShareMenuManager { post={post} basePath={basePath} embedUrlParamExtensions={embedUrlParamExtensions} + anonymousAccess={anonymousAccess} + showPublicUrlSwitch={showPublicUrlSwitch} /> </EuiWrappingPopover> </I18nProvider> diff --git a/src/plugins/share/public/types.ts b/src/plugins/share/public/types.ts index 88bb51389b001..31c9631571d35 100644 --- a/src/plugins/share/public/types.ts +++ b/src/plugins/share/public/types.ts @@ -9,6 +9,7 @@ import { ComponentType } from 'react'; import { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiContextMenuPanelItemDescriptorEntry } from '@elastic/eui/src/components/context_menu/context_menu'; +import type { Capabilities } from 'src/core/public'; /** * @public @@ -35,6 +36,7 @@ export interface ShareContext { sharingData: { [key: string]: unknown }; isDirty: boolean; onClose: () => void; + showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean; } /** diff --git a/src/plugins/share/tsconfig.json b/src/plugins/share/tsconfig.json index a6318af602b4d..985066915f1dd 100644 --- a/src/plugins/share/tsconfig.json +++ b/src/plugins/share/tsconfig.json @@ -10,6 +10,7 @@ "include": ["common/**/*", "public/**/*", "server/**/*"], "references": [ { "path": "../../core/tsconfig.json" }, - { "path": "../../plugins/kibana_utils/tsconfig.json" } + { "path": "../kibana_utils/tsconfig.json" }, + { "path": "../security_oss/tsconfig.json" } ] } diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js index 1dab41566da67..f66c011d1f928 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/math.js @@ -114,7 +114,7 @@ export function MathAgg(props) { values={{ link: ( <EuiLink - href="/elastic/tinymath/blob/master/docs/functions.md" + href="/elastic/kibana/blob/master/packages/kbn-tinymath/docs/functions.md" target="_blank" > <FormattedMessage diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index 5339266a47448..415133f711061 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -26,15 +26,6 @@ export const config: PluginConfigDescriptor<VisTypeTimeseriesConfig> = { schema: configSchema, }; -export { - AbstractSearchStrategy, - ReqFacade, -} from './lib/search_strategies/strategies/abstract_search_strategy'; - -export { VisPayload } from '../common/types'; - -export { DefaultSearchCapabilities } from './lib/search_strategies/default_search_capabilities'; - export function plugin(initializerContext: PluginInitializerContext) { return new VisTypeTimeseriesPlugin(initializerContext); } diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.test.ts similarity index 95% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.test.ts index d4e3064747ab0..105bfd53ce739 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.test.ts @@ -7,8 +7,8 @@ */ import { DefaultSearchCapabilities } from './default_search_capabilities'; -import { ReqFacade } from './strategies/abstract_search_strategy'; -import { VisPayload } from '../../../common/types'; +import type { ReqFacade } from '../strategies/abstract_search_strategy'; +import type { VisPayload } from '../../../../common/types'; describe('DefaultSearchCapabilities', () => { let defaultSearchCapabilities: DefaultSearchCapabilities; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.ts similarity index 89% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.ts index 1755e25138e8f..996efce4ce66e 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/default_search_capabilities.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/default_search_capabilities.ts @@ -11,10 +11,10 @@ import { convertIntervalToUnit, parseInterval, getSuitableUnit, -} from '../vis_data/helpers/unit_to_seconds'; -import { RESTRICTIONS_KEYS } from '../../../common/ui_restrictions'; -import { ReqFacade } from './strategies/abstract_search_strategy'; -import { VisPayload } from '../../../common/types'; +} from '../../vis_data/helpers/unit_to_seconds'; +import { RESTRICTIONS_KEYS } from '../../../../common/ui_restrictions'; +import type { ReqFacade } from '../strategies/abstract_search_strategy'; +import type { VisPayload } from '../../../../common/types'; const getTimezoneFromRequest = (request: ReqFacade<VisPayload>) => { return request.payload.timerange.timezone; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.test.ts similarity index 92% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.test.ts index 6c30895635fe5..443b700386c15 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.test.ts @@ -1,12 +1,16 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ + import { Unit } from '@elastic/datemath'; import { RollupSearchCapabilities } from './rollup_search_capabilities'; -import { ReqFacade, VisPayload } from '../../../../../src/plugins/vis_type_timeseries/server'; +import type { VisPayload } from '../../../../common/types'; +import type { ReqFacade } from '../strategies/abstract_search_strategy'; describe('Rollup Search Capabilities', () => { const testTimeZone = 'time_zone'; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.ts similarity index 86% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.ts index 015a371bd2a35..787a8ff1b2051 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_capabilities.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/capabilities/rollup_search_capabilities.ts @@ -1,16 +1,18 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ + import { get, has } from 'lodash'; -import { leastCommonInterval, isCalendarInterval } from './lib/interval_helper'; +import { leastCommonInterval, isCalendarInterval } from '../lib/interval_helper'; + +import { DefaultSearchCapabilities } from './default_search_capabilities'; -import { - ReqFacade, - DefaultSearchCapabilities, - VisPayload, -} from '../../../../../src/plugins/vis_type_timeseries/server'; +import type { VisPayload } from '../../../../common/types'; +import type { ReqFacade } from '../strategies/abstract_search_strategy'; export class RollupSearchCapabilities extends DefaultSearchCapabilities { rollupIndex: string; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/index.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/index.ts index 7dd7bfe780b52..2df6f002481b5 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/index.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/index.ts @@ -7,3 +7,11 @@ */ export { SearchStrategyRegistry } from './search_strategy_registry'; +export { DefaultSearchCapabilities } from './capabilities/default_search_capabilities'; + +export { + AbstractSearchStrategy, + ReqFacade, + RollupSearchStrategy, + DefaultSearchStrategy, +} from './strategies'; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.test.ts similarity index 94% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.test.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.test.ts index 31baeadce6527..158c1d74964b3 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.test.ts @@ -1,8 +1,11 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ + import { isCalendarInterval, leastCommonInterval } from './interval_helper'; describe('interval_helper', () => { diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.ts similarity index 74% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.ts index 91d73cecdf401..f4ac715b5b0f2 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/lib/interval_helper.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/lib/interval_helper.ts @@ -1,7 +1,9 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ import dateMath from '@elastic/datemath'; diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts index 81bf8920c54fc..21b746656c043 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategies_registry.test.ts @@ -5,14 +5,13 @@ * compliance with, at your election, the Elastic License or the Server Side * Public License, v 1. */ +import { get } from 'lodash'; +import { RequestFacade, SearchStrategyRegistry } from './search_strategy_registry'; +import { AbstractSearchStrategy, DefaultSearchStrategy } from './strategies'; +import { DefaultSearchCapabilities } from './capabilities/default_search_capabilities'; -import { SearchStrategyRegistry } from './search_strategy_registry'; -// @ts-ignore -import { AbstractSearchStrategy } from './strategies/abstract_search_strategy'; -// @ts-ignore -import { DefaultSearchStrategy } from './strategies/default_search_strategy'; -// @ts-ignore -import { DefaultSearchCapabilities } from './default_search_capabilities'; +const getPrivateField = <T>(registry: SearchStrategyRegistry, field: string) => + get(registry, field) as T; class MockSearchStrategy extends AbstractSearchStrategy { checkForViability() { @@ -28,23 +27,21 @@ describe('SearchStrategyRegister', () => { beforeAll(() => { registry = new SearchStrategyRegistry(); + registry.addStrategy(new DefaultSearchStrategy()); }); test('should init strategies register', () => { - expect( - registry.addStrategy({} as AbstractSearchStrategy)[0] instanceof DefaultSearchStrategy - ).toBe(true); + expect(getPrivateField(registry, 'strategies')).toHaveLength(1); }); test('should not add a strategy if it is not an instance of AbstractSearchStrategy', () => { const addedStrategies = registry.addStrategy({} as AbstractSearchStrategy); expect(addedStrategies.length).toEqual(1); - expect(addedStrategies[0] instanceof DefaultSearchStrategy).toBe(true); }); test('should return a DefaultSearchStrategy instance', async () => { - const req = {}; + const req = {} as RequestFacade; const indexPattern = '*'; const { searchStrategy, capabilities } = (await registry.getViableStrategy(req, indexPattern))!; @@ -62,7 +59,7 @@ describe('SearchStrategyRegister', () => { }); test('should return a MockSearchStrategy instance', async () => { - const req = {}; + const req = {} as RequestFacade; const indexPattern = '*'; const anotherSearchStrategy = new MockSearchStrategy(); registry.addStrategy(anotherSearchStrategy); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts index 9e7272f14f146..f3bf854f00ef4 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/search_strategy_registry.ts @@ -6,23 +6,15 @@ * Public License, v 1. */ -import { AbstractSearchStrategy } from './strategies/abstract_search_strategy'; -// @ts-ignore -import { DefaultSearchStrategy } from './strategies/default_search_strategy'; -// @ts-ignore import { extractIndexPatterns } from '../../../common/extract_index_patterns'; - -export type RequestFacade = any; - import { PanelSchema } from '../../../common/types'; +import { AbstractSearchStrategy, ReqFacade } from './strategies'; + +export type RequestFacade = ReqFacade<any>; export class SearchStrategyRegistry { private strategies: AbstractSearchStrategy[] = []; - constructor() { - this.addStrategy(new DefaultSearchStrategy()); - } - public addStrategy(searchStrategy: AbstractSearchStrategy) { if (searchStrategy instanceof AbstractSearchStrategy) { this.strategies.unshift(searchStrategy); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts similarity index 72% rename from src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts index a4fc48ccc6266..97876ec2579f0 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.test.ts @@ -7,29 +7,35 @@ */ import { from } from 'rxjs'; -import { AbstractSearchStrategy } from './abstract_search_strategy'; +import { AbstractSearchStrategy, ReqFacade } from './abstract_search_strategy'; +import type { VisPayload } from '../../../../common/types'; +import type { IFieldType } from '../../../../../data/common'; + +class FooSearchStrategy extends AbstractSearchStrategy {} describe('AbstractSearchStrategy', () => { - let abstractSearchStrategy; - let req; - let mockedFields; - let indexPattern; + let abstractSearchStrategy: AbstractSearchStrategy; + let req: ReqFacade; + let mockedFields: IFieldType[]; + let indexPattern: string; beforeEach(() => { mockedFields = []; - req = { + req = ({ payload: {}, pre: { indexPatternsFetcher: { getFieldsForWildcard: jest.fn().mockReturnValue(mockedFields), }, }, - getIndexPatternsService: jest.fn(() => ({ - find: jest.fn(() => []), - })), - }; + getIndexPatternsService: jest.fn(() => + Promise.resolve({ + find: jest.fn(() => []), + }) + ), + } as unknown) as ReqFacade<VisPayload>; - abstractSearchStrategy = new AbstractSearchStrategy(); + abstractSearchStrategy = new FooSearchStrategy(); }); test('should init an AbstractSearchStrategy instance', () => { @@ -42,7 +48,7 @@ describe('AbstractSearchStrategy', () => { const fields = await abstractSearchStrategy.getFieldsForWildcard(req, indexPattern); expect(fields).toEqual(mockedFields); - expect(req.pre.indexPatternsFetcher.getFieldsForWildcard).toHaveBeenCalledWith({ + expect(req.pre.indexPatternsFetcher!.getFieldsForWildcard).toHaveBeenCalledWith({ pattern: indexPattern, metaFields: [], fieldCapsOptions: { allow_no_indices: true }, @@ -54,7 +60,7 @@ describe('AbstractSearchStrategy', () => { const searchFn = jest.fn().mockReturnValue(from(Promise.resolve({}))); const responses = await abstractSearchStrategy.search( - { + ({ payload: { searchSession: { sessionId: '1', @@ -65,7 +71,7 @@ describe('AbstractSearchStrategy', () => { requestContext: { search: { search: searchFn }, }, - }, + } as unknown) as ReqFacade<VisPayload>, searches ); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts index 966daca87a208..bf7088145f347 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/abstract_search_strategy.ts @@ -8,12 +8,13 @@ import type { FakeRequest, IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server'; +import { indexPatterns } from '../../../../../data/server'; + import type { Framework } from '../../../plugin'; import type { IndexPatternsFetcher, IFieldType } from '../../../../../data/server'; import type { VisPayload } from '../../../../common/types'; import type { IndexPatternsService } from '../../../../../data/common'; -import { indexPatterns } from '../../../../../data/server'; -import { SanitizedFieldType } from '../../../../common/types'; +import type { SanitizedFieldType } from '../../../../common/types'; import type { VisTypeTimeseriesRequestHandlerContext } from '../../../types'; /** diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts index c6f7474ed86bf..00dbf17945011 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.test.ts @@ -7,8 +7,8 @@ */ import { DefaultSearchStrategy } from './default_search_strategy'; -import { ReqFacade } from './abstract_search_strategy'; -import { VisPayload } from '../../../../common/types'; +import type { ReqFacade } from './abstract_search_strategy'; +import type { VisPayload } from '../../../../common/types'; describe('DefaultSearchStrategy', () => { let defaultSearchStrategy: DefaultSearchStrategy; @@ -20,7 +20,6 @@ describe('DefaultSearchStrategy', () => { }); test('should init an DefaultSearchStrategy instance', () => { - expect(defaultSearchStrategy.name).toBe('default'); expect(defaultSearchStrategy.checkForViability).toBeDefined(); expect(defaultSearchStrategy.search).toBeDefined(); expect(defaultSearchStrategy.getFieldsForWildcard).toBeDefined(); diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts index 803926ad58c50..791ff4efd3936 100644 --- a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/default_search_strategy.ts @@ -7,12 +7,10 @@ */ import { AbstractSearchStrategy, ReqFacade } from './abstract_search_strategy'; -import { DefaultSearchCapabilities } from '../default_search_capabilities'; +import { DefaultSearchCapabilities } from '../capabilities/default_search_capabilities'; import { VisPayload } from '../../../../common/types'; export class DefaultSearchStrategy extends AbstractSearchStrategy { - name = 'default'; - checkForViability(req: ReqFacade<VisPayload>) { return Promise.resolve({ isViable: true, diff --git a/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/index.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/index.ts new file mode 100644 index 0000000000000..953624e476dc8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export { AbstractSearchStrategy, ReqFacade } from './abstract_search_strategy'; +export { DefaultSearchStrategy } from './default_search_strategy'; +export { RollupSearchStrategy } from './rollup_search_strategy'; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts similarity index 90% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts index e3fbe2daa3756..8e5c2fdabca5d 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.test.ts @@ -1,13 +1,17 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ + import { RollupSearchStrategy } from './rollup_search_strategy'; -import type { ReqFacade, VisPayload } from '../../../../../src/plugins/vis_type_timeseries/server'; -jest.mock('../../../../../src/plugins/vis_type_timeseries/server', () => { - const actual = jest.requireActual('../../../../../src/plugins/vis_type_timeseries/server'); +import type { VisPayload } from '../../../../common/types'; +import type { ReqFacade } from './abstract_search_strategy'; + +jest.mock('./abstract_search_strategy', () => { class AbstractSearchStrategyMock { getFieldsForWildcard() { return [ @@ -23,7 +27,6 @@ jest.mock('../../../../../src/plugins/vis_type_timeseries/server', () => { } return { - ...actual, AbstractSearchStrategy: AbstractSearchStrategyMock, }; }); @@ -52,7 +55,7 @@ describe('Rollup Search Strategy', () => { test('should create instance of RollupSearchRequest', () => { const rollupSearchStrategy = new RollupSearchStrategy(); - expect(rollupSearchStrategy.name).toBe('rollup'); + expect(rollupSearchStrategy).toBeDefined(); }); describe('checkForViability', () => { diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts similarity index 81% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts rename to src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts index 60fa51d0995db..5b5a1bd5db79e 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/search_strategies/rollup_search_strategy.ts +++ b/src/plugins/vis_type_timeseries/server/lib/search_strategies/strategies/rollup_search_strategy.ts @@ -1,17 +1,16 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. */ -import { - AbstractSearchStrategy, - ReqFacade, - VisPayload, -} from '../../../../../src/plugins/vis_type_timeseries/server'; -import { getCapabilitiesForRollupIndices } from '../../../../../src/plugins/data/server'; +import { ReqFacade, AbstractSearchStrategy } from './abstract_search_strategy'; +import { RollupSearchCapabilities } from '../capabilities/rollup_search_capabilities'; +import type { VisPayload } from '../../../../common/types'; -import { RollupSearchCapabilities } from './rollup_search_capabilities'; +import { getCapabilitiesForRollupIndices } from '../../../../../data/server'; const getRollupIndices = (rollupData: { [key: string]: any }) => Object.keys(rollupData); const isIndexPatternContainsWildcard = (indexPattern: string) => indexPattern.includes('*'); @@ -19,8 +18,6 @@ const isIndexPatternValid = (indexPattern: string) => indexPattern && typeof indexPattern === 'string' && !isIndexPatternContainsWildcard(indexPattern); export class RollupSearchStrategy extends AbstractSearchStrategy { - name = 'rollup'; - async search(req: ReqFacade<VisPayload>, bodies: any[]) { return super.search(req, bodies, 'rollup'); } diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts index d94362e681642..d13efcfe37149 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/fields_fetcher.ts @@ -6,7 +6,11 @@ * Public License, v 1. */ -import { AbstractSearchStrategy, DefaultSearchCapabilities, ReqFacade } from '../../..'; +import { + AbstractSearchStrategy, + DefaultSearchCapabilities, + ReqFacade, +} from '../../search_strategies'; export const createFieldsFetcher = ( req: ReqFacade, diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts index d97e948551b1a..a20df9145d987 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.test.ts @@ -8,7 +8,8 @@ import moment from 'moment'; import { getTimerange } from './get_timerange'; -import { ReqFacade, VisPayload } from '../../..'; +import type { ReqFacade } from '../../search_strategies'; +import type { VisPayload } from '../../../../common/types'; describe('getTimerange(req)', () => { test('should return a moment object for to and from', () => { diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts index b690ad0fb0325..2797839988ded 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/helpers/get_timerange.ts @@ -7,7 +7,8 @@ */ import { utc } from 'moment'; -import { ReqFacade, VisPayload } from '../../..'; +import type { ReqFacade } from '../../search_strategies'; +import type { VisPayload } from '../../../../common/types'; export const getTimerange = (req: ReqFacade<VisPayload>) => { const { min, max } = req.payload.timerange; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js index 06ff882190ce5..bcb158ebfe2bb 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/request_processors/series/date_histogram.test.js @@ -6,7 +6,7 @@ * Public License, v 1. */ -import { DefaultSearchCapabilities } from '../../../search_strategies/default_search_capabilities'; +import { DefaultSearchCapabilities } from '../../../search_strategies/capabilities/default_search_capabilities'; import { dateHistogram } from './date_histogram'; import { UI_SETTINGS } from '../../../../../../data/common'; diff --git a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js index 9d3296e76727e..1ea197976fb4f 100644 --- a/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js +++ b/src/plugins/vis_type_timeseries/server/lib/vis_data/response_processors/series/math.js @@ -12,7 +12,7 @@ import { getDefaultDecoration } from '../../helpers/get_default_decoration'; import { getSiblingAggValue } from '../../helpers/get_sibling_agg_value'; import { getSplits } from '../../helpers/get_splits'; import { mapBucket } from '../../helpers/map_bucket'; -import { evaluate } from 'tinymath'; +import { evaluate } from '@kbn/tinymath'; export function mathAgg(resp, panel, series, meta, extractFields) { return (next) => async (results) => { diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index adcd7e8bbf0d5..43b61f37ba3d3 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -23,10 +23,15 @@ import { PluginStart } from '../../data/server'; import { visDataRoutes } from './routes/vis'; // @ts-ignore import { fieldsRoutes } from './routes/fields'; -import { SearchStrategyRegistry } from './lib/search_strategies'; import { uiSettings } from './ui_settings'; import type { VisTypeTimeseriesRequestHandlerContext, VisTypeTimeseriesRouter } from './types'; +import { + SearchStrategyRegistry, + DefaultSearchStrategy, + RollupSearchStrategy, +} from './lib/search_strategies'; + export interface LegacySetup { server: Server; } @@ -45,7 +50,6 @@ export interface VisTypeTimeseriesSetup { fakeRequest: FakeRequest, options: GetVisDataOptions ) => ReturnType<GetVisData>; - addSearchStrategy: SearchStrategyRegistry['addStrategy']; } export interface Framework { @@ -76,6 +80,9 @@ export class VisTypeTimeseriesPlugin implements Plugin<VisTypeTimeseriesSetup> { const searchStrategyRegistry = new SearchStrategyRegistry(); + searchStrategyRegistry.addStrategy(new DefaultSearchStrategy()); + searchStrategyRegistry.addStrategy(new RollupSearchStrategy()); + const framework: Framework = { core, plugins, @@ -97,7 +104,6 @@ export class VisTypeTimeseriesPlugin implements Plugin<VisTypeTimeseriesSetup> { ) => { return await getVisData(requestContext, { ...fakeRequest, body: options }, framework); }, - addSearchStrategy: searchStrategyRegistry.addStrategy.bind(searchStrategyRegistry), }; } diff --git a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap index 8b813ee06b1b3..c70c4406a34f2 100644 --- a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap +++ b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap @@ -1,7 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`VegaVisualizations VegaVisualization - basics should show vega blank rectangle on top of a map (vegamap) 1`] = `"<div class=\\"vgaVis__view leaflet-container leaflet-grab leaflet-touch-drag\\" style=\\"height: 100%; position: relative;\\" tabindex=\\"0\\"><div class=\\"leaflet-pane leaflet-map-pane\\" style=\\"left: 0px; top: 0px;\\"><div class=\\"leaflet-pane leaflet-tile-pane\\"></div><div class=\\"leaflet-pane leaflet-shadow-pane\\"></div><div class=\\"leaflet-pane leaflet-overlay-pane\\"><div class=\\"leaflet-vega-container\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\" style=\\"left: 0px; top: 0px; cursor: default;\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"0\\" height=\\"0\\" viewBox=\\"0 0 0 0\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(0,0)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-rect role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"rect mark container\\"><path d=\\"M0,0h0v0h0Z\\" fill=\\"#0f0\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div></div><div class=\\"leaflet-pane leaflet-marker-pane\\"></div><div class=\\"leaflet-pane leaflet-tooltip-pane\\"></div><div class=\\"leaflet-pane leaflet-popup-pane\\"></div></div><div class=\\"leaflet-control-container\\"><div class=\\"leaflet-top leaflet-left\\"><div class=\\"leaflet-control-zoom leaflet-bar leaflet-control\\"><a class=\\"leaflet-control-zoom-in\\" href=\\"#\\" title=\\"Zoom in\\" role=\\"button\\" aria-label=\\"Zoom in\\">+</a><a class=\\"leaflet-control-zoom-out\\" href=\\"#\\" title=\\"Zoom out\\" role=\\"button\\" aria-label=\\"Zoom out\\">−</a></div></div><div class=\\"leaflet-top leaflet-right\\"></div><div class=\\"leaflet-bottom leaflet-left\\"></div><div class=\\"leaflet-bottom leaflet-right\\"><div class=\\"leaflet-control-attribution leaflet-control\\"></div></div></div></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`; - exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"<div class=\\"vgaVis__view\\" style=\\"height: 100%; cursor: default;\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"512\\" height=\\"512\\" viewBox=\\"0 0 512 512\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(0,0)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h512v512h-512Z\\"></path><g><g class=\\"mark-group role-scope\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-area role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"area mark container\\"><path d=\\"M0,512C18.962962962962962,512,37.925925925925924,512,56.888888888888886,512C75.85185185185185,512,94.81481481481481,512,113.77777777777777,512C132.74074074074073,512,151.7037037037037,512,170.66666666666666,512C189.62962962962962,512,208.59259259259258,512,227.55555555555554,512C246.5185185185185,512,265.48148148148147,512,284.44444444444446,512C303.4074074074074,512,322.3703703703704,512,341.3333333333333,512C360.29629629629625,512,379.25925925925924,512,398.2222222222222,512C417.18518518518516,512,436.1481481481481,512,455.1111111111111,512C474.0740740740741,512,493.037037037037,512,512,512L512,355.2C493.037037037037,324.79999999999995,474.0740740740741,294.4,455.1111111111111,294.4C436.1481481481481,294.4,417.18518518518516,457.6,398.2222222222222,457.6C379.25925925925924,457.6,360.29629629629625,233.60000000000002,341.3333333333333,233.60000000000002C322.3703703703704,233.60000000000002,303.4074074074074,435.2,284.44444444444446,435.2C265.48148148148147,435.2,246.5185185185185,345.6,227.55555555555554,345.6C208.59259259259258,345.6,189.62962962962962,451.2,170.66666666666666,451.2C151.7037037037037,451.2,132.74074074074073,252.8,113.77777777777777,252.8C94.81481481481481,252.8,75.85185185185185,346.1333333333333,56.888888888888886,374.4C37.925925925925924,402.66666666666663,18.962962962962962,412.5333333333333,0,422.4Z\\" fill=\\"#54B399\\" fill-opacity=\\"1\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-area role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"area mark container\\"><path d=\\"M0,422.4C18.962962962962962,412.5333333333333,37.925925925925924,402.66666666666663,56.888888888888886,374.4C75.85185185185185,346.1333333333333,94.81481481481481,252.8,113.77777777777777,252.8C132.74074074074073,252.8,151.7037037037037,451.2,170.66666666666666,451.2C189.62962962962962,451.2,208.59259259259258,345.6,227.55555555555554,345.6C246.5185185185185,345.6,265.48148148148147,435.2,284.44444444444446,435.2C303.4074074074074,435.2,322.3703703703704,233.60000000000002,341.3333333333333,233.60000000000002C360.29629629629625,233.60000000000002,379.25925925925924,457.6,398.2222222222222,457.6C417.18518518518516,457.6,436.1481481481481,294.4,455.1111111111111,294.4C474.0740740740741,294.4,493.037037037037,324.79999999999995,512,355.2L512,307.2C493.037037037037,275.2,474.0740740740741,243.2,455.1111111111111,243.2C436.1481481481481,243.2,417.18518518518516,371.2,398.2222222222222,371.2C379.25925925925924,371.2,360.29629629629625,22.399999999999977,341.3333333333333,22.399999999999977C322.3703703703704,22.399999999999977,303.4074074074074,278.4,284.44444444444446,278.4C265.48148148148147,278.4,246.5185185185185,204.8,227.55555555555554,192C208.59259259259258,179.20000000000002,189.62962962962962,185.6,170.66666666666666,172.8C151.7037037037037,160.00000000000003,132.74074074074073,83.19999999999999,113.77777777777777,83.19999999999999C94.81481481481481,83.19999999999999,75.85185185185185,83.19999999999999,56.888888888888886,83.19999999999999C37.925925925925924,83.19999999999999,18.962962962962962,164.79999999999998,0,246.39999999999998Z\\" fill=\\"#6092C0\\" fill-opacity=\\"1\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`; exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"<ul class=\\"vgaVis__messages\\"><li class=\\"vgaVis__message vgaVis__message--warn\\"><pre class=\\"vgaVis__messageCode\\">\\"width\\" and \\"height\\" params are ignored because \\"autosize\\" is enabled. Set \\"autosize\\": \\"none\\" to disable</pre></li></ul><div class=\\"vgaVis__view\\" style=\\"height: 100%; cursor: default;\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"0\\" height=\\"0\\" viewBox=\\"0 0 0 0\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(7,7)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0.5,0.5h0v0h0Z\\" fill=\\"transparent\\" stroke=\\"#ddd\\"></path><g><g class=\\"mark-line role-mark marks\\" role=\\"graphics-object\\" aria-roledescription=\\"line mark container\\"><path aria-label=\\"key: Dec 11, 2017; doc_count: 0\\" role=\\"graphics-symbol\\" aria-roledescription=\\"line mark\\" d=\\"M0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0\\" stroke=\\"#54B399\\" stroke-width=\\"2\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`; diff --git a/src/plugins/vis_type_vega/public/plugin.ts b/src/plugins/vis_type_vega/public/plugin.ts index 376ef84de23c3..c18a7d4dfcfbd 100644 --- a/src/plugins/vis_type_vega/public/plugin.ts +++ b/src/plugins/vis_type_vega/public/plugin.ts @@ -17,17 +17,18 @@ import { setData, setInjectedVars, setUISettings, - setMapsLegacyConfig, setInjectedMetadata, + setMapServiceSettings, } from './services'; import { createVegaFn } from './vega_fn'; import { createVegaTypeDefinition } from './vega_type'; -import { IServiceSettings } from '../../maps_legacy/public'; +import { IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; import { ConfigSchema } from '../config'; import { getVegaInspectorView } from './vega_inspector'; import { getVegaVisRenderer } from './vega_vis_renderer'; +import { MapServiceSettings } from './vega_view/vega_map_view/map_service_settings'; /** @internal */ export interface VegaVisualizationDependencies { @@ -44,7 +45,7 @@ export interface VegaPluginSetupDependencies { visualizations: VisualizationsSetup; inspector: InspectorSetup; data: DataPublicPluginSetup; - mapsLegacy: any; + mapsLegacy: MapsLegacyPluginSetup; } /** @internal */ @@ -68,8 +69,12 @@ export class VegaPlugin implements Plugin<Promise<void>, void> { enableExternalUrls: this.initializerContext.config.get().enableExternalUrls, emsTileLayerId: core.injectedMetadata.getInjectedVar('emsTileLayerId', true), }); + setUISettings(core.uiSettings); - setMapsLegacyConfig(mapsLegacy.config); + + setMapServiceSettings( + new MapServiceSettings(mapsLegacy.config, this.initializerContext.env.packageInfo.version) + ); const visualizationDependencies: Readonly<VegaVisualizationDependencies> = { core, diff --git a/src/plugins/vis_type_vega/public/services.ts b/src/plugins/vis_type_vega/public/services.ts index 157e355f93434..3e5d890c39ff4 100644 --- a/src/plugins/vis_type_vega/public/services.ts +++ b/src/plugins/vis_type_vega/public/services.ts @@ -10,7 +10,7 @@ import { CoreStart, NotificationsStart, IUiSettingsClient } from 'src/core/publi import { DataPublicPluginStart } from '../../data/public'; import { createGetterSetter } from '../../kibana_utils/public'; -import { MapsLegacyConfig } from '../../maps_legacy/config'; +import { MapServiceSettings } from './vega_view/vega_map_view/map_service_settings'; export const [getData, setData] = createGetterSetter<DataPublicPluginStart>('Data'); @@ -24,13 +24,14 @@ export const [getInjectedMetadata, setInjectedMetadata] = createGetterSetter< CoreStart['injectedMetadata'] >('InjectedMetadata'); +export const [ + getMapServiceSettings, + setMapServiceSettings, +] = createGetterSetter<MapServiceSettings>('MapServiceSettings'); + export const [getInjectedVars, setInjectedVars] = createGetterSetter<{ enableExternalUrls: boolean; emsTileLayerId: unknown; }>('InjectedVars'); -export const [getMapsLegacyConfig, setMapsLegacyConfig] = createGetterSetter<MapsLegacyConfig>( - 'MapsLegacyConfig' -); - export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls; diff --git a/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json b/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json index 9100de38ae387..a7e3b9dc7e024 100644 --- a/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json +++ b/src/plugins/vis_type_vega/public/test_utils/vega_map_test.json @@ -1,7 +1,7 @@ { "$schema": "https://vega.github.io/schema/vega/v5.json", "config": { - "kibana": { "renderer": "svg", "type": "map", "mapStyle": false} + "kibana": { "type": "map", "mapStyle": "default", "latitude": 25, "longitude": -70, "zoom": 3} }, "width": 512, "height": 512, diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts index d63288745986c..15132483b3659 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.d.ts @@ -18,12 +18,21 @@ interface VegaViewParams { serviceSettings: IServiceSettings; filterManager: DataPublicPluginStart['query']['filterManager']; timefilter: DataPublicPluginStart['query']['timefilter']['timefilter']; - // findIndex: (index: string) => Promise<...>; } export class VegaBaseView { constructor(params: VegaViewParams); init(): Promise<void>; onError(error: any): void; + onWarn(error: any): void; + setView(map: any): void; + setDebugValues(view: any, spec: any, vlspec: any): void; + _addDestroyHandler(handler: Function): void; + destroy(): Promise<void>; + + _$container: any; + _parser: any; + _vegaViewConfig: any; + _serviceSettings: any; } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 6971adaa55ec3..7c3915955419f 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -160,8 +160,6 @@ export class VegaBaseView { createViewConfig() { const config = { - // eslint-disable-next-line import/namespace - logLevel: vega.Warn, // note: eslint has a false positive here renderer: this._parser.renderer, }; @@ -189,6 +187,13 @@ export class VegaBaseView { }; config.loader = loader; + const logger = vega.logger(vega.Warn); + + logger.warn = this.onWarn.bind(this); + logger.error = this.onError.bind(this); + + config.logger = logger; + return config; } diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js b/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js deleted file mode 100644 index bf91b50ed9cf6..0000000000000 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_layer.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { KibanaMapLayer } from '../../../maps_legacy/public'; - -export class VegaMapLayer extends KibanaMapLayer { - constructor(spec, options, leaflet) { - super(); - - // Used by super.getAttributions() - this._attribution = options.attribution; - delete options.attribution; - this._leafletLayer = leaflet.vega(spec, options); - } - - getVegaView() { - return this._leafletLayer._view; - } - - getVegaSpec() { - return this._leafletLayer._spec; - } -} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js deleted file mode 100644 index 693045edeb7d0..0000000000000 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * and the Server Side Public License, v 1; you may not use this file except in - * compliance with, at your election, the Elastic License or the Server Side - * Public License, v 1. - */ - -import { i18n } from '@kbn/i18n'; -import { vega } from '../lib/vega'; -import { VegaBaseView } from './vega_base_view'; -import { VegaMapLayer } from './vega_map_layer'; -import { getMapsLegacyConfig, getUISettings } from '../services'; -import { lazyLoadMapsLegacyModules, TMS_IN_YML_ID } from '../../../maps_legacy/public'; - -const isUserConfiguredTmsLayer = ({ tilemap }) => Boolean(tilemap.url); - -export class VegaMapView extends VegaBaseView { - constructor(opts) { - super(opts); - } - - async getMapStyleOptions() { - const isDarkMode = getUISettings().get('theme:darkMode'); - const mapsLegacyConfig = getMapsLegacyConfig(); - const tmsServices = await this._serviceSettings.getTMSServices(); - const mapConfig = this._parser.mapConfig; - - let mapStyle; - - if (mapConfig.mapStyle !== 'default') { - mapStyle = mapConfig.mapStyle; - } else { - if (isUserConfiguredTmsLayer(mapsLegacyConfig)) { - mapStyle = TMS_IN_YML_ID; - } else { - mapStyle = mapsLegacyConfig.emsTileLayerId.bright; - } - } - - const mapOptions = tmsServices.find((s) => s.id === mapStyle); - - if (!mapOptions) { - this.onWarn( - i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', { - defaultMessage: '{mapStyleParam} was not found', - values: { mapStyleParam: `"mapStyle":${mapStyle}` }, - }) - ); - return null; - } - - return { - ...mapOptions, - ...(await this._serviceSettings.getAttributesForTMSLayer(mapOptions, true, isDarkMode)), - }; - } - - async _initViewCustomizations() { - const mapConfig = this._parser.mapConfig; - let baseMapOpts; - let limitMinZ = 0; - let limitMaxZ = 25; - - // In some cases, Vega may be initialized twice, e.g. after awaiting... - if (!this._$container) return; - - if (mapConfig.mapStyle !== false) { - baseMapOpts = await this.getMapStyleOptions(); - - if (baseMapOpts) { - limitMinZ = baseMapOpts.minZoom; - limitMaxZ = baseMapOpts.maxZoom; - } - } - - const validate = (name, value, dflt, min, max) => { - if (value === undefined) { - value = dflt; - } else if (value < min) { - this.onWarn( - i18n.translate('visTypeVega.mapView.resettingPropertyToMinValueWarningMessage', { - defaultMessage: 'Resetting {name} to {min}', - values: { name: `"${name}"`, min }, - }) - ); - value = min; - } else if (value > max) { - this.onWarn( - i18n.translate('visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage', { - defaultMessage: 'Resetting {name} to {max}', - values: { name: `"${name}"`, max }, - }) - ); - value = max; - } - return value; - }; - - let minZoom = validate('minZoom', mapConfig.minZoom, limitMinZ, limitMinZ, limitMaxZ); - let maxZoom = validate('maxZoom', mapConfig.maxZoom, limitMaxZ, limitMinZ, limitMaxZ); - if (minZoom > maxZoom) { - this.onWarn( - i18n.translate('visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage', { - defaultMessage: '{minZoomPropertyName} and {maxZoomPropertyName} have been swapped', - values: { - minZoomPropertyName: '"minZoom"', - maxZoomPropertyName: '"maxZoom"', - }, - }) - ); - [minZoom, maxZoom] = [maxZoom, minZoom]; - } - const zoom = validate('zoom', mapConfig.zoom, 2, minZoom, maxZoom); - - // let maxBounds = null; - // if (mapConfig.maxBounds) { - // const b = mapConfig.maxBounds; - // eslint-disable-next-line no-undef - // maxBounds = L.latLngBounds(L.latLng(b[1], b[0]), L.latLng(b[3], b[2])); - // } - - const modules = await lazyLoadMapsLegacyModules(); - - this._kibanaMap = new modules.KibanaMap(this._$container.get(0), { - zoom, - minZoom, - maxZoom, - center: [mapConfig.latitude, mapConfig.longitude], - zoomControl: mapConfig.zoomControl, - scrollWheelZoom: mapConfig.scrollWheelZoom, - }); - - if (baseMapOpts) { - this._kibanaMap.setBaseLayer({ - baseLayerType: 'tms', - options: baseMapOpts, - }); - } - - const vegaMapLayer = new VegaMapLayer( - this._parser.spec, - { - vega, - bindingsContainer: this._$controls.get(0), - delayRepaint: mapConfig.delayRepaint, - viewConfig: this._vegaViewConfig, - onWarning: this.onWarn.bind(this), - onError: this.onError.bind(this), - }, - modules.L - ); - - this._kibanaMap.addLayer(vegaMapLayer); - - this._addDestroyHandler(() => { - this._kibanaMap.removeLayer(vegaMapLayer); - if (baseMapOpts) { - this._kibanaMap.setBaseLayer(null); - } - this._kibanaMap.destroy(); - }); - - const vegaView = vegaMapLayer.getVegaView(); - await this.setView(vegaView); - this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec); - } -} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts new file mode 100644 index 0000000000000..ced1dc1bdc217 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/constants.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { TMS_IN_YML_ID } from '../../../../maps_legacy/public'; + +export const vegaLayerId = 'vega'; +export const userConfiguredLayerId = TMS_IN_YML_ID; +export const defaultMapConfig = { + maxZoom: 20, + minZoom: 0, + tileSize: 256, +}; + +export const defaultMabBoxStyle = { + /** + * according to the MapBox documentation that value should be '8' + * @see (https://docs.mapbox.com/mapbox-gl-js/style-spec/root/#version) + */ + version: 8, + sources: {}, + layers: [], +}; + +export const defaultProjection = { + name: 'projection', + type: 'mercator', + scale: { signal: '512*pow(2,zoom)/2/PI' }, + rotate: [{ signal: '-longitude' }, 0, 0], + center: [0, { signal: 'latitude' }], + translate: [{ signal: 'width/2' }, { signal: 'height/2' }], + fit: false, +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts new file mode 100644 index 0000000000000..c0ca7f04810d0 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export { initTmsRasterLayer } from './tms_raster_layer'; +export { initVegaLayer } from './vega_layer'; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts new file mode 100644 index 0000000000000..ea74a48dc9a74 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { initTmsRasterLayer } from './tms_raster_layer'; + +type InitTmsRasterLayerParams = Parameters<typeof initTmsRasterLayer>[0]; + +type IdType = InitTmsRasterLayerParams['id']; +type MapType = InitTmsRasterLayerParams['map']; +type ContextType = InitTmsRasterLayerParams['context']; + +describe('vega_map_view/tms_raster_layer', () => { + let id: IdType; + let map: MapType; + let context: ContextType; + + beforeEach(() => { + id = 'foo_tms_layer_id'; + map = ({ + addSource: jest.fn(), + addLayer: jest.fn(), + } as unknown) as MapType; + context = { + tiles: ['http://some.tile.com/map/{z}/{x}/{y}.jpg'], + maxZoom: 10, + minZoom: 2, + tileSize: 512, + }; + }); + + test('should register a new layer', () => { + initTmsRasterLayer({ id, map, context }); + + expect(map.addLayer).toHaveBeenCalledWith({ + id: 'foo_tms_layer_id', + maxzoom: 10, + minzoom: 2, + source: 'foo_tms_layer_id', + type: 'raster', + }); + + expect(map.addSource).toHaveBeenCalledWith('foo_tms_layer_id', { + scheme: 'xyz', + tileSize: 512, + tiles: ['http://some.tile.com/map/{z}/{x}/{y}.jpg'], + type: 'raster', + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts new file mode 100644 index 0000000000000..03fdce9bd8d93 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/tms_raster_layer.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { LayerParameters } from './types'; + +interface TMSRasterLayerContext { + tiles: string[]; + maxZoom: number; + minZoom: number; + tileSize: number; +} + +export const initTmsRasterLayer = ({ + id, + map, + context: { tiles, maxZoom, minZoom, tileSize }, +}: LayerParameters<TMSRasterLayerContext>) => { + map.addSource(id, { + type: 'raster', + tiles, + tileSize, + scheme: 'xyz', + }); + + map.addLayer({ + id, + type: 'raster', + source: id, + maxzoom: maxZoom, + minzoom: minZoom, + }); +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts new file mode 100644 index 0000000000000..1b7ac79312329 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { Map } from 'mapbox-gl'; + +export interface LayerParameters<TContext extends Record<string, any> = {}> { + id: string; + map: Map; + context: TContext; +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts new file mode 100644 index 0000000000000..97d231c5f7a6f --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { initVegaLayer } from './vega_layer'; + +type InitVegaLayerParams = Parameters<typeof initVegaLayer>[0]; + +type IdType = InitVegaLayerParams['id']; +type MapType = InitVegaLayerParams['map']; +type ContextType = InitVegaLayerParams['context']; + +describe('vega_map_view/tms_raster_layer', () => { + let id: IdType; + let map: MapType; + let context: ContextType; + + beforeEach(() => { + id = 'foo_vega_layer_id'; + map = ({ + getCanvasContainer: () => document.createElement('div'), + getCanvas: () => ({ + style: { + width: 100, + height: 100, + }, + }), + addLayer: jest.fn(), + } as unknown) as MapType; + context = { + vegaView: { + initialize: jest.fn(), + }, + updateVegaView: jest.fn(), + }; + }); + + test('should register a new custom layer', () => { + initVegaLayer({ id, map, context }); + + const calledWith = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0]; + expect(calledWith).toHaveProperty('id', 'foo_vega_layer_id'); + expect(calledWith).toHaveProperty('type', 'custom'); + }); + + test('should initialize vega container on "onAdd" hook', () => { + initVegaLayer({ id, map, context }); + const { onAdd } = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0]; + + onAdd(map); + expect(context.vegaView.initialize).toHaveBeenCalled(); + }); + + test('should update vega view on "render" hook', () => { + initVegaLayer({ id, map, context }); + const { render } = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0]; + + expect(context.updateVegaView).not.toHaveBeenCalled(); + render(); + expect(context.updateVegaView).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts new file mode 100644 index 0000000000000..a9b650fe4c58d --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/layers/vega_layer.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import type { Map, CustomLayerInterface } from 'mapbox-gl'; +import type { LayerParameters } from './types'; + +// @ts-ignore +import { vega } from '../../lib/vega'; + +export interface VegaLayerContext { + vegaView: vega.View; + updateVegaView: (map: Map, view: vega.View) => void; +} + +export function initVegaLayer({ + id, + map: mapInstance, + context: { vegaView, updateVegaView }, +}: LayerParameters<VegaLayerContext>) { + const vegaLayer: CustomLayerInterface = { + id, + type: 'custom', + onAdd(map: Map) { + const mapContainer = map.getCanvasContainer(); + const mapCanvas = map.getCanvas(); + const vegaContainer = document.createElement('div'); + + vegaContainer.style.position = 'absolute'; + vegaContainer.style.top = '0px'; + vegaContainer.style.width = mapCanvas.style.width; + vegaContainer.style.height = mapCanvas.style.height; + + mapContainer.appendChild(vegaContainer); + vegaView.initialize(vegaContainer); + }, + render() { + updateVegaView(mapInstance, vegaView); + }, + }; + + mapInstance.addLayer(vegaLayer); +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts new file mode 100644 index 0000000000000..0a477e5f62a7a --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { get } from 'lodash'; +import { uiSettingsServiceMock } from 'src/core/public/mocks'; + +import { MapServiceSettings, getAttributionsForTmsService } from './map_service_settings'; +import { MapsLegacyConfig } from '../../../../maps_legacy/config'; +import { EMSClient, TMSService } from '@elastic/ems-client'; +import { setUISettings } from '../../services'; + +const getPrivateField = <T>(mapServiceSettings: MapServiceSettings, privateField: string) => + get(mapServiceSettings, privateField) as T; + +describe('vega_map_view/map_service_settings', () => { + describe('MapServiceSettings', () => { + const appVersion = '99'; + let config: MapsLegacyConfig; + let getUiSettingsMockedValue: any; + + beforeEach(() => { + config = { + emsTileLayerId: { + desaturated: 'road_map_desaturated', + dark: 'dark_map', + }, + } as MapsLegacyConfig; + setUISettings({ + ...uiSettingsServiceMock.createSetupContract(), + get: () => getUiSettingsMockedValue, + }); + }); + + test('should be able to create instance of MapServiceSettings', () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + + expect(mapServiceSettings instanceof MapServiceSettings).toBeTruthy(); + expect(mapServiceSettings.hasUserConfiguredTmsLayer()).toBeFalsy(); + expect(mapServiceSettings.defaultTmsLayer()).toBe('road_map_desaturated'); + }); + + test('should be able to set user configured base layer through config', () => { + const mapServiceSettings = new MapServiceSettings( + { + ...config, + tilemap: { + url: 'http://some.tile.com/map/{z}/{x}/{y}.jpg', + options: { + attribution: 'attribution', + minZoom: 0, + maxZoom: 4, + }, + }, + }, + appVersion + ); + + expect(mapServiceSettings.defaultTmsLayer()).toBe('TMS in config/kibana.yml'); + expect(mapServiceSettings.hasUserConfiguredTmsLayer()).toBeTruthy(); + }); + + test('should load ems client only on executing getTmsService method', async () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + + expect(getPrivateField<EMSClient>(mapServiceSettings, 'emsClient')).toBeUndefined(); + + await mapServiceSettings.getTmsService('road_map'); + + expect( + getPrivateField<EMSClient>(mapServiceSettings, 'emsClient') instanceof EMSClient + ).toBeTruthy(); + }); + + test('should set isDarkMode value on executing getTmsService method', async () => { + const mapServiceSettings = new MapServiceSettings(config, appVersion); + getUiSettingsMockedValue = true; + + expect(getPrivateField<EMSClient>(mapServiceSettings, 'isDarkMode')).toBeFalsy(); + + await mapServiceSettings.getTmsService('road_map'); + + expect(getPrivateField<EMSClient>(mapServiceSettings, 'isDarkMode')).toBeTruthy(); + }); + + test('getAttributionsForTmsService method should return attributes in a correct form', () => { + const tmsService = ({ + getAttributions: jest.fn(() => [ + { url: 'https://fist_attr.com', label: 'fist_attr' }, + { url: 'https://second_attr.com', label: 'second_attr' }, + ]), + } as unknown) as TMSService; + + expect(getAttributionsForTmsService(tmsService)).toMatchInlineSnapshot(` + Array [ + "<a rel=\\"noreferrer noopener\\" href=\\"https://fist_attr.com\\">fist_attr</a>", + "<a rel=\\"noreferrer noopener\\" href=\\"https://second_attr.com\\">second_attr</a>", + ] + `); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts new file mode 100644 index 0000000000000..92dfc873e2715 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/map_service_settings.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; +import type { EMSClient, TMSService } from '@elastic/ems-client'; +import { getUISettings } from '../../services'; +import { userConfiguredLayerId } from './constants'; +import type { MapsLegacyConfig } from '../../../../maps_legacy/config'; + +type EmsClientConfig = ConstructorParameters<typeof EMSClient>[0]; + +const hasUserConfiguredTmsService = (config: MapsLegacyConfig) => Boolean(config.tilemap?.url); + +const initEmsClientAsync = async (config: Partial<EmsClientConfig>) => { + /** + * Build optimization: '@elastic/ems-client' should be loaded from a separate chunk + */ + const emsClientModule = await import('@elastic/ems-client'); + + return new emsClientModule.EMSClient({ + language: i18n.getLocale(), + appName: 'kibana', + // Wrap to avoid errors passing window fetch + fetchFunction(input: RequestInfo, init?: RequestInit) { + return fetch(input, init); + }, + ...config, + } as EmsClientConfig); +}; + +export class MapServiceSettings { + private emsClient?: EMSClient; + private isDarkMode: boolean = false; + + constructor(public config: MapsLegacyConfig, private appVersion: string) {} + + private isInitialized() { + return Boolean(this.emsClient); + } + + public hasUserConfiguredTmsLayer() { + return hasUserConfiguredTmsService(this.config); + } + + public defaultTmsLayer() { + const { dark, desaturated } = this.config.emsTileLayerId; + + if (this.hasUserConfiguredTmsLayer()) { + return userConfiguredLayerId; + } + + return this.isDarkMode ? dark : desaturated; + } + + private async initialize() { + this.isDarkMode = getUISettings().get('theme:darkMode'); + + this.emsClient = await initEmsClientAsync({ + appVersion: this.appVersion, + fileApiUrl: this.config.emsFileApiUrl, + tileApiUrl: this.config.emsTileApiUrl, + landingPageUrl: this.config.emsLandingPageUrl, + }); + } + + public async getTmsService(tmsTileLayer: string) { + if (!this.isInitialized()) { + await this.initialize(); + } + return this.emsClient?.findTMSServiceById(tmsTileLayer); + } +} + +export function getAttributionsForTmsService(tmsService: TMSService) { + return tmsService.getAttributions().map(({ label, url }) => { + const anchorTag = document.createElement('a'); + + anchorTag.textContent = label; + anchorTag.setAttribute('rel', 'noreferrer noopener'); + anchorTag.setAttribute('href', url); + + return anchorTag.outerHTML; + }); +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts new file mode 100644 index 0000000000000..921e604354b2e --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +export { validateZoomSettings } from './validation_helper'; +export { injectMapPropsIntoSpec } from './vsi_helper'; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts new file mode 100644 index 0000000000000..c2eb37980b741 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { validateZoomSettings } from './validation_helper'; + +type ValidateZoomSettingsParams = Parameters<typeof validateZoomSettings>; + +type MapConfigType = ValidateZoomSettingsParams[0]; +type LimitsType = ValidateZoomSettingsParams[1]; +type OnWarnType = ValidateZoomSettingsParams[2]; + +describe('vega_map_view/validation_helper', () => { + describe('validateZoomSettings', () => { + let mapConfig: MapConfigType; + let limits: LimitsType; + let onWarn: OnWarnType; + + beforeEach(() => { + onWarn = jest.fn(); + mapConfig = { + maxZoom: 10, + minZoom: 5, + zoom: 5, + }; + limits = { + maxZoom: 15, + minZoom: 2, + }; + }); + + test('should return validated interval', () => { + expect(validateZoomSettings(mapConfig, limits, onWarn)).toEqual({ + maxZoom: 10, + minZoom: 5, + zoom: 5, + }); + }); + + test('should return default interval in case if mapConfig not provided', () => { + mapConfig = {} as MapConfigType; + expect(validateZoomSettings(mapConfig, limits, onWarn)).toEqual({ + maxZoom: 15, + minZoom: 2, + zoom: 3, + }); + }); + + test('should reset MaxZoom if the passed value is greater than the limit', () => { + mapConfig = { + ...mapConfig, + maxZoom: 20, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "maxZoom" to 15'); + expect(result.maxZoom).toEqual(15); + }); + + test('should reset MinZoom if the passed value is greater than the limit', () => { + mapConfig = { + ...mapConfig, + minZoom: 0, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "minZoom" to 2'); + expect(result.minZoom).toEqual(2); + }); + + test('should reset Zoom if the passed value is greater than the max limit', () => { + mapConfig = { + ...mapConfig, + zoom: 45, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "zoom" to 10'); + expect(result.zoom).toEqual(10); + }); + + test('should reset Zoom if the passed value is greater than the min limit', () => { + mapConfig = { + ...mapConfig, + zoom: 0, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('Resetting "zoom" to 5'); + expect(result.zoom).toEqual(5); + }); + + test('should swap min <--> max values', () => { + mapConfig = { + maxZoom: 10, + minZoom: 15, + }; + + const result = validateZoomSettings(mapConfig, limits, onWarn); + + expect(onWarn).toBeCalledWith('"minZoom" and "maxZoom" have been swapped'); + expect(result).toEqual({ maxZoom: 15, minZoom: 10, zoom: 10 }); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts new file mode 100644 index 0000000000000..5e6f45790ae2d --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/validation_helper.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; + +function validate( + name: string, + value: number, + defaultValue: number, + min: number, + max: number, + onWarn: (message: string) => void +) { + if (value === undefined) { + value = defaultValue; + } else if (value < min) { + onWarn( + i18n.translate('visTypeVega.mapView.resettingPropertyToMinValueWarningMessage', { + defaultMessage: 'Resetting {name} to {min}', + values: { name: `"${name}"`, min }, + }) + ); + value = min; + } else if (value > max) { + onWarn( + i18n.translate('visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage', { + defaultMessage: 'Resetting {name} to {max}', + values: { name: `"${name}"`, max }, + }) + ); + value = max; + } + return value; +} + +export function validateZoomSettings( + mapConfig: { + maxZoom: number; + minZoom: number; + zoom?: number; + }, + limits: { + maxZoom: number; + minZoom: number; + }, + onWarn: (message: any) => void +) { + const DEFAULT_ZOOM = 3; + + let { maxZoom, minZoom, zoom = DEFAULT_ZOOM } = mapConfig; + + minZoom = validate('minZoom', minZoom, limits.minZoom, limits.minZoom, limits.maxZoom, onWarn); + maxZoom = validate('maxZoom', maxZoom, limits.maxZoom, limits.minZoom, limits.maxZoom, onWarn); + + if (minZoom > maxZoom) { + onWarn( + i18n.translate('visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage', { + defaultMessage: '{minZoomPropertyName} and {maxZoomPropertyName} have been swapped', + values: { + minZoomPropertyName: '"minZoom"', + maxZoomPropertyName: '"maxZoom"', + }, + }) + ); + [minZoom, maxZoom] = [maxZoom, minZoom]; + } + + zoom = validate('zoom', zoom, DEFAULT_ZOOM, minZoom, maxZoom, onWarn); + + return { + zoom, + minZoom, + maxZoom, + }; +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts new file mode 100644 index 0000000000000..e671b9059f358 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { injectMapPropsIntoSpec } from './vsi_helper'; +import { VegaSpec } from '../../../data_model/types'; + +describe('vega_map_view/vsi_helper', () => { + describe('injectMapPropsIntoSpec', () => { + test('should inject map properties into vega spec', () => { + const spec = ({ + $schema: 'https://vega.github.io/schema/vega/v5.json', + config: { + kibana: { type: 'map', latitude: 25, longitude: -70, zoom: 3 }, + }, + } as unknown) as VegaSpec; + + expect(injectMapPropsIntoSpec(spec)).toMatchInlineSnapshot(` + Object { + "$schema": "https://vega.github.io/schema/vega/v5.json", + "autosize": "none", + "config": Object { + "kibana": Object { + "latitude": 25, + "longitude": -70, + "type": "map", + "zoom": 3, + }, + }, + "projections": Array [ + Object { + "center": Array [ + 0, + Object { + "signal": "latitude", + }, + ], + "fit": false, + "name": "projection", + "rotate": Array [ + Object { + "signal": "-longitude", + }, + 0, + 0, + ], + "scale": Object { + "signal": "512*pow(2,zoom)/2/PI", + }, + "translate": Array [ + Object { + "signal": "width/2", + }, + Object { + "signal": "height/2", + }, + ], + "type": "mercator", + }, + ], + "signals": Array [ + Object { + "name": "zoom", + }, + Object { + "name": "latitude", + }, + Object { + "name": "longitude", + }, + ], + } + `); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts new file mode 100644 index 0000000000000..0022f68637659 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/utils/vsi_helper.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +// @ts-expect-error +// eslint-disable-next-line import/no-extraneous-dependencies +import Vsi from 'vega-spec-injector'; + +import { VegaSpec } from '../../../data_model/types'; +import { defaultProjection } from '../constants'; + +export const injectMapPropsIntoSpec = (spec: VegaSpec) => { + const vsi = new Vsi(); + + vsi.overrideField(spec, 'autosize', 'none'); + vsi.addToList(spec, 'signals', ['zoom', 'latitude', 'longitude']); + vsi.addToList(spec, 'projections', [defaultProjection]); + + return spec; +}; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss new file mode 100644 index 0000000000000..33e63e7ef317c --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/vega_map_view.scss @@ -0,0 +1,7 @@ +@import '~mapbox-gl/dist/mapbox-gl.css'; + +.vgaVis { + .mapboxgl-canvas-container { + cursor: auto; + } +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts new file mode 100644 index 0000000000000..fd176e5d20a2f --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.test.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import 'jest-canvas-mock'; + +import type { TMSService } from '@elastic/ems-client'; +import { VegaMapView } from './view'; +import { VegaViewParams } from '../vega_base_view'; +import { VegaParser } from '../../data_model/vega_parser'; +import { TimeCache } from '../../data_model/time_cache'; +import { SearchAPI } from '../../data_model/search_api'; +import vegaMap from '../../test_utils/vega_map_test.json'; +import { coreMock } from '../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../data/public/mocks'; +import { IServiceSettings } from '../../../../maps_legacy/public'; +import type { MapsLegacyConfig } from '../../../../maps_legacy/config'; +import { MapServiceSettings } from './map_service_settings'; +import { userConfiguredLayerId } from './constants'; +import { + setInjectedVars, + setData, + setNotifications, + setMapServiceSettings, + setUISettings, +} from '../../services'; + +jest.mock('../../lib/vega', () => ({ + vega: jest.requireActual('vega'), + vegaLite: jest.requireActual('vega-lite'), +})); + +jest.mock('mapbox-gl', () => ({ + Map: jest.fn().mockImplementation(() => ({ + getLayer: () => '', + removeLayer: jest.fn(), + once: (eventName: string, handler: Function) => handler(), + remove: () => jest.fn(), + getCanvas: () => ({ clientWidth: 512, clientHeight: 512 }), + getCenter: () => ({ lat: 20, lng: 20 }), + getZoom: () => 3, + addControl: jest.fn(), + addLayer: jest.fn(), + })), + MapboxOptions: jest.fn(), + NavigationControl: jest.fn(), +})); + +jest.mock('./layers', () => ({ + initVegaLayer: jest.fn(), + initTmsRasterLayer: jest.fn(), +})); + +import { initVegaLayer, initTmsRasterLayer } from './layers'; +import { Map, NavigationControl } from 'mapbox-gl'; + +describe('vega_map_view/view', () => { + describe('VegaMapView', () => { + const coreStart = coreMock.createStart(); + const dataPluginStart = dataPluginMock.createStartContract(); + const mockGetServiceSettings = async () => { + return {} as IServiceSettings; + }; + let vegaParser: VegaParser; + + setInjectedVars({ + emsTileLayerId: {}, + enableExternalUrls: true, + }); + setData(dataPluginStart); + setNotifications(coreStart.notifications); + setUISettings(coreStart.uiSettings); + + const getTmsService = jest.fn().mockReturnValue(({ + getVectorStyleSheet: () => ({ + version: 8, + sources: {}, + layers: [], + }), + getMaxZoom: async () => 20, + getMinZoom: async () => 0, + getAttributions: () => [{ url: 'tms_attributions' }], + } as unknown) as TMSService); + const config = { + tilemap: { + url: 'test', + options: { + attribution: 'tilemap-attribution', + minZoom: 0, + maxZoom: 20, + }, + }, + } as MapsLegacyConfig; + + function setMapService(defaultTmsLayer: string) { + setMapServiceSettings(({ + getTmsService, + defaultTmsLayer: () => defaultTmsLayer, + config, + } as unknown) as MapServiceSettings); + } + + async function createVegaMapView() { + await vegaParser.parseAsync(); + return new VegaMapView({ + vegaParser, + filterManager: dataPluginStart.query.filterManager, + timefilter: dataPluginStart.query.timefilter.timefilter, + fireEvent: (event: any) => {}, + parentEl: document.createElement('div'), + } as VegaViewParams); + } + + beforeEach(() => { + vegaParser = new VegaParser( + JSON.stringify(vegaMap), + new SearchAPI({ + search: dataPluginStart.search, + uiSettings: coreStart.uiSettings, + injectedMetadata: coreStart.injectedMetadata, + }), + new TimeCache(dataPluginStart.query.timefilter.timefilter, 0), + {}, + mockGetServiceSettings + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should be added TmsRasterLayer and do not use tmsService if mapStyle is "user_configured"', async () => { + setMapService(userConfiguredLayerId); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + const { longitude, latitude, scrollWheelZoom } = vegaMapView._parser.mapConfig; + expect(Map).toHaveBeenCalledWith({ + style: { + version: 8, + sources: {}, + layers: [], + }, + customAttribution: 'tilemap-attribution', + container: vegaMapView._$container.get(0), + minZoom: 0, + maxZoom: 20, + zoom: 3, + scrollZoom: scrollWheelZoom, + center: [longitude, latitude], + }); + expect(getTmsService).not.toHaveBeenCalled(); + expect(initTmsRasterLayer).toHaveBeenCalled(); + expect(initVegaLayer).toHaveBeenCalled(); + }); + + test('should not be added TmsRasterLayer and use tmsService if mapStyle is not "user_configured"', async () => { + setMapService('road_map_desaturated'); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + const { longitude, latitude, scrollWheelZoom } = vegaMapView._parser.mapConfig; + expect(Map).toHaveBeenCalledWith({ + style: { + version: 8, + sources: {}, + layers: [], + }, + customAttribution: ['<a rel="noreferrer noopener" href="tms_attributions"></a>'], + container: vegaMapView._$container.get(0), + minZoom: 0, + maxZoom: 20, + zoom: 3, + scrollZoom: scrollWheelZoom, + center: [longitude, latitude], + }); + expect(getTmsService).toHaveBeenCalled(); + expect(initTmsRasterLayer).not.toHaveBeenCalled(); + expect(initVegaLayer).toHaveBeenCalled(); + }); + + test('should be added NavigationControl', async () => { + setMapService('road_map_desaturated'); + const vegaMapView = await createVegaMapView(); + + await vegaMapView.init(); + + expect(NavigationControl).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts new file mode 100644 index 0000000000000..6a31eb0b37833 --- /dev/null +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { i18n } from '@kbn/i18n'; +import { Map, Style, NavigationControl, MapboxOptions } from 'mapbox-gl'; + +import { initTmsRasterLayer, initVegaLayer } from './layers'; +import { VegaBaseView } from '../vega_base_view'; +import { getMapServiceSettings } from '../../services'; +import { getAttributionsForTmsService } from './map_service_settings'; +import type { MapServiceSettings } from './map_service_settings'; + +import { + defaultMapConfig, + defaultMabBoxStyle, + userConfiguredLayerId, + vegaLayerId, +} from './constants'; + +import { validateZoomSettings, injectMapPropsIntoSpec } from './utils'; + +// @ts-expect-error +import { vega } from '../../lib/vega'; + +import './vega_map_view.scss'; + +async function updateVegaView(mapBoxInstance: Map, vegaView: vega.View) { + const mapCanvas = mapBoxInstance.getCanvas(); + const { lat, lng } = mapBoxInstance.getCenter(); + let shouldRender = false; + + const sendSignal = (sig: string, value: any) => { + if (vegaView.signal(sig) !== value) { + vegaView.signal(sig, value); + shouldRender = true; + } + }; + + sendSignal('width', mapCanvas.clientWidth); + sendSignal('height', mapCanvas.clientHeight); + sendSignal('latitude', lat); + sendSignal('longitude', lng); + sendSignal('zoom', mapBoxInstance.getZoom()); + + if (shouldRender) { + await vegaView.runAsync(); + } +} + +export class VegaMapView extends VegaBaseView { + private mapServiceSettings: MapServiceSettings = getMapServiceSettings(); + private mapStyle = this.getMapStyle(); + + private getMapStyle() { + const { mapStyle } = this._parser.mapConfig; + + return mapStyle === 'default' ? this.mapServiceSettings.defaultTmsLayer() : mapStyle; + } + + private get shouldShowZoomControl() { + return Boolean(this._parser.mapConfig.zoomControl); + } + + private getMapParams(defaults: { maxZoom: number; minZoom: number }): Partial<MapboxOptions> { + const { longitude, latitude, scrollWheelZoom } = this._parser.mapConfig; + const zoomSettings = validateZoomSettings(this._parser.mapConfig, defaults, this.onWarn); + + return { + ...zoomSettings, + center: [longitude, latitude], + scrollZoom: scrollWheelZoom, + }; + } + + private async initMapContainer(vegaView: vega.View) { + let style: Style = defaultMabBoxStyle; + let customAttribution: MapboxOptions['customAttribution'] = []; + const zoomSettings = { + minZoom: defaultMapConfig.minZoom, + maxZoom: defaultMapConfig.maxZoom, + }; + + if (this.mapStyle && this.mapStyle !== userConfiguredLayerId) { + const tmsService = await this.mapServiceSettings.getTmsService(this.mapStyle); + + if (!tmsService) { + this.onWarn( + i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', { + defaultMessage: '{mapStyleParam} was not found', + values: { mapStyleParam: `"mapStyle":${this.mapStyle}` }, + }) + ); + return; + } + zoomSettings.maxZoom = (await tmsService.getMaxZoom()) ?? defaultMapConfig.maxZoom; + zoomSettings.minZoom = (await tmsService.getMinZoom()) ?? defaultMapConfig.minZoom; + customAttribution = getAttributionsForTmsService(tmsService); + style = (await tmsService.getVectorStyleSheet()) as Style; + } else { + customAttribution = this.mapServiceSettings.config.tilemap.options.attribution; + } + + // In some cases, Vega may be initialized twice, e.g. after awaiting... + if (!this._$container) return; + + const mapBoxInstance = new Map({ + style, + customAttribution, + container: this._$container.get(0), + ...this.getMapParams({ ...zoomSettings }), + }); + + const initMapComponents = () => { + this.initControls(mapBoxInstance); + this.initLayers(mapBoxInstance, vegaView); + + this._addDestroyHandler(() => { + if (mapBoxInstance.getLayer(vegaLayerId)) { + mapBoxInstance.removeLayer(vegaLayerId); + } + if (mapBoxInstance.getLayer(userConfiguredLayerId)) { + mapBoxInstance.removeLayer(userConfiguredLayerId); + } + mapBoxInstance.remove(); + }); + }; + + mapBoxInstance.once('load', initMapComponents); + } + + private initControls(mapBoxInstance: Map) { + if (this.shouldShowZoomControl) { + mapBoxInstance.addControl(new NavigationControl({ showCompass: false }), 'top-left'); + } + } + + private initLayers(mapBoxInstance: Map, vegaView: vega.View) { + const shouldShowUserConfiguredLayer = this.mapStyle === userConfiguredLayerId; + + if (shouldShowUserConfiguredLayer) { + const { url, options } = this.mapServiceSettings.config.tilemap; + + initTmsRasterLayer({ + id: userConfiguredLayerId, + map: mapBoxInstance, + context: { + tiles: [url!], + maxZoom: options.maxZoom ?? defaultMapConfig.maxZoom, + minZoom: options.minZoom ?? defaultMapConfig.minZoom, + tileSize: options.tileSize ?? defaultMapConfig.tileSize, + }, + }); + } + + initVegaLayer({ + id: vegaLayerId, + map: mapBoxInstance, + context: { + vegaView, + updateVegaView, + }, + }); + } + + protected async _initViewCustomizations() { + const vegaView = new vega.View( + vega.parse(injectMapPropsIntoSpec(this._parser.spec)), + this._vegaViewConfig + ); + + this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec); + this.setView(vegaView); + + await this.initMapContainer(vegaView); + } +} diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_view.js index 8b6ebbe9c7594..2fd7e4fd606fd 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_view.js @@ -16,8 +16,6 @@ export class VegaView extends VegaBaseView { const view = new vega.View(vega.parse(this._parser.spec), this._vegaViewConfig); - view.warn = this.onWarn.bind(this); - view.error = this.onError.bind(this); if (this._parser.useResize) this.updateVegaSize(view); view.initialize(this._$container.get(0), this._$controls.get(0)); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js index af396dbf778d2..926c03e79bff9 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.test.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -10,13 +10,10 @@ import 'jest-canvas-mock'; import $ from 'jquery'; -import 'leaflet/dist/leaflet.js'; -import 'leaflet-vega'; import { createVegaVisualization } from './vega_visualization'; import vegaliteGraph from './test_utils/vegalite_graph.json'; import vegaGraph from './test_utils/vega_graph.json'; -import vegaMapGraph from './test_utils/vega_map_test.json'; import { VegaParser } from './data_model/vega_parser'; import { SearchAPI } from './data_model/search_api'; @@ -146,32 +143,5 @@ describe('VegaVisualizations', () => { vegaVis.destroy(); } }); - - test('should show vega blank rectangle on top of a map (vegamap)', async () => { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, jest.fn()); - const vegaParser = new VegaParser( - JSON.stringify(vegaMapGraph), - new SearchAPI({ - search: dataPluginStart.search, - uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, - }), - 0, - 0, - mockGetServiceSettings - ); - await vegaParser.parseAsync(); - - mockedWidthValue = 256; - mockedHeightValue = 256; - - await vegaVis.render(vegaParser); - expect(domNode.innerHTML).toMatchSnapshot(); - } finally { - vegaVis.destroy(); - } - }); }); }); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.ts b/src/plugins/vis_type_vega/public/vega_visualization.ts index ae4d23db48ee4..14dea362bc8c5 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.ts +++ b/src/plugins/vis_type_vega/public/vega_visualization.ts @@ -13,7 +13,14 @@ import { VegaVisualizationDependencies } from './plugin'; import { getNotifications, getData } from './services'; import type { VegaView } from './vega_view/vega_view'; -export const createVegaVisualization = ({ getServiceSettings }: VegaVisualizationDependencies) => +type VegaVisType = new (el: HTMLDivElement, fireEvent: IInterpreterRenderHandlers['event']) => { + render(visData: VegaParser): Promise<void>; + destroy(): void; +}; + +export const createVegaVisualization = ({ + getServiceSettings, +}: VegaVisualizationDependencies): VegaVisType => class VegaVisualization { private readonly dataPlugin = getData(); private vegaView: InstanceType<typeof VegaView> | null = null; @@ -71,7 +78,7 @@ export const createVegaVisualization = ({ getServiceSettings }: VegaVisualizatio }; if (vegaParser.useMap) { - const { VegaMapView } = await import('./vega_view/vega_map_view'); + const { VegaMapView } = await import('./vega_view/vega_map_view/view'); this.vegaView = new VegaMapView(vegaViewParams); } else { const { VegaView: VegaViewClass } = await import('./vega_view/vega_view'); diff --git a/src/plugins/vis_type_vega/tsconfig.json b/src/plugins/vis_type_vega/tsconfig.json new file mode 100644 index 0000000000000..c013056ba4566 --- /dev/null +++ b/src/plugins/vis_type_vega/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*", + "public/**/*", + "*.ts", + // have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636 + "public/test_utils/vega_map_test.json" + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../data/tsconfig.json" }, + { "path": "../visualizations/tsconfig.json" }, + { "path": "../maps_legacy/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + { "path": "../inspector/tsconfig.json" }, + { "path": "../home/tsconfig.json" }, + { "path": "../usage_collection/tsconfig.json" }, + { "path": "../kibana_utils/tsconfig.json" }, + { "path": "../kibana_react/tsconfig.json" }, + { "path": "../vis_default_editor/tsconfig.json" }, + ] +} diff --git a/src/plugins/visualize/common/constants.ts b/src/plugins/visualize/common/constants.ts index b37eadd9b67e5..7e353ca86698a 100644 --- a/src/plugins/visualize/common/constants.ts +++ b/src/plugins/visualize/common/constants.ts @@ -7,3 +7,5 @@ */ export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size'; +export const STATE_STORAGE_KEY = '_a'; +export const GLOBAL_STATE_STORAGE_KEY = '_g'; diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index 7f5c7d0dc08a2..2256a7a7f550d 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -11,7 +11,8 @@ "visualizations", "embeddable", "dashboard", - "uiActions" + "uiActions", + "presentationUtil" ], "optionalPlugins": [ "home", @@ -22,7 +23,6 @@ "kibanaUtils", "kibanaReact", "home", - "presentationUtil", "discover" ] } diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 38e2b59009b38..34131ae2dc7fb 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -40,6 +40,7 @@ export const VisualizeListing = () => { savedObjectsTagging, uiSettings, visualizeCapabilities, + kbnUrlStateStorage, }, } = useKibana<VisualizeServices>(); const { pathname } = useLocation(); @@ -94,11 +95,10 @@ export const VisualizeListing = () => { ); const noItemsFragment = useMemo(() => getNoItemsMessage(createNewVis), [createNewVis]); - const tableColumns = useMemo(() => getTableColumns(application, history, savedObjectsTagging), [ - application, - history, - savedObjectsTagging, - ]); + const tableColumns = useMemo( + () => getTableColumns(application, kbnUrlStateStorage, savedObjectsTagging), + [application, kbnUrlStateStorage, savedObjectsTagging] + ); const fetchItems = useCallback( (filter) => { diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index b0d931c6c87fa..02da16c9e67ca 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -69,7 +69,6 @@ const TopNav = ({ }, [visInstance.embeddableHandler] ); - const savedObjectsClient = services.savedObjects.client; const config = useMemo(() => { if (isEmbeddableRendered) { @@ -85,7 +84,6 @@ const TopNav = ({ stateContainer, visualizationIdFromUrl, stateTransfer: services.stateTransferService, - savedObjectsClient, embeddableId, }, services @@ -104,7 +102,6 @@ const TopNav = ({ visualizationIdFromUrl, services, embeddableId, - savedObjectsClient, ]); const [indexPatterns, setIndexPatterns] = useState<IndexPattern[]>( vis.data.indexPattern ? [vis.data.indexPattern] : [] diff --git a/src/plugins/visualize/public/application/index.tsx b/src/plugins/visualize/public/application/index.tsx index 455e51a8f58d4..ae11e1de486ea 100644 --- a/src/plugins/visualize/public/application/index.tsx +++ b/src/plugins/visualize/public/application/index.tsx @@ -30,9 +30,11 @@ export const renderApp = ( const app = ( <Router history={services.history}> <KibanaContextProvider services={services}> - <services.i18n.Context> - <VisualizeApp onAppLeave={onAppLeave} /> - </services.i18n.Context> + <services.presentationUtil.ContextProvider> + <services.i18n.Context> + <VisualizeApp onAppLeave={onAppLeave} /> + </services.i18n.Context> + </services.presentationUtil.ContextProvider> </KibanaContextProvider> </Router> ); diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index d923851a68d9c..5d884889367bc 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -34,6 +34,7 @@ import { SharePluginStart } from 'src/plugins/share/public'; import { SavedObjectsStart, SavedObject } from 'src/plugins/saved_objects/public'; import { EmbeddableStart, EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import { UrlForwardingStart } from 'src/plugins/url_forwarding/public'; +import { PresentationUtilPluginStart } from 'src/plugins/presentation_util/public'; import { EventEmitter } from 'events'; import { DashboardStart } from '../../../dashboard/public'; import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; @@ -93,6 +94,7 @@ export interface VisualizeServices extends CoreStart { dashboard: DashboardStart; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; savedObjectsTagging?: SavedObjectsTaggingApi; + presentationUtil: PresentationUtilPluginStart; } export interface SavedVisInstance { diff --git a/src/plugins/visualize/public/application/utils/get_table_columns.tsx b/src/plugins/visualize/public/application/utils/get_table_columns.tsx index daa419b5f31b4..d9dafa7335671 100644 --- a/src/plugins/visualize/public/application/utils/get_table_columns.tsx +++ b/src/plugins/visualize/public/application/utils/get_table_columns.tsx @@ -7,14 +7,15 @@ */ import React from 'react'; -import { History } from 'history'; import { EuiBetaBadge, EuiButton, EuiEmptyPrompt, EuiIcon, EuiLink, EuiBadge } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; - import { ApplicationStart } from 'kibana/public'; +import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; import { VisualizationListItem } from 'src/plugins/visualizations/public'; import type { SavedObjectsTaggingApi } from 'src/plugins/saved_objects_tagging_oss/public'; +import { RedirectAppLinks } from '../../../../kibana_react/public'; +import { getVisualizeListItemLink } from './get_visualize_list_item_link'; const getBadge = (item: VisualizationListItem) => { if (item.stage === 'beta') { @@ -72,7 +73,7 @@ const renderItemTypeIcon = (item: VisualizationListItem) => { export const getTableColumns = ( application: ApplicationStart, - history: History, + kbnUrlStateStorage: IKbnUrlStateStorage, taggingApi?: SavedObjectsTaggingApi ) => [ { @@ -84,18 +85,14 @@ export const getTableColumns = ( render: (field: string, { editApp, editUrl, title, error }: VisualizationListItem) => // In case an error occurs i.e. the vis has wrong type, we render the vis but without the link !error ? ( - <EuiLink - onClick={() => { - if (editApp) { - application.navigateToApp(editApp, { path: editUrl }); - } else if (editUrl) { - history.push(editUrl); - } - }} - data-test-subj={`visListingTitleLink-${title.split(' ').join('-')}`} - > - {field} - </EuiLink> + <RedirectAppLinks application={application}> + <EuiLink + href={getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl)} + data-test-subj={`visListingTitleLink-${title.split(' ').join('-')}`} + > + {field} + </EuiLink> + </RedirectAppLinks> ) : ( field ), diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.test.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.test.tsx new file mode 100644 index 0000000000000..2b7706d4f9d9f --- /dev/null +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { Capabilities } from 'src/core/public'; +import { showPublicUrlSwitch } from './get_top_nav_config'; + +describe('showPublicUrlSwitch', () => { + test('returns false if "visualize" app is not available', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns false if "visualize" app is not accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + visualize: { + show: false, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(false); + }); + + test('returns true if "visualize" app is not available an accessible', () => { + const anonymousUserCapabilities: Capabilities = { + catalogue: {}, + management: {}, + navLinks: {}, + visualize: { + show: true, + }, + }; + const result = showPublicUrlSwitch(anonymousUserCapabilities); + + expect(result).toBe(true); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index c4aefb397cd8a..d782937bce40a 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { Capabilities } from 'src/core/public'; import { TopNavMenuData } from 'src/plugins/navigation/public'; import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public'; import { @@ -19,7 +20,6 @@ import { } from '../../../../saved_objects/public'; import { SavedObjectSaveModalDashboard } from '../../../../presentation_util/public'; import { unhashUrl } from '../../../../kibana_utils/public'; -import { SavedObjectsClientContract } from '../../../../../core/public'; import { VisualizeServices, @@ -30,6 +30,14 @@ import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from './breadcrumbs'; import { EmbeddableStateTransfer } from '../../../../embeddable/public'; +interface VisualizeCapabilities { + createShortUrl: boolean; + delete: boolean; + save: boolean; + saveQuery: boolean; + show: boolean; +} + interface TopNavConfigParams { hasUnsavedChanges: boolean; setHasUnsavedChanges: (value: boolean) => void; @@ -41,10 +49,17 @@ interface TopNavConfigParams { stateContainer: VisualizeAppStateContainer; visualizationIdFromUrl?: string; stateTransfer: EmbeddableStateTransfer; - savedObjectsClient: SavedObjectsClientContract; embeddableId?: string; } +export const showPublicUrlSwitch = (anonymousUserCapabilities: Capabilities) => { + if (!anonymousUserCapabilities.visualize) return false; + + const visualize = (anonymousUserCapabilities.visualize as unknown) as VisualizeCapabilities; + + return !!visualize.show; +}; + export const getTopNavConfig = ( { hasUnsavedChanges, @@ -55,7 +70,6 @@ export const getTopNavConfig = ( hasUnappliedChanges, visInstance, stateContainer, - savedObjectsClient, visualizationIdFromUrl, stateTransfer, embeddableId, @@ -71,6 +85,7 @@ export const getTopNavConfig = ( i18n: { Context: I18nContext }, dashboard, savedObjectsTagging, + presentationUtil, }: VisualizeServices ) => { const { vis, embeddableHandler } = visInstance; @@ -243,6 +258,7 @@ export const getTopNavConfig = ( title: savedVis?.title, }, isDirty: hasUnappliedChanges || hasUnsavedChanges, + showPublicUrlSwitch, }); } }, @@ -379,39 +395,43 @@ export const getTopNavConfig = ( ); } - const saveModal = - !!originatingApp || - !dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables ? ( - <SavedObjectSaveModalOrigin - documentInfo={savedVis || { title: '' }} - onSave={onSave} - options={tagOptions} - getAppNameFromId={stateTransfer.getAppNameFromId} - objectType={'visualization'} - onClose={() => {}} - originatingApp={originatingApp} - returnToOriginSwitchLabel={ - originatingApp && embeddableId - ? i18n.translate('visualize.topNavMenu.updatePanel', { - defaultMessage: 'Update panel on {originatingAppName}', - values: { - originatingAppName: stateTransfer.getAppNameFromId(originatingApp), - }, - }) - : undefined - } - /> - ) : ( - <SavedObjectSaveModalDashboard - documentInfo={savedVis || { title: '' }} - onSave={onSave} - tagOptions={tagOptions} - objectType={'visualization'} - onClose={() => {}} - savedObjectsClient={savedObjectsClient} - /> - ); - showSaveModal(saveModal, I18nContext); + const useByRefFlow = + !!originatingApp || !dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables; + + const saveModal = useByRefFlow ? ( + <SavedObjectSaveModalOrigin + documentInfo={savedVis || { title: '' }} + onSave={onSave} + options={tagOptions} + getAppNameFromId={stateTransfer.getAppNameFromId} + objectType={'visualization'} + onClose={() => {}} + originatingApp={originatingApp} + returnToOriginSwitchLabel={ + originatingApp && embeddableId + ? i18n.translate('visualize.topNavMenu.updatePanel', { + defaultMessage: 'Update panel on {originatingAppName}', + values: { + originatingAppName: stateTransfer.getAppNameFromId(originatingApp), + }, + }) + : undefined + } + /> + ) : ( + <SavedObjectSaveModalDashboard + documentInfo={savedVis || { title: '' }} + onSave={onSave} + tagOptions={tagOptions} + objectType={'visualization'} + onClose={() => {}} + /> + ); + showSaveModal( + saveModal, + I18nContext, + !useByRefFlow ? presentationUtil.ContextProvider : React.Fragment + ); }, }, ] diff --git a/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.test.ts b/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.test.ts new file mode 100644 index 0000000000000..80fd1c8740f2c --- /dev/null +++ b/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { getVisualizeListItemLink } from './get_visualize_list_item_link'; +import { ApplicationStart } from 'kibana/public'; +import { createHashHistory } from 'history'; +import { createKbnUrlStateStorage } from '../../../../kibana_utils/public'; +import { esFilters } from '../../../../data/public'; +import { GLOBAL_STATE_STORAGE_KEY } from '../../../common/constants'; + +jest.mock('../../services', () => { + return { + getUISettings: () => ({ + get: jest.fn(), + }), + }; +}); + +const application = ({ + getUrlForApp: jest.fn((appId: string, options?: { path?: string; absolute?: boolean }) => { + return `/app/${appId}${options?.path}`; + }), +} as unknown) as ApplicationStart; + +const history = createHashHistory(); +const kbnUrlStateStorage = createKbnUrlStateStorage({ + history, + useHash: false, +}); +kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { time: { from: 'now-7d', to: 'now' } }); + +describe('listing item link is correct for each app', () => { + test('creates a link to classic visualization if editApp is not defined', async () => { + const editUrl = 'edit/id'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, undefined, editUrl); + expect(url).toMatchInlineSnapshot(`"/app/visualize#${editUrl}?_g=(time:(from:now-7d,to:now))"`); + }); + + test('creates a link for the app given if editApp is defined', async () => { + const editUrl = '#/edit/id'; + const editApp = 'lens'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl); + expect(url).toMatchInlineSnapshot(`"/app/${editApp}${editUrl}?_g=(time:(from:now-7d,to:now))"`); + }); + + describe('when global time changes', () => { + beforeEach(() => { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + time: { + from: '2021-01-05T11:45:53.375Z', + to: '2021-01-21T11:46:00.990Z', + }, + }); + }); + + test('it propagates the correct time on the query', async () => { + const editUrl = '#/edit/id'; + const editApp = 'lens'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl); + expect(url).toMatchInlineSnapshot( + `"/app/${editApp}${editUrl}?_g=(time:(from:'2021-01-05T11:45:53.375Z',to:'2021-01-21T11:46:00.990Z'))"` + ); + }); + }); + + describe('when global refreshInterval changes', () => { + beforeEach(() => { + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + refreshInterval: { pause: false, value: 300 }, + }); + }); + + test('it propagates the refreshInterval on the query', async () => { + const editUrl = '#/edit/id'; + const editApp = 'lens'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl); + expect(url).toMatchInlineSnapshot( + `"/app/${editApp}${editUrl}?_g=(refreshInterval:(pause:!f,value:300))"` + ); + }); + }); + + describe('when global filters change', () => { + beforeEach(() => { + const filters = [ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + }, + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { query: 'q1' }, + $state: { + store: esFilters.FilterStateStore.GLOBAL_STATE, + }, + }, + ]; + kbnUrlStateStorage.set(GLOBAL_STATE_STORAGE_KEY, { + filters, + }); + }); + + test('propagates the filters on the query', async () => { + const editUrl = '#/edit/id'; + const editApp = 'lens'; + const url = getVisualizeListItemLink(application, kbnUrlStateStorage, editApp, editUrl); + expect(url).toMatchInlineSnapshot( + `"/app/${editApp}${editUrl}?_g=(filters:!((meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1)),('$state':(store:globalState),meta:(alias:!n,disabled:!f,negate:!f),query:(query:q1))))"` + ); + }); + }); +}); diff --git a/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.ts b/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.ts new file mode 100644 index 0000000000000..2ded3ce8c2745 --- /dev/null +++ b/src/plugins/visualize/public/application/utils/get_visualize_list_item_link.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ +import { ApplicationStart } from 'kibana/public'; +import { IKbnUrlStateStorage } from 'src/plugins/kibana_utils/public'; +import { QueryState } from '../../../../data/public'; +import { setStateToKbnUrl } from '../../../../kibana_utils/public'; +import { getUISettings } from '../../services'; +import { GLOBAL_STATE_STORAGE_KEY } from '../../../common/constants'; +import { APP_NAME } from '../visualize_constants'; + +export const getVisualizeListItemLink = ( + application: ApplicationStart, + kbnUrlStateStorage: IKbnUrlStateStorage, + editApp: string | undefined, + editUrl: string +) => { + // for visualizations the editApp is undefined + let url = application.getUrlForApp(editApp ?? APP_NAME, { + path: editApp ? editUrl : `#${editUrl}`, + }); + const useHash = getUISettings().get('state:storeInSessionStorage'); + const globalStateInUrl = kbnUrlStateStorage.get<QueryState>(GLOBAL_STATE_STORAGE_KEY) || {}; + + url = setStateToKbnUrl<QueryState>(GLOBAL_STATE_STORAGE_KEY, globalStateInUrl, { useHash }, url); + return url; +}; diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index 111ee7b0041ed..8d02e08549663 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -20,6 +20,7 @@ import { ScopedHistory, } from 'kibana/public'; +import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { Storage, createKbnUrlTracker, @@ -62,6 +63,7 @@ export interface VisualizePluginStartDependencies { savedObjects: SavedObjectsStart; dashboard: DashboardStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + presentationUtil: PresentationUtilPluginStart; } export interface VisualizePluginSetupDependencies { @@ -204,6 +206,7 @@ export class VisualizePlugin dashboard: pluginsStart.dashboard, setHeaderActionMenu: params.setHeaderActionMenu, savedObjectsTagging: pluginsStart.savedObjectsTaggingOss?.getTaggingApi(), + presentationUtil: pluginsStart.presentationUtil, }; params.element.classList.add('visAppWrapper'); diff --git a/src/plugins/visualize/public/url_generator.ts b/src/plugins/visualize/public/url_generator.ts index 15f05106130de..57fa9b2ae4801 100644 --- a/src/plugins/visualize/public/url_generator.ts +++ b/src/plugins/visualize/public/url_generator.ts @@ -16,9 +16,7 @@ import { } from '../../data/public'; import { setStateToKbnUrl } from '../../kibana_utils/public'; import { UrlGeneratorsDefinition } from '../../share/public'; - -const STATE_STORAGE_KEY = '_a'; -const GLOBAL_STATE_STORAGE_KEY = '_g'; +import { STATE_STORAGE_KEY, GLOBAL_STATE_STORAGE_KEY } from '../common/constants'; export const VISUALIZE_APP_URL_GENERATOR = 'VISUALIZE_APP_URL_GENERATOR'; diff --git a/test/accessibility/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index c6a194ace9c25..d29d17484486c 100644 --- a/test/accessibility/services/a11y/a11y.ts +++ b/test/accessibility/services/a11y/a11y.ts @@ -61,11 +61,7 @@ export function A11yProvider({ getService }: FtrProviderContext) { exclude: ([] as string[]) .concat(excludeTestSubj || []) .map((ts) => [testSubjectToCss(ts)]) - .concat([ - [ - '.leaflet-vega-container[role="graphics-document"][aria-roledescription="visualization"]', - ], - ]), + .concat([['[role="graphics-document"][aria-roledescription="visualization"]']]), }; } diff --git a/test/functional/apps/dashboard/dashboard_save.ts b/test/functional/apps/dashboard/dashboard_save.ts index 27cbba7db393d..e36136cd45141 100644 --- a/test/functional/apps/dashboard/dashboard_save.ts +++ b/test/functional/apps/dashboard/dashboard_save.ts @@ -12,7 +12,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header']); const listingTable = getService('listingTable'); - describe('dashboard save', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/89476 + describe.skip('dashboard save', function describeIndexTests() { this.tags('includeFirefox'); const dashboardName = 'Dashboard Save Test'; const dashboardNameEnterKey = 'Dashboard Save Test with Enter Key'; diff --git a/test/functional/apps/discover/_data_grid.ts b/test/functional/apps/discover/_data_grid.ts index 3bac05c5b18fc..1329e7657ad9c 100644 --- a/test/functional/apps/discover/_data_grid.ts +++ b/test/functional/apps/discover/_data_grid.ts @@ -38,7 +38,7 @@ export default function ({ const getTitles = async () => (await testSubjects.getVisibleText('dataGridHeader')).replace(/\s|\r?\n|\r/g, ' '); - expect(await getTitles()).to.be('Time (@timestamp) _source'); + expect(await getTitles()).to.be('Time (@timestamp) Document'); await PageObjects.discover.clickFieldListItemAdd('bytes'); expect(await getTitles()).to.be('Time (@timestamp) bytes'); @@ -50,7 +50,7 @@ export default function ({ expect(await getTitles()).to.be('Time (@timestamp) agent'); await PageObjects.discover.clickFieldListItemAdd('agent'); - expect(await getTitles()).to.be('Time (@timestamp) _source'); + expect(await getTitles()).to.be('Time (@timestamp) Document'); }); }); } diff --git a/test/functional/apps/discover/_data_grid_doc_table.ts b/test/functional/apps/discover/_data_grid_doc_table.ts index 8481065c18466..10cdd7e866af9 100644 --- a/test/functional/apps/discover/_data_grid_doc_table.ts +++ b/test/functional/apps/discover/_data_grid_doc_table.ts @@ -33,6 +33,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('discover'); }); + after(async function () { + log.debug('reset uiSettings'); + await kibanaServer.uiSettings.replace({}); + }); + it('should show the first 50 rows by default', async function () { // with the default range the number of hits is ~14000 const rows = await dataGrid.getDocTableRows(); diff --git a/test/functional/apps/discover/_data_grid_field_data.ts b/test/functional/apps/discover/_data_grid_field_data.ts index bdbaacc33c1bd..3eec84ad3d7c0 100644 --- a/test/functional/apps/discover/_data_grid_field_data.ts +++ b/test/functional/apps/discover/_data_grid_field_data.ts @@ -57,7 +57,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('doc view should show Time and _source columns', async function () { - const expectedHeader = 'Time (@timestamp) _source'; + const expectedHeader = 'Time (@timestamp) Document'; const DocHeader = await dataGrid.getHeaderFields(); expect(DocHeader.join(' ')).to.be(expectedHeader); }); diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts index 1176dd6395d2c..bf0a027553832 100644 --- a/test/functional/apps/discover/_discover.ts +++ b/test/functional/apps/discover/_discover.ts @@ -227,7 +227,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); describe('usage of discover:searchOnPageLoad', () => { - it('should fetch data from ES initially when discover:searchOnPageLoad is false', async function () { + it('should not fetch data from ES initially when discover:searchOnPageLoad is false', async function () { await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': false }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); @@ -235,7 +235,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await PageObjects.discover.getNrOfFetches()).to.be(0); }); - it('should not fetch data from ES initially when discover:searchOnPageLoad is true', async function () { + it('should fetch data from ES initially when discover:searchOnPageLoad is true', async function () { await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': true }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 6e6c53ec04985..ec6c455ecc979 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -26,7 +26,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const testSubjects = getService('testSubjects'); - describe('saved queries saved objects', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/89477 + describe.skip('saved queries saved objects', function describeIndexTests() { before(async function () { log.debug('load kibana index with default index pattern'); await esArchiver.load('discover'); diff --git a/test/functional/apps/getting_started/_shakespeare.ts b/test/functional/apps/getting_started/_shakespeare.ts index 95abbf9fa8a78..5a891af0de93d 100644 --- a/test/functional/apps/getting_started/_shakespeare.ts +++ b/test/functional/apps/getting_started/_shakespeare.ts @@ -30,8 +30,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // https://www.elastic.co/guide/en/kibana/current/tutorial-load-dataset.html - // Failing: See https://github.com/elastic/kibana/issues/82206 - describe.skip('Shakespeare', function describeIndexTests() { + describe('Shakespeare', function describeIndexTests() { // index starts on the first "count" metric at 1 // Each new metric or aggregation added to a visualization gets the next index. // So to modify a metric or aggregation tests need to keep track of the diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 438dd6f8adce2..a9fe2026112b6 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -20,8 +20,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardExpect = getService('dashboardExpect'); const PageObjects = getPageObjects(['common', 'header', 'home', 'dashboard', 'timePicker']); - // Failing: See https://github.com/elastic/kibana/issues/89379 - describe.skip('sample data', function describeIndexTests() { + describe('sample data', function describeIndexTests() { before(async () => { await security.testUser.setRoles(['kibana_admin', 'kibana_sample_admin']); await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { diff --git a/test/functional/apps/management/_import_objects.ts b/test/functional/apps/management/_import_objects.ts index 07811c9c68e45..754406938e47b 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 104d41b7e2a75..46619b89dfc59 100644 --- a/test/functional/apps/management/_scripted_fields_preview.js +++ b/test/functional/apps/management/_scripted_fields_preview.js @@ -13,7 +13,8 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['settings']); const SCRIPTED_FIELD_NAME = 'myScriptedField'; - describe('scripted fields preview', () => { + // FLAKY: https://github.com/elastic/kibana/issues/89475 + describe.skip('scripted fields preview', () => { before(async function () { await browser.setWindowSize(1200, 800); await PageObjects.settings.createIndexPattern(); diff --git a/test/functional/fixtures/es_archiver/visualize/data.json b/test/functional/fixtures/es_archiver/visualize/data.json index 56397351562de..66941e201e9ba 100644 --- a/test/functional/fixtures/es_archiver/visualize/data.json +++ b/test/functional/fixtures/es_archiver/visualize/data.json @@ -269,3 +269,24 @@ } } } + +{ + "type": "doc", + "value": { + "id": "visualization:VegaMap", + "index": ".kibana", + "source": { + "type": "visualization", + "visualization": { + "description": "VegaMap", + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}" + }, + "title": "VegaMap", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[],\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega/v5.json\\n config: {\\n kibana: {type: \\\"map\\\", latitude: 25, longitude: -70, zoom: 3}\\n }\\n data: [\\n {\\n name: table\\n url: {\\n index: kibana_sample_data_flights\\n %context%: true\\n // Uncomment to enable time filtering\\n // %timefield%: timestamp\\n body: {\\n size: 0\\n aggs: {\\n origins: {\\n terms: {field: \\\"OriginAirportID\\\", size: 10000}\\n aggs: {\\n originLocation: {\\n top_hits: {\\n size: 1\\n _source: {\\n includes: [\\\"OriginLocation\\\", \\\"Origin\\\"]\\n }\\n }\\n }\\n distinations: {\\n terms: {field: \\\"DestAirportID\\\", size: 10000}\\n aggs: {\\n destLocation: {\\n top_hits: {\\n size: 1\\n _source: {\\n includes: [\\\"DestLocation\\\"]\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n format: {property: \\\"aggregations.origins.buckets\\\"}\\n transform: [\\n {\\n type: geopoint\\n projection: projection\\n fields: [\\n originLocation.hits.hits[0]._source.OriginLocation.lon\\n originLocation.hits.hits[0]._source.OriginLocation.lat\\n ]\\n }\\n ]\\n }\\n {\\n name: selectedDatum\\n on: [\\n {trigger: \\\"!selected\\\", remove: true}\\n {trigger: \\\"selected\\\", insert: \\\"selected\\\"}\\n ]\\n }\\n ]\\n signals: [\\n {\\n name: selected\\n value: null\\n on: [\\n {events: \\\"@airport:mouseover\\\", update: \\\"datum\\\"}\\n {events: \\\"@airport:mouseout\\\", update: \\\"null\\\"}\\n ]\\n }\\n ]\\n scales: [\\n {\\n name: airportSize\\n type: linear\\n domain: {data: \\\"table\\\", field: \\\"doc_count\\\"}\\n range: [\\n {signal: \\\"zoom*zoom*0.2+1\\\"}\\n {signal: \\\"zoom*zoom*10+1\\\"}\\n ]\\n }\\n ]\\n marks: [\\n {\\n type: group\\n from: {\\n facet: {\\n name: facetedDatum\\n data: selectedDatum\\n field: distinations.buckets\\n }\\n }\\n data: [\\n {\\n name: facetDatumElems\\n source: facetedDatum\\n transform: [\\n {\\n type: geopoint\\n projection: projection\\n fields: [\\n destLocation.hits.hits[0]._source.DestLocation.lon\\n destLocation.hits.hits[0]._source.DestLocation.lat\\n ]\\n }\\n {type: \\\"formula\\\", expr: \\\"{x:parent.x, y:parent.y}\\\", as: \\\"source\\\"}\\n {type: \\\"formula\\\", expr: \\\"{x:datum.x, y:datum.y}\\\", as: \\\"target\\\"}\\n {type: \\\"linkpath\\\", shape: \\\"diagonal\\\"}\\n ]\\n }\\n ]\\n scales: [\\n {\\n name: lineThickness\\n type: log\\n clamp: true\\n range: [1, 8]\\n }\\n {\\n name: lineOpacity\\n type: log\\n clamp: true\\n range: [0.2, 0.8]\\n }\\n ]\\n marks: [\\n {\\n from: {data: \\\"facetDatumElems\\\"}\\n type: path\\n interactive: false\\n encode: {\\n update: {\\n path: {field: \\\"path\\\"}\\n stroke: {value: \\\"black\\\"}\\n strokeWidth: {scale: \\\"lineThickness\\\", field: \\\"doc_count\\\"}\\n strokeOpacity: {scale: \\\"lineOpacity\\\", field: \\\"doc_count\\\"}\\n }\\n }\\n }\\n ]\\n }\\n {\\n name: airport\\n type: symbol\\n from: {data: \\\"table\\\"}\\n encode: {\\n update: {\\n size: {scale: \\\"airportSize\\\", field: \\\"doc_count\\\"}\\n xc: {signal: \\\"datum.x\\\"}\\n yc: {signal: \\\"datum.y\\\"}\\n tooltip: {\\n signal: \\\"{title: datum.originLocation.hits.hits[0]._source.Origin + ' (' + datum.key + ')', connnections: length(datum.distinations.buckets), flights: datum.doc_count}\\\"\\n }\\n }\\n }\\n }\\n ]\\n}\"},\"title\":\"[Flights] Airport Connections (Hover Over Airport)\",\"type\":\"vega\"}" + } + } + } +} diff --git a/test/plugin_functional/test_suites/data_plugin/session.ts b/test/plugin_functional/test_suites/data_plugin/session.ts index ac958ead321bc..5567958cfd878 100644 --- a/test/plugin_functional/test_suites/data_plugin/session.ts +++ b/test/plugin_functional/test_suites/data_plugin/session.ts @@ -42,10 +42,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide await PageObjects.header.waitUntilLoadingHasFinished(); const sessionIds = await getSessionIds(); - // Discover calls destroy on index pattern change, which explicitly closes a session - expect(sessionIds.length).to.be(2); - expect(sessionIds[0].length).to.be(0); - expect(sessionIds[1].length).not.to.be(0); + expect(sessionIds.length).to.be(1); }); it('Starts on a refresh', async () => { diff --git a/test/scripts/checks/test_projects.sh b/test/scripts/checks/test_projects.sh index 56f15f6839e9d..be3fe4c4be9d0 100755 --- a/test/scripts/checks/test_projects.sh +++ b/test/scripts/checks/test_projects.sh @@ -3,4 +3,4 @@ source src/dev/ci_setup/setup_env.sh checks-reporter-with-killswitch "Test Projects" \ - yarn kbn run test --exclude kibana --oss --skip-kibana-plugins + yarn kbn run test --exclude kibana --oss --skip-kibana-plugins --skip-missing diff --git a/test/scripts/jenkins_unit.sh b/test/scripts/jenkins_unit.sh index 9e387f97a016e..6e28f9c3ef56a 100755 --- a/test/scripts/jenkins_unit.sh +++ b/test/scripts/jenkins_unit.sh @@ -2,6 +2,12 @@ source test/scripts/jenkins_test_setup.sh +rename_coverage_file() { + test -f target/kibana-coverage/jest/coverage-final.json \ + && mv target/kibana-coverage/jest/coverage-final.json \ + target/kibana-coverage/jest/$1-coverage-final.json +} + if [[ -z "$CODE_COVERAGE" ]] ; then # Lint ./test/scripts/lint/eslint.sh @@ -28,8 +34,13 @@ if [[ -z "$CODE_COVERAGE" ]] ; then ./test/scripts/checks/test_hardening.sh else echo " -> Running jest tests with coverage" - node scripts/jest --ci --verbose --maxWorkers=6 --coverage || true; - + node scripts/jest --ci --verbose --coverage --config jest.config.oss.js || true; + rename_coverage_file "oss" + echo "" + echo "" echo " -> Running jest integration tests with coverage" - node scripts/jest_integration --ci --verbose --coverage || true; + node --max-old-space-size=8192 scripts/jest_integration --ci --verbose --coverage || true; + rename_coverage_file "oss-integration" + echo "" + echo "" fi diff --git a/test/scripts/jenkins_xpack.sh b/test/scripts/jenkins_xpack.sh new file mode 100755 index 0000000000000..66fb5ae5370bc --- /dev/null +++ b/test/scripts/jenkins_xpack.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +source test/scripts/jenkins_test_setup.sh + +if [[ -z "$CODE_COVERAGE" ]] ; then + echo " -> Running jest tests" + + ./test/scripts/test/xpack_jest_unit.sh +else + echo " -> Build runtime for canvas" + # build runtime for canvas + echo "NODE_ENV=$NODE_ENV" + node ./x-pack/plugins/canvas/scripts/shareable_runtime + echo " -> Running jest tests with coverage" + cd x-pack + node --max-old-space-size=6144 scripts/jest --ci --verbose --maxWorkers=5 --coverage --config jest.config.js || true; + # rename file in order to be unique one + test -f ../target/kibana-coverage/jest/coverage-final.json \ + && mv ../target/kibana-coverage/jest/coverage-final.json \ + ../target/kibana-coverage/jest/xpack-coverage-final.json + echo "" + echo "" +fi diff --git a/test/scripts/test/jest_integration.sh b/test/scripts/test/jest_integration.sh index c48d9032466a3..78ed804f88430 100755 --- a/test/scripts/test/jest_integration.sh +++ b/test/scripts/test/jest_integration.sh @@ -3,4 +3,4 @@ source src/dev/ci_setup/setup_env.sh checks-reporter-with-killswitch "Jest Integration Tests" \ - node scripts/jest_integration --ci --verbose --coverage + node scripts/jest_integration --ci --verbose diff --git a/test/scripts/test/jest_unit.sh b/test/scripts/test/jest_unit.sh index 14d7268c6f36d..88c0fe528b88c 100755 --- a/test/scripts/test/jest_unit.sh +++ b/test/scripts/test/jest_unit.sh @@ -3,4 +3,4 @@ source src/dev/ci_setup/setup_env.sh checks-reporter-with-killswitch "Jest Unit Tests" \ - node scripts/jest --ci --verbose --maxWorkers=10 --coverage + node scripts/jest --config jest.config.oss.js --ci --verbose --maxWorkers=5 diff --git a/test/scripts/test/xpack_jest_unit.sh b/test/scripts/test/xpack_jest_unit.sh new file mode 100755 index 0000000000000..33b1c8a2b5183 --- /dev/null +++ b/test/scripts/test/xpack_jest_unit.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +source src/dev/ci_setup/setup_env.sh + +checks-reporter-with-killswitch "X-Pack Jest" \ + node scripts/jest x-pack --ci --verbose --maxWorkers=5 diff --git a/test/visual_regression/config.ts b/test/visual_regression/config.ts index c4951760fc756..60219efc61e6c 100644 --- a/test/visual_regression/config.ts +++ b/test/visual_regression/config.ts @@ -15,7 +15,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...functionalConfig.getAll(), - testFiles: [require.resolve('./tests/console_app'), require.resolve('./tests/discover')], + testFiles: [ + require.resolve('./tests/console_app'), + require.resolve('./tests/discover'), + require.resolve('./tests/vega'), + ], services, diff --git a/test/visual_regression/tests/vega/index.ts b/test/visual_regression/tests/vega/index.ts new file mode 100644 index 0000000000000..6f79ee834b3dc --- /dev/null +++ b/test/visual_regression/tests/vega/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { DEFAULT_OPTIONS } from '../../services/visual_testing/visual_testing'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +// Width must be the same as visual_testing or canvas image widths will get skewed +const [SCREEN_WIDTH] = DEFAULT_OPTIONS.widths || []; + +export default function ({ getService, loadTestFile }: FtrProviderContext) { + const browser = getService('browser'); + + describe('vega app', function () { + this.tags('ciGroup6'); + + before(function () { + return browser.setWindowSize(SCREEN_WIDTH, 1000); + }); + + loadTestFile(require.resolve('./vega_map_visualization')); + }); +} diff --git a/test/visual_regression/tests/vega/vega_map_visualization.ts b/test/visual_regression/tests/vega/vega_map_visualization.ts new file mode 100644 index 0000000000000..98aad0cb87795 --- /dev/null +++ b/test/visual_regression/tests/vega/vega_map_visualization.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * and the Server Side Public License, v 1; you may not use this file except in + * compliance with, at your election, the Elastic License or the Server Side + * Public License, v 1. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'visualize', 'visChart', 'visEditor', 'vegaChart']); + const visualTesting = getService('visualTesting'); + + describe('vega chart in visualize app', () => { + before(async () => { + await esArchiver.loadIfNeeded('kibana_sample_data_flights'); + await esArchiver.loadIfNeeded('visualize'); + }); + + after(async () => { + await esArchiver.unload('kibana_sample_data_flights'); + await esArchiver.unload('visualize'); + }); + + it('should show map with vega layer', async function () { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await PageObjects.visualize.openSavedVisualization('VegaMap'); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + await visualTesting.snapshot(); + }); + }); +} diff --git a/tsconfig.json b/tsconfig.json index 334a3febfddda..2647ac9a9d75e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,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 +53,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 +86,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 +110,7 @@ { "path": "./src/plugins/vis_type_timelion/tsconfig.json" }, { "path": "./src/plugins/vis_type_timeseries/tsconfig.json" }, { "path": "./src/plugins/vis_type_vislib/tsconfig.json" }, + { "path": "./src/plugins/vis_type_vega/tsconfig.json" }, { "path": "./src/plugins/vis_type_xy/tsconfig.json" }, { "path": "./src/plugins/visualizations/tsconfig.json" }, { "path": "./src/plugins/visualize/tsconfig.json" }, diff --git a/tsconfig.refs.json b/tsconfig.refs.json index a8eecd278160c..fa1b533a3dd38 100644 --- a/tsconfig.refs.json +++ b/tsconfig.refs.json @@ -24,7 +24,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" }, @@ -50,6 +49,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 782cc4271a06b..8223d85d53289 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -23,3 +23,10 @@ declare module '*.svg' { // eslint-disable-next-line import/no-default-export export default content; } + +// Storybook references this module. It's @ts-ignored in the codebase but when +// built into its dist it strips that out. Add it here to avoid a type checking +// error. +// +// See https://github.com/storybookjs/storybook/issues/11684 +declare module 'react-syntax-highlighter/dist/cjs/create-element'; diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index e393f3a5d2150..609d8f78aeb96 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -197,6 +197,13 @@ def ingest(jobName, buildNumber, buildUrl, timestamp, previousSha, teamAssignmen def runTests() { parallel([ 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), + 'x-pack-intake-agent': { + withEnv([ + 'NODE_ENV=test' // Needed for jest tests only + ]) { + workers.intake('x-pack-intake', './test/scripts/jenkins_xpack.sh')() + } + }, 'kibana-oss-agent' : workers.functional( 'kibana-oss-tests', { kibanaPipeline.buildOss() }, diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index e49692568cec8..93cb7a719bbe8 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -179,21 +179,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([ diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 74ad1267e9355..3493a95f0bdce 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -35,6 +35,7 @@ def test() { kibanaPipeline.scriptTask('Jest Unit Tests', 'test/scripts/test/jest_unit.sh'), kibanaPipeline.scriptTask('API Integration Tests', 'test/scripts/test/api_integration.sh'), + kibanaPipeline.scriptTask('X-Pack Jest Unit Tests', 'test/scripts/test/xpack_jest_unit.sh'), ]) } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index bfac437f3500a..f95c4286b3f26 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -38,6 +38,7 @@ "xpack.maps": ["plugins/maps"], "xpack.ml": ["plugins/ml"], "xpack.monitoring": ["plugins/monitoring"], + "xpack.osquery": ["plugins/osquery"], "xpack.painlessLab": "plugins/painless_lab", "xpack.remoteClusters": "plugins/remote_clusters", "xpack.reporting": ["plugins/reporting"], diff --git a/x-pack/build_chromium/README.md b/x-pack/build_chromium/README.md index 51c034e510024..9934d06a9d96a 100644 --- a/x-pack/build_chromium/README.md +++ b/x-pack/build_chromium/README.md @@ -15,11 +15,12 @@ gain familiarity. 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. +5. Ensure that there's enough room left on the disk: 100GB is required. `ncdu` is a good linux util to verify what's claming space. ## Usage ``` +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 diff --git a/x-pack/jest.config.js b/x-pack/jest.config.js new file mode 100644 index 0000000000000..8158987213cd2 --- /dev/null +++ b/x-pack/jest.config.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '..', + projects: ['<rootDir>/x-pack/plugins/*/jest.config.js'], +}; diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 8b6c25e1c3f24..cd97213a64dcc 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -402,6 +402,9 @@ describe('create()', () => { enabled: true, enabledActionTypes: ['some-not-ignored-action-type'], allowedHosts: ['*'], + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, }); const localActionTypeRegistryParams = { diff --git a/x-pack/plugins/actions/server/actions_config.mock.ts b/x-pack/plugins/actions/server/actions_config.mock.ts index 67ab495fc9678..e403fd99fe985 100644 --- a/x-pack/plugins/actions/server/actions_config.mock.ts +++ b/x-pack/plugins/actions/server/actions_config.mock.ts @@ -14,6 +14,8 @@ const createActionsConfigMock = () => { ensureHostnameAllowed: jest.fn().mockReturnValue({}), ensureUriAllowed: jest.fn().mockReturnValue({}), ensureActionTypeEnabled: jest.fn().mockReturnValue({}), + isRejectUnauthorizedCertificatesEnabled: jest.fn().mockReturnValue(true), + getProxySettings: jest.fn().mockReturnValue(undefined), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 56c58054ca799..c8b771b647e0d 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -4,22 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ActionsConfigType } from './types'; +import { ActionsConfig } from './config'; import { getActionsConfigurationUtilities, AllowedHosts, EnabledActionTypes, } from './actions_config'; -const DefaultActionsConfig: ActionsConfigType = { +const defaultActionsConfig: ActionsConfig = { enabled: false, allowedHosts: [], enabledActionTypes: [], + preconfigured: {}, + proxyRejectUnauthorizedCertificates: true, + rejectUnauthorized: true, }; describe('ensureUriAllowed', () => { test('returns true when "any" hostnames are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], @@ -30,7 +34,7 @@ describe('ensureUriAllowed', () => { }); test('throws when the hostname in the requested uri is not in the allowedHosts', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(() => getActionsConfigurationUtilities(config).ensureUriAllowed('https://github.com/elastic/kibana') ).toThrowErrorMatchingInlineSnapshot( @@ -39,7 +43,7 @@ describe('ensureUriAllowed', () => { }); test('throws when the uri cannot be parsed as a valid URI', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(() => getActionsConfigurationUtilities(config).ensureUriAllowed('github.com/elastic') ).toThrowErrorMatchingInlineSnapshot( @@ -48,7 +52,8 @@ describe('ensureUriAllowed', () => { }); test('returns true when the hostname in the requested uri is in the allowedHosts', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], @@ -61,7 +66,8 @@ describe('ensureUriAllowed', () => { describe('ensureHostnameAllowed', () => { test('returns true when "any" hostnames are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], @@ -72,7 +78,7 @@ describe('ensureHostnameAllowed', () => { }); test('throws when the hostname in the requested uri is not in the allowedHosts', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(() => getActionsConfigurationUtilities(config).ensureHostnameAllowed('github.com') ).toThrowErrorMatchingInlineSnapshot( @@ -81,7 +87,8 @@ describe('ensureHostnameAllowed', () => { }); test('returns true when the hostname in the requested uri is in the allowedHosts', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], @@ -94,7 +101,8 @@ describe('ensureHostnameAllowed', () => { describe('isUriAllowed', () => { test('returns true when "any" hostnames are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], @@ -105,21 +113,22 @@ describe('isUriAllowed', () => { }); test('throws when the hostname in the requested uri is not in the allowedHosts', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect( getActionsConfigurationUtilities(config).isUriAllowed('https://github.com/elastic/kibana') ).toEqual(false); }); test('throws when the uri cannot be parsed as a valid URI', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(getActionsConfigurationUtilities(config).isUriAllowed('github.com/elastic')).toEqual( false ); }); test('returns true when the hostname in the requested uri is in the allowedHosts', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], @@ -132,7 +141,8 @@ describe('isUriAllowed', () => { describe('isHostnameAllowed', () => { test('returns true when "any" hostnames are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [AllowedHosts.Any], enabledActionTypes: [], @@ -141,12 +151,13 @@ describe('isHostnameAllowed', () => { }); test('throws when the hostname in the requested uri is not in the allowedHosts', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(getActionsConfigurationUtilities(config).isHostnameAllowed('github.com')).toEqual(false); }); test('returns true when the hostname in the requested uri is in the allowedHosts', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: ['github.com'], enabledActionTypes: [], @@ -157,7 +168,8 @@ describe('isHostnameAllowed', () => { describe('isActionTypeEnabled', () => { test('returns true when "any" actionTypes are allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', EnabledActionTypes.Any], @@ -166,7 +178,8 @@ describe('isActionTypeEnabled', () => { }); test('returns false when no actionType is allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: [], @@ -175,7 +188,8 @@ describe('isActionTypeEnabled', () => { }); test('returns false when the actionType is not in the enabled list', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['foo'], @@ -184,7 +198,8 @@ describe('isActionTypeEnabled', () => { }); test('returns true when the actionType is in the enabled list', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', 'foo'], @@ -195,7 +210,8 @@ describe('isActionTypeEnabled', () => { describe('ensureActionTypeEnabled', () => { test('does not throw when any actionType is allowed', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', EnabledActionTypes.Any], @@ -204,7 +220,7 @@ describe('ensureActionTypeEnabled', () => { }); test('throws when no actionType is not allowed', () => { - const config: ActionsConfigType = DefaultActionsConfig; + const config: ActionsConfig = defaultActionsConfig; expect(() => getActionsConfigurationUtilities(config).ensureActionTypeEnabled('foo') ).toThrowErrorMatchingInlineSnapshot( @@ -213,7 +229,8 @@ describe('ensureActionTypeEnabled', () => { }); test('throws when actionType is not enabled', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore'], @@ -226,7 +243,8 @@ describe('ensureActionTypeEnabled', () => { }); test('does not throw when actionType is enabled', () => { - const config: ActionsConfigType = { + const config: ActionsConfig = { + ...defaultActionsConfig, enabled: false, allowedHosts: [], enabledActionTypes: ['ignore', 'foo'], diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index ebac80e70f4a8..396f59094a2d9 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -10,8 +10,9 @@ import url from 'url'; import { curry } from 'lodash'; import { pipe } from 'fp-ts/lib/pipeable'; -import { ActionsConfigType } from './types'; +import { ActionsConfig } from './config'; import { ActionTypeDisabledError } from './lib'; +import { ProxySettings } from './types'; export enum AllowedHosts { Any = '*', @@ -33,6 +34,8 @@ export interface ActionsConfigurationUtilities { ensureHostnameAllowed: (hostname: string) => void; ensureUriAllowed: (uri: string) => void; ensureActionTypeEnabled: (actionType: string) => void; + isRejectUnauthorizedCertificatesEnabled: () => boolean; + getProxySettings: () => undefined | ProxySettings; } function allowListErrorMessage(field: AllowListingField, value: string) { @@ -56,14 +59,14 @@ function disabledActionTypeErrorMessage(actionType: string) { }); } -function isAllowed({ allowedHosts }: ActionsConfigType, hostname: string | null): boolean { +function isAllowed({ allowedHosts }: ActionsConfig, hostname: string | null): boolean { const allowed = new Set(allowedHosts); if (allowed.has(AllowedHosts.Any)) return true; if (hostname && allowed.has(hostname)) return true; return false; } -function isHostnameAllowedInUri(config: ActionsConfigType, uri: string): boolean { +function isHostnameAllowedInUri(config: ActionsConfig, uri: string): boolean { return pipe( tryCatch(() => url.parse(uri)), map((parsedUrl) => parsedUrl.hostname), @@ -73,7 +76,7 @@ function isHostnameAllowedInUri(config: ActionsConfigType, uri: string): boolean } function isActionTypeEnabledInConfig( - { enabledActionTypes }: ActionsConfigType, + { enabledActionTypes }: ActionsConfig, actionType: string ): boolean { const enabled = new Set(enabledActionTypes); @@ -82,8 +85,20 @@ function isActionTypeEnabledInConfig( return false; } +function getProxySettingsFromConfig(config: ActionsConfig): undefined | ProxySettings { + if (!config.proxyUrl) { + return undefined; + } + + return { + proxyUrl: config.proxyUrl, + proxyHeaders: config.proxyHeaders, + proxyRejectUnauthorizedCertificates: config.proxyRejectUnauthorizedCertificates, + }; +} + export function getActionsConfigurationUtilities( - config: ActionsConfigType + config: ActionsConfig ): ActionsConfigurationUtilities { const isHostnameAllowed = curry(isAllowed)(config); const isUriAllowed = curry(isHostnameAllowedInUri)(config); @@ -92,6 +107,8 @@ export function getActionsConfigurationUtilities( isHostnameAllowed, isUriAllowed, isActionTypeEnabled, + getProxySettings: () => getProxySettingsFromConfig(config), + isRejectUnauthorizedCertificatesEnabled: () => config.rejectUnauthorized, ensureUriAllowed(uri: string) { if (!isUriAllowed(uri)) { throw new Error(allowListErrorMessage(AllowListingField.URL, uri)); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 91c71a78a8ee0..5d803e504593e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -277,6 +277,16 @@ describe('execute()', () => { `); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, "content": Object { "message": "a message to you @@ -286,7 +296,6 @@ describe('execute()', () => { "subject": "the subject", }, "hasAuth": true, - "proxySettings": undefined, "routing": Object { "bcc": Array [ "jimmy@example.com", @@ -327,6 +336,16 @@ describe('execute()', () => { await actionType.executor(customExecutorOptions); expect(sendEmailMock.mock.calls[0][1]).toMatchInlineSnapshot(` Object { + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, "content": Object { "message": "a message to you @@ -336,7 +355,6 @@ describe('execute()', () => { "subject": "the subject", }, "hasAuth": false, - "proxySettings": undefined, "routing": Object { "bcc": Array [ "jimmy@example.com", diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 4afbbb3a33615..b8a3467b27b54 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -156,7 +156,7 @@ export function getActionType(params: GetActionTypeParams): EmailActionType { params: ParamsSchema, }, renderParameterTemplates, - executor: curry(executor)({ logger, publicBaseUrl }), + executor: curry(executor)({ logger, publicBaseUrl, configurationUtilities }), }; } @@ -178,7 +178,12 @@ async function executor( { logger, publicBaseUrl, - }: { logger: GetActionTypeParams['logger']; publicBaseUrl: GetActionTypeParams['publicBaseUrl'] }, + configurationUtilities, + }: { + logger: GetActionTypeParams['logger']; + publicBaseUrl: GetActionTypeParams['publicBaseUrl']; + configurationUtilities: ActionsConfigurationUtilities; + }, execOptions: EmailActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -221,8 +226,8 @@ async function executor( subject: params.subject, message: `${params.message}${EMAIL_FOOTER_DIVIDER}${footerMessage}`, }, - proxySettings: execOptions.proxySettings, hasAuth: config.hasAuth, + configurationUtilities, }; let result; diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts index d701fad0e0c2f..6fdd1cb28d7bb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/index.ts @@ -72,13 +72,16 @@ export function getActionType( }), params: ExecutorParamsSchema, }, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } // action executor async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: ActionTypeExecutorOptions< JiraPublicConfigurationType, JiraSecretConfigurationType, @@ -95,7 +98,7 @@ async function executor( secrets, }, logger, - execOptions.proxySettings + configurationUtilities ); if (!api[subAction]) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts index 7e8770ffbd629..aa33389303081 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.test.ts @@ -11,6 +11,7 @@ import * as utils from '../lib/axios_utils'; import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; interface ResponseError extends Error { @@ -28,6 +29,7 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); const issueTypesResponse = { data: { @@ -116,7 +118,8 @@ describe('Jira service', () => { config: { apiUrl: 'https://siem-kibana.atlassian.net/', projectKey: 'CK' }, secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, - logger + logger, + configurationUtilities ); }); @@ -132,7 +135,8 @@ describe('Jira service', () => { config: { apiUrl: null, projectKey: 'CK' }, secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -144,7 +148,8 @@ describe('Jira service', () => { config: { apiUrl: 'test.com', projectKey: null }, secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -156,7 +161,8 @@ describe('Jira service', () => { config: { apiUrl: 'test.com' }, secrets: { apiToken: '', email: 'elastic@elastic.com' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -168,7 +174,8 @@ describe('Jira service', () => { config: { apiUrl: 'test.com' }, secrets: { apiToken: '', email: undefined }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -193,6 +200,7 @@ describe('Jira service', () => { axios, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', logger, + configurationUtilities, }); }); @@ -293,6 +301,7 @@ describe('Jira service', () => { url: 'https://siem-kibana.atlassian.net/rest/api/2/issue', logger, method: 'post', + configurationUtilities, data: { fields: { summary: 'title', @@ -331,6 +340,7 @@ describe('Jira service', () => { url: 'https://siem-kibana.atlassian.net/rest/api/2/issue', logger, method: 'post', + configurationUtilities, data: { fields: { summary: 'title', @@ -424,6 +434,7 @@ describe('Jira service', () => { axios, logger, method: 'put', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', data: { fields: { @@ -510,6 +521,7 @@ describe('Jira service', () => { axios, logger, method: 'post', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1/comment', data: { body: 'comment' }, }); @@ -568,6 +580,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/capabilities', }); }); @@ -642,6 +655,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta?projectKeys=CK&expand=projects.issuetypes.fields', }); @@ -724,6 +738,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta/CK/issuetypes', }); }); @@ -807,6 +822,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta?projectKeys=CK&issuetypeIds=10006&expand=projects.issuetypes.fields', }); @@ -928,6 +944,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/createmeta/CK/issuetypes/10006', }); }); @@ -988,6 +1005,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: `https://siem-kibana.atlassian.net/rest/api/2/search?jql=project%3D%22CK%22%20and%20summary%20~%22Test%20title%22`, }); }); @@ -1032,6 +1050,7 @@ describe('Jira service', () => { axios, logger, method: 'get', + configurationUtilities, url: `https://siem-kibana.atlassian.net/rest/api/2/issue/RJ-107`, }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts index f5e1b2e4411e3..791bfbaf5d69b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/jira/service.ts @@ -26,7 +26,7 @@ import { import * as i18n from './translations'; import { request, getErrorMessage } from '../lib/axios_utils'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; const VERSION = '2'; const BASE_URL = `rest/api/${VERSION}`; @@ -39,7 +39,7 @@ const createMetaCapabilities = ['list-project-issuetypes', 'list-issuetype-field export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - proxySettings?: ProxySettings + configurationUtilities: ActionsConfigurationUtilities ): ExternalService => { const { apiUrl: url, projectKey } = config as JiraPublicConfigurationType; const { apiToken, email } = secrets as JiraSecretConfigurationType; @@ -173,7 +173,7 @@ export const createExternalService = ( axios: axiosInstance, url: `${incidentUrl}/${id}`, logger, - proxySettings, + configurationUtilities, }); const { fields, ...rest } = res.data; @@ -222,7 +222,7 @@ export const createExternalService = ( data: { fields, }, - proxySettings, + configurationUtilities, }); const updatedIncident = await getIncident(res.data.id); @@ -263,7 +263,7 @@ export const createExternalService = ( url: `${incidentUrl}/${incidentId}`, logger, data: { fields }, - proxySettings, + configurationUtilities, }); const updatedIncident = await getIncident(incidentId as string); @@ -297,7 +297,7 @@ export const createExternalService = ( url: getCommentsURL(incidentId), logger, data: { body: comment.comment }, - proxySettings, + configurationUtilities, }); return { @@ -324,7 +324,7 @@ export const createExternalService = ( method: 'get', url: capabilitiesUrl, logger, - proxySettings, + configurationUtilities, }); return { ...res.data }; @@ -350,7 +350,7 @@ export const createExternalService = ( method: 'get', url: getIssueTypesOldAPIURL, logger, - proxySettings, + configurationUtilities, }); const issueTypes = res.data.projects[0]?.issuetypes ?? []; @@ -361,7 +361,7 @@ export const createExternalService = ( method: 'get', url: getIssueTypesUrl, logger, - proxySettings, + configurationUtilities, }); const issueTypes = res.data.values; @@ -389,7 +389,7 @@ export const createExternalService = ( method: 'get', url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsOldAPIURL, issueTypeId), logger, - proxySettings, + configurationUtilities, }); const fields = res.data.projects[0]?.issuetypes[0]?.fields || {}; @@ -400,7 +400,7 @@ export const createExternalService = ( method: 'get', url: createGetIssueTypeFieldsUrl(getIssueTypeFieldsUrl, issueTypeId), logger, - proxySettings, + configurationUtilities, }); const fields = res.data.values.reduce( @@ -459,7 +459,7 @@ export const createExternalService = ( method: 'get', url: query, logger, - proxySettings, + configurationUtilities, }); return normalizeSearchResults(res.data?.issues ?? []); @@ -483,7 +483,7 @@ export const createExternalService = ( method: 'get', url: getIssueUrl, logger, - proxySettings, + configurationUtilities, }); return normalizeIssue(res.data ?? {}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts index e106b17ad223f..23e16b7463914 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.test.ts @@ -5,12 +5,15 @@ */ import axios from 'axios'; +import { Agent as HttpsAgent } from 'https'; import { Logger } from '../../../../../../src/core/server'; import { addTimeZoneToDate, request, patch, getErrorMessage } from './axios_utils'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; import { getProxyAgents } from './get_proxy_agents'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; +const configurationUtilities = actionsConfigMock.create(); jest.mock('axios'); const axiosMock = (axios as unknown) as jest.Mock; @@ -41,13 +44,14 @@ describe('request', () => { axios, url: '/test', logger, + configurationUtilities, }); expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'get', data: {}, httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: expect.any(HttpsAgent), proxy: false, }); expect(res).toEqual({ @@ -58,20 +62,17 @@ describe('request', () => { }); test('it have been called with proper proxy agent for a valid url', async () => { - const proxySettings = { + configurationUtilities.getProxySettings.mockReturnValue({ proxyRejectUnauthorizedCertificates: true, proxyUrl: 'https://localhost:1212', - }; - const { httpAgent, httpsAgent } = getProxyAgents(proxySettings, logger); + }); + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); const res = await request({ axios, url: 'http://testProxy', logger, - proxySettings: { - proxyUrl: 'https://localhost:1212', - proxyRejectUnauthorizedCertificates: true, - }, + configurationUtilities, }); expect(axiosMock).toHaveBeenCalledWith('http://testProxy', { @@ -89,21 +90,22 @@ describe('request', () => { }); test('it have been called with proper proxy agent for an invalid url', async () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: ':nope:', + proxyRejectUnauthorizedCertificates: false, + }); const res = await request({ axios, url: 'https://testProxy', logger, - proxySettings: { - proxyUrl: ':nope:', - proxyRejectUnauthorizedCertificates: false, - }, + configurationUtilities, }); expect(axiosMock).toHaveBeenCalledWith('https://testProxy', { method: 'get', data: {}, httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: expect.any(HttpsAgent), proxy: false, }); expect(res).toEqual({ @@ -114,13 +116,20 @@ describe('request', () => { }); test('it fetch correctly', async () => { - const res = await request({ axios, url: '/test', method: 'post', logger, data: { id: '123' } }); + const res = await request({ + axios, + url: '/test', + method: 'post', + logger, + data: { id: '123' }, + configurationUtilities, + }); expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'post', data: { id: '123' }, httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: expect.any(HttpsAgent), proxy: false, }); expect(res).toEqual({ @@ -140,12 +149,12 @@ describe('patch', () => { }); test('it fetch correctly', async () => { - await patch({ axios, url: '/test', data: { id: '123' }, logger }); + await patch({ axios, url: '/test', data: { id: '123' }, logger, configurationUtilities }); expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'patch', data: { id: '123' }, httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: expect.any(HttpsAgent), proxy: false, }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts index 78c6b91b57dc0..a70a452737dc6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts @@ -6,8 +6,8 @@ import { AxiosInstance, Method, AxiosResponse, AxiosBasicCredentials } from 'axios'; import { Logger } from '../../../../../../src/core/server'; -import { ProxySettings } from '../../types'; import { getProxyAgents } from './get_proxy_agents'; +import { ActionsConfigurationUtilities } from '../../actions_config'; export const request = async <T = unknown>({ axios, @@ -15,7 +15,7 @@ export const request = async <T = unknown>({ logger, method = 'get', data, - proxySettings, + configurationUtilities, ...rest }: { axios: AxiosInstance; @@ -24,12 +24,12 @@ export const request = async <T = unknown>({ method?: Method; data?: T; params?: unknown; - proxySettings?: ProxySettings; + configurationUtilities: ActionsConfigurationUtilities; headers?: Record<string, string> | null; validateStatus?: (status: number) => boolean; auth?: AxiosBasicCredentials; }): Promise<AxiosResponse> => { - const { httpAgent, httpsAgent } = getProxyAgents(proxySettings, logger); + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); return await axios(url, { ...rest, @@ -47,13 +47,13 @@ export const patch = async <T = unknown>({ url, data, logger, - proxySettings, + configurationUtilities, }: { axios: AxiosInstance; url: string; data: T; logger: Logger; - proxySettings?: ProxySettings; + configurationUtilities: ActionsConfigurationUtilities; }): Promise<AxiosResponse> => { return request({ axios, @@ -61,7 +61,7 @@ export const patch = async <T = unknown>({ logger, method: 'patch', data, - proxySettings, + configurationUtilities, }); }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.test.ts index 759ca92968263..da2ad9bb3990d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.test.ts @@ -4,41 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Agent as HttpsAgent } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; import { getProxyAgents } from './get_proxy_agents'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; describe('getProxyAgents', () => { + const configurationUtilities = actionsConfigMock.create(); + test('get agents for valid proxy URL', () => { - const { httpAgent, httpsAgent } = getProxyAgents( - { proxyUrl: 'https://someproxyhost', proxyRejectUnauthorizedCertificates: false }, - logger - ); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + }); + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); expect(httpAgent instanceof HttpProxyAgent).toBeTruthy(); expect(httpsAgent instanceof HttpsProxyAgent).toBeTruthy(); }); - test('return undefined agents for invalid proxy URL', () => { - const { httpAgent, httpsAgent } = getProxyAgents( - { proxyUrl: ':nope: not a valid URL', proxyRejectUnauthorizedCertificates: false }, - logger - ); - expect(httpAgent).toBe(undefined); - expect(httpsAgent).toBe(undefined); - }); - - test('return undefined agents for null proxy options', () => { - const { httpAgent, httpsAgent } = getProxyAgents(null, logger); + test('return default agents for invalid proxy URL', () => { + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: ':nope: not a valid URL', + proxyRejectUnauthorizedCertificates: false, + }); + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); expect(httpAgent).toBe(undefined); - expect(httpsAgent).toBe(undefined); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); }); - test('return undefined agents for undefined proxy options', () => { - const { httpAgent, httpsAgent } = getProxyAgents(undefined, logger); + test('return default agents for undefined proxy options', () => { + const { httpAgent, httpsAgent } = getProxyAgents(configurationUtilities, logger); expect(httpAgent).toBe(undefined); - expect(httpsAgent).toBe(undefined); + expect(httpsAgent instanceof HttpsAgent).toBeTruthy(); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.ts index 45f962429ad2b..a49889570f4bf 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_proxy_agents.ts @@ -4,28 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Agent } from 'http'; +import { Agent as HttpAgent } from 'http'; +import { Agent as HttpsAgent } from 'https'; import HttpProxyAgent from 'http-proxy-agent'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { Logger } from '../../../../../../src/core/server'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; interface GetProxyAgentsResponse { - httpAgent: Agent | undefined; - httpsAgent: Agent | undefined; + httpAgent: HttpAgent | undefined; + httpsAgent: HttpsAgent | undefined; } export function getProxyAgents( - proxySettings: ProxySettings | undefined | null, + configurationUtilities: ActionsConfigurationUtilities, logger: Logger ): GetProxyAgentsResponse { - const undefinedResponse = { + const proxySettings = configurationUtilities.getProxySettings(); + const defaultResponse = { httpAgent: undefined, - httpsAgent: undefined, + httpsAgent: new HttpsAgent({ + rejectUnauthorized: configurationUtilities.isRejectUnauthorizedCertificatesEnabled(), + }), }; if (!proxySettings) { - return undefinedResponse; + return defaultResponse; } logger.debug(`Creating proxy agents for proxy: ${proxySettings.proxyUrl}`); @@ -34,7 +38,7 @@ export function getProxyAgents( proxyUrl = new URL(proxySettings.proxyUrl); } catch (err) { logger.warn(`invalid proxy URL "${proxySettings.proxyUrl}" ignored`); - return undefinedResponse; + return defaultResponse; } const httpAgent = new HttpProxyAgent(proxySettings.proxyUrl); @@ -45,8 +49,8 @@ export function getProxyAgents( headers: proxySettings.proxyHeaders, // do not fail on invalid certs if value is false rejectUnauthorized: proxySettings.proxyRejectUnauthorizedCertificates, - }) as unknown) as Agent; - // vsCode wasn't convinced HttpsProxyAgent is an http.Agent, so we convinced it + }) as unknown) as HttpsAgent; + // vsCode wasn't convinced HttpsProxyAgent is an https.Agent, so we convinced it return { httpAgent, httpsAgent }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts index d78237beb98a1..51a4e3f857153 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/post_pagerduty.ts @@ -6,23 +6,24 @@ import axios, { AxiosResponse } from 'axios'; import { Logger } from '../../../../../../src/core/server'; -import { Services, ProxySettings } from '../../types'; +import { Services } from '../../types'; import { request } from './axios_utils'; +import { ActionsConfigurationUtilities } from '../../actions_config'; interface PostPagerdutyOptions { apiUrl: string; data: unknown; headers: Record<string, string>; services: Services; - proxySettings?: ProxySettings; } // post an event to pagerduty export async function postPagerduty( options: PostPagerdutyOptions, - logger: Logger + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities ): Promise<AxiosResponse> { - const { apiUrl, data, headers, proxySettings } = options; + const { apiUrl, data, headers } = options; const axiosInstance = axios.create(); return await request({ @@ -31,8 +32,8 @@ export async function postPagerduty( method: 'post', logger, data, - proxySettings, headers, + configurationUtilities, validateStatus: () => true, }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index a1c4041628bd5..bd23aba618544 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -13,6 +13,7 @@ import { sendEmail } from './send_email'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; +import { actionsConfigMock } from '../../actions_config.mock'; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; @@ -136,7 +137,7 @@ describe('send_email module', () => { "port": 1025, "secure": false, "tls": Object { - "rejectUnauthorized": undefined, + "rejectUnauthorized": true, }, }, ] @@ -223,6 +224,10 @@ function getSendEmailOptions( { content = {}, routing = {}, transport = {} } = {}, proxySettings?: ProxySettings ) { + const configurationUtilities = actionsConfigMock.create(); + if (proxySettings) { + configurationUtilities.getProxySettings.mockReturnValue(proxySettings); + } return { content: { ...content, @@ -242,8 +247,8 @@ function getSendEmailOptions( user: 'elastic', password: 'changeme', }, - proxySettings, hasAuth: true, + configurationUtilities, }; } @@ -251,6 +256,10 @@ function getSendEmailOptionsNoAuth( { content = {}, routing = {}, transport = {} } = {}, proxySettings?: ProxySettings ) { + const configurationUtilities = actionsConfigMock.create(); + if (proxySettings) { + configurationUtilities.getProxySettings.mockReturnValue(proxySettings); + } return { content: { ...content, @@ -267,7 +276,7 @@ function getSendEmailOptionsNoAuth( transport: { ...transport, }, - proxySettings, hasAuth: false, + configurationUtilities, }; } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index f3cdf82bfe8cd..2ade4f9e9dcb4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -9,7 +9,7 @@ import nodemailer from 'nodemailer'; import { default as MarkdownIt } from 'markdown-it'; import { Logger } from '../../../../../../src/core/server'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -18,9 +18,8 @@ export interface SendEmailOptions { transport: Transport; routing: Routing; content: Content; - proxySettings?: ProxySettings; - rejectUnauthorized?: boolean; hasAuth: boolean; + configurationUtilities: ActionsConfigurationUtilities; } // config validation ensures either service is set or host/port are set @@ -47,12 +46,14 @@ export interface Content { // send an email export async function sendEmail(logger: Logger, options: SendEmailOptions): Promise<unknown> { - const { transport, routing, content, proxySettings, rejectUnauthorized, hasAuth } = options; + const { transport, routing, content, configurationUtilities, hasAuth } = options; const { service, host, port, secure, user, password } = transport; const { from, to, cc, bcc } = routing; const { subject, message } = content; const transportConfig: Record<string, unknown> = {}; + const proxySettings = configurationUtilities.getProxySettings(); + const rejectUnauthorized = configurationUtilities.isRejectUnauthorizedCertificatesEnabled(); if (hasAuth && user != null && password != null) { transportConfig.auth = { diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts index ccd25da2397bb..688e75aece43c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -139,7 +139,7 @@ export function getActionType({ secrets: SecretsSchema, params: ParamsSchema, }, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } @@ -166,7 +166,10 @@ function getPagerDutyApiUrl(config: ActionTypeConfigType): string { // action executor async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: PagerDutyActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -174,7 +177,6 @@ async function executor( const secrets = execOptions.secrets; const params = execOptions.params; const services = execOptions.services; - const proxySettings = execOptions.proxySettings; const apiUrl = getPagerDutyApiUrl(config); const headers = { @@ -185,7 +187,11 @@ async function executor( let response; try { - response = await postPagerduty({ apiUrl, data, headers, services, proxySettings }, logger); + response = await postPagerduty( + { apiUrl, data, headers, services }, + logger, + configurationUtilities + ); } catch (err) { const message = i18n.translate('xpack.actions.builtin.pagerduty.postingErrorMessage', { defaultMessage: 'error posting pagerduty event', diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts index fca99f81d62bd..a14a1c7d4c8af 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts @@ -63,13 +63,16 @@ export function getActionType( }), params: ExecutorParamsSchema, }, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } // action executor async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: ActionTypeExecutorOptions< ResilientPublicConfigurationType, ResilientSecretConfigurationType, @@ -86,7 +89,7 @@ async function executor( secrets, }, logger, - execOptions.proxySettings + configurationUtilities ); if (!api[subAction]) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts index 97d8b64fb6535..326f0a6ed5f8b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -12,6 +12,7 @@ import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; import { incidentTypes, resilientFields, severity } from './mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; @@ -28,6 +29,7 @@ axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; const now = Date.now; const TIMESTAMP = 1589391874472; +const configurationUtilities = actionsConfigMock.create(); // Incident update makes three calls to the API. // The function below mocks this calls. @@ -86,7 +88,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: 'https://resilient.elastic.co/', orgId: '201' }, secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, }, - logger + logger, + configurationUtilities ); }); @@ -155,7 +158,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: null, orgId: '201' }, secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -167,7 +171,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: 'test.com', orgId: null }, secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -179,7 +184,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: 'test.com', orgId: '201' }, secrets: { apiKeyId: '', apiKeySecret: 'secret' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -191,7 +197,8 @@ describe('IBM Resilient service', () => { config: { apiUrl: 'test.com', orgId: '201' }, secrets: { apiKeyId: '', apiKeySecret: undefined }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -226,6 +233,7 @@ describe('IBM Resilient service', () => { params: { text_content_output_format: 'objects_convert', }, + configurationUtilities, }); }); @@ -294,6 +302,7 @@ describe('IBM Resilient service', () => { 'https://resilient.elastic.co/rest/orgs/201/incidents?text_content_output_format=objects_convert', logger, method: 'post', + configurationUtilities, data: { name: 'title', description: { @@ -367,6 +376,7 @@ describe('IBM Resilient service', () => { axios, logger, method: 'patch', + configurationUtilities, url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', data: { changes: [ @@ -480,7 +490,7 @@ describe('IBM Resilient service', () => { axios, logger, method: 'post', - proxySettings: undefined, + configurationUtilities, url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments', data: { text: { @@ -584,6 +594,7 @@ describe('IBM Resilient service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://resilient.elastic.co/rest/orgs/201/types/incident/fields', }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts index a13204f8bb1d8..ec31de4f2afd5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -24,7 +24,7 @@ import { import * as i18n from './translations'; import { getErrorMessage, request } from '../lib/axios_utils'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; const VIEW_INCIDENT_URL = `#incidents`; @@ -93,7 +93,7 @@ export const formatUpdateRequest = ({ export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - proxySettings?: ProxySettings + configurationUtilities: ActionsConfigurationUtilities ): ExternalService => { const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType; const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType; @@ -130,7 +130,7 @@ export const createExternalService = ( params: { text_content_output_format: 'objects_convert', }, - proxySettings, + configurationUtilities, }); return { ...res.data, description: res.data.description?.content ?? '' }; @@ -178,7 +178,7 @@ export const createExternalService = ( method: 'post', logger, data, - proxySettings, + configurationUtilities, }); return { @@ -208,7 +208,7 @@ export const createExternalService = ( url: `${incidentUrl}/${incidentId}`, logger, data, - proxySettings, + configurationUtilities, }); if (!res.data.success) { @@ -241,7 +241,7 @@ export const createExternalService = ( url: getCommentsURL(incidentId), logger, data: { text: { format: 'text', content: comment.comment } }, - proxySettings, + configurationUtilities, }); return { @@ -266,7 +266,7 @@ export const createExternalService = ( method: 'get', url: incidentTypesUrl, logger, - proxySettings, + configurationUtilities, }); const incidentTypes = res.data?.values ?? []; @@ -288,7 +288,7 @@ export const createExternalService = ( method: 'get', url: severityUrl, logger, - proxySettings, + configurationUtilities, }); const incidentTypes = res.data?.values ?? []; @@ -309,7 +309,7 @@ export const createExternalService = ( axios: axiosInstance, url: incidentFieldsUrl, logger, - proxySettings, + configurationUtilities, }); return res.data ?? []; } catch (error) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index 1f75d439200e3..107d86f111deb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -60,14 +60,17 @@ export function getActionType( }), params: ExecutorParamsSchema, }, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } // action executor const supportedSubActions: string[] = ['getFields', 'pushToService']; async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: ActionTypeExecutorOptions< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, @@ -84,7 +87,7 @@ async function executor( secrets, }, logger, - execOptions.proxySettings + configurationUtilities ); if (!api[subAction]) { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts index 1a6412f9ceb5b..4ef0e7da166e6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts @@ -11,6 +11,7 @@ import * as utils from '../lib/axios_utils'; import { ExternalService } from './types'; import { Logger } from '../../../../../../src/core/server'; import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; import { serviceNowCommonFields } from './mocks'; const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>; @@ -27,6 +28,7 @@ jest.mock('../lib/axios_utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; const patchMock = utils.patch as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); describe('ServiceNow service', () => { let service: ExternalService; @@ -39,7 +41,8 @@ describe('ServiceNow service', () => { config: { apiUrl: 'https://dev102283.service-now.com/' }, secrets: { username: 'admin', password: 'admin' }, }, - logger + logger, + configurationUtilities ); }); @@ -55,7 +58,8 @@ describe('ServiceNow service', () => { config: { apiUrl: null }, secrets: { username: 'admin', password: 'admin' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -67,7 +71,8 @@ describe('ServiceNow service', () => { config: { apiUrl: 'test.com' }, secrets: { username: '', password: 'admin' }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -79,7 +84,8 @@ describe('ServiceNow service', () => { config: { apiUrl: 'test.com' }, secrets: { username: '', password: undefined }, }, - logger + logger, + configurationUtilities ) ).toThrow(); }); @@ -103,6 +109,7 @@ describe('ServiceNow service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', }); }); @@ -147,6 +154,7 @@ describe('ServiceNow service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/incident', method: 'post', data: { short_description: 'title', description: 'desc' }, @@ -200,6 +208,7 @@ describe('ServiceNow service', () => { expect(patchMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1', data: { short_description: 'title', description: 'desc' }, }); @@ -248,6 +257,7 @@ describe('ServiceNow service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, logger, + configurationUtilities, url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory', }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts index 96faf6d338b90..108fe06bcbcaa 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts @@ -12,7 +12,7 @@ import * as i18n from './translations'; import { Logger } from '../../../../../../src/core/server'; import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types'; import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils'; -import { ProxySettings } from '../../types'; +import { ActionsConfigurationUtilities } from '../../actions_config'; const API_VERSION = 'v2'; const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`; @@ -24,7 +24,7 @@ const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`; export const createExternalService = ( { config, secrets }: ExternalServiceCredentials, logger: Logger, - proxySettings?: ProxySettings + configurationUtilities: ActionsConfigurationUtilities ): ExternalService => { const { apiUrl: url } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; @@ -58,7 +58,7 @@ export const createExternalService = ( axios: axiosInstance, url: `${incidentUrl}/${id}`, logger, - proxySettings, + configurationUtilities, }); checkInstance(res); return { ...res.data.result }; @@ -75,8 +75,8 @@ export const createExternalService = ( axios: axiosInstance, url: incidentUrl, logger, - proxySettings, params, + configurationUtilities, }); checkInstance(res); return res.data.result.length > 0 ? { ...res.data.result } : undefined; @@ -93,9 +93,9 @@ export const createExternalService = ( axios: axiosInstance, url: `${incidentUrl}`, logger, - proxySettings, method: 'post', data: { ...(incident as Record<string, unknown>) }, + configurationUtilities, }); checkInstance(res); return { @@ -118,7 +118,7 @@ export const createExternalService = ( url: `${incidentUrl}/${incidentId}`, logger, data: { ...(incident as Record<string, unknown>) }, - proxySettings, + configurationUtilities, }); checkInstance(res); return { @@ -143,7 +143,7 @@ export const createExternalService = ( axios: axiosInstance, url: fieldsUrl, logger, - proxySettings, + configurationUtilities, }); checkInstance(res); return res.data.result.length > 0 ? res.data.result : []; diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index e73f8d91b0847..cfac23e624a04 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -165,10 +165,6 @@ describe('execute()', () => { config: {}, secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, - proxySettings: { - proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, - }, }); expect(response).toMatchInlineSnapshot(` Object { @@ -194,9 +190,14 @@ describe('execute()', () => { }); test('calls the mock executor with success proxy', async () => { + const configurationUtilities = actionsConfigMock.create(); + configurationUtilities.getProxySettings.mockReturnValue({ + proxyUrl: 'https://someproxyhost', + proxyRejectUnauthorizedCertificates: false, + }); const actionTypeProxy = getActionType({ logger: mockedLogger, - configurationUtilities: actionsConfigMock.create(), + configurationUtilities, }); await actionTypeProxy.executor({ actionId: 'some-id', @@ -204,10 +205,6 @@ describe('execute()', () => { config: {}, secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, - proxySettings: { - proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, - }, }); expect(mockedLogger.debug).toHaveBeenCalledWith( 'IncomingWebhook was called with proxyUrl https://someproxyhost' diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index 02026eb729727..5d2c5a24b3edd 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -6,7 +6,6 @@ import { URL } from 'url'; import { curry } from 'lodash'; -import { Agent } from 'http'; import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook'; @@ -56,7 +55,7 @@ export const ActionTypeId = '.slack'; export function getActionType({ logger, configurationUtilities, - executor = curry(slackExecutor)({ logger }), + executor = curry(slackExecutor)({ logger, configurationUtilities }), }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; @@ -116,7 +115,10 @@ function validateActionTypeConfig( // action executor async function slackExecutor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: SlackActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -126,15 +128,15 @@ async function slackExecutor( let result: IncomingWebhookResult; const { webhookUrl } = secrets; const { message } = params; + const proxySettings = configurationUtilities.getProxySettings(); - let httpProxyAgent: Agent | undefined; - if (execOptions.proxySettings) { - const httpProxyAgents = getProxyAgents(execOptions.proxySettings, logger); - httpProxyAgent = webhookUrl.toLowerCase().startsWith('https') - ? httpProxyAgents.httpsAgent - : httpProxyAgents.httpAgent; + const proxyAgents = getProxyAgents(configurationUtilities, logger); + const httpProxyAgent = webhookUrl.toLowerCase().startsWith('https') + ? proxyAgents.httpsAgent + : proxyAgents.httpAgent; - logger.debug(`IncomingWebhook was called with proxyUrl ${execOptions.proxySettings.proxyUrl}`); + if (proxySettings) { + logger.debug(`IncomingWebhook was called with proxyUrl ${proxySettings.proxyUrl}`); } try { diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts index a9fce2f0a9ebf..4ca25013e9691 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.test.ts @@ -160,38 +160,47 @@ describe('execute()', () => { params: { message: 'this invocation should succeed' }, }); expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "axios": undefined, - "data": Object { - "text": "this invocation should succeed", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from teams action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], + Object { + "axios": undefined, + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "data": Object { + "text": "this invocation should succeed", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "proxySettings": undefined, - "url": "http://example.com", - } + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "url": "http://example.com", + } `); expect(response).toMatchInlineSnapshot(` Object { @@ -211,47 +220,49 @@ describe('execute()', () => { config: {}, secrets: { webhookUrl: 'http://example.com' }, params: { message: 'this invocation should succeed' }, - proxySettings: { - proxyUrl: 'https://someproxyhost', - proxyRejectUnauthorizedCertificates: false, - }, }); expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "axios": undefined, - "data": Object { - "text": "this invocation should succeed", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from teams action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], + Object { + "axios": undefined, + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "data": Object { + "text": "this invocation should succeed", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from teams action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "proxySettings": Object { - "proxyRejectUnauthorizedCertificates": false, - "proxyUrl": "https://someproxyhost", - }, - "url": "http://example.com", - } + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "url": "http://example.com", + } `); expect(response).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/actions/server/builtin_action_types/teams.ts b/x-pack/plugins/actions/server/builtin_action_types/teams.ts index 088e30db4e3ce..857110d2f53c4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/teams.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/teams.ts @@ -63,7 +63,7 @@ export function getActionType({ }), params: ParamsSchema, }, - executor: curry(teamsExecutor)({ logger }), + executor: curry(teamsExecutor)({ logger, configurationUtilities }), }; } @@ -95,7 +95,10 @@ function validateActionTypeConfig( // action executor async function teamsExecutor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: TeamsActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -114,7 +117,7 @@ async function teamsExecutor( url: webhookUrl, logger, data, - proxySettings: execOptions.proxySettings, + configurationUtilities, }) ); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index dbbd2a029caa9..80614e6b1336d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -279,43 +279,52 @@ describe('execute()', () => { }); expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "auth": Object { - "password": "123", - "username": "abc", - }, - "axios": undefined, - "data": "some data", - "headers": Object { - "aheader": "a value", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from webhook action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], + Object { + "auth": Object { + "password": "123", + "username": "abc", + }, + "axios": undefined, + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "data": "some data", + "headers": Object { + "aheader": "a value", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from webhook action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "proxySettings": undefined, - "url": "https://abc.def/my-webhook", - } + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "url": "https://abc.def/my-webhook", + } `); }); @@ -338,39 +347,48 @@ describe('execute()', () => { }); expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(` - Object { - "axios": undefined, - "data": "some data", - "headers": Object { - "aheader": "a value", - }, - "logger": Object { - "context": Array [], - "debug": [MockFunction] { - "calls": Array [ - Array [ - "response from webhook action \\"some-id\\": [HTTP 200] ", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], + Object { + "axios": undefined, + "configurationUtilities": Object { + "ensureActionTypeEnabled": [MockFunction], + "ensureHostnameAllowed": [MockFunction], + "ensureUriAllowed": [MockFunction], + "getProxySettings": [MockFunction], + "isActionTypeEnabled": [MockFunction], + "isHostnameAllowed": [MockFunction], + "isRejectUnauthorizedCertificatesEnabled": [MockFunction], + "isUriAllowed": [MockFunction], + }, + "data": "some data", + "headers": Object { + "aheader": "a value", + }, + "logger": Object { + "context": Array [], + "debug": [MockFunction] { + "calls": Array [ + Array [ + "response from webhook action \\"some-id\\": [HTTP 200] ", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, }, - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - "method": "post", - "proxySettings": undefined, - "url": "https://abc.def/my-webhook", - } + ], + }, + "error": [MockFunction], + "fatal": [MockFunction], + "get": [MockFunction], + "info": [MockFunction], + "log": [MockFunction], + "trace": [MockFunction], + "warn": [MockFunction], + }, + "method": "post", + "url": "https://abc.def/my-webhook", + } `); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts index fa6d2663c94ab..76063deee0f5e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -94,7 +94,7 @@ export function getActionType({ params: ParamsSchema, }, renderParameterTemplates, - executor: curry(executor)({ logger }), + executor: curry(executor)({ logger, configurationUtilities }), }; } @@ -138,7 +138,10 @@ function validateActionTypeConfig( // action executor export async function executor( - { logger }: { logger: Logger }, + { + logger, + configurationUtilities, + }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities }, execOptions: WebhookActionTypeExecutorOptions ): Promise<ActionTypeExecutorResult<unknown>> { const actionId = execOptions.actionId; @@ -162,7 +165,7 @@ export async function executor( ...basicAuth, headers, data, - proxySettings: execOptions.proxySettings, + configurationUtilities, }) ); @@ -202,7 +205,7 @@ export async function executor( ); } return errorResultInvalid(actionId, message); - } else if (error.isAxiosError) { + } else if (error.code) { const message = `[${error.code}] ${error.message}`; logger.error(`error on ${actionId} webhook event: ${message}`); return errorResultRequestFailed(actionId, message); diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 6c4857bff4e81..4e59dfd099811 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -6,10 +6,9 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { PluginInitializerContext, PluginConfigDescriptor } from '../../../../src/core/server'; import { ActionsPlugin } from './plugin'; -import { configSchema } from './config'; +import { configSchema, ActionsConfig } from './config'; import { ActionsClient as ActionsClientClass } from './actions_client'; import { ActionsAuthorization as ActionsAuthorizationClass } from './authorization/actions_authorization'; -import { ActionsConfigType } from './types'; export type ActionsClient = PublicMethodsOf<ActionsClientClass>; export type ActionsAuthorization = PublicMethodsOf<ActionsAuthorizationClass>; @@ -52,7 +51,7 @@ export { asSavedObjectExecutionSource, asHttpRequestExecutionSource } from './li export const plugin = (initContext: PluginInitializerContext) => new ActionsPlugin(initContext); -export const config: PluginConfigDescriptor<ActionsConfigType> = { +export const config: PluginConfigDescriptor<ActionsConfig> = { schema: configSchema, deprecations: ({ renameFromRoot }) => [ renameFromRoot('xpack.actions.whitelistedHosts', 'xpack.actions.allowedHosts'), diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 695613a59eff1..fa33c1226ec9e 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -12,7 +12,6 @@ import { GetServicesFunction, RawAction, PreConfiguredAction, - ProxySettings, } from '../types'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { SpacesServiceStart } from '../../../spaces/server'; @@ -33,7 +32,6 @@ export interface ActionExecutorContext { actionTypeRegistry: ActionTypeRegistryContract; eventLogger: IEventLogger; preconfiguredActions: PreConfiguredAction[]; - proxySettings?: ProxySettings; } export interface ExecuteOptions<Source = unknown> { @@ -87,7 +85,6 @@ export class ActionExecutor { eventLogger, preconfiguredActions, getActionsClientWithRequest, - proxySettings, } = this.actionExecutorContext!; const services = getServices(request); @@ -145,7 +142,6 @@ export class ActionExecutor { params: validatedParams, config: validatedConfig, secrets: validatedSecrets, - proxySettings, }); } catch (err) { rawResult = { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 1543f8d7a07ce..22400a08a2a09 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -357,15 +357,6 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi encryptedSavedObjectsClient, actionTypeRegistry: actionTypeRegistry!, preconfiguredActions, - proxySettings: - this.actionsConfig && this.actionsConfig.proxyUrl - ? { - proxyUrl: this.actionsConfig.proxyUrl, - proxyHeaders: this.actionsConfig.proxyHeaders, - proxyRejectUnauthorizedCertificates: this.actionsConfig - .proxyRejectUnauthorizedCertificates, - } - : undefined, }); const spaceIdToNamespace = (spaceId?: string) => { diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index f545c0fc96633..0bcf02c6f83ae 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -55,12 +55,6 @@ export interface ActionsPlugin { start: PluginStartContract; } -export interface ActionsConfigType { - enabled: boolean; - allowedHosts: string[]; - enabledActionTypes: string[]; -} - // the parameters passed to an action type executor function export interface ActionTypeExecutorOptions<Config, Secrets, Params> { actionId: string; @@ -68,7 +62,6 @@ export interface ActionTypeExecutorOptions<Config, Secrets, Params> { config: Config; secrets: Secrets; params: Params; - proxySettings?: ProxySettings; } export interface ActionResult<Config extends ActionTypeConfig = ActionTypeConfig> { diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts index 1b0fe03633531..08a3fd007554e 100644 --- a/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts +++ b/x-pack/plugins/actions/server/usage/actions_telemetry.test.ts @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getTotalCount } from './actions_telemetry'; +import { getInUseTotalCount, getTotalCount } from './actions_telemetry'; describe('actions telemetry', () => { - test('getTotalCount should replace action types names with . to __', async () => { + test('getTotalCount should replace first symbol . to __ for action types names', async () => { const mockEsClient = jest.fn(); mockEsClient.mockReturnValue({ aggregations: { byActionTypeId: { value: { - types: { '.index': 1, '.server-log': 1 }, + types: { '.index': 1, '.server-log': 1, 'some.type': 1, 'another.type.': 1 }, }, }, }, @@ -56,6 +56,38 @@ describe('actions telemetry', () => { updated_at: '2020-03-26T18:46:44.449Z', }, }, + { + _id: 'action:00000000-1', + _index: '.kibana_1', + _score: 0, + _source: { + action: { + actionTypeId: 'some.type', + config: {}, + name: 'test type', + secrets: {}, + }, + references: [], + type: 'action', + updated_at: '2020-03-26T18:46:44.449Z', + }, + }, + { + _id: 'action:00000000-2', + _index: '.kibana_1', + _score: 0, + _source: { + action: { + actionTypeId: 'another.type.', + config: {}, + name: 'test another type', + secrets: {}, + }, + references: [], + type: 'action', + updated_at: '2020-03-26T18:46:44.449Z', + }, + }, ], }, }); @@ -69,6 +101,58 @@ Object { "countByType": Object { "__index": 1, "__server-log": 1, + "another.type__": 1, + "some.type": 1, + }, + "countTotal": 4, +} +`); + }); + + test('getInUseTotalCount', async () => { + const mockEsClient = jest.fn(); + mockEsClient.mockReturnValue({ + aggregations: { + refs: { + actionRefIds: { + value: { + connectorIds: { '1': 'action-0', '123': 'action-0' }, + total: 2, + }, + }, + }, + hits: { + hits: [], + }, + }, + }); + const actionsBulkGet = jest.fn(); + actionsBulkGet.mockReturnValue({ + saved_objects: [ + { + id: '1', + attributes: { + actionTypeId: '.server-log', + }, + }, + { + id: '123', + attributes: { + actionTypeId: '.slack', + }, + }, + ], + }); + const telemetry = await getInUseTotalCount(mockEsClient, actionsBulkGet, 'test'); + + expect(mockEsClient).toHaveBeenCalledTimes(1); + expect(actionsBulkGet).toHaveBeenCalledTimes(1); + + expect(telemetry).toMatchInlineSnapshot(` +Object { + "countByType": Object { + "__server-log": 1, + "__slack": 1, }, "countTotal": 2, } diff --git a/x-pack/plugins/actions/server/usage/actions_telemetry.ts b/x-pack/plugins/actions/server/usage/actions_telemetry.ts index e3ff2552fed9c..cc49232150eee 100644 --- a/x-pack/plugins/actions/server/usage/actions_telemetry.ts +++ b/x-pack/plugins/actions/server/usage/actions_telemetry.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from 'kibana/server'; +import { + LegacyAPICaller, + SavedObjectsBaseOptions, + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, +} from 'kibana/server'; +import { ActionResult } from '../types'; export async function getTotalCount(callCluster: LegacyAPICaller, kibanaIndex: string) { const scriptedMetric = { @@ -58,14 +64,23 @@ export async function getTotalCount(callCluster: LegacyAPICaller, kibanaIndex: s // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [key.replace('.', '__')]: searchResult.aggregations.byActionTypeId.value.types[key], + [replaceFirstAndLastDotSymbols(key)]: searchResult.aggregations.byActionTypeId.value.types[ + key + ], }), {} ), }; } -export async function getInUseTotalCount(callCluster: LegacyAPICaller, kibanaIndex: string) { +export async function getInUseTotalCount( + callCluster: LegacyAPICaller, + actionsBulkGet: ( + objects?: SavedObjectsBulkGetObject[] | undefined, + options?: SavedObjectsBaseOptions | undefined + ) => Promise<SavedObjectsBulkResponse<ActionResult<Record<string, unknown>>>>, + kibanaIndex: string +): Promise<{ countTotal: number; countByType: Record<string, number> }> { const scriptedMetric = { scripted_metric: { init_script: 'state.connectorIds = new HashMap(); state.total = 0;', @@ -145,7 +160,32 @@ export async function getInUseTotalCount(callCluster: LegacyAPICaller, kibanaInd }, }); - return actionResults.aggregations.refs.actionRefIds.value.total; + const bulkFilter = Object.entries( + actionResults.aggregations.refs.actionRefIds.value.connectorIds + ).map(([key]) => ({ + id: key, + type: 'action', + fields: ['id', 'actionTypeId'], + })); + const actions = await actionsBulkGet(bulkFilter); + const countByType = actions.saved_objects.reduce( + (actionTypeCount: Record<string, number>, action) => { + const alertTypeId = replaceFirstAndLastDotSymbols(action.attributes.actionTypeId); + const currentCount = + actionTypeCount[alertTypeId] !== undefined ? actionTypeCount[alertTypeId] : 0; + actionTypeCount[alertTypeId] = currentCount + 1; + return actionTypeCount; + }, + {} + ); + return { countTotal: actionResults.aggregations.refs.actionRefIds.value.total, countByType }; +} + +function replaceFirstAndLastDotSymbols(strToReplace: string) { + const hasFirstSymbolDot = strToReplace.startsWith('.'); + const appliedString = hasFirstSymbolDot ? strToReplace.replace('.', '__') : strToReplace; + const hasLastSymbolDot = strToReplace.endsWith('.'); + return hasLastSymbolDot ? `${appliedString.slice(0, -1)}__` : appliedString; } // TODO: Implement executions count telemetry with eventLog, when it will write to index diff --git a/x-pack/plugins/actions/server/usage/task.ts b/x-pack/plugins/actions/server/usage/task.ts index 176ba29ef748a..001db21ffebcc 100644 --- a/x-pack/plugins/actions/server/usage/task.ts +++ b/x-pack/plugins/actions/server/usage/task.ts @@ -4,13 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, CoreSetup, LegacyAPICaller } from 'kibana/server'; +import { + Logger, + CoreSetup, + LegacyAPICaller, + SavedObjectsBulkGetObject, + SavedObjectsBaseOptions, +} from 'kibana/server'; import moment from 'moment'; import { RunContext, TaskManagerSetupContract, TaskManagerStartContract, } from '../../../task_manager/server'; +import { ActionResult } from '../types'; import { getTotalCount, getInUseTotalCount } from './actions_telemetry'; export const TELEMETRY_TASK_TYPE = 'actions_telemetry'; @@ -66,19 +73,30 @@ export function telemetryTaskRunner(logger: Logger, core: CoreSetup, kibanaIndex client.callAsInternalUser(...args) ); }; + const actionsBulkGet = ( + objects?: SavedObjectsBulkGetObject[], + options?: SavedObjectsBaseOptions + ) => { + return core + .getStartServices() + .then(([{ savedObjects }]) => + savedObjects.createInternalRepository(['action']).bulkGet<ActionResult>(objects, options) + ); + }; return { async run() { return Promise.all([ getTotalCount(callCluster, kibanaIndex), - getInUseTotalCount(callCluster, kibanaIndex), + getInUseTotalCount(callCluster, actionsBulkGet, kibanaIndex), ]) - .then(([totalAggegations, countActiveTotal]) => { + .then(([totalAggegations, totalInUse]) => { return { state: { runs: (state.runs || 0) + 1, count_total: totalAggegations.countTotal, count_by_type: totalAggegations.countByType, - count_active_total: countActiveTotal, + count_active_total: totalInUse.countTotal, + count_active_by_type: totalInUse.countByType, }, runAt: getNextMidnight(), }; diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index cbdfec642fa74..a17b46d7d7abe 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -15,6 +15,7 @@ export * from './alert_instance_summary'; export * from './builtin_action_groups'; export * from './disabled_action_groups'; export * from './alert_notify_when_type'; +export * from './parse_duration'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/alerts/server/usage/alerts_telemetry.test.ts b/x-pack/plugins/alerts/server/usage/alerts_telemetry.test.ts index 171f80cf11cb8..843100194e4b6 100644 --- a/x-pack/plugins/alerts/server/usage/alerts_telemetry.test.ts +++ b/x-pack/plugins/alerts/server/usage/alerts_telemetry.test.ts @@ -7,13 +7,13 @@ import { getTotalCountInUse } from './alerts_telemetry'; describe('alerts telemetry', () => { - test('getTotalCountInUse should replace action types names with . to __', async () => { + test('getTotalCountInUse should replace first "." symbol to "__" in alert types names', async () => { const mockEsClient = jest.fn(); mockEsClient.mockReturnValue({ aggregations: { byAlertTypeId: { value: { - types: { '.index-threshold': 2 }, + types: { '.index-threshold': 2, 'logs.alert.document.count': 1, 'document.test.': 1 }, }, }, }, @@ -30,8 +30,10 @@ describe('alerts telemetry', () => { Object { "countByType": Object { "__index-threshold": 2, + "document.test__": 1, + "logs.alert.document.count": 1, }, - "countTotal": 2, + "countTotal": 4, } `); }); diff --git a/x-pack/plugins/alerts/server/usage/alerts_telemetry.ts b/x-pack/plugins/alerts/server/usage/alerts_telemetry.ts index 6edebb1decb61..72b189aa67f4b 100644 --- a/x-pack/plugins/alerts/server/usage/alerts_telemetry.ts +++ b/x-pack/plugins/alerts/server/usage/alerts_telemetry.ts @@ -250,7 +250,7 @@ export async function getTotalCountAggregations(callCluster: LegacyAPICaller, ki // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [key.replace('.', '__')]: results.aggregations.byAlertTypeId.value.types[key], + [replaceFirstAndLastDotSymbols(key)]: results.aggregations.byAlertTypeId.value.types[key], }), {} ), @@ -310,11 +310,20 @@ export async function getTotalCountInUse(callCluster: LegacyAPICaller, kibanaIne // eslint-disable-next-line @typescript-eslint/no-explicit-any (obj: any, key: string) => ({ ...obj, - [key.replace('.', '__')]: searchResult.aggregations.byAlertTypeId.value.types[key], + [replaceFirstAndLastDotSymbols(key)]: searchResult.aggregations.byAlertTypeId.value.types[ + key + ], }), {} ), }; } +function replaceFirstAndLastDotSymbols(strToReplace: string) { + const hasFirstSymbolDot = strToReplace.startsWith('.'); + const appliedString = hasFirstSymbolDot ? strToReplace.replace('.', '__') : strToReplace; + const hasLastSymbolDot = strToReplace.endsWith('.'); + return hasLastSymbolDot ? `${appliedString.slice(0, -1)}__` : appliedString; +} + // TODO: Implement executions count telemetry with eventLog, when it will write to index diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index a41faba2e9382..b76dd85f5ee6c 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -254,6 +254,6 @@ export const generalSettings: RawSettingDefinition[] = [ 'Used to restrict requests to certain URLs from being instrumented. This config accepts a comma-separated list of wildcard patterns of URL paths that should be ignored. When an incoming HTTP request is detected, its request path will be tested against each element in this list. For example, adding `/home/index` to this list would match and remove instrumentation from `http://localhost/home/index` as well as `http://whatever.com/home/index?value1=123`', } ), - includeAgents: ['java', 'nodejs', 'python', 'dotnet', 'ruby'], + includeAgents: ['java', 'nodejs', 'python', 'dotnet', 'ruby', 'go'], }, ]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index 4f319e4dd7016..1251f9c2f1bec 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -50,6 +50,7 @@ describe('filterByAgent', () => { 'sanitize_field_names', 'span_frames_min_duration', 'stack_trace_limit', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); diff --git a/x-pack/plugins/apm/server/lib/services/get_throughput.ts b/x-pack/plugins/apm/server/lib/services/get_throughput.ts index 29071f96e3a06..bde826a568da9 100644 --- a/x-pack/plugins/apm/server/lib/services/get_throughput.ts +++ b/x-pack/plugins/apm/server/lib/services/get_throughput.ts @@ -27,12 +27,17 @@ interface Options { type ESResponse = PromiseReturnType<typeof fetcher>; -function transform(response: ESResponse) { +function transform(response: ESResponse, options: Options) { + const { end, start } = options.setup; + const deltaAsMinutes = (end - start) / 1000 / 60; if (response.hits.total.value === 0) { return []; } const buckets = response.aggregations?.throughput.buckets ?? []; - return buckets.map(({ key: x, doc_count: y }) => ({ x, y })); + return buckets.map(({ key: x, doc_count: y }) => ({ + x, + y: y / deltaAsMinutes, + })); } async function fetcher({ @@ -82,6 +87,6 @@ async function fetcher({ export async function getThroughput(options: Options) { return { - throughput: transform(await fetcher(options)), + throughput: transform(await fetcher(options), options), }; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts index e36644530eae8..0f200a92a41f1 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/math.ts @@ -5,7 +5,7 @@ */ // @ts-expect-error no @typed def; Elastic library -import { evaluate } from 'tinymath'; +import { evaluate } from '@kbn/tinymath'; import { pivotObjectArray } from '../../../common/lib/pivot_object_array'; import { Datatable, isDatatable, ExpressionFunctionDefinition } from '../../../types'; import { getFunctionHelp, getFunctionErrors } from '../../../i18n'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts index 7dee587895485..a04f39c66bc26 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/get_field_names.test.ts @@ -5,7 +5,7 @@ */ // @ts-expect-error untyped library -import { parse } from 'tinymath'; +import { parse } from '@kbn/tinymath'; import { getFieldNames } from './pointseries/lib/get_field_names'; describe('getFieldNames', () => { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts index f79f189f363d4..b528eb63ef2b6 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/index.ts @@ -5,7 +5,7 @@ */ // @ts-expect-error Untyped Elastic library -import { evaluate } from 'tinymath'; +import { evaluate } from '@kbn/tinymath'; import { groupBy, zipObject, omit, uniqBy } from 'lodash'; import moment from 'moment'; import { ExpressionFunctionDefinition } from 'src/plugins/expressions/common'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js index 1cbfafe8103ed..ed1f1d5e6c706 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/get_expression_type.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { parse } from 'tinymath'; +import { parse } from '@kbn/tinymath'; import { getFieldType } from '../../../../../common/lib/get_field_type'; import { isColumnReference } from './is_column_reference'; import { getFieldNames } from './get_field_names'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts index aed9861e1250c..54e1adbeddd78 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/server/pointseries/lib/is_column_reference.ts @@ -5,7 +5,7 @@ */ // @ts-expect-error untyped library -import { parse } from 'tinymath'; +import { parse } from '@kbn/tinymath'; export function isColumnReference(mathExpression: string | null): boolean { if (mathExpression == null) { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js index 7f5fe8b2cce12..fbde9f7f63f41 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/datacolumn/get_form_object.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { parse } from 'tinymath'; +import { parse } from '@kbn/tinymath'; import { unquoteString } from '../../../../common/lib/unquote_string'; // break out into separate function, write unit tests first diff --git a/x-pack/plugins/canvas/common/lib/handlebars.js b/x-pack/plugins/canvas/common/lib/handlebars.js index 0b7ef38fe8f6d..ae5063e173525 100644 --- a/x-pack/plugins/canvas/common/lib/handlebars.js +++ b/x-pack/plugins/canvas/common/lib/handlebars.js @@ -5,7 +5,7 @@ */ import Hbars from 'handlebars/dist/handlebars'; -import { evaluate } from 'tinymath'; +import { evaluate } from '@kbn/tinymath'; import { pivotObjectArray } from './pivot_object_array'; // example use: {{math rows 'mean(price - cost)' 2}} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx index 6c46c849c79bc..1b6acf341c08e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview_empty.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import { EuiButton } from '@elastic/eui'; -import { CURRENT_MAJOR_VERSION } from '../../../../../common/version'; +import { docLinks } from '../../../shared/doc_links'; import { DocumentCreationButtons, DocumentCreationFlyout } from '../document_creation'; import { EmptyEngineOverview } from './engine_overview_empty'; @@ -24,10 +24,8 @@ describe('EmptyEngineOverview', () => { expect(wrapper.find('h1').text()).toEqual('Engine setup'); }); - it('renders correctly versioned documentation URLs', () => { - expect(wrapper.find(EuiButton).prop('href')).toEqual( - `https://www.elastic.co/guide/en/app-search/${CURRENT_MAJOR_VERSION}/index.html` - ); + it('renders a documentation link', () => { + expect(wrapper.find(EuiButton).prop('href')).toEqual(`${docLinks.appSearchBase}/index.html`); }); it('renders document creation components', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts index 41e9bfa19e0f0..7f12f7d29671a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/routes.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CURRENT_MAJOR_VERSION } from '../../../common/version'; +import { docLinks } from '../shared/doc_links'; -export const DOCS_PREFIX = `https://www.elastic.co/guide/en/app-search/${CURRENT_MAJOR_VERSION}`; +export const DOCS_PREFIX = docLinks.appSearchBase; export const ROOT_PATH = '/'; export const SETUP_GUIDE_PATH = '/setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts deleted file mode 100644 index 7e774616ff598..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/documentation_links.ts +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CURRENT_MAJOR_VERSION } from '../../../../common/version'; - -export const ENT_SEARCH_DOCS_PREFIX = `https://www.elastic.co/guide/en/enterprise-search/${CURRENT_MAJOR_VERSION}`; - -export const CLOUD_DOCS_PREFIX = `https://www.elastic.co/guide/en/cloud/current`; // Cloud does not have version-prefixed documentation diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts index 8fa3ccdcb863e..4d4ff5f52ef20 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/index.ts @@ -5,4 +5,3 @@ */ export { DEFAULT_META } from './default_meta'; -export * from './documentation_links'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.test.ts new file mode 100644 index 0000000000000..3bee87dbfda3d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { docLinks } from './'; + +describe('DocLinks', () => { + it('setDocLinks', () => { + const links = { + DOC_LINK_VERSION: '', + ELASTIC_WEBSITE_URL: 'https://elastic.co/', + links: { + enterpriseSearch: { + base: 'http://elastic.enterprise.search', + appSearchBase: 'http://elastic.app.search', + workplaceSearchBase: 'http://elastic.workplace.search', + }, + }, + }; + + docLinks.setDocLinks(links as any); + + expect(docLinks.enterpriseSearchBase).toEqual('http://elastic.enterprise.search'); + expect(docLinks.appSearchBase).toEqual('http://elastic.app.search'); + expect(docLinks.workplaceSearchBase).toEqual('http://elastic.workplace.search'); + expect(docLinks.cloudBase).toEqual('https://elastic.co/guide/en/cloud/current'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts new file mode 100644 index 0000000000000..3ecb28d1d4729 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DocLinksStart } from 'kibana/public'; + +class DocLinks { + public enterpriseSearchBase: string; + public appSearchBase: string; + public workplaceSearchBase: string; + public cloudBase: string; + + constructor() { + this.enterpriseSearchBase = ''; + this.appSearchBase = ''; + this.workplaceSearchBase = ''; + this.cloudBase = ''; + } + + public setDocLinks(docLinks: DocLinksStart): void { + this.enterpriseSearchBase = docLinks.links.enterpriseSearch.base; + this.appSearchBase = docLinks.links.enterpriseSearch.appSearchBase; + this.workplaceSearchBase = docLinks.links.enterpriseSearch.workplaceSearchBase; + this.cloudBase = `${docLinks.ELASTIC_WEBSITE_URL}guide/en/cloud/current`; + } +} + +export const docLinks = new DocLinks(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/index.ts similarity index 80% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/index.ts rename to x-pack/plugins/enterprise_search/public/applications/shared/doc_links/index.ts index 0228a524f8129..a926efd59a574 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { MinAgeInputField } from './min_age_input_field'; +export { docLinks } from './doc_links'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/setup_guide/cloud/instructions.tsx index 383fd4b11108a..26bbc8814d108 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; @@ -73,7 +73,7 @@ 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 @@ -115,7 +115,7 @@ 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 diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 6eedc9270b83f..e72e28aa47d9b 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 @@ -500,6 +500,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', { @@ -527,3 +542,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/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index 1e4b51e157724..ef1bb03b7921c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -6,8 +6,7 @@ import { generatePath } from 'react-router-dom'; -import { CURRENT_MAJOR_VERSION } from '../../../common/version'; -import { ENT_SEARCH_DOCS_PREFIX } from '../shared/constants'; +import { docLinks } from '../shared/doc_links'; export const SETUP_GUIDE_PATH = '/setup_guide'; @@ -16,7 +15,7 @@ export const NOT_FOUND_PATH = '/404'; export const LEAVE_FEEDBACK_EMAIL = 'support@elastic.co'; export const LEAVE_FEEDBACK_URL = `mailto:${LEAVE_FEEDBACK_EMAIL}?Subject=Elastic%20Workplace%20Search%20Feedback`; -export const DOCS_PREFIX = `https://www.elastic.co/guide/en/workplace-search/${CURRENT_MAJOR_VERSION}`; +export const DOCS_PREFIX = docLinks.workplaceSearchBase; export const DOCUMENT_PERMISSIONS_DOCS_URL = `${DOCS_PREFIX}/workplace-search-sources-document-permissions.html`; export const DOCUMENT_PERMISSIONS_SYNC_DOCS_URL = `${DOCUMENT_PERMISSIONS_DOCS_URL}#sources-permissions-synchronizing`; export const PRIVATE_SOURCES_DOCS_URL = `${DOCUMENT_PERMISSIONS_DOCS_URL}#sources-permissions-org-private`; @@ -42,7 +41,7 @@ export const ZENDESK_DOCS_URL = `${DOCS_PREFIX}/workplace-search-zendesk-connect export const CUSTOM_SOURCE_DOCS_URL = `${DOCS_PREFIX}/workplace-search-custom-api-sources.html`; export const CUSTOM_API_DOCS_URL = `${DOCS_PREFIX}/workplace-search-custom-sources-api.html`; export const CUSTOM_API_DOCUMENT_PERMISSIONS_DOCS_URL = `${CUSTOM_SOURCE_DOCS_URL}#custom-api-source-document-level-access-control`; -export const ENT_SEARCH_LICENSE_MANAGEMENT = `${ENT_SEARCH_DOCS_PREFIX}/license-management.html`; +export const ENT_SEARCH_LICENSE_MANAGEMENT = `${docLinks.enterpriseSearchBase}/license-management.html`; export const PERSONAL_PATH = '/p'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index 6fe87290737f5..61cf1ed34fdcc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -35,6 +35,7 @@ import { FeatureIds, Configuration, Features } from '../../../../types'; import { DOCUMENT_PERMISSIONS_DOCS_URL } from '../../../../routes'; import { SourceFeatures } from './source_features'; +import { LEARN_MORE_LINK } from '../../constants'; import { CONNECT_REMOTE, CONNECT_PRIVATE, @@ -206,7 +207,7 @@ export const ConnectInstance: React.FC<ConnectInstanceProps> = ({ values={{ link: ( <EuiLink target="_blank" href={DOCUMENT_PERMISSIONS_DOCS_URL}> - Learn more + {LEARN_MORE_LINK} </EuiLink> ), }} @@ -242,7 +243,6 @@ export const ConnectInstance: React.FC<ConnectInstanceProps> = ({ <EuiFormRow> <EuiButton color="primary" type="submit" fill isLoading={formLoading}> - Connect {name} {i18n.translate('xpack.enterpriseSearch.workplaceSearch.contentSource.connect.button', { defaultMessage: 'Connect {name}', values: { name }, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts index 7d891953e618b..8ac3edeb0f308 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/constants.ts @@ -236,13 +236,6 @@ export const OAUTH_SAVE_CONFIG_BUTTON = i18n.translate( } ); -export const OAUTH_REMOVE_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.remove.button', - { - defaultMessage: 'Remove', - } -); - export const OAUTH_BACK_BUTTON = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.back.button', { @@ -292,20 +285,6 @@ export const SAVE_CUSTOM_API_KEYS_BODY = i18n.translate( } ); -export const SAVE_CUSTOM_ACCESS_TOKEN_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.accessToken.label', - { - defaultMessage: 'Access Token', - } -); - -export const SAVE_CUSTOM_ID_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.id.label', - { - defaultMessage: 'ID', - } -); - export const SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.visualWalkthrough.title', { @@ -327,13 +306,6 @@ export const SAVE_CUSTOM_DOC_PERMISSIONS_TITLE = i18n.translate( } ); -export const SAVE_CUSTOM_FEATURES_BUTTON = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSource.saveCustom.features.button', - { - defaultMessage: 'Learn about Platinum features', - } -); - export const SOURCE_FEATURES_SEARCHABLE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.sourceFeatures.searchable.text', { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx index e6e428ecab115..1bab035b8f379 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_config.tsx @@ -28,14 +28,10 @@ import { BASE_URL_LABEL, CLIENT_ID_LABEL, CLIENT_SECRET_LABEL, + REMOVE_BUTTON, } from '../../../../constants'; -import { - OAUTH_SAVE_CONFIG_BUTTON, - OAUTH_REMOVE_BUTTON, - OAUTH_BACK_BUTTON, - OAUTH_STEP_2, -} from './constants'; +import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON, OAUTH_STEP_2 } from './constants'; import { LicensingLogic } from '../../../../../../applications/shared/licensing'; @@ -99,7 +95,7 @@ export const SaveConfig: React.FC<SaveConfigProps> = ({ const deleteButton = ( <EuiButton color="danger" fill disabled={buttonLoading} onClick={onDeleteConfig}> - {OAUTH_REMOVE_BUTTON} + {REMOVE_BUTTON} </EuiButton> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx index 28aeaec2b47df..8e3bd1c6ab2b5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/save_custom.tsx @@ -36,18 +36,17 @@ import { getSourcesPath, } from '../../../../routes'; +import { ACCESS_TOKEN_LABEL, ID_LABEL, LEARN_CUSTOM_FEATURES_BUTTON } from '../../constants'; + import { SAVE_CUSTOM_BODY1, SAVE_CUSTOM_BODY2, SAVE_CUSTOM_RETURN_BUTTON, SAVE_CUSTOM_API_KEYS_TITLE, SAVE_CUSTOM_API_KEYS_BODY, - SAVE_CUSTOM_ACCESS_TOKEN_LABEL, - SAVE_CUSTOM_ID_LABEL, SAVE_CUSTOM_VISUAL_WALKTHROUGH_TITLE, SAVE_CUSTOM_STYLING_RESULTS_TITLE, SAVE_CUSTOM_DOC_PERMISSIONS_TITLE, - SAVE_CUSTOM_FEATURES_BUTTON, } from './constants'; interface SaveCustomProps { @@ -109,10 +108,10 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({ <p>{SAVE_CUSTOM_API_KEYS_BODY}</p> </EuiText> <EuiSpacer /> - <CredentialItem label={SAVE_CUSTOM_ID_LABEL} value={id} testSubj="ContentSourceId" /> + <CredentialItem label={ID_LABEL} value={id} testSubj="ContentSourceId" /> <EuiSpacer /> <CredentialItem - label={SAVE_CUSTOM_ACCESS_TOKEN_LABEL} + label={ACCESS_TOKEN_LABEL} value={accessToken} testSubj="AccessToken" /> @@ -200,7 +199,7 @@ export const SaveCustom: React.FC<SaveCustomProps> = ({ <EuiSpacer size="xs" /> <EuiText size="s"> <EuiLink target="_blank" href={ENT_SEARCH_LICENSE_MANAGEMENT}> - {SAVE_CUSTOM_FEATURES_BUTTON} + {LEARN_CUSTOM_FEATURES_BUTTON} </EuiLink> </EuiText> </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/constants.ts index 0e6cbb2560128..3b04456b1f59c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/constants.ts @@ -7,15 +7,142 @@ import { i18n } from '@kbn/i18n'; export const LEAVE_UNASSIGNED_FIELD = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.leaveUnassignedField', + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.leaveUnassigned.field', { defaultMessage: 'Leave unassigned', } ); export const SUCCESS_MESSAGE = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.successMessage', + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.success.message', { defaultMessage: 'Display Settings have been successfuly updated.', } ); + +export const UNSAVED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.unsaved.message', + { + defaultMessage: 'Your display settings have not been saved. Are you sure you want to leave?', + } +); + +export const DISPLAY_SETTINGS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.displaySettings.title', + { + defaultMessage: 'Display Settings', + } +); + +export const DISPLAY_SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.displaySettings.description', + { + defaultMessage: + 'Customize the content and appearance of your Custom API Source search results.', + } +); + +export const DISPLAY_SETTINGS_EMPTY_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.displaySettingsEmpty.title', + { + defaultMessage: 'You have no content yet', + } +); + +export const DISPLAY_SETTINGS_EMPTY_BODY = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.displaySettingsEmpty.body', + { + defaultMessage: 'You need some content to display in order to configure the display settings.', + } +); + +export const SEARCH_RESULTS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.searchResults.label', + { + defaultMessage: 'Search Results', + } +); + +export const RESULT_DETAIL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.resultDetail.label', + { + defaultMessage: 'Result Detail', + } +); + +export const SUBTITLE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.subtitle.label', + { + defaultMessage: 'Subtitle', + } +); + +export const TITLE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.title.label', + { + defaultMessage: 'Title', + } +); + +export const EMPTY_FIELDS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.emptyFields.description', + { + defaultMessage: 'Add fields and move them into the order you want them to appear.', + } +); + +export const VISIBLE_FIELDS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.visibleFields.title', + { + defaultMessage: 'Visible fields', + } +); + +export const PREVIEW_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.preview.title', + { + defaultMessage: 'Preview', + } +); + +export const SEARCH_RESULTS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.searchResults.title', + { + defaultMessage: 'Search Results settings', + } +); + +export const FEATURED_RESULTS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.featuredResults.title', + { + defaultMessage: 'Featured Results', + } +); + +export const FEATURED_RESULTS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.featuredResults.description', + { + defaultMessage: 'A matching document will appear as a single bold card.', + } +); + +export const STANDARD_RESULTS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.standardResults.title', + { + defaultMessage: 'Standard Results', + } +); + +export const STANDARD_RESULTS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.standardResults.description', + { + defaultMessage: 'Somewhat matching documents will appear as a set.', + } +); + +export const SEARCH_RESULTS_ROW_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.searchResultsRow.helpText', + { + defaultMessage: 'This area is optional', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx index cf066f3157e39..19ccfab11a729 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/display_settings.tsx @@ -32,15 +32,23 @@ import { AppLogic } from '../../../../app_logic'; import { Loading } from '../../../../../shared/loading'; import { ViewContentHeader } from '../../../../components/shared/view_content_header'; +import { SAVE_BUTTON } from '../../../../constants'; +import { + UNSAVED_MESSAGE, + DISPLAY_SETTINGS_TITLE, + DISPLAY_SETTINGS_DESCRIPTION, + DISPLAY_SETTINGS_EMPTY_TITLE, + DISPLAY_SETTINGS_EMPTY_BODY, + SEARCH_RESULTS_LABEL, + RESULT_DETAIL_LABEL, +} from './constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { FieldEditorModal } from './field_editor_modal'; import { ResultDetail } from './result_detail'; import { SearchResults } from './search_results'; -const UNSAVED_MESSAGE = - 'Your display settings have not been saved. Are you sure you want to leave?'; - interface DisplaySettingsProps { tabId: number; } @@ -77,12 +85,12 @@ export const DisplaySettings: React.FC<DisplaySettingsProps> = ({ tabId }) => { const tabs = [ { id: 'search_results', - name: 'Search Results', + name: SEARCH_RESULTS_LABEL, content: <SearchResults />, }, { id: 'result_detail', - name: 'Result Detail', + name: RESULT_DETAIL_LABEL, content: <ResultDetail />, }, ] as EuiTabbedContentTab[]; @@ -105,12 +113,12 @@ export const DisplaySettings: React.FC<DisplaySettingsProps> = ({ tabId }) => { <> <form onSubmit={handleFormSubmit}> <ViewContentHeader - title="Display Settings" - description="Customize the content and appearance of your Custom API Source search results." + title={DISPLAY_SETTINGS_TITLE} + description={DISPLAY_SETTINGS_DESCRIPTION} action={ hasDocuments ? ( <EuiButton type="submit" disabled={!unsavedChanges} fill={true}> - Save + {SAVE_BUTTON} </EuiButton> ) : null } @@ -125,10 +133,8 @@ export const DisplaySettings: React.FC<DisplaySettingsProps> = ({ tabId }) => { <EuiPanel className="euiPanel--inset"> <EuiEmptyPrompt iconType="indexRollupApp" - title={<h2>You have no content yet</h2>} - body={ - <p>You need some content to display in order to configure the display settings.</p> - } + title={<h2>{DISPLAY_SETTINGS_EMPTY_TITLE}</h2>} + body={<p>{DISPLAY_SETTINGS_EMPTY_BODY}</p>} /> </EuiPanel> )} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx index 3278140a2dfe6..3ca70979cc247 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_result_detail_card.tsx @@ -11,6 +11,8 @@ import { useValues } from 'kea'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { URL_LABEL } from '../../../../constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { CustomSourceIcon } from './custom_source_icon'; @@ -50,7 +52,7 @@ export const ExampleResultDetailCard: React.FC = () => { <div className="eui-textTruncate">{result[urlField]}</div> ) : ( <span className="example-result-content-placeholder" data-test-subj="DefaultUrlLabel"> - URL + {URL_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx index aa7bc4d917886..7f033d8f8d97a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_search_result_group.tsx @@ -10,6 +10,8 @@ import { isColorDark, hexToRgb } from '@elastic/eui'; import classNames from 'classnames'; import { useValues } from 'kea'; +import { DESCRIPTION_LABEL } from '../../../../constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { CustomSourceIcon } from './custom_source_icon'; @@ -65,7 +67,7 @@ export const ExampleSearchResultGroup: React.FC = () => { className="example-result-content-placeholder" data-test-subj="DefaultDescriptionLabel" > - Description + {DESCRIPTION_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx index a80680d219aef..cdd883413481d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/example_standout_result.tsx @@ -11,6 +11,8 @@ import { useValues } from 'kea'; import { isColorDark, hexToRgb } from '@elastic/eui'; +import { DESCRIPTION_LABEL } from '../../../../constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { CustomSourceIcon } from './custom_source_icon'; @@ -60,7 +62,7 @@ export const ExampleStandoutResult: React.FC = () => { className="example-result-content-placeholder" data-test-subj="DefaultDescriptionLabel" > - Description + {DESCRIPTION_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx index 587916a741d66..e220e07153867 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/field_editor_modal.tsx @@ -23,6 +23,8 @@ import { EuiSelect, } from '@elastic/eui'; +import { CANCEL_BUTTON, FIELD_LABEL, UPDATE_LABEL, ADD_LABEL } from '../../../../constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; const emptyField = { fieldName: '', label: '' }; @@ -53,14 +55,16 @@ export const FieldEditorModal: React.FC = () => { } }; - const ACTION_LABEL = isEditing ? 'Update' : 'Add'; + const ACTION_LABEL = isEditing ? UPDATE_LABEL : ADD_LABEL; return ( <EuiOverlayMask> <form onSubmit={handleSubmit}> <EuiModal onClose={toggleFieldEditorModal} maxWidth={475}> <EuiModalHeader> - <EuiModalHeaderTitle>{ACTION_LABEL} Field</EuiModalHeaderTitle> + <EuiModalHeaderTitle> + {ACTION_LABEL} {FIELD_LABEL} + </EuiModalHeaderTitle> </EuiModalHeader> <EuiModalBody> <EuiForm> @@ -89,9 +93,9 @@ export const FieldEditorModal: React.FC = () => { </EuiForm> </EuiModalBody> <EuiModalFooter> - <EuiButtonEmpty onClick={toggleFieldEditorModal}>Cancel</EuiButtonEmpty> + <EuiButtonEmpty onClick={toggleFieldEditorModal}>{CANCEL_BUTTON}</EuiButtonEmpty> <EuiButton data-test-subj="FieldSubmitButton" color="primary" fill={true} type="submit"> - {ACTION_LABEL} Field + {ACTION_LABEL} {FIELD_LABEL} </EuiButton> </EuiModalFooter> </EuiModal> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx index 5ee484250ca62..48e285cdcc778 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/result_detail.tsx @@ -26,6 +26,9 @@ import { EuiTitle, } from '@elastic/eui'; +import { ADD_FIELD_LABEL, EDIT_FIELD_LABEL, REMOVE_FIELD_LABEL } from '../../../../constants'; +import { VISIBLE_FIELDS_TITLE, EMPTY_FIELDS_DESCRIPTION, PREVIEW_TITLE } from './constants'; + import { DisplaySettingsLogic } from './display_settings_logic'; import { ExampleResultDetailCard } from './example_result_detail_card'; @@ -55,7 +58,7 @@ export const ResultDetail: React.FC = () => { <EuiFlexGroup alignItems="center" justifyContent="spaceBetween"> <EuiFlexItem> <EuiTitle size="s"> - <h3>Visible Fields</h3> + <h3>{VISIBLE_FIELDS_TITLE}</h3> </EuiTitle> </EuiFlexItem> <EuiFlexItem grow={false}> @@ -65,7 +68,7 @@ export const ResultDetail: React.FC = () => { disabled={availableFieldOptions.length < 1} data-test-subj="AddFieldButton" > - Add Field + {ADD_FIELD_LABEL} </EuiButtonEmpty> </EuiFlexItem> </EuiFlexGroup> @@ -106,13 +109,13 @@ export const ResultDetail: React.FC = () => { <div> <EuiButtonIcon data-test-subj="EditFieldButton" - aria-label="Edit Field" + aria-label={EDIT_FIELD_LABEL} iconType="pencil" onClick={() => openEditDetailField(index)} /> <EuiButtonIcon data-test-subj="RemoveFieldButton" - aria-label="Remove Field" + aria-label={REMOVE_FIELD_LABEL} iconType="cross" onClick={() => removeDetailField(index)} /> @@ -127,9 +130,7 @@ export const ResultDetail: React.FC = () => { </EuiDroppable> </EuiDragDropContext> ) : ( - <p data-test-subj="EmptyFieldsDescription"> - Add fields and move them into the order you want them to appear. - </p> + <p data-test-subj="EmptyFieldsDescription">{EMPTY_FIELDS_DESCRIPTION}</p> )} </> </EuiFormRow> @@ -138,7 +139,7 @@ export const ResultDetail: React.FC = () => { <EuiFlexItem> <EuiPanel> <EuiTitle size="s"> - <h3>Preview</h3> + <h3>{PREVIEW_TITLE}</h3> </EuiTitle> <EuiSpacer /> <ExampleResultDetailCard /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx index c1a65d1c52b65..95096331a49d3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/search_results.tsx @@ -21,7 +21,18 @@ import { } from '@elastic/eui'; import { DisplaySettingsLogic } from './display_settings_logic'; -import { LEAVE_UNASSIGNED_FIELD } from './constants'; + +import { DESCRIPTION_LABEL } from '../../../../constants'; +import { + LEAVE_UNASSIGNED_FIELD, + SEARCH_RESULTS_TITLE, + SEARCH_RESULTS_ROW_HELP_TEXT, + PREVIEW_TITLE, + FEATURED_RESULTS_TITLE, + FEATURED_RESULTS_DESCRIPTION, + STANDARD_RESULTS_TITLE, + STANDARD_RESULTS_DESCRIPTION, +} from './constants'; import { ExampleSearchResultGroup } from './example_search_result_group'; import { ExampleStandoutResult } from './example_standout_result'; @@ -51,7 +62,7 @@ export const SearchResults: React.FC = () => { <EuiFlexItem> <EuiSpacer size="m" /> <EuiTitle size="s"> - <h3>Search Result Settings</h3> + <h3>{SEARCH_RESULTS_TITLE}</h3> </EuiTitle> <EuiSpacer size="s" /> <EuiForm> @@ -89,7 +100,7 @@ export const SearchResults: React.FC = () => { </EuiFormRow> <EuiFormRow label="Subtitle" - helpText="This area is optional" + helpText={SEARCH_RESULTS_ROW_HELP_TEXT} onMouseOver={toggleSubtitleFieldHover} onMouseOut={toggleSubtitleFieldHover} onFocus={toggleSubtitleFieldHover} @@ -107,8 +118,8 @@ export const SearchResults: React.FC = () => { /> </EuiFormRow> <EuiFormRow - label="Description" - helpText="This area is optional" + label={DESCRIPTION_LABEL} + helpText={SEARCH_RESULTS_ROW_HELP_TEXT} onMouseOver={toggleDescriptionFieldHover} onMouseOut={toggleDescriptionFieldHover} onFocus={toggleDescriptionFieldHover} @@ -130,27 +141,23 @@ export const SearchResults: React.FC = () => { <EuiFlexItem> <EuiPanel> <EuiTitle size="s"> - <h3>Preview</h3> + <h3>{PREVIEW_TITLE}</h3> </EuiTitle> <EuiSpacer /> <div className="section-header"> <EuiTitle size="xs"> - <h4>Featured Results</h4> + <h4>{FEATURED_RESULTS_TITLE}</h4> </EuiTitle> - <p className="section-header__description"> - A matching document will appear as a single bold card. - </p> + <p className="section-header__description">{FEATURED_RESULTS_DESCRIPTION}</p> </div> <EuiSpacer /> <ExampleStandoutResult /> <EuiSpacer /> <div className="section-header"> <EuiTitle size="xs"> - <h4>Standard Results</h4> + <h4>{STANDARD_RESULTS_TITLE}</h4> </EuiTitle> - <p className="section-header__description"> - Somewhat matching documents will appear as a set. - </p> + <p className="section-header__description">{STANDARD_RESULTS_DESCRIPTION}</p> </div> <EuiSpacer /> <ExampleSearchResultGroup /> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx index d2f26cd6726df..77f77ad3d3cb9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/subtitle_field.tsx @@ -10,6 +10,8 @@ import classNames from 'classnames'; import { Result } from '../../../../types'; +import { SUBTITLE_LABEL } from './constants'; + interface SubtitleFieldProps { result: Result; subtitleField: string | null; @@ -31,7 +33,7 @@ export const SubtitleField: React.FC<SubtitleFieldProps> = ({ <div className="eui-textTruncate">{result[subtitleField]}</div> ) : ( <span data-test-subj="DefaultSubtitleLabel" className="example-result-content-placeholder"> - Subtitle + {SUBTITLE_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx index fa975c8b11ce0..00b548043aae5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/display_settings/title_field.tsx @@ -10,6 +10,8 @@ import classNames from 'classnames'; import { Result } from '../../../../types'; +import { TITLE_LABEL } from './constants'; + interface TitleFieldProps { result: Result; titleField: string | null; @@ -32,7 +34,7 @@ export const TitleField: React.FC<TitleFieldProps> = ({ result, titleField, titl </div> ) : ( <span className="example-result-content-placeholder" data-test-subj="DefaultTitleLabel"> - Title + {TITLE_LABEL} </span> )} </div> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index a0797305de6ca..be20eefa1b481 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -8,6 +8,8 @@ import React from 'react'; import { useValues } from 'kea'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiEmptyPrompt, EuiFlexGroup, @@ -36,6 +38,38 @@ import { getGroupPath, } from '../../../routes'; +import { + RECENT_ACTIVITY_TITLE, + CREDENTIALS_TITLE, + DOCUMENTATION_LINK_TITLE, +} from '../../../constants'; +import { + SOURCES_NO_CONTENT_TITLE, + CONTENT_SUMMARY_TITLE, + CONTENT_TYPE_HEADER, + ITEMS_HEADER, + EVENT_HEADER, + STATUS_HEADER, + TIME_HEADER, + TOTAL_DOCUMENTS_LABEL, + EMPTY_ACTIVITY_TITLE, + GROUP_ACCESS_TITLE, + CONFIGURATION_TITLE, + DOCUMENT_PERMISSIONS_TITLE, + DOCUMENT_PERMISSIONS_TEXT, + DOCUMENT_PERMISSIONS_DISABLED_TEXT, + LEARN_MORE_LINK, + STATUS_HEADING, + STATUS_TEXT, + ADDITIONAL_CONFIG_HEADING, + EXTERNAL_IDENTITIES_LINK, + ACCESS_TOKEN_LABEL, + ID_LABEL, + LEARN_CUSTOM_FEATURES_BUTTON, + DOC_PERMISSIONS_DESCRIPTION, + CUSTOM_CALLOUT_TITLE, +} from '../constants'; + import { AppLogic } from '../../../app_logic'; import { ComponentLoader } from '../../../components/shared/component_loader'; @@ -88,7 +122,7 @@ export const Overview: React.FC = () => { <EuiSpacer size="s" /> <EuiPanel paddingSize="l" className="euiPanel--inset" data-test-subj="EmptyDocumentSummary"> <EuiEmptyPrompt - title={<h2>No content yet</h2>} + title={<h2>{SOURCES_NO_CONTENT_TITLE}</h2>} iconType="documents" iconColor="subdued" /> @@ -100,7 +134,7 @@ export const Overview: React.FC = () => { <div className="content-section"> <div className="section-header"> <EuiTitle size="xs"> - <h4>Content summary</h4> + <h4>{CONTENT_SUMMARY_TITLE}</h4> </EuiTitle> </div> <EuiSpacer size="s" /> @@ -111,14 +145,14 @@ export const Overview: React.FC = () => { ) : ( <EuiTable> <EuiTableHeader> - <EuiTableHeaderCell>Content Type</EuiTableHeaderCell> - <EuiTableHeaderCell>Items</EuiTableHeaderCell> + <EuiTableHeaderCell>{CONTENT_TYPE_HEADER}</EuiTableHeaderCell> + <EuiTableHeaderCell>{ITEMS_HEADER}</EuiTableHeaderCell> </EuiTableHeader> <EuiTableBody> {tableContent} <EuiTableRow> <EuiTableRowCell> - <strong>Total documents</strong> + <strong>{TOTAL_DOCUMENTS_LABEL}</strong> </EuiTableRowCell> <EuiTableRowCell> <strong>{totalDocuments.toLocaleString('en-US')}</strong> @@ -137,7 +171,7 @@ export const Overview: React.FC = () => { <EuiSpacer size="s" /> <EuiPanel paddingSize="l" className="euiPanel--inset" data-test-subj="EmptyActivitySummary"> <EuiEmptyPrompt - title={<h2>There is no recent activity</h2>} + title={<h2>{EMPTY_ACTIVITY_TITLE}</h2>} iconType="clock" iconColor="subdued" /> @@ -148,9 +182,9 @@ export const Overview: React.FC = () => { const activitiesTable = ( <EuiTable> <EuiTableHeader> - <EuiTableHeaderCell>Event</EuiTableHeaderCell> - {!custom && <EuiTableHeaderCell>Status</EuiTableHeaderCell>} - <EuiTableHeaderCell>Time</EuiTableHeaderCell> + <EuiTableHeaderCell>{EVENT_HEADER}</EuiTableHeaderCell> + {!custom && <EuiTableHeaderCell>{STATUS_HEADER}</EuiTableHeaderCell>} + <EuiTableHeaderCell>{TIME_HEADER}</EuiTableHeaderCell> </EuiTableHeader> <EuiTableBody> {activities.map(({ details: activityDetails, event, time, status }, i) => ( @@ -186,7 +220,7 @@ export const Overview: React.FC = () => { <div className="content-section"> <div className="section-header"> <EuiTitle size="xs"> - <h3>Recent activity</h3> + <h3>{RECENT_ACTIVITY_TITLE}</h3> </EuiTitle> </div> <EuiSpacer size="s" /> @@ -198,7 +232,7 @@ export const Overview: React.FC = () => { const groupsSummary = ( <> <EuiText> - <h4>Group Access</h4> + <h4>{GROUP_ACCESS_TITLE}</h4> </EuiText> <EuiSpacer size="s" /> <EuiFlexGroup direction="column" gutterSize="s" data-test-subj="GroupsSummary"> @@ -223,7 +257,7 @@ export const Overview: React.FC = () => { <> <EuiSpacer size="l" /> <EuiText> - <h4>Configuration</h4> + <h4>{CONFIGURATION_TITLE}</h4> </EuiText> <EuiSpacer size="s" /> <EuiPanel> @@ -251,7 +285,7 @@ export const Overview: React.FC = () => { <> <EuiSpacer /> <EuiTitle size="s"> - <h4>Document-level permissions</h4> + <h4>{DOCUMENT_PERMISSIONS_TITLE}</h4> </EuiTitle> <EuiSpacer size="m" /> <EuiPanel> @@ -261,7 +295,7 @@ export const Overview: React.FC = () => { </EuiFlexItem> <EuiFlexItem> <EuiText> - <strong>Using document-level permissions</strong> + <strong>{DOCUMENT_PERMISSIONS_TEXT}</strong> </EuiText> </EuiFlexItem> </EuiFlexGroup> @@ -273,7 +307,7 @@ export const Overview: React.FC = () => { <> <EuiSpacer /> <EuiTitle size="s"> - <h4>Document-level permissions</h4> + <h4>{DOCUMENT_PERMISSIONS_TITLE}</h4> </EuiTitle> <EuiSpacer size="m" /> <EuiPanel className="euiPanel--inset" data-test-subj="DocumentPermissionsDisabled"> @@ -284,13 +318,20 @@ export const Overview: React.FC = () => { </EuiFlexItem> <EuiFlexItem> <EuiText size="m"> - <strong>Disabled for this source</strong> + <strong>{DOCUMENT_PERMISSIONS_DISABLED_TEXT}</strong> </EuiText> <EuiText size="s"> - <EuiLink target="_blank" href={DOCUMENT_PERMISSIONS_DOCS_URL}> - Learn more - </EuiLink>{' '} - about permissions + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.learnMore.text" + defaultMessage="{learnMoreLink} about permissions" + values={{ + learnMoreLink: ( + <EuiLink target="_blank" href={DOCUMENT_PERMISSIONS_DOCS_URL}> + {LEARN_MORE_LINK} + </EuiLink> + ), + }} + /> </EuiText> </EuiFlexItem> </EuiFlexGroup> @@ -303,7 +344,7 @@ export const Overview: React.FC = () => { <EuiPanel> <EuiText size="s"> <h6> - <EuiTextColor color="subdued">Status</EuiTextColor> + <EuiTextColor color="subdued">{STATUS_HEADER}</EuiTextColor> </h6> </EuiText> <EuiSpacer size="s" /> @@ -313,10 +354,10 @@ export const Overview: React.FC = () => { </EuiFlexItem> <EuiFlexItem> <EuiText> - <strong>Everything looks good</strong> + <strong>{STATUS_HEADING}</strong> </EuiText> <EuiText size="s"> - <p>Your endpoints are ready to accept requests.</p> + <p>{STATUS_TEXT}</p> </EuiText> </EuiFlexItem> </EuiFlexGroup> @@ -327,7 +368,7 @@ export const Overview: React.FC = () => { <EuiPanel data-test-subj="PermissionsStatus"> <EuiText size="s"> <h6> - <EuiTextColor color="subdued">Status</EuiTextColor> + <EuiTextColor color="subdued">{STATUS_HEADING}</EuiTextColor> </h6> </EuiText> <EuiSpacer size="s" /> @@ -337,15 +378,21 @@ export const Overview: React.FC = () => { </EuiFlexItem> <EuiFlexItem> <EuiText> - <strong>Requires additional configuration</strong> + <strong>{ADDITIONAL_CONFIG_HEADING}</strong> </EuiText> <EuiText size="s"> <p> - The{' '} - <EuiLink target="_blank" href={EXTERNAL_IDENTITIES_DOCS_URL}> - External Identities API - </EuiLink>{' '} - must be used to configure user access mappings. Read the guide to learn more. + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.externalIdentities.text" + defaultMessage="The {externalIdentitiesLink} must be used to configure user access mappings. Read the guide to learn more." + values={{ + externalIdentitiesLink: ( + <EuiLink target="_blank" href={EXTERNAL_IDENTITIES_DOCS_URL}> + {EXTERNAL_IDENTITIES_LINK} + </EuiLink> + ), + }} + /> </p> </EuiText> </EuiFlexItem> @@ -357,13 +404,13 @@ export const Overview: React.FC = () => { <EuiPanel> <EuiText size="s"> <h6> - <EuiTextColor color="subdued">Credentials</EuiTextColor> + <EuiTextColor color="subdued">{CREDENTIALS_TITLE}</EuiTextColor> </h6> </EuiText> <EuiSpacer size="s" /> - <CredentialItem label="ID" value={id} testSubj="ContentSourceId" /> + <CredentialItem label={ID_LABEL} value={id} testSubj="ContentSourceId" /> <EuiSpacer size="s" /> - <CredentialItem label="Access Token" value={accessToken} testSubj="AccessToken" /> + <CredentialItem label={ACCESS_TOKEN_LABEL} value={accessToken} testSubj="AccessToken" /> </EuiPanel> ); @@ -377,7 +424,7 @@ export const Overview: React.FC = () => { <EuiPanel> <EuiText size="s"> <h6> - <EuiTextColor color="subdued">Documentation</EuiTextColor> + <EuiTextColor color="subdued">{DOCUMENTATION_LINK_TITLE}</EuiTextColor> </h6> </EuiText> <EuiSpacer size="s" /> @@ -393,18 +440,15 @@ export const Overview: React.FC = () => { <LicenseBadge /> <EuiSpacer size="s" /> <EuiTitle size="xs"> - <h4>Document-level permissions</h4> + <h4>{DOCUMENT_PERMISSIONS_TITLE}</h4> </EuiTitle> <EuiText size="s"> - <p> - Document-level permissions manage content access content on individual or group - attributes. Allow or deny access to specific documents. - </p> + <p>{DOC_PERMISSIONS_DESCRIPTION}</p> </EuiText> <EuiSpacer size="s" /> <EuiText size="s"> <EuiLink target="_blank" href={ENT_SEARCH_LICENSE_MANAGEMENT}> - Learn about Platinum features + {LEARN_CUSTOM_FEATURES_BUTTON} </EuiLink> </EuiText> </EuiPanel> @@ -449,13 +493,20 @@ export const Overview: React.FC = () => { <EuiFlexItem> <DocumentationCallout data-test-subj="DocumentationCallout" - title="Getting started with custom sources?" + title={CUSTOM_CALLOUT_TITLE} > <p> - <EuiLink target="_blank" href={CUSTOM_SOURCE_DOCS_URL}> - Learn more - </EuiLink>{' '} - about custom sources. + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.learnMoreCustom.text" + defaultMessage="{learnMoreLink} about custom sources." + values={{ + learnMoreLink: ( + <EuiLink target="_blank" href={CUSTOM_SOURCE_DOCS_URL}> + {LEARN_MORE_LINK} + </EuiLink> + ), + }} + /> </p> </DocumentationCallout> </EuiFlexItem> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx index 16aceacbddcd5..3f7d99629ca4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_added.tsx @@ -10,6 +10,8 @@ import { Location } from 'history'; import { useActions, useValues } from 'kea'; import { Redirect, useLocation } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; + import { setErrorMessage } from '../../../../shared/flash_messages'; import { parseQueryParams } from '../../../../../applications/shared/query_params'; @@ -37,7 +39,13 @@ export const SourceAdded: React.FC = () => { const decodedName = decodeURIComponent(name); if (hasError) { - const defaultError = `${decodedName} failed to connect.`; + const defaultError = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceAdded.error', + { + defaultMessage: '{decodedName} failed to connect.', + values: { decodedName }, + } + ); setErrorMessage(errorMessages ? errorMessages.join(' ') : defaultError); } else { setAddedSource(decodedName, indexPermissions, serviceType); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx index 728d21eb1530f..cac74d37f9f66 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_content.tsx @@ -10,6 +10,9 @@ import { useActions, useValues } from 'kea'; import { startCase } from 'lodash'; import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiButton, EuiButtonEmpty, @@ -41,6 +44,16 @@ import { TablePaginationBar } from '../../../components/shared/table_pagination_ import { ViewContentHeader } from '../../../components/shared/view_content_header'; import { CUSTOM_SERVICE_TYPE } from '../../../constants'; +import { + NO_CONTENT_MESSAGE, + CUSTOM_DOCUMENTATION_LINK, + TITLE_HEADING, + LAST_UPDATED_HEADING, + GO_BUTTON, + RESET_BUTTON, + SOURCE_CONTENT_TITLE, + CONTENT_LOADING_TEXT, +} from '../constants'; import { SourceLogic } from '../source_logic'; @@ -78,8 +91,11 @@ export const SourceContent: React.FC = () => { const showPagination = totalPages > 1; const hasItems = totalItems > 0; const emptyMessage = contentFilterValue - ? `No results for '${contentFilterValue}'` - : "This source doesn't have any content yet"; + ? i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.noContentForValue.message', { + defaultMessage: "No results for '{contentFilterValue}'", + values: { contentFilterValue }, + }) + : NO_CONTENT_MESSAGE; const paginationOptions = { totalPages, @@ -101,10 +117,17 @@ export const SourceContent: React.FC = () => { body={ isCustomSource ? ( <p> - Learn more about adding content in our{' '} - <EuiLink target="_blank" href={CUSTOM_SOURCE_DOCS_URL}> - documentation - </EuiLink> + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.customSourceDocs.text" + defaultMessage="Learn more about adding content in our {documentationLink}" + values={{ + documentationLink: ( + <EuiLink target="_blank" href={CUSTOM_SOURCE_DOCS_URL}> + {CUSTOM_DOCUMENTATION_LINK} + </EuiLink> + ), + }} + /> </p> ) : null } @@ -143,9 +166,9 @@ export const SourceContent: React.FC = () => { <EuiSpacer size="m" /> <EuiTable> <EuiTableHeader> - <EuiTableHeaderCell>Title</EuiTableHeaderCell> + <EuiTableHeaderCell>{TITLE_HEADING}</EuiTableHeaderCell> <EuiTableHeaderCell>{startCase(urlField)}</EuiTableHeaderCell> - <EuiTableHeaderCell>Last Updated</EuiTableHeaderCell> + <EuiTableHeaderCell>{LAST_UPDATED_HEADING}</EuiTableHeaderCell> </EuiTableHeader> <EuiTableBody>{contentItems.map(contentItem)}</EuiTableBody> </EuiTable> @@ -167,12 +190,12 @@ export const SourceContent: React.FC = () => { color="primary" onClick={() => setContentFilterValue(searchTerm)} > - Go + {GO_BUTTON} </EuiButton> </EuiFlexItem> <EuiFlexItem grow={false}> <EuiButtonEmpty disabled={!searchTerm} onClick={resetFederatedSearchTerm}> - Reset + {RESET_BUTTON} </EuiButtonEmpty> </EuiFlexItem> </> @@ -180,12 +203,18 @@ export const SourceContent: React.FC = () => { return ( <> - <ViewContentHeader title="Source content" /> + <ViewContentHeader title={SOURCE_CONTENT_TITLE} /> <EuiFlexGroup gutterSize="s"> <EuiFlexItem grow={false}> <EuiFieldSearch disabled={!hasItems && !contentFilterValue} - placeholder={`${isFederatedSource ? 'Search' : 'Filter'} content...`} + placeholder={i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceContent.searchBar.placeholder', + { + defaultMessage: '{prefix} content...', + values: { prefix: isFederatedSource ? 'Search' : 'Filter' }, + } + )} incremental={!isFederatedSource} isClearable={!isFederatedSource} onSearch={setContentFilterValue} @@ -197,7 +226,7 @@ export const SourceContent: React.FC = () => { {isFederatedSource && federatedSearchControls} </EuiFlexGroup> <EuiSpacer size="xl" /> - {sectionLoading && <ComponentLoader text="Loading content..." />} + {sectionLoading && <ComponentLoader text={CONTENT_LOADING_TEXT} />} {!sectionLoading && (hasItems ? contentTable : emptyState)} </> ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx index 8e3a116e3ac33..ee877e8f61ad6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_info_card.tsx @@ -18,6 +18,8 @@ import { import { SourceIcon } from '../../../components/shared/source_icon'; +import { REMOTE_SOURCE_LABEL, CREATED_LABEL, STATUS_LABEL, READY_TEXT } from '../constants'; + interface SourceInfoCardProps { sourceName: string; sourceType: string; @@ -54,7 +56,7 @@ export const SourceInfoCard: React.FC<SourceInfoCardProps> = ({ <EuiFlexItem grow={null}> <EuiSpacer size="xs" /> <EuiBadge iconType="online" iconSide="left"> - Remote Source + {REMOTE_SOURCE_LABEL} </EuiBadge> </EuiFlexItem> </EuiFlexGroup> @@ -63,7 +65,7 @@ export const SourceInfoCard: React.FC<SourceInfoCardProps> = ({ <EuiFlexItem> <EuiText textAlign="right" size="s"> - <strong>Created: </strong> + <strong>{CREATED_LABEL}</strong> {dateCreated} </EuiText> @@ -71,12 +73,12 @@ export const SourceInfoCard: React.FC<SourceInfoCardProps> = ({ <EuiFlexGroup gutterSize="none" justifyContent="flexEnd" alignItems="center"> <EuiFlexItem grow={null}> <EuiText textAlign="right" size="s"> - <strong>Status: </strong> + <strong>{STATUS_LABEL}</strong> </EuiText> </EuiFlexItem> <EuiFlexItem grow={null}> <EuiText textAlign="right" size="s"> - <EuiHealth color="success">Ready to search</EuiHealth> + <EuiHealth color="success">{READY_TEXT}</EuiHealth> </EuiText> </EuiFlexItem> </EuiFlexGroup> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index 8d3219be9b02a..5f47fa2d5927b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -10,6 +10,8 @@ import { useActions, useValues } from 'kea'; import { isEmpty } from 'lodash'; import { Link } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiButton, EuiButtonEmpty, @@ -21,6 +23,24 @@ import { EuiFormRow, } from '@elastic/eui'; +import { + CANCEL_BUTTON, + OK_BUTTON, + CONFIRM_MODAL_TITLE, + SAVE_CHANGES_BUTTON, + REMOVE_BUTTON, +} from '../../../constants'; +import { + SOURCE_SETTINGS_TITLE, + SOURCE_SETTINGS_DESCRIPTION, + SOURCE_NAME_LABEL, + SOURCE_CONFIG_TITLE, + SOURCE_CONFIG_DESCRIPTION, + SOURCE_CONFIG_LINK, + SOURCE_REMOVE_TITLE, + SOURCE_REMOVE_DESCRIPTION, +} from '../constants'; + import { ContentSection } from '../../../components/shared/content_section'; import { SourceConfigFields } from '../../../components/shared/source_config_fields'; import { ViewContentHeader } from '../../../components/shared/view_content_header'; @@ -85,16 +105,22 @@ export const SourceSettings: React.FC = () => { const confirmModal = ( <EuiOverlayMask> <EuiConfirmModal - title="Please confirm" + title={CONFIRM_MODAL_TITLE} onConfirm={handleSourceRemoval} onCancel={hideConfirm} buttonColor="danger" - cancelButtonText="Cancel" - confirmButtonText="Ok" + cancelButtonText={CANCEL_BUTTON} + confirmButtonText={OK_BUTTON} defaultFocusedButton="confirm" > - Your source documents will be deleted from Workplace Search. <br /> - Are you sure you want to remove {name}? + <FormattedMessage + id="xpack.enterpriseSearch.workplaceSearch.sources.settingsModal.text" + defaultMessage="Your source documents will be deleted from Workplace Search.{lineBreak}Are you sure you want to remove {name}?" + values={{ + name, + lineBreak: <br />, + }} + /> </EuiConfirmModal> </EuiOverlayMask> ); @@ -102,10 +128,7 @@ export const SourceSettings: React.FC = () => { return ( <> <ViewContentHeader title="Source settings" /> - <ContentSection - title="Content source name" - description="Customize the name of this content source." - > + <ContentSection title={SOURCE_SETTINGS_TITLE} description={SOURCE_SETTINGS_DESCRIPTION}> <form onSubmit={submitNameChange}> <EuiFlexGroup> <EuiFlexItem grow={false}> @@ -114,7 +137,7 @@ export const SourceSettings: React.FC = () => { value={inputValue} size={64} onChange={handleNameChange} - aria-label="Source Name" + aria-label={SOURCE_NAME_LABEL} disabled={buttonLoading} data-test-subj="SourceNameInput" /> @@ -127,17 +150,14 @@ export const SourceSettings: React.FC = () => { onClick={submitNameChange} data-test-subj="SaveChangesButton" > - Save changes + {SAVE_CHANGES_BUTTON} </EuiButton> </EuiFlexItem> </EuiFlexGroup> </form> </ContentSection> {showConfig && ( - <ContentSection - title="Content source configuration" - description="Edit content source connector settings to change." - > + <ContentSection title={SOURCE_CONFIG_TITLE} description={SOURCE_CONFIG_DESCRIPTION}> <SourceConfigFields clientId={clientId} clientSecret={clientSecret} @@ -147,12 +167,12 @@ export const SourceSettings: React.FC = () => { /> <EuiFormRow> <Link to={editPath}> - <EuiButtonEmpty flush="left">Edit content source connector settings</EuiButtonEmpty> + <EuiButtonEmpty flush="left">{SOURCE_CONFIG_LINK}</EuiButtonEmpty> </Link> </EuiFormRow> </ContentSection> )} - <ContentSection title="Remove this source" description="This action cannot be undone."> + <ContentSection title={SOURCE_REMOVE_TITLE} description={SOURCE_REMOVE_DESCRIPTION}> <EuiButton isLoading={buttonLoading} data-test-subj="DeleteSourceButton" @@ -160,7 +180,7 @@ export const SourceSettings: React.FC = () => { color="danger" onClick={showConfirm} > - Remove + {REMOVE_BUTTON} </EuiButton> {confirmModalVisible && confirmModal} </ContentSection> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts new file mode 100644 index 0000000000000..48b8a06b2549c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/constants.ts @@ -0,0 +1,313 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const SOURCES_NO_CONTENT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.noContent.title', + { + defaultMessage: 'No content yet', + } +); + +export const CONTENT_SUMMARY_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.contentSummary.title', + { + defaultMessage: 'Content summary', + } +); + +export const CONTENT_TYPE_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.contentType.header', + { + defaultMessage: 'Content type', + } +); + +export const ITEMS_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.items.header', + { + defaultMessage: 'Items', + } +); + +export const EVENT_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.event.header', + { + defaultMessage: 'Event', + } +); + +export const STATUS_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.status.header', + { + defaultMessage: 'Status', + } +); + +export const TIME_HEADER = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.time.header', + { + defaultMessage: 'Time', + } +); + +export const TOTAL_DOCUMENTS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.totalDocuments.label', + { + defaultMessage: 'Total documents', + } +); + +export const EMPTY_ACTIVITY_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.emptyActivity.title', + { + defaultMessage: 'There is no recent activity', + } +); + +export const GROUP_ACCESS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.groupAccess.title', + { + defaultMessage: 'Group access', + } +); + +export const CONFIGURATION_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.configuration.title', + { + defaultMessage: 'Configuration', + } +); + +export const DOCUMENT_PERMISSIONS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.documentPermissions.title', + { + defaultMessage: 'Document-level permissions', + } +); + +export const DOCUMENT_PERMISSIONS_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.documentPermissions.text', + { + defaultMessage: 'Using document-level permissions', + } +); + +export const DOCUMENT_PERMISSIONS_DISABLED_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.documentPermissionsDisabled.text', + { + defaultMessage: 'Disabled for this sources', + } +); + +export const LEARN_MORE_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.learnMore.link', + { + defaultMessage: 'Learn more', + } +); + +export const STATUS_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.status.heading', + { + defaultMessage: 'Everything looks good', + } +); + +export const STATUS_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.status.text', + { + defaultMessage: 'Your endpoints are ready to accept requests.', + } +); + +export const ADDITIONAL_CONFIG_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.additionalConfig.heading', + { + defaultMessage: 'Requires additional configuration', + } +); + +export const EXTERNAL_IDENTITIES_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.externalIdentities.link', + { + defaultMessage: 'External Identities API', + } +); + +export const ACCESS_TOKEN_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.accessToken.label', + { + defaultMessage: 'Access Token', + } +); + +export const ID_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.id.label', { + defaultMessage: 'ID', +}); + +export const LEARN_CUSTOM_FEATURES_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.learnCustom.features.button', + { + defaultMessage: 'Learn about Platinum features', + } +); + +export const DOC_PERMISSIONS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.docPermissions.description', + { + defaultMessage: + 'Document-level permissions manage content access content on individual or group attributes. Allow or deny access to specific documents.', + } +); + +export const CUSTOM_CALLOUT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.customCallout.title', + { + defaultMessage: 'Getting started with custom sources?', + } +); + +export const NO_CONTENT_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.noContentEmpty.message', + { + defaultMessage: "This source doesn't have any content yet", + } +); + +export const CUSTOM_DOCUMENTATION_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.customSourceDocs.link', + { + defaultMessage: 'documentation', + } +); + +export const TITLE_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.title.heading', + { + defaultMessage: 'Title', + } +); + +export const LAST_UPDATED_HEADING = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.lastUpdated.heading', + { + defaultMessage: 'Last updated', + } +); + +export const GO_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.go.button', + { + defaultMessage: 'Go', + } +); + +export const RESET_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSources.displaySettings.reset.button', + { + defaultMessage: 'Reset', + } +); + +export const SOURCE_CONTENT_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceContent.title', + { + defaultMessage: 'Source content', + } +); + +export const CONTENT_LOADING_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.contentLoading.text', + { + defaultMessage: 'Loading content...', + } +); + +export const REMOTE_SOURCE_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.remoteSource.label', + { + defaultMessage: 'Remote source', + } +); + +export const CREATED_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.created.label', + { + defaultMessage: 'Created: ', + } +); + +export const STATUS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.status.label', + { + defaultMessage: 'Status: ', + } +); + +export const READY_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.ready.text', + { + defaultMessage: 'Ready to search', + } +); + +export const SOURCE_SETTINGS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.settings.title', + { + defaultMessage: 'Content source name', + } +); + +export const SOURCE_SETTINGS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.settings.description', + { + defaultMessage: 'Customize the name of this content source.', + } +); + +export const SOURCE_CONFIG_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.config.title', + { + defaultMessage: 'Content source configuration', + } +); + +export const SOURCE_CONFIG_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.config.description', + { + defaultMessage: 'Edit content source connector settings to change.', + } +); + +export const SOURCE_CONFIG_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.config.link', + { + defaultMessage: 'Edit content source connector settings', + } +); + +export const SOURCE_REMOVE_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.remove.title', + { + defaultMessage: 'Remove this source', + } +); + +export const SOURCE_REMOVE_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.config.description', + { + defaultMessage: 'Edit content source connector settings to change.', + } +); + +export const SOURCE_NAME_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.sourceName.label', + { + defaultMessage: 'Source name', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx index 766aa511ebb2d..2402a862a62e7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/add_group_modal.tsx @@ -22,6 +22,8 @@ import { EuiOverlayMask, } from '@elastic/eui'; +import { CANCEL_BUTTON } from '../../../constants'; + import { GroupsLogic } from '../groups_logic'; const ADD_GROUP_HEADER = i18n.translate( @@ -30,12 +32,6 @@ const ADD_GROUP_HEADER = i18n.translate( defaultMessage: 'Add a group', } ); -const ADD_GROUP_CANCEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.cancel.action', - { - defaultMessage: 'Cancel', - } -); const ADD_GROUP_SUBMIT = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.addGroup.submit.action', { @@ -72,7 +68,7 @@ export const AddGroupModal: React.FC<{}> = () => { </EuiModalBody> <EuiModalFooter> - <EuiButtonEmpty onClick={closeNewGroupModal}>{ADD_GROUP_CANCEL}</EuiButtonEmpty> + <EuiButtonEmpty onClick={closeNewGroupModal}>{CANCEL_BUTTON}</EuiButtonEmpty> <EuiButton disabled={!newGroupName} onClick={saveNewGroup} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx index cbfb22915c4eb..ba7cb95b65a8f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_manager_modal.tsx @@ -29,6 +29,7 @@ import { import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { Group } from '../../../types'; +import { CANCEL_BUTTON } from '../../../constants'; import { SOURCES_PATH } from '../../../routes'; import noSharedSourcesIcon from '../../../assets/share_circle.svg'; @@ -36,12 +37,6 @@ import noSharedSourcesIcon from '../../../assets/share_circle.svg'; import { GroupLogic } from '../group_logic'; import { GroupsLogic } from '../groups_logic'; -const CANCEL_BUTTON_TEXT = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerCancel', - { - defaultMessage: 'Cancel', - } -); const UPDATE_BUTTON_TEXT = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.groupManagerUpdate', { @@ -153,7 +148,7 @@ export const GroupManagerModal: React.FC<GroupManagerModalProps> = ({ <EuiFlexGroup gutterSize="none"> <EuiFlexItem grow={false}> <EuiButtonEmpty data-test-subj="CloseGroupsModal" onClick={handleClose}> - {CANCEL_BUTTON_TEXT} + {CANCEL_BUTTON} </EuiButtonEmpty> </EuiFlexItem> <EuiFlexItem grow={false}> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx index 6f55c03746aa8..a1cf7b2ca0a25 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/groups/components/group_overview.tsx @@ -22,6 +22,8 @@ import { EuiHorizontalRule, } from '@elastic/eui'; +import { CANCEL_BUTTON } from '../../../constants'; + import { AppLogic } from '../../../app_logic'; import { TruncatedContent } from '../../../../shared/truncate'; import { ContentSection } from '../../../components/shared/content_section'; @@ -99,12 +101,6 @@ const REMOVE_BUTTON_TEXT = i18n.translate( defaultMessage: 'Remove group', } ); -const CANCEL_REMOVE_BUTTON_TEXT = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.groups.overview.cancelRemoveButtonText', - { - defaultMessage: 'Cancel', - } -); const CONFIRM_TITLE_TEXT = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.groups.overview.confirmTitleText', { @@ -238,7 +234,7 @@ export const GroupOverview: React.FC = () => { onConfirm={deleteGroup} confirmButtonText={CONFIRM_REMOVE_BUTTON_TEXT} title={CONFIRM_TITLE_TEXT} - cancelButtonText={CANCEL_REMOVE_BUTTON_TEXT} + cancelButtonText={CANCEL_BUTTON} defaultFocusedButton="confirm" > {CONFIRM_REMOVE_DESCRIPTION} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 6911196afa81d..a81df1aab83bd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -16,6 +16,7 @@ import { ContentSection } from '../../components/shared/content_section'; import { TelemetryLogic } from '../../../shared/telemetry'; import { getWorkplaceSearchUrl } from '../../../shared/enterprise_search_url'; import { SOURCE_DETAILS_PATH, getContentSourcePath } from '../../routes'; +import { RECENT_ACTIVITY_TITLE } from '../../constants'; import { AppLogic } from '../../app_logic'; import { OverviewLogic } from './overview_logic'; @@ -38,15 +39,7 @@ export const RecentActivity: React.FC = () => { const { activityFeed } = useValues(OverviewLogic); return ( - <ContentSection - title={ - <FormattedMessage - id="xpack.enterpriseSearch.workplaceSearch.recentActivity.title" - defaultMessage="Recent activity" - /> - } - headerSpacer="m" - > + <ContentSection title={RECENT_ACTIVITY_TITLE} headerSpacer="m"> <EuiPanel> {activityFeed.length > 0 ? ( <> diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 632bb425f203e..5f467c872447d 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -6,6 +6,7 @@ import { AppMountParameters, + CoreStart, CoreSetup, HttpSetup, Plugin, @@ -27,6 +28,8 @@ import { } from '../common/constants'; import { InitialAppData } from '../common/types'; +import { docLinks } from './applications/shared/doc_links'; + export interface ClientConfigType { host?: string; } @@ -153,7 +156,11 @@ export class EnterpriseSearchPlugin implements Plugin { } } - public start() {} + public start(core: CoreStart) { + // This must be called here in start() and not in `applications/index.tsx` to prevent loading + // race conditions with our apps' `routes.ts` being initialized before `renderApp()` + docLinks.setDocLinks(core.docLinks); + } public stop() {} diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index 545b3b1517145..32f08e685c75d 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyClusterClient } from 'src/core/server'; +import { ElasticsearchClient } from 'src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; import { ClusterClientAdapter, @@ -15,20 +15,21 @@ import { contextMock } from './context.mock'; import { findOptionsSchema } from '../event_log_client'; import { delay } from '../lib/delay'; import { times } from 'lodash'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { RequestEvent } from '@elastic/elasticsearch'; -type EsClusterClient = Pick<jest.Mocked<LegacyClusterClient>, 'callAsInternalUser' | 'asScoped'>; type MockedLogger = ReturnType<typeof loggingSystemMock['createLogger']>; let logger: MockedLogger; -let clusterClient: EsClusterClient; +let clusterClient: DeeplyMockedKeys<ElasticsearchClient>; let clusterClientAdapter: IClusterClientAdapter; beforeEach(() => { logger = loggingSystemMock.createLogger(); - clusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; clusterClientAdapter = new ClusterClientAdapter({ logger, - clusterClientPromise: Promise.resolve(clusterClient), + elasticsearchClientPromise: Promise.resolve(clusterClient), context: contextMock.create(), }); }); @@ -38,16 +39,16 @@ describe('indexDocument', () => { clusterClientAdapter.indexDocument({ body: { message: 'foo' }, index: 'event-log' }); await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length !== 0; + return clusterClient.bulk.mock.calls.length !== 0; }); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('bulk', { + expect(clusterClient.bulk).toHaveBeenCalledWith({ body: [{ create: { _index: 'event-log' } }, { message: 'foo' }], }); }); test('should log an error when cluster client throws an error', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('expected failure')); + clusterClient.bulk.mockRejectedValue(new Error('expected failure')); clusterClientAdapter.indexDocument({ body: { message: 'foo' }, index: 'event-log' }); await retryUntil('cluster client bulk called', () => { return logger.error.mock.calls.length !== 0; @@ -69,7 +70,7 @@ describe('shutdown()', () => { const resultPromise = clusterClientAdapter.shutdown(); await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length !== 0; + return clusterClient.bulk.mock.calls.length !== 0; }); const result = await resultPromise; @@ -85,7 +86,7 @@ describe('buffering documents', () => { } await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length !== 0; + return clusterClient.bulk.mock.calls.length !== 0; }); const expectedBody = []; @@ -93,7 +94,7 @@ describe('buffering documents', () => { expectedBody.push({ create: { _index: 'event-log' } }, { message: `foo ${i}` }); } - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('bulk', { + expect(clusterClient.bulk).toHaveBeenCalledWith({ body: expectedBody, }); }); @@ -105,7 +106,7 @@ describe('buffering documents', () => { } await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length >= 2; + return clusterClient.bulk.mock.calls.length >= 2; }); const expectedBody = []; @@ -113,18 +114,18 @@ describe('buffering documents', () => { expectedBody.push({ create: { _index: 'event-log' } }, { message: `foo ${i}` }); } - expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(1, 'bulk', { + expect(clusterClient.bulk).toHaveBeenNthCalledWith(1, { body: expectedBody, }); - expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(2, 'bulk', { + expect(clusterClient.bulk).toHaveBeenNthCalledWith(2, { body: [{ create: { _index: 'event-log' } }, { message: `foo 100` }], }); }); test('should handle lots of docs correctly with a delay in the bulk index', async () => { // @ts-ignore - clusterClient.callAsInternalUser.mockImplementation = async () => await delay(100); + clusterClient.bulk.mockImplementation = async () => await delay(100); const docs = times(EVENT_BUFFER_LENGTH * 10, (i) => ({ body: { message: `foo ${i}` }, @@ -137,7 +138,7 @@ describe('buffering documents', () => { } await retryUntil('cluster client bulk called', () => { - return clusterClient.callAsInternalUser.mock.calls.length >= 10; + return clusterClient.bulk.mock.calls.length >= 10; }); for (let i = 0; i < 10; i++) { @@ -149,7 +150,7 @@ describe('buffering documents', () => { ); } - expect(clusterClient.callAsInternalUser).toHaveBeenNthCalledWith(i + 1, 'bulk', { + expect(clusterClient.bulk).toHaveBeenNthCalledWith(i + 1, { body: expectedBody, }); } @@ -164,19 +165,19 @@ describe('doesIlmPolicyExist', () => { test('should call cluster with proper arguments', async () => { await clusterClientAdapter.doesIlmPolicyExist('foo'); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('transport.request', { + expect(clusterClient.transport.request).toHaveBeenCalledWith({ method: 'GET', path: '/_ilm/policy/foo', }); }); test('should return false when 404 error is returned by Elasticsearch', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(notFoundError); + clusterClient.transport.request.mockRejectedValue(notFoundError); await expect(clusterClientAdapter.doesIlmPolicyExist('foo')).resolves.toEqual(false); }); test('should throw error when error is not 404', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.transport.request.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.doesIlmPolicyExist('foo') ).rejects.toThrowErrorMatchingInlineSnapshot(`"error checking existance of ilm policy: Fail"`); @@ -189,9 +190,9 @@ describe('doesIlmPolicyExist', () => { describe('createIlmPolicy', () => { test('should call cluster client with given policy', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ success: true }); + clusterClient.transport.request.mockResolvedValue(asApiResponse({ success: true })); await clusterClientAdapter.createIlmPolicy('foo', { args: true }); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('transport.request', { + expect(clusterClient.transport.request).toHaveBeenCalledWith({ method: 'PUT', path: '/_ilm/policy/foo', body: { args: true }, @@ -199,7 +200,7 @@ describe('createIlmPolicy', () => { }); test('should throw error when call cluster client throws', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.transport.request.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.createIlmPolicy('foo', { args: true }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"error creating ilm policy: Fail"`); @@ -209,23 +210,23 @@ describe('createIlmPolicy', () => { describe('doesIndexTemplateExist', () => { test('should call cluster with proper arguments', async () => { await clusterClientAdapter.doesIndexTemplateExist('foo'); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsTemplate', { + expect(clusterClient.indices.existsTemplate).toHaveBeenCalledWith({ name: 'foo', }); }); test('should return true when call cluster returns true', async () => { - clusterClient.callAsInternalUser.mockResolvedValue(true); + clusterClient.indices.existsTemplate.mockResolvedValue(asApiResponse(true)); await expect(clusterClientAdapter.doesIndexTemplateExist('foo')).resolves.toEqual(true); }); test('should return false when call cluster returns false', async () => { - clusterClient.callAsInternalUser.mockResolvedValue(false); + clusterClient.indices.existsTemplate.mockResolvedValue(asApiResponse(false)); await expect(clusterClientAdapter.doesIndexTemplateExist('foo')).resolves.toEqual(false); }); test('should throw error when call cluster throws an error', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.indices.existsTemplate.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.doesIndexTemplateExist('foo') ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -237,7 +238,7 @@ describe('doesIndexTemplateExist', () => { describe('createIndexTemplate', () => { test('should call cluster with given template', async () => { await clusterClientAdapter.createIndexTemplate('foo', { args: true }); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.putTemplate', { + expect(clusterClient.indices.putTemplate).toHaveBeenCalledWith({ name: 'foo', create: true, body: { args: true }, @@ -245,16 +246,16 @@ describe('createIndexTemplate', () => { }); test(`should throw error if index template still doesn't exist after error is thrown`, async () => { - clusterClient.callAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - clusterClient.callAsInternalUser.mockResolvedValueOnce(false); + clusterClient.indices.putTemplate.mockRejectedValueOnce(new Error('Fail')); + clusterClient.indices.existsTemplate.mockResolvedValueOnce(asApiResponse(false)); await expect( clusterClientAdapter.createIndexTemplate('foo', { args: true }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"error creating index template: Fail"`); }); test('should not throw error if index template exists after error is thrown', async () => { - clusterClient.callAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - clusterClient.callAsInternalUser.mockResolvedValueOnce(true); + clusterClient.indices.putTemplate.mockRejectedValueOnce(new Error('Fail')); + clusterClient.indices.existsTemplate.mockResolvedValueOnce(asApiResponse(true)); await clusterClientAdapter.createIndexTemplate('foo', { args: true }); }); }); @@ -262,23 +263,23 @@ describe('createIndexTemplate', () => { describe('doesAliasExist', () => { test('should call cluster with proper arguments', async () => { await clusterClientAdapter.doesAliasExist('foo'); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.existsAlias', { + expect(clusterClient.indices.existsAlias).toHaveBeenCalledWith({ name: 'foo', }); }); test('should return true when call cluster returns true', async () => { - clusterClient.callAsInternalUser.mockResolvedValueOnce(true); + clusterClient.indices.existsAlias.mockResolvedValueOnce(asApiResponse(true)); await expect(clusterClientAdapter.doesAliasExist('foo')).resolves.toEqual(true); }); test('should return false when call cluster returns false', async () => { - clusterClient.callAsInternalUser.mockResolvedValueOnce(false); + clusterClient.indices.existsAlias.mockResolvedValueOnce(asApiResponse(false)); await expect(clusterClientAdapter.doesAliasExist('foo')).resolves.toEqual(false); }); test('should throw error when call cluster throws an error', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.indices.existsAlias.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.doesAliasExist('foo') ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -290,14 +291,14 @@ describe('doesAliasExist', () => { describe('createIndex', () => { test('should call cluster with proper arguments', async () => { await clusterClientAdapter.createIndex('foo'); - expect(clusterClient.callAsInternalUser).toHaveBeenCalledWith('indices.create', { + expect(clusterClient.indices.create).toHaveBeenCalledWith({ index: 'foo', body: {}, }); }); test('should throw error when not getting an error of type resource_already_exists_exception', async () => { - clusterClient.callAsInternalUser.mockRejectedValue(new Error('Fail')); + clusterClient.indices.create.mockRejectedValue(new Error('Fail')); await expect( clusterClientAdapter.createIndex('foo') ).rejects.toThrowErrorMatchingInlineSnapshot(`"error creating initial index: Fail"`); @@ -312,7 +313,7 @@ describe('createIndex', () => { type: 'resource_already_exists_exception', }, }; - clusterClient.callAsInternalUser.mockRejectedValue(err); + clusterClient.indices.create.mockRejectedValue(err); await clusterClientAdapter.createIndex('foo'); }); }); @@ -321,12 +322,14 @@ describe('queryEventsBySavedObject', () => { const DEFAULT_OPTIONS = findOptionsSchema.validate({}); test('should call cluster with proper arguments with non-default namespace', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); await clusterClientAdapter.queryEventsBySavedObjects( 'index-name', 'namespace', @@ -335,14 +338,14 @@ describe('queryEventsBySavedObject', () => { DEFAULT_OPTIONS ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchInlineSnapshot(` Object { "body": Object { "from": 0, "query": Object { "bool": Object { + "filter": Array [], "must": Array [ Object { "nested": Object { @@ -400,12 +403,14 @@ describe('queryEventsBySavedObject', () => { }); test('should call cluster with proper arguments with default namespace', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); await clusterClientAdapter.queryEventsBySavedObjects( 'index-name', undefined, @@ -414,14 +419,14 @@ describe('queryEventsBySavedObject', () => { DEFAULT_OPTIONS ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchInlineSnapshot(` Object { "body": Object { "from": 0, "query": Object { "bool": Object { + "filter": Array [], "must": Array [ Object { "nested": Object { @@ -481,12 +486,14 @@ describe('queryEventsBySavedObject', () => { }); test('should call cluster with sort', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); await clusterClientAdapter.queryEventsBySavedObjects( 'index-name', 'namespace', @@ -495,8 +502,7 @@ describe('queryEventsBySavedObject', () => { { ...DEFAULT_OPTIONS, sort_field: 'event.end', sort_order: 'desc' } ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchObject({ index: 'index-name', body: { @@ -506,12 +512,14 @@ describe('queryEventsBySavedObject', () => { }); test('supports open ended date', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); const start = '2020-07-08T00:52:28.350Z'; @@ -523,14 +531,14 @@ describe('queryEventsBySavedObject', () => { { ...DEFAULT_OPTIONS, start } ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchInlineSnapshot(` Object { "body": Object { "from": 0, "query": Object { "bool": Object { + "filter": Array [], "must": Array [ Object { "nested": Object { @@ -595,12 +603,14 @@ describe('queryEventsBySavedObject', () => { }); test('supports optional date range', async () => { - clusterClient.callAsInternalUser.mockResolvedValue({ - hits: { - hits: [], - total: { value: 0 }, - }, - }); + clusterClient.search.mockResolvedValue( + asApiResponse({ + hits: { + hits: [], + total: { value: 0 }, + }, + }) + ); const start = '2020-07-08T00:52:28.350Z'; const end = '2020-07-08T00:00:00.000Z'; @@ -613,14 +623,14 @@ describe('queryEventsBySavedObject', () => { { ...DEFAULT_OPTIONS, start, end } ); - const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; - expect(method).toEqual('search'); + const [query] = clusterClient.search.mock.calls[0]; expect(query).toMatchInlineSnapshot(` Object { "body": Object { "from": 0, "query": Object { "bool": Object { + "filter": Array [], "must": Array [ Object { "nested": Object { @@ -697,6 +707,12 @@ type RetryableFunction = () => boolean; const RETRY_UNTIL_DEFAULT_COUNT = 20; const RETRY_UNTIL_DEFAULT_WAIT = 1000; // milliseconds +function asApiResponse<T>(body: T): RequestEvent<T> { + return { + body, + } as RequestEvent<T>; +} + async function retryUntil( label: string, fn: RetryableFunction, diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index 5d4c33f319fcc..4488dc74556ca 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -5,20 +5,18 @@ */ import { Subject } from 'rxjs'; -import { bufferTime, filter, switchMap } from 'rxjs/operators'; +import { bufferTime, filter as rxFilter, switchMap } from 'rxjs/operators'; import { reject, isUndefined } from 'lodash'; -import { Client } from 'elasticsearch'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { Logger, LegacyClusterClient } from 'src/core/server'; -import { ESSearchResponse } from '../../../../typings/elasticsearch'; +import { Logger, ElasticsearchClient } from 'src/core/server'; import { EsContext } from '.'; import { IEvent, IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types'; import { FindOptionsType } from '../event_log_client'; +import { esKuery } from '../../../../../src/plugins/data/server'; export const EVENT_BUFFER_TIME = 1000; // milliseconds export const EVENT_BUFFER_LENGTH = 100; -export type EsClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; export type IClusterClientAdapter = PublicMethodsOf<ClusterClientAdapter>; export interface Doc { @@ -28,7 +26,7 @@ export interface Doc { export interface ConstructorOpts { logger: Logger; - clusterClientPromise: Promise<EsClusterClient>; + elasticsearchClientPromise: Promise<ElasticsearchClient>; context: EsContext; } @@ -41,14 +39,14 @@ export interface QueryEventsBySavedObjectResult { export class ClusterClientAdapter { private readonly logger: Logger; - private readonly clusterClientPromise: Promise<EsClusterClient>; + private readonly elasticsearchClientPromise: Promise<ElasticsearchClient>; private readonly docBuffer$: Subject<Doc>; private readonly context: EsContext; private readonly docsBufferedFlushed: Promise<void>; constructor(opts: ConstructorOpts) { this.logger = opts.logger; - this.clusterClientPromise = opts.clusterClientPromise; + this.elasticsearchClientPromise = opts.elasticsearchClientPromise; this.context = opts.context; this.docBuffer$ = new Subject<Doc>(); @@ -58,7 +56,7 @@ export class ClusterClientAdapter { this.docsBufferedFlushed = this.docBuffer$ .pipe( bufferTime(EVENT_BUFFER_TIME, null, EVENT_BUFFER_LENGTH), - filter((docs) => docs.length > 0), + rxFilter((docs) => docs.length > 0), switchMap(async (docs) => await this.indexDocuments(docs)) ) .toPromise(); @@ -97,7 +95,8 @@ export class ClusterClientAdapter { } try { - await this.callEs<ReturnType<Client['bulk']>>('bulk', { body: bulkBody }); + const esClient = await this.elasticsearchClientPromise; + await esClient.bulk({ body: bulkBody }); } catch (err) { this.logger.error( `error writing bulk events: "${err.message}"; docs: ${JSON.stringify(bulkBody)}` @@ -111,7 +110,8 @@ export class ClusterClientAdapter { path: `/_ilm/policy/${policyName}`, }; try { - await this.callEs('transport.request', request); + const esClient = await this.elasticsearchClientPromise; + await esClient.transport.request(request); } catch (err) { if (err.statusCode === 404) return false; throw new Error(`error checking existance of ilm policy: ${err.message}`); @@ -119,14 +119,15 @@ export class ClusterClientAdapter { return true; } - public async createIlmPolicy(policyName: string, policy: unknown): Promise<void> { + public async createIlmPolicy(policyName: string, policy: Record<string, unknown>): Promise<void> { const request = { method: 'PUT', path: `/_ilm/policy/${policyName}`, body: policy, }; try { - await this.callEs('transport.request', request); + const esClient = await this.elasticsearchClientPromise; + await esClient.transport.request(request); } catch (err) { throw new Error(`error creating ilm policy: ${err.message}`); } @@ -135,27 +136,18 @@ export class ClusterClientAdapter { public async doesIndexTemplateExist(name: string): Promise<boolean> { let result; try { - result = await this.callEs<ReturnType<Client['indices']['existsTemplate']>>( - 'indices.existsTemplate', - { name } - ); + const esClient = await this.elasticsearchClientPromise; + result = (await esClient.indices.existsTemplate({ name })).body; } catch (err) { throw new Error(`error checking existance of index template: ${err.message}`); } return result as boolean; } - public async createIndexTemplate(name: string, template: unknown): Promise<void> { - const addTemplateParams = { - name, - create: true, - body: template, - }; + public async createIndexTemplate(name: string, template: Record<string, unknown>): Promise<void> { try { - await this.callEs<ReturnType<Client['indices']['putTemplate']>>( - 'indices.putTemplate', - addTemplateParams - ); + const esClient = await this.elasticsearchClientPromise; + await esClient.indices.putTemplate({ name, body: template, create: true }); } catch (err) { // The error message doesn't have a type attribute we can look to guarantee it's due // to the template already existing (only long message) so we'll check ourselves to see @@ -171,19 +163,21 @@ export class ClusterClientAdapter { public async doesAliasExist(name: string): Promise<boolean> { let result; try { - result = await this.callEs<ReturnType<Client['indices']['existsAlias']>>( - 'indices.existsAlias', - { name } - ); + const esClient = await this.elasticsearchClientPromise; + result = (await esClient.indices.existsAlias({ name })).body; } catch (err) { throw new Error(`error checking existance of initial index: ${err.message}`); } return result as boolean; } - public async createIndex(name: string, body: unknown = {}): Promise<void> { + public async createIndex( + name: string, + body: string | Record<string, unknown> = {} + ): Promise<void> { try { - await this.callEs<ReturnType<Client['indices']['create']>>('indices.create', { + const esClient = await this.elasticsearchClientPromise; + await esClient.indices.create({ index: name, body, }); @@ -200,7 +194,7 @@ export class ClusterClientAdapter { type: string, ids: string[], // eslint-disable-next-line @typescript-eslint/naming-convention - { page, per_page: perPage, start, end, sort_field, sort_order }: FindOptionsType + { page, per_page: perPage, start, end, sort_field, sort_order, filter }: FindOptionsType ): Promise<QueryEventsBySavedObjectResult> { const defaultNamespaceQuery = { bool: { @@ -220,12 +214,26 @@ export class ClusterClientAdapter { }; const namespaceQuery = namespace === undefined ? defaultNamespaceQuery : namedNamespaceQuery; + const esClient = await this.elasticsearchClientPromise; + let dslFilterQuery; + try { + dslFilterQuery = filter + ? esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(filter)) + : []; + } catch (err) { + this.debug(`Invalid kuery syntax for the filter (${filter}) error:`, { + message: err.message, + statusCode: err.statusCode, + }); + throw err; + } const body = { size: perPage, from: (page - 1) * perPage, sort: { [sort_field]: { order: sort_order } }, query: { bool: { + filter: dslFilterQuery, must: reject( [ { @@ -283,8 +291,10 @@ export class ClusterClientAdapter { try { const { - hits: { hits, total }, - }: ESSearchResponse<unknown, {}> = await this.callEs('search', { + body: { + hits: { hits, total }, + }, + } = await esClient.search({ index, track_total_hits: true, body, @@ -293,7 +303,7 @@ export class ClusterClientAdapter { page, per_page: perPage, total: total.value, - data: hits.map((hit) => hit._source) as IValidatedEvent[], + data: hits.map((hit: { _source: unknown }) => hit._source) as IValidatedEvent[], }; } catch (err) { throw new Error( @@ -302,24 +312,6 @@ export class ClusterClientAdapter { } } - // We have a common problem typing ES-DSL Queries - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async callEs<ESQueryResult = unknown>(operation: string, body?: any) { - try { - this.debug(`callEs(${operation}) calls:`, body); - const clusterClient = await this.clusterClientPromise; - const result = await clusterClient.callAsInternalUser(operation, body); - this.debug(`callEs(${operation}) result:`, result); - return result as ESQueryResult; - } catch (err) { - this.debug(`callEs(${operation}) error:`, { - message: err.message, - statusCode: err.statusCode, - }); - throw err; - } - } - private debug(message: string, object?: unknown) { const objectString = object == null ? '' : JSON.stringify(object); this.logger.debug(`esContext: ${message} ${objectString}`); diff --git a/x-pack/plugins/event_log/server/es/context.test.ts b/x-pack/plugins/event_log/server/es/context.test.ts index 5f26399618e38..fc137b4e45b13 100644 --- a/x-pack/plugins/event_log/server/es/context.test.ts +++ b/x-pack/plugins/event_log/server/es/context.test.ts @@ -5,27 +5,28 @@ */ import { createEsContext } from './context'; -import { LegacyClusterClient, Logger } from '../../../../../src/core/server'; +import { ElasticsearchClient, Logger } from '../../../../../src/core/server'; import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import { RequestEvent } from '@elastic/elasticsearch'; jest.mock('../lib/../../../../package.json', () => ({ version: '1.2.3' })); jest.mock('./init'); -type EsClusterClient = Pick<jest.Mocked<LegacyClusterClient>, 'callAsInternalUser' | 'asScoped'>; let logger: Logger; -let clusterClient: EsClusterClient; +let elasticsearchClient: DeeplyMockedKeys<ElasticsearchClient>; beforeEach(() => { logger = loggingSystemMock.createLogger(); - clusterClient = elasticsearchServiceMock.createLegacyClusterClient(); + elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; }); describe('createEsContext', () => { test('should return is ready state as falsy if not initialized', () => { const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test0', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); expect(context.initialized).toBeFalsy(); @@ -37,9 +38,9 @@ describe('createEsContext', () => { test('should return esNames', () => { const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test-index', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); const esNames = context.esNames; @@ -57,12 +58,12 @@ describe('createEsContext', () => { test('should return exist false for esAdapter ilm policy, index template and alias before initialize', async () => { const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test1', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); - clusterClient.callAsInternalUser.mockResolvedValue(false); - + elasticsearchClient.indices.existsTemplate.mockResolvedValue(asApiResponse(false)); + elasticsearchClient.indices.existsAlias.mockResolvedValue(asApiResponse(false)); const doesAliasExist = await context.esAdapter.doesAliasExist(context.esNames.alias); expect(doesAliasExist).toBeFalsy(); @@ -75,11 +76,11 @@ describe('createEsContext', () => { test('should return exist true for esAdapter ilm policy, index template and alias after initialize', async () => { const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test2', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); - clusterClient.callAsInternalUser.mockResolvedValue(true); + elasticsearchClient.indices.existsTemplate.mockResolvedValue(asApiResponse(true)); context.initialize(); const doesIlmPolicyExist = await context.esAdapter.doesIlmPolicyExist( @@ -100,12 +101,18 @@ describe('createEsContext', () => { jest.requireMock('./init').initializeEs.mockResolvedValue(false); const context = createEsContext({ logger, - clusterClientPromise: Promise.resolve(clusterClient), indexNameRoot: 'test2', kibanaVersion: '1.2.3', + elasticsearchClientPromise: Promise.resolve(elasticsearchClient), }); context.initialize(); const success = await context.waitTillReady(); expect(success).toBe(false); }); }); + +function asApiResponse<T>(body: T): RequestEvent<T> { + return { + body, + } as RequestEvent<T>; +} diff --git a/x-pack/plugins/event_log/server/es/context.ts b/x-pack/plugins/event_log/server/es/context.ts index c1777d6979c5c..26f249d3b2c06 100644 --- a/x-pack/plugins/event_log/server/es/context.ts +++ b/x-pack/plugins/event_log/server/es/context.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, LegacyClusterClient } from 'src/core/server'; +import { Logger, ElasticsearchClient } from 'src/core/server'; import { EsNames, getEsNames } from './names'; import { initializeEs } from './init'; import { ClusterClientAdapter, IClusterClientAdapter } from './cluster_client_adapter'; import { createReadySignal, ReadySignal } from '../lib/ready_signal'; -export type EsClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; - export interface EsContext { logger: Logger; esNames: EsNames; @@ -34,9 +32,9 @@ export function createEsContext(params: EsContextCtorParams): EsContext { export interface EsContextCtorParams { logger: Logger; - clusterClientPromise: Promise<EsClusterClient>; indexNameRoot: string; kibanaVersion: string; + elasticsearchClientPromise: Promise<ElasticsearchClient>; } class EsContextImpl implements EsContext { @@ -53,7 +51,7 @@ class EsContextImpl implements EsContext { this.initialized = false; this.esAdapter = new ClusterClientAdapter({ logger: params.logger, - clusterClientPromise: params.clusterClientPromise, + elasticsearchClientPromise: params.elasticsearchClientPromise, context: this, }); } diff --git a/x-pack/plugins/event_log/server/es/index.ts b/x-pack/plugins/event_log/server/es/index.ts index ad1409e33589f..adc7ed011aa14 100644 --- a/x-pack/plugins/event_log/server/es/index.ts +++ b/x-pack/plugins/event_log/server/es/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EsClusterClient, EsContext, createEsContext } from './context'; +export { EsContext, createEsContext } from './context'; diff --git a/x-pack/plugins/event_log/server/event_log_client.ts b/x-pack/plugins/event_log/server/event_log_client.ts index 63453c6327da2..091f997fe62ea 100644 --- a/x-pack/plugins/event_log/server/event_log_client.ts +++ b/x-pack/plugins/event_log/server/event_log_client.ts @@ -6,14 +6,14 @@ import { Observable } from 'rxjs'; import { schema, TypeOf } from '@kbn/config-schema'; -import { LegacyClusterClient, KibanaRequest } from 'src/core/server'; +import { IClusterClient, KibanaRequest } from 'src/core/server'; import { SpacesServiceStart } from '../../spaces/server'; import { EsContext } from './es'; import { IEventLogClient } from './types'; import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; import { SavedObjectBulkGetterResult } from './saved_object_provider_registry'; -export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; +export type PluginClusterClient = Pick<IClusterClient, 'asInternalUser'>; export type AdminClusterClient$ = Observable<PluginClusterClient>; const optionalDateFieldSchema = schema.maybe( @@ -48,12 +48,13 @@ export const findOptionsSchema = schema.object({ sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')], { defaultValue: 'asc', }), + filter: schema.maybe(schema.string()), }); // page & perPage are required, other fields are optional // using schema.maybe allows us to set undefined, but not to make the field optional export type FindOptionsType = Pick< TypeOf<typeof findOptionsSchema>, - 'page' | 'per_page' | 'sort_field' | 'sort_order' + 'page' | 'per_page' | 'sort_field' | 'sort_order' | 'filter' > & Partial<TypeOf<typeof findOptionsSchema>>; diff --git a/x-pack/plugins/event_log/server/event_log_service.ts b/x-pack/plugins/event_log/server/event_log_service.ts index 9249288d33939..0bc675fee928d 100644 --- a/x-pack/plugins/event_log/server/event_log_service.ts +++ b/x-pack/plugins/event_log/server/event_log_service.ts @@ -5,14 +5,14 @@ */ import { Observable } from 'rxjs'; -import { LegacyClusterClient } from 'src/core/server'; +import { IClusterClient } from 'src/core/server'; import { Plugin } from './plugin'; import { EsContext } from './es'; import { IEvent, IEventLogger, IEventLogService, IEventLogConfig } from './types'; import { EventLogger } from './event_logger'; import { SavedObjectProvider, SavedObjectProviderRegistry } from './saved_object_provider_registry'; -export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; +export type PluginClusterClient = Pick<IClusterClient, 'asInternalUser'>; export type AdminClusterClient$ = Observable<PluginClusterClient>; type SystemLogger = Plugin['systemLogger']; diff --git a/x-pack/plugins/event_log/server/event_log_start_service.ts b/x-pack/plugins/event_log/server/event_log_start_service.ts index 51dd7d6e95d15..82b8f06c251a3 100644 --- a/x-pack/plugins/event_log/server/event_log_start_service.ts +++ b/x-pack/plugins/event_log/server/event_log_start_service.ts @@ -5,14 +5,14 @@ */ import { Observable } from 'rxjs'; -import { LegacyClusterClient, KibanaRequest } from 'src/core/server'; +import { IClusterClient, KibanaRequest } from 'src/core/server'; import { SpacesServiceStart } from '../../spaces/server'; import { EsContext } from './es'; import { IEventLogClientService } from './types'; import { EventLogClient } from './event_log_client'; import { SavedObjectProviderRegistry } from './saved_object_provider_registry'; -export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; +export type PluginClusterClient = Pick<IClusterClient, 'asInternalUser'>; export type AdminClusterClient$ = Observable<PluginClusterClient>; interface EventLogServiceCtorParams { diff --git a/x-pack/plugins/event_log/server/plugin.ts b/x-pack/plugins/event_log/server/plugin.ts index 3bf726de71856..e2e31864eb31f 100644 --- a/x-pack/plugins/event_log/server/plugin.ts +++ b/x-pack/plugins/event_log/server/plugin.ts @@ -12,7 +12,7 @@ import { Logger, Plugin as CorePlugin, PluginInitializerContext, - LegacyClusterClient, + IClusterClient, SharedGlobalConfig, IContextProvider, } from 'src/core/server'; @@ -33,7 +33,7 @@ import { EventLogClientService } from './event_log_start_service'; import { SavedObjectProviderRegistry } from './saved_object_provider_registry'; import { findByIdsRoute } from './routes/find_by_ids'; -export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>; +export type PluginClusterClient = Pick<IClusterClient, 'asInternalUser'>; const PROVIDER = 'eventLog'; @@ -77,9 +77,9 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi logger: this.systemLogger, // TODO: get index prefix from config.get(kibana.index) indexNameRoot: kibanaIndex, - clusterClientPromise: core + elasticsearchClientPromise: core .getStartServices() - .then(([{ elasticsearch }]) => elasticsearch.legacy.client), + .then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser), kibanaVersion: this.kibanaVersion, }); diff --git a/x-pack/plugins/fleet/common/constants/agent.ts b/x-pack/plugins/fleet/common/constants/agent.ts index 8bfb32b5ed2b0..2e9161ca1c534 100644 --- a/x-pack/plugins/fleet/common/constants/agent.ts +++ b/x-pack/plugins/fleet/common/constants/agent.ts @@ -24,3 +24,4 @@ export const AGENT_POLICY_ROLLOUT_RATE_LIMIT_INTERVAL_MS = 1000; export const AGENT_POLICY_ROLLOUT_RATE_LIMIT_REQUEST_PER_INTERVAL = 5; export const AGENTS_INDEX = '.fleet-agents'; +export const AGENT_ACTIONS_INDEX = '.fleet-actions'; diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index 5ba4de914c724..ece669293fdff 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -8,6 +8,8 @@ export const ASSETS_SAVED_OBJECT_TYPE = 'epm-packages-assets'; export const INDEX_PATTERN_SAVED_OBJECT_TYPE = 'index-pattern'; export const MAX_TIME_COMPLETE_INSTALL = 60000; +export const FLEET_SERVER_PACKAGE = 'fleet_server'; + export const requiredPackages = { System: 'system', Endpoint: 'endpoint', diff --git a/x-pack/plugins/fleet/common/constants/index.ts b/x-pack/plugins/fleet/common/constants/index.ts index bdc5714f7e2fe..409375f81d6fe 100644 --- a/x-pack/plugins/fleet/common/constants/index.ts +++ b/x-pack/plugins/fleet/common/constants/index.ts @@ -19,3 +19,12 @@ export * from './settings'; // for the actual setting to differ from the default. Can we retrieve the real // setting in the future? export const SO_SEARCH_LIMIT = 10000; + +export const FLEET_SERVER_INDICES = [ + '.fleet-actions', + '.fleet-agents', + '.fleet-enrollment-api-keys', + '.fleet-policies', + '.fleet-policies-leader', + '.fleet-servers', +]; diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index e09fbfc80b196..d0df9b73dd88a 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -326,7 +326,6 @@ export interface IndexTemplate { template: { settings: any; mappings: any; - aliases: object; }; data_stream: { hidden?: boolean }; composed_of: string[]; diff --git a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts index 8925f3386dfb8..fcead1bc89749 100644 --- a/x-pack/plugins/fleet/server/collectors/agent_collectors.ts +++ b/x-pack/plugins/fleet/server/collectors/agent_collectors.ts @@ -6,6 +6,7 @@ import { ElasticsearchClient, SavedObjectsClient } from 'kibana/server'; import * as AgentService from '../services/agents'; +import { isFleetServerSetup } from '../services/fleet_server_migration'; export interface AgentUsage { total: number; online: number; @@ -18,7 +19,7 @@ export const getAgentUsage = async ( esClient?: ElasticsearchClient ): Promise<AgentUsage> => { // TODO: unsure if this case is possible at all. - if (!soClient || !esClient) { + if (!soClient || !esClient || !(await isFleetServerSetup())) { return { total: 0, online: 0, @@ -26,6 +27,7 @@ export const getAgentUsage = async ( offline: 0, }; } + const { total, online, error, offline } = await AgentService.getAgentStatusForAgentPolicy( soClient, esClient diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 253b614dc228a..a0eb1547a3d63 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -81,7 +81,7 @@ import { agentCheckinState } from './services/agents/checkin/state'; import { registerFleetUsageCollector } from './collectors/register'; import { getInstallation } from './services/epm/packages'; import { makeRouterEnforcingSuperuser } from './routes/security'; -import { runFleetServerMigration } from './services/fleet_server_migration'; +import { isFleetServerSetup } from './services/fleet_server_migration'; export interface FleetSetupDeps { licensing: LicensingPluginSetup; @@ -299,7 +299,14 @@ export class FleetPlugin if (fleetServerEnabled) { // We need licence to be initialized before using the SO service. await this.licensing$.pipe(first()).toPromise(); - await runFleetServerMigration(); + + const fleetSetup = await isFleetServerSetup(); + + if (!fleetSetup) { + this.logger?.warn( + 'Extra setup is needed to be able to use central management for agent, please visit the Fleet app in Kibana.' + ); + } } return { diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index dcc686e565b8e..7b4149819dc78 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -6,7 +6,10 @@ import { SavedObjectsServiceSetup, SavedObjectsType } from 'kibana/server'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; -import { migratePackagePolicyToV7110 } from '../../../security_solution/common'; +import { + migratePackagePolicyToV7110, + migratePackagePolicyToV7120, +} from '../../../security_solution/common'; import { OUTPUT_SAVED_OBJECT_TYPE, AGENT_POLICY_SAVED_OBJECT_TYPE, @@ -273,6 +276,7 @@ const getSavedObjectTypes = ( migrations: { '7.10.0': migratePackagePolicyToV7100, '7.11.0': migratePackagePolicyToV7110, + '7.12.0': migratePackagePolicyToV7120, }, }, [PACKAGES_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index 0333fb024a717..2d2478843c454 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -95,8 +95,7 @@ exports[`tests loading base.yml: base.yml 1`] = ` "managed_by": "ingest-manager", "managed": true } - }, - "aliases": {} + } }, "data_stream": {}, "composed_of": [], @@ -205,8 +204,7 @@ exports[`tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` "managed_by": "ingest-manager", "managed": true } - }, - "aliases": {} + } }, "data_stream": {}, "composed_of": [], @@ -1699,8 +1697,7 @@ exports[`tests loading system.yml: system.yml 1`] = ` "managed_by": "ingest-manager", "managed": true } - }, - "aliases": {} + } }, "data_stream": {}, "composed_of": [], diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index fd75139d4cd45..e1fa2a0b18b59 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 1a50b5c9df767..44065a9346c5d 100644 --- a/x-pack/plugins/fleet/server/services/fleet_server_migration.ts +++ b/x-pack/plugins/fleet/server/services/fleet_server_migration.ts @@ -9,15 +9,39 @@ import { ENROLLMENT_API_KEYS_INDEX, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, FleetServerEnrollmentAPIKey, + FLEET_SERVER_PACKAGE, + FLEET_SERVER_INDICES, } from '../../common'; import { listEnrollmentApiKeys, getEnrollmentAPIKey } from './api_keys/enrollment_api_key_so'; import { appContextService } from './app_context'; +import { getInstallation } from './epm/packages'; + +export async function isFleetServerSetup() { + const pkgInstall = await getInstallation({ + savedObjectsClient: getInternalUserSOClient(), + pkgName: FLEET_SERVER_PACKAGE, + }); + + if (!pkgInstall) { + return false; + } + + const esClient = appContextService.getInternalUserESClient(); + + const exists = await Promise.all( + FLEET_SERVER_INDICES.map(async (index) => { + const res = await esClient.indices.exists({ + index, + }); + return res.statusCode !== 404; + }) + ); + + return exists.every((exist) => exist === true); +} export async function runFleetServerMigration() { - const logger = appContextService.getLogger(); - logger.info('Starting fleet server migration'); await migrateEnrollmentApiKeys(); - logger.info('Fleet server migration finished'); } function getInternalUserSOClient() { diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index 0dcdfeb7b3801..ff96e2724c892 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -11,6 +11,7 @@ import { agentPolicyService } from './agent_policy'; import { outputService } from './output'; import { ensureInstalledDefaultPackages, + ensureInstalledPackage, ensurePackagesCompletedInstall, } from './epm/packages/install'; import { @@ -20,6 +21,8 @@ import { Installation, Output, DEFAULT_AGENT_POLICIES_PACKAGES, + FLEET_SERVER_PACKAGE, + FLEET_SERVER_INDICES, } from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; import { getPackageInfo } from './epm/packages'; @@ -29,6 +32,8 @@ import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { createDefaultSettings } from './settings'; import { ensureAgentActionPolicyChangeExists } from './agents'; +import { appContextService } from './app_context'; +import { runFleetServerMigration } from './fleet_server_migration'; const FLEET_ENROLL_USERNAME = 'fleet_enroll'; const FLEET_ENROLL_ROLE = 'fleet_enroll'; @@ -77,6 +82,15 @@ async function createSetupSideEffects( // By moving this outside of the Promise.all, the upgrade will occur first, and then we'll attempt to reinstall any // packages that are stuck in the installing state. await ensurePackagesCompletedInstall(soClient, callCluster); + if (appContextService.getConfig()?.agents.fleetServerEnabled) { + await ensureInstalledPackage({ + savedObjectsClient: soClient, + pkgName: FLEET_SERVER_PACKAGE, + callCluster, + }); + await ensureFleetServerIndicesCreated(esClient); + await runFleetServerMigration(); + } // If we just created the default policy, ensure default packages are added to it if (defaultAgentPolicyCreated) { @@ -144,6 +158,21 @@ async function updateFleetRoleIfExists(callCluster: CallESAsCurrentUser) { return putFleetRole(callCluster); } +async function ensureFleetServerIndicesCreated(esClient: ElasticsearchClient) { + await Promise.all( + FLEET_SERVER_INDICES.map(async (index) => { + const res = await esClient.indices.exists({ + index, + }); + if (res.statusCode === 404) { + await esClient.indices.create({ + index, + }); + } + }) + ); +} + async function putFleetRole(callCluster: CallESAsCurrentUser) { return callCluster('transport.request', { method: 'PUT', diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts index 1ffbae39d3705..1b49416ebbbe9 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/app/app.test.ts @@ -21,6 +21,9 @@ const PERCENT_SIGN_NAME = 'test%'; const PERCENT_SIGN_WITH_OTHER_CHARS_NAME = 'test%#'; const PERCENT_SIGN_25_SEQUENCE = 'test%25'; +const createPolicyTitle = 'Create Policy'; +const editPolicyTitle = 'Edit Policy'; + window.scrollTo = jest.fn(); jest.mock('@elastic/eui', () => { @@ -52,7 +55,7 @@ describe('<App />', () => { await actions.clickCreatePolicyButton(); component.update(); - expect(testBed.find('policyTitle').text()).toBe(`Create an index lifecycle policy`); + expect(testBed.find('policyTitle').text()).toBe(createPolicyTitle); expect(testBed.find('policyNameField').props().value).toBe(''); }); @@ -68,7 +71,7 @@ describe('<App />', () => { await actions.clickCreatePolicyButton(); component.update(); - expect(testBed.find('policyTitle').text()).toBe(`Create an index lifecycle policy`); + expect(testBed.find('policyTitle').text()).toBe(createPolicyTitle); expect(testBed.find('policyNameField').props().value).toBe(''); }); }); @@ -89,9 +92,7 @@ describe('<App />', () => { await actions.clickPolicyNameLink(); component.update(); - expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${SPECIAL_CHARS_NAME}` - ); + expect(testBed.find('policyTitle').text()).toBe(`${editPolicyTitle} ${SPECIAL_CHARS_NAME}`); }); test('loading edit policy page url works', async () => { @@ -102,9 +103,7 @@ describe('<App />', () => { const { component } = testBed; component.update(); - expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${SPECIAL_CHARS_NAME}` - ); + expect(testBed.find('policyTitle').text()).toBe(`${editPolicyTitle} ${SPECIAL_CHARS_NAME}`); }); // using double encoding to counteract react-router's v5 internal decodeURI call @@ -117,9 +116,7 @@ describe('<App />', () => { const { component } = testBed; component.update(); - expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${SPECIAL_CHARS_NAME}` - ); + expect(testBed.find('policyTitle').text()).toBe(`${editPolicyTitle} ${SPECIAL_CHARS_NAME}`); }); }); @@ -136,9 +133,7 @@ describe('<App />', () => { const { component } = testBed; component.update(); - expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${PERCENT_SIGN_NAME}` - ); + expect(testBed.find('policyTitle').text()).toBe(`${editPolicyTitle} ${PERCENT_SIGN_NAME}`); }); test('loading edit policy page url with double encoding works', async () => { @@ -149,9 +144,7 @@ describe('<App />', () => { const { component } = testBed; component.update(); - expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${PERCENT_SIGN_NAME}` - ); + expect(testBed.find('policyTitle').text()).toBe(`${editPolicyTitle} ${PERCENT_SIGN_NAME}`); }); }); @@ -174,7 +167,7 @@ describe('<App />', () => { component.update(); expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${PERCENT_SIGN_WITH_OTHER_CHARS_NAME}` + `${editPolicyTitle} ${PERCENT_SIGN_WITH_OTHER_CHARS_NAME}` ); }); @@ -188,7 +181,7 @@ describe('<App />', () => { // known issue https://github.com/elastic/kibana/issues/82440 expect(testBed.find('policyTitle').text()).not.toBe( - `Edit index lifecycle policy ${PERCENT_SIGN_WITH_OTHER_CHARS_NAME}` + `${editPolicyTitle} ${PERCENT_SIGN_WITH_OTHER_CHARS_NAME}` ); }); @@ -203,7 +196,7 @@ describe('<App />', () => { component.update(); expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${PERCENT_SIGN_WITH_OTHER_CHARS_NAME}` + `${editPolicyTitle} ${PERCENT_SIGN_WITH_OTHER_CHARS_NAME}` ); }); }); @@ -225,7 +218,7 @@ describe('<App />', () => { component.update(); expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${PERCENT_SIGN_25_SEQUENCE}` + `${editPolicyTitle} ${PERCENT_SIGN_25_SEQUENCE}` ); }); @@ -239,7 +232,7 @@ describe('<App />', () => { // known issue https://github.com/elastic/kibana/issues/82440 expect(testBed.find('policyTitle').text()).not.toBe( - `Edit index lifecycle policy ${PERCENT_SIGN_25_SEQUENCE}` + `${editPolicyTitle} ${PERCENT_SIGN_25_SEQUENCE}` ); }); @@ -254,7 +247,7 @@ describe('<App />', () => { component.update(); expect(testBed.find('policyTitle').text()).toBe( - `Edit index lifecycle policy ${PERCENT_SIGN_25_SEQUENCE}` + `${editPolicyTitle} ${PERCENT_SIGN_25_SEQUENCE}` ); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 72a0372628a22..64b654b030236 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -89,6 +89,13 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte component.update(); }; + const createFormCheckboxAction = (dataTestSubject: string) => async (checked: boolean) => { + await act(async () => { + form.selectCheckBox(dataTestSubject, checked); + }); + component.update(); + }; + function createFormSetValueAction<V extends string = string>(dataTestSubject: string) { return async (value: V) => { await act(async () => { @@ -146,17 +153,21 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte forceMergeFieldExists: () => exists(toggleSelector), toggleForceMerge: createFormToggleAction(toggleSelector), setForcemergeSegmentsCount: createFormSetValueAction(`${phase}-selectedForceMergeSegments`), - setBestCompression: createFormToggleAction(`${phase}-bestCompression`), + setBestCompression: createFormCheckboxAction(`${phase}-bestCompression`), }; }; - const setIndexPriority = (phase: Phases) => - createFormSetValueAction(`${phase}-phaseIndexPriority`); + const createIndexPriorityActions = (phase: Phases) => { + const toggleSelector = `${phase}-indexPrioritySwitch`; + return { + indexPriorityExists: () => exists(toggleSelector), + toggleIndexPriority: createFormToggleAction(toggleSelector), + setIndexPriority: createFormSetValueAction(`${phase}-indexPriority`), + }; + }; const enable = (phase: Phases) => createFormToggleAction(`enablePhaseSwitch-${phase}`); - const warmPhaseOnRollover = createFormToggleAction(`warm-warmPhaseOnRollover`); - const setMinAgeValue = (phase: Phases) => createFormSetValueAction(`${phase}-selectedMinimumAge`); const setMinAgeUnits = (phase: Phases) => @@ -190,13 +201,15 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte await createFormSetValueAction(`${phase}-selectedReplicaCount`)(value); }; - const setShrink = (phase: Phases) => async (value: string) => { - await createFormToggleAction(`${phase}-shrinkSwitch`)(true); - await createFormSetValueAction(`${phase}-selectedPrimaryShardCount`)(value); + const createShrinkActions = (phase: Phases) => { + const toggleSelector = `${phase}-shrinkSwitch`; + return { + shrinkExists: () => exists(toggleSelector), + toggleShrink: createFormToggleAction(toggleSelector), + setShrink: createFormSetValueAction(`${phase}-primaryShardCount`), + }; }; - const shrinkExists = (phase: Phases) => () => exists(`${phase}-shrinkSwitch`); - const setFreeze = createFormToggleAction('freezeSwitch'); const freezeExists = () => exists('freezeSwitch'); @@ -250,25 +263,22 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte toggleRollover, toggleDefaultRollover, ...createForceMergeActions('hot'), - setIndexPriority: setIndexPriority('hot'), - setShrink: setShrink('hot'), - shrinkExists: shrinkExists('hot'), + ...createIndexPriorityActions('hot'), + ...createShrinkActions('hot'), setReadonly: setReadonly('hot'), ...createSearchableSnapshotActions('hot'), }, warm: { enable: enable('warm'), - warmPhaseOnRollover, setMinAgeValue: setMinAgeValue('warm'), setMinAgeUnits: setMinAgeUnits('warm'), setDataAllocation: setDataAllocation('warm'), setSelectedNodeAttribute: setSelectedNodeAttribute('warm'), setReplicas: setReplicas('warm'), - setShrink: setShrink('warm'), - shrinkExists: shrinkExists('warm'), + ...createShrinkActions('warm'), ...createForceMergeActions('warm'), setReadonly: setReadonly('warm'), - setIndexPriority: setIndexPriority('warm'), + ...createIndexPriorityActions('warm'), }, cold: { enable: enable('cold'), @@ -279,7 +289,7 @@ export const setup = async (arg?: { appServicesContext: Partial<AppServicesConte setReplicas: setReplicas('cold'), setFreeze, freezeExists, - setIndexPriority: setIndexPriority('cold'), + ...createIndexPriorityActions('cold'), ...createSearchableSnapshotActions('cold'), }, delete: { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index e42e503fb853a..bb96e8b4df239 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -76,9 +76,6 @@ describe('<EditPolicy />', () => { max_size: '50gb', unknown_setting: 123, // Made up setting that should stay preserved }, - set_priority: { - priority: 100, - }, }, min_age: '0ms', }, @@ -126,8 +123,10 @@ describe('<EditPolicy />', () => { await actions.hot.toggleForceMerge(true); await actions.hot.setForcemergeSegmentsCount('123'); await actions.hot.setBestCompression(true); + await actions.hot.toggleShrink(true); await actions.hot.setShrink('2'); await actions.hot.setReadonly(true); + await actions.hot.toggleIndexPriority(true); await actions.hot.setIndexPriority('123'); await actions.savePolicy(); @@ -186,13 +185,7 @@ describe('<EditPolicy />', () => { const hotActions = policy.phases.hot.actions; const rolloverAction = hotActions.rollover; expect(rolloverAction).toBe(undefined); - expect(hotActions).toMatchInlineSnapshot(` - Object { - "set_priority": Object { - "priority": 100, - }, - } - `); + expect(hotActions).toMatchInlineSnapshot(`Object {}`); }); test('enabling searchable snapshot should hide force merge, freeze and shrink in subsequent phases', async () => { @@ -260,6 +253,7 @@ describe('<EditPolicy />', () => { "priority": 50, }, }, + "min_age": "0ms", } `); }); @@ -270,6 +264,7 @@ describe('<EditPolicy />', () => { await actions.warm.setDataAllocation('node_attrs'); await actions.warm.setSelectedNodeAttribute('test:123'); await actions.warm.setReplicas('123'); + await actions.warm.toggleShrink(true); await actions.warm.setShrink('123'); await actions.warm.toggleForceMerge(true); await actions.warm.setForcemergeSegmentsCount('123'); @@ -290,9 +285,6 @@ describe('<EditPolicy />', () => { "max_age": "30d", "max_size": "50gb", }, - "set_priority": Object { - "priority": 100, - }, }, "min_age": "0ms", }, @@ -316,24 +308,12 @@ describe('<EditPolicy />', () => { "number_of_shards": 123, }, }, + "min_age": "0ms", }, }, } `); }); - - test('setting warm phase on rollover to "false"', async () => { - const { actions } = testBed; - await actions.warm.enable(true); - await actions.warm.warmPhaseOnRollover(false); - await actions.warm.setMinAgeValue('123'); - await actions.warm.setMinAgeUnits('d'); - await actions.savePolicy(); - const latestRequest = server.requests[server.requests.length - 1]; - const warmPhaseMinAge = JSON.parse(JSON.parse(latestRequest.requestBody).body).phases.warm - .min_age; - expect(warmPhaseMinAge).toBe('123d'); - }); }); describe('policy with include and exclude', () => { @@ -458,9 +438,6 @@ describe('<EditPolicy />', () => { "max_age": "30d", "max_size": "50gb", }, - "set_priority": Object { - "priority": 100, - }, }, "min_age": "0ms", }, @@ -662,9 +639,6 @@ describe('<EditPolicy />', () => { "allocate": Object { "number_of_replicas": 123, }, - "set_priority": Object { - "priority": 50, - }, } `); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 6aa6c3177ca5d..d847c3a7f9766 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -157,7 +157,7 @@ const setPhaseIndexPriority = async ( phase: string, priority: string | number ) => { - const priorityInput = findTestSubject(rendered, `${phase}-phaseIndexPriority`); + const priorityInput = findTestSubject(rendered, `${phase}-indexPriority`); await act(async () => { priorityInput.simulate('change', { target: { value: priority } }); }); @@ -324,9 +324,6 @@ describe('edit policy', () => { max_age: '30d', max_size: '50gb', }, - set_priority: { - priority: 100, - }, }, min_age: '0ms', }, @@ -451,6 +448,7 @@ describe('edit policy', () => { const rendered = mountWithIntl(component); await noRollover(rendered); await setPolicyName(rendered, 'mypolicy'); + await setPhaseIndexPriority(rendered, 'hot', '-1'); waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); @@ -512,7 +510,7 @@ describe('edit policy', () => { }); rendered.update(); await setPhaseAfter(rendered, 'warm', '1'); - const shrinkInput = findTestSubject(rendered, 'warm-selectedPrimaryShardCount'); + const shrinkInput = findTestSubject(rendered, 'warm-primaryShardCount'); await act(async () => { shrinkInput.simulate('change', { target: { value: '0' } }); }); @@ -529,7 +527,7 @@ describe('edit policy', () => { findTestSubject(rendered, 'warm-shrinkSwitch').simulate('click'); }); rendered.update(); - const shrinkInput = findTestSubject(rendered, 'warm-selectedPrimaryShardCount'); + const shrinkInput = findTestSubject(rendered, 'warm-primaryShardCount'); await act(async () => { shrinkInput.simulate('change', { target: { value: '-1' } }); }); @@ -845,7 +843,7 @@ describe('edit policy', () => { await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - // Assert that only the custom and off options exist + // Assert that default, custom and 'none' options exist findTestSubject(rendered, 'dataTierSelect').simulate('click'); expect(findTestSubject(rendered, 'defaultDataAllocationOption').exists()).toBeTruthy(); expect(findTestSubject(rendered, 'customDataAllocationOption').exists()).toBeTruthy(); @@ -885,7 +883,7 @@ describe('edit policy', () => { await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); - // Assert that only the custom and off options exist + // Assert that default, custom and 'none' options exist findTestSubject(rendered, 'dataTierSelect').simulate('click'); expect(findTestSubject(rendered, 'defaultDataAllocationOption').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'customDataAllocationOption').exists()).toBeTruthy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts index a892a7a031a87..6eae59ec4e6ea 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts @@ -4,16 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - SerializedPhase, - DeletePhase, - SerializedPolicy, - RolloverAction, -} from '../../../common/types'; +import { SerializedPolicy, RolloverAction } from '../../../common/types'; -export const defaultSetPriority: string = '100'; - -export const defaultPhaseIndexPriority: string = '50'; +export const defaultIndexPriority = { + hot: '100', + warm: '50', + cold: '0', +}; export const defaultRolloverAction: RolloverAction = { max_age: '30d', @@ -30,15 +27,3 @@ export const defaultPolicy: SerializedPolicy = { }, }, }; - -export const defaultNewDeletePhase: DeletePhase = { - phaseEnabled: false, - selectedMinimumAge: '0', - selectedMinimumAgeUnits: 'd', - waitForSnapshotPolicy: '', -}; - -export const serializedPhaseInitialization: SerializedPhase = { - min_age: '0ms', - actions: {}, -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.scss new file mode 100644 index 0000000000000..96ca0c3a61067 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.scss @@ -0,0 +1,16 @@ +.ilmActivePhaseHighlight { + border-left: $euiBorderWidthThin solid $euiColorLightShade; + height: 100%; + + &.hotPhase.active { + border-left-color: $euiColorVis9_behindText; + } + + &.warmPhase.active { + border-left-color: $euiColorVis5_behindText; + } + + &.coldPhase.active { + border-left-color: $euiColorVis1_behindText; + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.tsx new file mode 100644 index 0000000000000..64db9e1ec5481 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.tsx @@ -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 React, { FunctionComponent } from 'react'; + +import './active_highlight.scss'; + +interface Props { + phase: 'hot' | 'warm' | 'cold'; + enabled: boolean; +} +export const ActiveHighlight: FunctionComponent<Props> = ({ phase, enabled }) => { + return <div className={`ilmActivePhaseHighlight ${phase}Phase ${enabled ? 'active' : ''} `} />; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/index.ts new file mode 100644 index 0000000000000..a1db0c3997edb --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/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 { ActiveHighlight } from './active_highlight'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index d22206d7ae4de..960b632d70bd4 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -11,6 +11,7 @@ export { OptionalLabel } from './optional_label'; export { PolicyJsonFlyout } from './policy_json_flyout'; export { DescribedFormRow, ToggleFieldWithDescribedFormRow } from './described_form_row'; export { FieldLoadingError } from './field_loading_error'; +export { ActiveHighlight } from './active_highlight'; export { Timeline } from './timeline'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index 4e1ec76c52a77..976f584ef4d3a 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -9,28 +9,21 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; -import { EuiDescribedFormGroup, EuiTextColor, EuiAccordion } from '@elastic/eui'; +import { EuiTextColor } from '@elastic/eui'; -import { Phases } from '../../../../../../../common/types'; +import { useFormData } from '../../../../../../shared_imports'; -import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; - -import { useEditPolicyContext } from '../../../edit_policy_context'; import { useConfigurationIssues } from '../../../form'; -import { - LearnMoreLink, - ActiveBadge, - DescribedFormRow, - ToggleFieldWithDescribedFormRow, -} from '../../'; +import { LearnMoreLink, ToggleFieldWithDescribedFormRow } from '../../'; import { - MinAgeInputField, DataTierAllocationField, - SetPriorityInputField, SearchableSnapshotField, + IndexPriorityField, + ReplicasField, } from '../shared_fields'; +import { Phase } from '../phase'; const i18nTexts = { dataTierAllocation: { @@ -41,166 +34,64 @@ const i18nTexts = { }, }; -const coldProperty: keyof Phases = 'cold'; - const formFieldPaths = { enabled: '_meta.cold.enabled', searchableSnapshot: 'phases.cold.actions.searchable_snapshot.snapshot_repository', }; export const ColdPhase: FunctionComponent = () => { - const { policy } = useEditPolicyContext(); const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); const [formData] = useFormData({ - watch: [formFieldPaths.enabled, formFieldPaths.searchableSnapshot], + watch: [formFieldPaths.searchableSnapshot], }); - const enabled = get(formData, formFieldPaths.enabled); const showReplicasField = get(formData, formFieldPaths.searchableSnapshot) == null; return ( - <div id="coldPhaseContent" aria-live="polite" role="region"> - <> - {/* Section title group; containing min age */} - <EuiDescribedFormGroup + <Phase phase={'cold'}> + <SearchableSnapshotField phase={'cold'} /> + + {showReplicasField && <ReplicasField phase={'cold'} />} + + {/* Freeze section */} + {!isUsingSearchableSnapshotInHotPhase && ( + <ToggleFieldWithDescribedFormRow title={ - <div> - <h2 className="eui-displayInlineBlock eui-alignMiddle"> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseLabel" - defaultMessage="Cold phase" - /> - </h2>{' '} - {enabled && <ActiveBadge />} - </div> + <h3> + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeText" + defaultMessage="Freeze" + /> + </h3> } - titleSize="s" description={ - <> - <p> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescriptionText" - defaultMessage="You are querying your index less frequently, so you can allocate shards - on significantly less performant hardware. - Because your queries are slower, you can reduce the number of replicas." - /> - </p> - <UseField - path={formFieldPaths.enabled} - component={ToggleField} - componentProps={{ - fullWidth: false, - euiFieldProps: { - 'data-test-subj': 'enablePhaseSwitch-cold', - 'aria-controls': 'coldPhaseContent', - }, - }} - /> - </> + <EuiTextColor color="subdued"> + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText" + defaultMessage="Make the index read-only and minimize its memory footprint." + />{' '} + <LearnMoreLink docPath="ilm-freeze.html" /> + </EuiTextColor> } fullWidth + titleSize="xs" + switchProps={{ + 'data-test-subj': 'freezeSwitch', + path: '_meta.cold.freezeEnabled', + }} > - {enabled && <MinAgeInputField phase="cold" />} - </EuiDescribedFormGroup> - {enabled && ( - <> - <SearchableSnapshotField phase="cold" /> - - <EuiAccordion - id="ilmWarmPhaseAdvancedSettings" - buttonContent={i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.advancedSettingsButton', - { - defaultMessage: 'Advanced settings', - } - )} - paddingSize="m" - > - { - /* Replicas section */ - showReplicasField && ( - <DescribedFormRow - title={ - <h3> - {i18n.translate('xpack.indexLifecycleMgmt.coldPhase.replicasTitle', { - defaultMessage: 'Replicas', - })} - </h3> - } - description={i18n.translate( - 'xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasDescription', - { - defaultMessage: - 'Set the number of replicas. Remains the same as the previous phase by default.', - } - )} - switchProps={{ - 'data-test-subj': 'cold-setReplicasSwitch', - label: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel', - { defaultMessage: 'Set replicas' } - ), - initialValue: - policy.phases.cold?.actions?.allocate?.number_of_replicas != null, - }} - fullWidth - > - <UseField - path="phases.cold.actions.allocate.number_of_replicas" - component={NumericField} - componentProps={{ - fullWidth: false, - euiFieldProps: { - 'data-test-subj': `${coldProperty}-selectedReplicaCount`, - min: 0, - }, - }} - /> - </DescribedFormRow> - ) - } - - {/* Freeze section */} - {!isUsingSearchableSnapshotInHotPhase && ( - <ToggleFieldWithDescribedFormRow - title={ - <h3> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeText" - defaultMessage="Freeze" - /> - </h3> - } - description={ - <EuiTextColor color="subdued"> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText" - defaultMessage="Make the index read-only and minimize its memory footprint." - />{' '} - <LearnMoreLink docPath="ilm-freeze.html" /> - </EuiTextColor> - } - fullWidth - titleSize="xs" - switchProps={{ - 'data-test-subj': 'freezeSwitch', - path: '_meta.cold.freezeEnabled', - }} - > - <div /> - </ToggleFieldWithDescribedFormRow> - )} - {/* Data tier allocation section */} - <DataTierAllocationField - description={i18nTexts.dataTierAllocation.description} - phase={coldProperty} - /> - <SetPriorityInputField phase={coldProperty} /> - </EuiAccordion> - </> - )} - </> - </div> + <div /> + </ToggleFieldWithDescribedFormRow> + )} + + {/* Data tier allocation section */} + <DataTierAllocationField + description={i18nTexts.dataTierAllocation.description} + phase={'cold'} + /> + + <IndexPriorityField phase={'cold'} /> + </Phase> ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx index 37323b97edc92..5c43bb413eb5e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx @@ -13,7 +13,7 @@ import { useFormData, UseField, ToggleField } from '../../../../../../shared_imp import { ActiveBadge, LearnMoreLink, OptionalLabel } from '../../index'; -import { MinAgeInputField, SnapshotPoliciesField } from '../shared_fields'; +import { MinAgeField, SnapshotPoliciesField } from '../shared_fields'; const formFieldPaths = { enabled: '_meta.delete.enabled', @@ -63,7 +63,7 @@ export const DeletePhase: FunctionComponent = () => { } fullWidth > - {enabled && <MinAgeInputField phase="delete" />} + {enabled && <MinAgeField phase="delete" />} </EuiDescribedFormGroup> {enabled ? ( <EuiDescribedFormGroup diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index a777f30fd2e42..fb7c9a80acba0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -12,16 +12,12 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiDescribedFormGroup, EuiCallOut, - EuiAccordion, EuiTextColor, EuiSwitch, EuiIconTip, } from '@elastic/eui'; -import { Phases } from '../../../../../../../common/types'; - import { useFormData, UseField, SelectField, NumericField } from '../../../../../../shared_imports'; import { i18nTexts } from '../../../i18n_texts'; @@ -32,20 +28,19 @@ import { useEditPolicyContext } from '../../../edit_policy_context'; import { ROLLOVER_FORM_PATHS, isUsingDefaultRolloverPath } from '../../../constants'; -import { LearnMoreLink, ActiveBadge, DescribedFormRow } from '../../'; +import { LearnMoreLink, DescribedFormRow } from '../../'; import { ForcemergeField, - SetPriorityInputField, + IndexPriorityField, SearchableSnapshotField, ReadonlyField, ShrinkField, } from '../shared_fields'; +import { Phase } from '../phase'; import { maxSizeStoredUnits, maxAgeUnits } from './constants'; -const hotProperty: keyof Phases = 'hot'; - export const HotPhase: FunctionComponent = () => { const { license } = useEditPolicyContext(); const [formData] = useFormData({ @@ -56,245 +51,210 @@ export const HotPhase: FunctionComponent = () => { const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); return ( - <> - <EuiDescribedFormGroup - fullWidth - titleSize="s" + <Phase phase={'hot'}> + <DescribedFormRow title={ - <div> - <h2 className="eui-displayInlineBlock eui-alignMiddle"> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseLabel" - defaultMessage="Hot phase" - /> - </h2>{' '} - <ActiveBadge /> - </div> + <h3> + {i18n.translate('xpack.indexLifecycleMgmt.hotPhase.rolloverFieldTitle', { + defaultMessage: 'Rollover', + })} + </h3> } description={ - <p> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescriptionMessage" - defaultMessage="This phase is required. You are actively querying and - writing to your index. For faster updates, you can roll over the index when it gets too big or too old." - /> - </p> + <> + <EuiTextColor color="subdued"> + <p> + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDescriptionMessage" + defaultMessage="Automate rollover of time series data for efficient storage and higher performance." + />{' '} + <LearnMoreLink + text={ + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText" + defaultMessage="Learn more" + /> + } + docPath="ilm-rollover.html" + /> + </p> + </EuiTextColor> + <EuiSpacer /> + <UseField<boolean> path={isUsingDefaultRolloverPath}> + {(field) => ( + <> + <EuiSwitch + label={field.label} + checked={field.value} + onChange={(e) => field.setValue(e.target.checked)} + data-test-subj="useDefaultRolloverSwitch" + /> +   + <EuiIconTip + type="questionInCircle" + content={ + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDefaultsTipContent" + defaultMessage="Rollover when an index is 30 days old or reaches 50 gigabytes." + /> + } + /> + </> + )} + </UseField> + </> } + fullWidth > - <div /> - </EuiDescribedFormGroup> - - <EuiAccordion - id="ilmHotPhaseAdvancedSettings" - buttonContent={i18n.translate('xpack.indexLifecycleMgmt.hotPhase.advancedSettingsButton', { - defaultMessage: 'Advanced settings', - })} - paddingSize="m" - > - <DescribedFormRow - title={ - <h3> - {i18n.translate('xpack.indexLifecycleMgmt.hotPhase.rolloverFieldTitle', { - defaultMessage: 'Rollover', - })} - </h3> - } - description={ - <> - <EuiTextColor color="subdued"> - <p> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDescriptionMessage" - defaultMessage="Automate rollover of time series data for efficient storage and higher performance." - />{' '} - <LearnMoreLink - text={ + {isUsingDefaultRollover === false ? ( + <div aria-live="polite" role="region"> + <UseField<boolean> path="_meta.hot.customRollover.enabled"> + {(field) => ( + <> + <EuiSwitch + label={field.label} + checked={field.value} + onChange={(e) => field.setValue(e.target.checked)} + data-test-subj="rolloverSwitch" + /> +   + <EuiIconTip + type="questionInCircle" + content={ <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText" - defaultMessage="Learn more" + id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.enableRolloverTipContent" + defaultMessage="Roll over to a new index when the + current index meets one of the defined conditions." /> } - docPath="ilm-rollover.html" /> - </p> - </EuiTextColor> - <EuiSpacer /> - <UseField<boolean> path={isUsingDefaultRolloverPath}> - {(field) => ( + </> + )} + </UseField> + {isUsingRollover && ( + <> + <EuiSpacer size="m" /> + {showEmptyRolloverFieldsError && ( <> - <EuiSwitch - label={field.label} - checked={field.value} - onChange={(e) => field.setValue(e.target.checked)} - data-test-subj="useDefaultRolloverSwitch" - /> -   - <EuiIconTip - type="questionInCircle" - content={ - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDefaultsTipContent" - defaultMessage="Rollover when an index is 30 days old or reaches 50 gigabytes." - /> - } - /> + <EuiCallOut + title={i18nTexts.editPolicy.errors.rollOverConfigurationCallout.title} + data-test-subj="rolloverSettingsRequired" + color="danger" + > + <div>{i18nTexts.editPolicy.errors.rollOverConfigurationCallout.body}</div> + </EuiCallOut> + <EuiSpacer size="s" /> </> )} - </UseField> - </> - } - fullWidth - > - {isUsingDefaultRollover === false ? ( - <div aria-live="polite" role="region"> - <UseField<boolean> path="_meta.hot.customRollover.enabled"> - {(field) => ( - <> - <EuiSwitch - label={field.label} - checked={field.value} - onChange={(e) => field.setValue(e.target.checked)} - data-test-subj="rolloverSwitch" + <EuiFlexGroup> + <EuiFlexItem style={{ maxWidth: 188 }}> + <UseField path={ROLLOVER_FORM_PATHS.maxSize}> + {(field) => { + const showErrorCallout = field.errors.some( + (e) => e.code === ROLLOVER_EMPTY_VALIDATION + ); + if (showErrorCallout !== showEmptyRolloverFieldsError) { + setShowEmptyRolloverFieldsError(showErrorCallout); + } + return ( + <NumericField + field={field} + euiFieldProps={{ + 'data-test-subj': `hot-selectedMaxSizeStored`, + min: 1, + }} + /> + ); + }} + </UseField> + </EuiFlexItem> + <EuiFlexItem style={{ maxWidth: 188 }}> + <UseField + key="_meta.hot.customRollover.maxStorageSizeUnit" + path="_meta.hot.customRollover.maxStorageSizeUnit" + component={SelectField} + componentProps={{ + 'data-test-subj': `hot-selectedMaxSizeStoredUnits`, + hasEmptyLabelSpace: true, + euiFieldProps: { + options: maxSizeStoredUnits, + 'aria-label': i18n.translate( + 'xpack.indexLifecycleMgmt.hotPhase.maximumIndexSizeUnitsAriaLabel', + { + defaultMessage: 'Maximum index size units', + } + ), + }, + }} /> -   - <EuiIconTip - type="questionInCircle" - content={ - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.hotPhase.enableRolloverTipContent" - defaultMessage="Roll over to a new index when the - current index meets one of the defined conditions." - /> - } + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem style={{ maxWidth: 188 }}> + <UseField + path={ROLLOVER_FORM_PATHS.maxDocs} + component={NumericField} + componentProps={{ + euiFieldProps: { + 'data-test-subj': `hot-selectedMaxDocuments`, + min: 1, + }, + }} /> - </> - )} - </UseField> - {isUsingRollover && ( - <> - <EuiSpacer size="m" /> - {showEmptyRolloverFieldsError && ( - <> - <EuiCallOut - title={i18nTexts.editPolicy.errors.rollOverConfigurationCallout.title} - data-test-subj="rolloverSettingsRequired" - color="danger" - > - <div>{i18nTexts.editPolicy.errors.rollOverConfigurationCallout.body}</div> - </EuiCallOut> - <EuiSpacer size="s" /> - </> - )} - <EuiFlexGroup> - <EuiFlexItem style={{ maxWidth: 188 }}> - <UseField path={ROLLOVER_FORM_PATHS.maxSize}> - {(field) => { - const showErrorCallout = field.errors.some( - (e) => e.code === ROLLOVER_EMPTY_VALIDATION - ); - if (showErrorCallout !== showEmptyRolloverFieldsError) { - setShowEmptyRolloverFieldsError(showErrorCallout); - } - return ( - <NumericField - field={field} - euiFieldProps={{ - 'data-test-subj': `${hotProperty}-selectedMaxSizeStored`, - min: 1, - }} - /> - ); - }} - </UseField> - </EuiFlexItem> - <EuiFlexItem style={{ maxWidth: 188 }}> - <UseField - key="_meta.hot.customRollover.maxStorageSizeUnit" - path="_meta.hot.customRollover.maxStorageSizeUnit" - component={SelectField} - componentProps={{ - 'data-test-subj': `${hotProperty}-selectedMaxSizeStoredUnits`, - hasEmptyLabelSpace: true, - euiFieldProps: { - options: maxSizeStoredUnits, - 'aria-label': i18n.translate( - 'xpack.indexLifecycleMgmt.hotPhase.maximumIndexSizeUnitsAriaLabel', - { - defaultMessage: 'Maximum index size units', - } - ), - }, - }} - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer /> - <EuiFlexGroup> - <EuiFlexItem style={{ maxWidth: 188 }}> - <UseField - path={ROLLOVER_FORM_PATHS.maxDocs} - component={NumericField} - componentProps={{ - euiFieldProps: { - 'data-test-subj': `${hotProperty}-selectedMaxDocuments`, - min: 1, - }, - }} - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer /> - <EuiFlexGroup> - <EuiFlexItem style={{ maxWidth: 188 }}> - <UseField - path={ROLLOVER_FORM_PATHS.maxAge} - component={NumericField} - componentProps={{ - euiFieldProps: { - 'data-test-subj': `${hotProperty}-selectedMaxAge`, - min: 1, - }, - }} - /> - </EuiFlexItem> - <EuiFlexItem style={{ maxWidth: 188 }}> - <UseField - key="_meta.hot.customRollover.maxAgeUnit" - path="_meta.hot.customRollover.maxAgeUnit" - component={SelectField} - componentProps={{ - 'data-test-subj': `${hotProperty}-selectedMaxAgeUnits`, - hasEmptyLabelSpace: true, - euiFieldProps: { - 'aria-label': i18n.translate( - 'xpack.indexLifecycleMgmt.hotPhase.maximumAgeUnitsAriaLabel', - { - defaultMessage: 'Maximum age units', - } - ), - options: maxAgeUnits, - }, - }} - /> - </EuiFlexItem> - </EuiFlexGroup> - </> - )} - </div> - ) : ( - <div /> - )} - </DescribedFormRow> - {isUsingRollover && ( - <> - {<ForcemergeField phase="hot" />} - <ShrinkField phase="hot" /> - {license.canUseSearchableSnapshot() && <SearchableSnapshotField phase="hot" />} - <ReadonlyField phase={'hot'} /> - </> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer /> + <EuiFlexGroup> + <EuiFlexItem style={{ maxWidth: 188 }}> + <UseField + path={ROLLOVER_FORM_PATHS.maxAge} + component={NumericField} + componentProps={{ + euiFieldProps: { + 'data-test-subj': `hot-selectedMaxAge`, + min: 1, + }, + }} + /> + </EuiFlexItem> + <EuiFlexItem style={{ maxWidth: 188 }}> + <UseField + key="_meta.hot.customRollover.maxAgeUnit" + path="_meta.hot.customRollover.maxAgeUnit" + component={SelectField} + componentProps={{ + 'data-test-subj': `hot-selectedMaxAgeUnits`, + hasEmptyLabelSpace: true, + euiFieldProps: { + 'aria-label': i18n.translate( + 'xpack.indexLifecycleMgmt.hotPhase.maximumAgeUnitsAriaLabel', + { + defaultMessage: 'Maximum age units', + } + ), + options: maxAgeUnits, + }, + }} + /> + </EuiFlexItem> + </EuiFlexGroup> + </> + )} + </div> + ) : ( + <div /> )} - <SetPriorityInputField phase={hotProperty} /> - </EuiAccordion> - </> + </DescribedFormRow> + {isUsingRollover && ( + <> + {<ForcemergeField phase={'hot'} />} + <ShrinkField phase={'hot'} /> + {license.canUseSearchableSnapshot() && <SearchableSnapshotField phase={'hot'} />} + <ReadonlyField phase={'hot'} /> + </> + )} + <IndexPriorityField phase={'hot'} /> + </Phase> ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/index.ts index 076c16e87e8d6..c2c7e6a769071 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/index.ts @@ -11,3 +11,5 @@ export { WarmPhase } from './warm_phase'; export { ColdPhase } from './cold_phase'; export { DeletePhase } from './delete_phase'; + +export { Phase } from './phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase.tsx new file mode 100644 index 0000000000000..6de18f1c1d3cb --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase.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 React, { FunctionComponent, useState } from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiTitle, + EuiSpacer, + EuiText, + EuiButtonEmpty, +} from '@elastic/eui'; +import { get } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ToggleField, UseField, useFormData } from '../../../../../shared_imports'; +import { i18nTexts } from '../../i18n_texts'; + +import { ActiveHighlight } from '../active_highlight'; +import { MinAgeField } from './shared_fields'; + +interface Props { + phase: 'hot' | 'warm' | 'cold'; +} + +export const Phase: FunctionComponent<Props> = ({ children, phase }) => { + const enabledPath = `_meta.${phase}.enabled`; + const [formData] = useFormData({ + watch: [enabledPath], + }); + + // hot phase is always enabled + const enabled = get(formData, enabledPath) || phase === 'hot'; + + const [isShowingSettings, setShowingSettings] = useState<boolean>(false); + return ( + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <ActiveHighlight phase={phase} enabled={enabled} /> + </EuiFlexItem> + <EuiFlexItem> + <EuiPanel hasShadow={enabled}> + <EuiFlexGroup wrap> + <EuiFlexItem> + <EuiFlexGroup alignItems="center" gutterSize={'s'}> + {phase !== 'hot' && ( + <EuiFlexItem grow={false}> + <UseField + path={enabledPath} + component={ToggleField} + componentProps={{ + euiFieldProps: { + 'data-test-subj': `enablePhaseSwitch-${phase}`, + showLabel: false, + }, + }} + /> + </EuiFlexItem> + )} + <EuiFlexItem grow={false}> + <EuiTitle size={'s'}> + <h2>{i18nTexts.editPolicy.titles[phase]}</h2> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + {enabled && ( + <EuiFlexItem> + <EuiFlexGroup + justifyContent="spaceBetween" + alignItems="center" + gutterSize={'xs'} + wrap + > + <EuiFlexItem grow={true}> + {phase !== 'hot' && <MinAgeField phase={phase} />} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + data-test-subj={`${phase}-settingsSwitch`} + onClick={() => { + setShowingSettings(!isShowingSettings); + }} + size="xs" + iconType="controlsVertical" + iconSide="left" + aria-controls={`${phase}-phaseContent`} + > + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.phaseSettings.buttonLabel" + defaultMessage="Settings" + /> + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + )} + </EuiFlexGroup> + <EuiSpacer /> + <EuiText color="subdued" size={'s'} style={{ maxWidth: '50%' }}> + {i18nTexts.editPolicy.descriptions[phase]} + </EuiText> + + {enabled && ( + <div style={isShowingSettings ? {} : { display: 'none' }} id={`${phase}-phaseContent`}> + <EuiSpacer /> + {children} + </div> + )} + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx index 8776dbbbc7553..8d6807c90dae8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx @@ -6,9 +6,8 @@ import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer, EuiTextColor } from '@elastic/eui'; -import { UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; +import { UseField, CheckBoxField, NumericField } from '../../../../../../shared_imports'; import { i18nTexts } from '../../../i18n_texts'; @@ -38,50 +37,43 @@ export const ForcemergeField: React.FunctionComponent<Props> = ({ phase }) => { </h3> } description={ - <EuiTextColor color="subdued"> + <> <FormattedMessage id="xpack.indexLifecycleMgmt.editPolicy.forceMerge.enableExplanationText" defaultMessage="Reduce the number of segments in your shard by merging smaller files and clearing deleted ones." />{' '} <LearnMoreLink docPath="ilm-forcemerge.html" /> - </EuiTextColor> + </> } titleSize="xs" fullWidth switchProps={{ - 'aria-label': i18nTexts.editPolicy.forceMergeEnabledFieldLabel, - 'data-test-subj': `${phase}-forceMergeSwitch`, - 'aria-controls': 'forcemergeContent', label: i18nTexts.editPolicy.forceMergeEnabledFieldLabel, + 'data-test-subj': `${phase}-forceMergeSwitch`, initialValue: initialToggleValue, }} > - <EuiSpacer /> - <div id="forcemergeContent" aria-live="polite" role="region"> - <UseField - key={`phases.${phase}.actions.forcemerge.max_num_segments`} - path={`phases.${phase}.actions.forcemerge.max_num_segments`} - component={NumericField} - componentProps={{ - fullWidth: false, - euiFieldProps: { - 'data-test-subj': `${phase}-selectedForceMergeSegments`, - min: 1, - }, - }} - /> - <UseField - key={`_meta.${phase}.bestCompression`} - path={`_meta.${phase}.bestCompression`} - component={ToggleField} - componentProps={{ - hasEmptyLabelSpace: true, - euiFieldProps: { - 'data-test-subj': `${phase}-bestCompression`, - }, - }} - /> - </div> + <UseField + path={`phases.${phase}.actions.forcemerge.max_num_segments`} + component={NumericField} + componentProps={{ + fullWidth: false, + euiFieldProps: { + 'data-test-subj': `${phase}-selectedForceMergeSegments`, + min: 1, + }, + }} + /> + <UseField + path={`_meta.${phase}.bestCompression`} + component={CheckBoxField} + componentProps={{ + hasEmptyLabelSpace: true, + euiFieldProps: { + 'data-test-subj': `${phase}-bestCompression`, + }, + }} + /> </DescribedFormRow> ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts index 15167672265fd..710df7e95f8fc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts @@ -8,9 +8,7 @@ export { DataTierAllocationField } from './data_tier_allocation_field'; export { ForcemergeField } from './forcemerge_field'; -export { SetPriorityInputField } from './set_priority_input_field'; - -export { MinAgeInputField } from './min_age_input_field'; +export { MinAgeField } from './min_age_field'; export { SnapshotPoliciesField } from './snapshot_policies_field'; @@ -19,3 +17,7 @@ export { ShrinkField } from './shrink_field'; export { SearchableSnapshotField } from './searchable_snapshot_field'; export { ReadonlyField } from './readonly_field'; + +export { ReplicasField } from './replicas_field'; + +export { IndexPriorityField } from './index_priority_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index_priority_field.tsx similarity index 50% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input_field.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index_priority_field.tsx index 328587a379b76..570033812c247 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/set_priority_input_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index_priority_field.tsx @@ -4,23 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiTextColor, EuiDescribedFormGroup } from '@elastic/eui'; - -import { Phases } from '../../../../../../../common/types'; +import { i18n } from '@kbn/i18n'; +import { EuiSpacer, EuiTextColor } from '@elastic/eui'; import { UseField, NumericField } from '../../../../../../shared_imports'; - -import { LearnMoreLink } from '../..'; +import { LearnMoreLink, DescribedFormRow } from '../..'; +import { useEditPolicyContext } from '../../../edit_policy_context'; interface Props { - phase: keyof Phases & string; + phase: 'hot' | 'warm' | 'cold'; } -export const SetPriorityInputField: FunctionComponent<Props> = ({ phase }) => { +export const IndexPriorityField: FunctionComponent<Props> = ({ phase }) => { + const { policy, isNewPolicy } = useEditPolicyContext(); + + const initialToggleValue = useMemo<boolean>(() => { + return ( + isNewPolicy || // enable index priority for new policies + !policy.phases[phase]?.actions || // enable index priority for new phases + policy.phases[phase]?.actions?.set_priority != null // enable index priority if it's set + ); + }, [isNewPolicy, policy.phases, phase]); + return ( - <EuiDescribedFormGroup + <DescribedFormRow title={ <h3> <FormattedMessage @@ -41,19 +50,27 @@ export const SetPriorityInputField: FunctionComponent<Props> = ({ phase }) => { } titleSize="xs" fullWidth + switchProps={{ + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.indexPriority.indexPriorityEnabledFieldLabel', + { + defaultMessage: 'Set index priority', + } + ), + 'data-test-subj': `${phase}-indexPrioritySwitch`, + initialValue: initialToggleValue, + }} > + <EuiSpacer /> <UseField - key={`phases.${phase}.actions.set_priority.priority`} path={`phases.${phase}.actions.set_priority.priority`} component={NumericField} - componentProps={{ + euiFieldProps={{ fullWidth: false, - euiFieldProps: { - 'data-test-subj': `${phase}-phaseIndexPriority`, - min: 0, - }, + 'data-test-subj': `${phase}-indexPriority`, + min: 0, }} /> - </EuiDescribedFormGroup> + </DescribedFormRow> ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/index.ts new file mode 100644 index 0000000000000..43ef0cf5d9b71 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/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 { MinAgeField } from './min_age_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx new file mode 100644 index 0000000000000..8a84b7fa0e762 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiFieldNumber, + EuiFieldNumberProps, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiText, +} from '@elastic/eui'; + +import { UseField, getFieldValidityAndErrorMessage } from '../../../../../../../shared_imports'; + +import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; + +type PhaseWithMinAgeAction = 'warm' | 'cold' | 'delete'; + +const i18nTexts = { + daysOptionLabel: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.daysOptionLabel', { + defaultMessage: 'days', + }), + + hoursOptionLabel: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.hoursOptionLabel', { + defaultMessage: 'hours', + }), + minutesOptionLabel: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.minutesOptionLabel', { + defaultMessage: 'minutes', + }), + + secondsOptionLabel: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.secondsOptionLabel', { + defaultMessage: 'seconds', + }), + millisecondsOptionLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.milliSecondsOptionLabel', + { + defaultMessage: 'milliseconds', + } + ), + + microsecondsOptionLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.microSecondsOptionLabel', + { + defaultMessage: 'microseconds', + } + ), + + nanosecondsOptionLabel: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.nanoSecondsOptionLabel', + { + defaultMessage: 'nanoseconds', + } + ), +}; + +interface Props { + phase: PhaseWithMinAgeAction; +} + +export const MinAgeField: FunctionComponent<Props> = ({ phase }): React.ReactElement => { + return ( + <UseField path={`phases.${phase}.min_age`}> + {(field) => { + const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + return ( + <EuiFormRow fullWidth isInvalid={isInvalid} error={errorMessage}> + <EuiFlexGroup gutterSize={'s'} alignItems={'center'} justifyContent={'spaceBetween'}> + <EuiFlexItem grow={false}> + <EuiText className={'eui-textNoWrap'} size={'xs'}> + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.minimumAge.minimumAgeFieldLabel" + defaultMessage="Move data into phase when:" + /> + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={true}> + <EuiFlexGroup gutterSize={'s'}> + <EuiFlexItem grow={false}> + <EuiFieldNumber + style={{ minWidth: 50 }} + compressed + aria-label={getTimingLabelForPhase(phase)} + isInvalid={isInvalid} + value={field.value as EuiFieldNumberProps['value']} + onChange={field.onChange} + isLoading={field.isValidating} + data-test-subj={`${phase}-selectedMinimumAge`} + min={0} + /> + </EuiFlexItem> + <EuiFlexItem grow={true} style={{ minWidth: 165 }}> + <UseField path={`_meta.${phase}.minAgeUnit`}> + {(unitField) => { + const { isInvalid: isUnitFieldInvalid } = getFieldValidityAndErrorMessage( + unitField + ); + return ( + <EuiSelect + compressed + value={unitField.value as string} + onChange={(e) => { + unitField.setValue(e.target.value); + }} + isInvalid={isUnitFieldInvalid} + append={'old'} + data-test-subj={`${phase}-selectedMinimumAgeUnits`} + aria-label={getUnitsAriaLabelForPhase(phase)} + options={[ + { + value: 'd', + text: i18nTexts.daysOptionLabel, + }, + { + value: 'h', + text: i18nTexts.hoursOptionLabel, + }, + { + value: 'm', + text: i18nTexts.minutesOptionLabel, + }, + { + value: 's', + text: i18nTexts.secondsOptionLabel, + }, + { + value: 'ms', + text: i18nTexts.millisecondsOptionLabel, + }, + { + value: 'micros', + text: i18nTexts.microsecondsOptionLabel, + }, + { + value: 'nanos', + text: i18nTexts.nanosecondsOptionLabel, + }, + ]} + /> + ); + }} + </UseField> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFormRow> + ); + }} + </UseField> + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/util.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/util.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/util.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/util.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/min_age_input_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/min_age_input_field.tsx deleted file mode 100644 index 59086ce572252..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/min_age_input_field.tsx +++ /dev/null @@ -1,205 +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 } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; - -import { UseField, NumericField, SelectField } from '../../../../../../../shared_imports'; - -import { LearnMoreLink } from '../../../learn_more_link'; -import { useConfigurationIssues } from '../../../../form'; - -import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; - -type PhaseWithMinAgeAction = 'warm' | 'cold' | 'delete'; - -interface Props { - phase: PhaseWithMinAgeAction; -} - -export const MinAgeInputField: FunctionComponent<Props> = ({ phase }): React.ReactElement => { - const { isUsingRollover: rolloverEnabled } = useConfigurationIssues(); - - let daysOptionLabel; - let hoursOptionLabel; - let minutesOptionLabel; - let secondsOptionLabel; - let millisecondsOptionLabel; - let microsecondsOptionLabel; - let nanosecondsOptionLabel; - - if (rolloverEnabled) { - daysOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverDaysOptionLabel', - { - defaultMessage: 'days from rollover', - } - ); - - hoursOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverHoursOptionLabel', - { - defaultMessage: 'hours from rollover', - } - ); - minutesOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMinutesOptionLabel', - { - defaultMessage: 'minutes from rollover', - } - ); - - secondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverSecondsOptionLabel', - { - defaultMessage: 'seconds from rollover', - } - ); - millisecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMilliSecondsOptionLabel', - { - defaultMessage: 'milliseconds from rollover', - } - ); - - microsecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMicroSecondsOptionLabel', - { - defaultMessage: 'microseconds from rollover', - } - ); - - nanosecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverNanoSecondsOptionLabel', - { - defaultMessage: 'nanoseconds from rollover', - } - ); - } else { - daysOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationDaysOptionLabel', - { - defaultMessage: 'days from index creation', - } - ); - - hoursOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationHoursOptionLabel', - { - defaultMessage: 'hours from index creation', - } - ); - - minutesOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMinutesOptionLabel', - { - defaultMessage: 'minutes from index creation', - } - ); - - secondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationSecondsOptionLabel', - { - defaultMessage: 'seconds from index creation', - } - ); - - millisecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMilliSecondsOptionLabel', - { - defaultMessage: 'milliseconds from index creation', - } - ); - - microsecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMicroSecondsOptionLabel', - { - defaultMessage: 'microseconds from index creation', - } - ); - - nanosecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationNanoSecondsOptionLabel', - { - defaultMessage: 'nanoseconds from index creation', - } - ); - } - - return ( - <EuiFlexGroup> - <EuiFlexItem style={{ maxWidth: 140 }}> - <UseField - path={`phases.${phase}.min_age`} - component={NumericField} - componentProps={{ - label: getTimingLabelForPhase(phase), - helpText: ( - <LearnMoreLink - docPath="ilm-index-lifecycle.html#ilm-phase-transitions" - text={ - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.learnAboutTimingText" - defaultMessage="Learn about timing" - /> - } - /> - ), - euiFieldProps: { - 'data-test-subj': `${phase}-selectedMinimumAge`, - min: 0, - }, - }} - /> - </EuiFlexItem> - <EuiFlexItem style={{ maxWidth: 236 }}> - <UseField - path={`_meta.${phase}.minAgeUnit`} - component={SelectField} - componentProps={{ - hasEmptyLabelSpace: true, - euiFieldProps: { - 'data-test-subj': `${phase}-selectedMinimumAgeUnits`, - 'aria-label': getUnitsAriaLabelForPhase(phase), - options: [ - { - value: 'd', - text: daysOptionLabel, - }, - { - value: 'h', - text: hoursOptionLabel, - }, - { - value: 'm', - text: minutesOptionLabel, - }, - { - value: 's', - text: secondsOptionLabel, - }, - { - value: 'ms', - text: millisecondsOptionLabel, - }, - { - value: 'micros', - text: microsecondsOptionLabel, - }, - { - value: 'nanos', - text: nanosecondsOptionLabel, - }, - ], - }, - }} - /> - </EuiFlexItem> - </EuiFlexGroup> - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/replicas_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/replicas_field.tsx new file mode 100644 index 0000000000000..6d8e019ff8a0c --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/replicas_field.tsx @@ -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 React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { UseField, NumericField } from '../../../../../../shared_imports'; +import { useEditPolicyContext } from '../../../edit_policy_context'; +import { DescribedFormRow } from '../../described_form_row'; + +interface Props { + phase: 'warm' | 'cold'; +} + +export const ReplicasField: FunctionComponent<Props> = ({ phase }) => { + const { policy } = useEditPolicyContext(); + const initialValue = policy.phases[phase]?.actions?.allocate?.number_of_replicas != null; + return ( + <DescribedFormRow + title={ + <h3> + {i18n.translate('xpack.indexLifecycleMgmt.numberOfReplicas.formRowTitle', { + defaultMessage: 'Replicas', + })} + </h3> + } + description={i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.numberOfReplicas.formRowDescription', + { + defaultMessage: + 'Set the number of replicas. Remains the same as the previous phase by default.', + } + )} + switchProps={{ + 'data-test-subj': `${phase}-setReplicasSwitch`, + label: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.numberOfReplicas.switchLabel', { + defaultMessage: 'Set replicas', + }), + initialValue, + }} + fullWidth + > + <UseField + path={`phases.${phase}.actions.allocate.number_of_replicas`} + component={NumericField} + componentProps={{ + fullWidth: false, + euiFieldProps: { + 'data-test-subj': `${phase}-selectedReplicaCount`, + min: 0, + }, + }} + /> + </DescribedFormRow> + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/shrink_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/shrink_field.tsx index c5fc31d9839bd..da200e9e68d17 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/shrink_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/shrink_field.tsx @@ -42,32 +42,28 @@ export const ShrinkField: FunctionComponent<Props> = ({ phase }) => { } titleSize="xs" switchProps={{ - 'aria-controls': 'shrinkContent', 'data-test-subj': `${phase}-shrinkSwitch`, label: i18nTexts.editPolicy.shrinkLabel, - 'aria-label': i18nTexts.editPolicy.shrinkLabel, initialValue: Boolean(policy.phases[phase]?.actions?.shrink), }} fullWidth > - <div id="shrinkContent" aria-live="polite" role="region"> - <EuiFlexGroup> - <EuiFlexItem> - <UseField - path={path} - component={NumericField} - componentProps={{ - fullWidth: false, - euiFieldProps: { - 'data-test-subj': `${phase}-selectedPrimaryShardCount`, - min: 1, - }, - }} - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer /> - </div> + <EuiFlexGroup> + <EuiFlexItem> + <UseField + path={path} + component={NumericField} + componentProps={{ + fullWidth: false, + euiFieldProps: { + 'data-test-subj': `${phase}-primaryShardCount`, + min: 1, + }, + }} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer /> </DescribedFormRow> ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index 77078e94d7e98..47255da08df72 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -5,30 +5,21 @@ */ import React, { FunctionComponent } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; -import { EuiSpacer, EuiDescribedFormGroup, EuiAccordion } from '@elastic/eui'; - -import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; - -import { Phases } from '../../../../../../../common/types'; - -import { useEditPolicyContext } from '../../../edit_policy_context'; import { useConfigurationIssues } from '../../../form'; -import { ActiveBadge, DescribedFormRow } from '../../'; - import { - MinAgeInputField, ForcemergeField, - SetPriorityInputField, + IndexPriorityField, DataTierAllocationField, ShrinkField, ReadonlyField, + ReplicasField, } from '../shared_fields'; +import { Phase } from '../phase'; + const i18nTexts = { dataTierAllocation: { description: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.dataTier.description', { @@ -37,153 +28,26 @@ const i18nTexts = { }, }; -const warmProperty: keyof Phases = 'warm'; - -const formFieldPaths = { - enabled: '_meta.warm.enabled', - warmPhaseOnRollover: '_meta.warm.warmPhaseOnRollover', -}; - export const WarmPhase: FunctionComponent = () => { - const { policy } = useEditPolicyContext(); - const { isUsingSearchableSnapshotInHotPhase, isUsingRollover } = useConfigurationIssues(); - const [formData] = useFormData({ - watch: [formFieldPaths.enabled, formFieldPaths.warmPhaseOnRollover], - }); - - const enabled = get(formData, formFieldPaths.enabled); - const warmPhaseOnRollover = get(formData, formFieldPaths.warmPhaseOnRollover); + const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); return ( - <div id="warmPhaseContent" aria-live="polite" role="region" aria-relevant="additions"> - <> - <EuiDescribedFormGroup - title={ - <div> - <h2 className="eui-displayInlineBlock eui-alignMiddle"> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseLabel" - defaultMessage="Warm phase" - /> - </h2>{' '} - {enabled && <ActiveBadge />} - </div> - } - titleSize="s" - description={ - <> - <p> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseDescriptionMessage" - defaultMessage="You are still querying your index, but it is read-only. - You can allocate shards to less performant hardware. - For faster searches, you can reduce the number of shards and force merge segments." - /> - </p> - <UseField - path={formFieldPaths.enabled} - component={ToggleField} - componentProps={{ - euiFieldProps: { - 'data-test-subj': 'enablePhaseSwitch-warm', - 'aria-controls': 'warmPhaseContent', - }, - }} - /> - </> - } - fullWidth - > - <> - {enabled && ( - <> - {isUsingRollover && ( - <UseField - path={formFieldPaths.warmPhaseOnRollover} - component={ToggleField} - componentProps={{ - fullWidth: false, - euiFieldProps: { - 'data-test-subj': `${warmProperty}-warmPhaseOnRollover`, - }, - }} - /> - )} - {(!warmPhaseOnRollover || !isUsingRollover) && ( - <> - <EuiSpacer size="m" /> - <MinAgeInputField phase="warm" /> - </> - )} - </> - )} - </> - </EuiDescribedFormGroup> + <Phase phase={'warm'}> + <ReplicasField phase={'warm'} /> - {enabled && ( - <EuiAccordion - id="ilmWarmPhaseAdvancedSettings" - buttonContent={i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.advancedSettingsButton', - { - defaultMessage: 'Advanced settings', - } - )} - paddingSize="m" - > - <DescribedFormRow - title={ - <h3> - {i18n.translate('xpack.indexLifecycleMgmt.warmPhase.replicasTitle', { - defaultMessage: 'Replicas', - })} - </h3> - } - description={i18n.translate( - 'xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasDescription', - { - defaultMessage: - 'Set the number of replicas. Remains the same as the previous phase by default.', - } - )} - switchProps={{ - 'data-test-subj': 'warm-setReplicasSwitch', - label: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel', - { defaultMessage: 'Set replicas' } - ), - initialValue: policy.phases.warm?.actions?.allocate?.number_of_replicas != null, - }} - fullWidth - > - <UseField - path="phases.warm.actions.allocate.number_of_replicas" - component={NumericField} - componentProps={{ - fullWidth: false, - euiFieldProps: { - 'data-test-subj': `${warmProperty}-selectedReplicaCount`, - min: 0, - }, - }} - /> - </DescribedFormRow> + {!isUsingSearchableSnapshotInHotPhase && <ShrinkField phase={'warm'} />} - {!isUsingSearchableSnapshotInHotPhase && <ShrinkField phase="warm" />} + {!isUsingSearchableSnapshotInHotPhase && <ForcemergeField phase={'warm'} />} - {!isUsingSearchableSnapshotInHotPhase && <ForcemergeField phase="warm" />} + <ReadonlyField phase={'warm'} /> - <ReadonlyField phase={'warm'} /> + {/* Data tier allocation section */} + <DataTierAllocationField + description={i18nTexts.dataTierAllocation.description} + phase={'warm'} + /> - {/* Data tier allocation section */} - <DataTierAllocationField - description={i18nTexts.dataTierAllocation.description} - phase={warmProperty} - /> - <SetPriorityInputField phase="warm" /> - </EuiAccordion> - )} - </> - </div> + <IndexPriorityField phase={'warm'} /> + </Phase> ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 228a0f9fdb942..b1cf41773de3c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState, useMemo } from 'react'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; import { get } from 'lodash'; import { RouteComponentProps } from 'react-router-dom'; @@ -16,7 +16,6 @@ import { i18n } from '@kbn/i18n'; import { EuiButton, EuiButtonEmpty, - EuiDescribedFormGroup, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -24,33 +23,35 @@ import { EuiPage, EuiPageBody, EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, EuiSpacer, EuiSwitch, EuiText, EuiTitle, } from '@elastic/eui'; -import { useForm, UseField, TextField, useFormData } from '../../../shared_imports'; +import { TextField, UseField, useForm, useFormData } from '../../../shared_imports'; import { toasts } from '../../services/notification'; import { savePolicy } from './save_policy'; import { - LearnMoreLink, - PolicyJsonFlyout, ColdPhase, DeletePhase, HotPhase, + PolicyJsonFlyout, WarmPhase, Timeline, } from './components'; -import { schema, deserializer, createSerializer, createPolicyNameValidations, Form } from './form'; +import { createPolicyNameValidations, createSerializer, deserializer, Form, schema } from './form'; import { useEditPolicyContext } from './edit_policy_context'; import { FormInternal } from './types'; +import { createDocLink } from '../../services/documentation'; export interface Props { history: RouteComponentProps['history']; @@ -75,7 +76,7 @@ export const EditPolicy: React.FunctionComponent<Props> = ({ history }) => { return createSerializer(isNewPolicy ? undefined : currentPolicy); }, [isNewPolicy, currentPolicy]); - const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); + const [saveAsNew, setSaveAsNew] = useState(false); const originalPolicyName: string = isNewPolicy ? '' : policyName!; const { form } = useForm({ @@ -116,7 +117,7 @@ export const EditPolicy: React.FunctionComponent<Props> = ({ history }) => { ); } else { const success = await savePolicy( - { ...policy, name: saveAsNew ? currentPolicyName : originalPolicyName }, + { ...policy, name: saveAsNew || isNewPolicy ? currentPolicyName : originalPolicyName }, isNewPolicy || saveAsNew ); if (success) { @@ -132,216 +133,187 @@ export const EditPolicy: React.FunctionComponent<Props> = ({ history }) => { return ( <EuiPage> <EuiPageBody> - <EuiPageContent - className="ilmEditPolicyPageContent" - verticalPosition="center" - horizontalPosition="center" - > - <EuiTitle size="l" data-test-subj="policyTitle"> - <h1> - {isNewPolicy - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create an index lifecycle policy', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', - values: { originalPolicyName }, - })} - </h1> - </EuiTitle> - - <div className="euiAnimateContentLoad"> - <Form form={form}> - <EuiSpacer size="xs" /> - <EuiText color="subdued"> - <p> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.lifecyclePolicyDescriptionText" - defaultMessage="Use an index policy to automate the four phases of the index lifecycle, - from actively writing to the index to deleting it." - />{' '} - <LearnMoreLink - docPath="index-lifecycle-management.html" - text={ + <EuiPageContent> + <EuiPageContentHeader> + <EuiPageContentHeaderSection> + <EuiTitle size="l" data-test-subj="policyTitle"> + <h1> + {isNewPolicy + ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { + defaultMessage: 'Create Policy', + }) + : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { + defaultMessage: 'Edit Policy {originalPolicyName}', + values: { originalPolicyName }, + })} + </h1> + </EuiTitle> + </EuiPageContentHeaderSection> + <EuiPageContentHeaderSection> + <EuiButtonEmpty + href={createDocLink('index-lifecycle-management.html')} + target="_blank" + iconType="help" + > + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.documentationLinkText" + defaultMessage="Documentation" + /> + </EuiButtonEmpty> + </EuiPageContentHeaderSection> + </EuiPageContentHeader> + <Form form={form}> + {isNewPolicy ? null : ( + <Fragment> + <EuiText> + <p> + <strong> <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.learnAboutIndexLifecycleManagementLinkText" - defaultMessage="Learn about the index lifecycle." + id="xpack.indexLifecycleMgmt.editPolicy.editingExistingPolicyMessage" + defaultMessage="You are editing an existing policy" /> - } - /> - </p> - </EuiText> - - <EuiSpacer /> - - {isNewPolicy ? null : ( - <Fragment> - <EuiText> - <p> - <strong> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.editingExistingPolicyMessage" - defaultMessage="You are editing an existing policy" - /> - </strong> - .{' '} - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.editingExistingPolicyExplanationMessage" - defaultMessage="Any changes you make will affect the indices that are + </strong> + .{' '} + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.editingExistingPolicyExplanationMessage" + defaultMessage="Any changes you make will affect the indices that are attached to this policy. Alternatively, you can save these changes in a new policy." - /> - </p> - </EuiText> - <EuiSpacer /> - - <EuiFormRow> - <EuiSwitch - data-test-subj="saveAsNewSwitch" - style={{ maxWidth: '100%' }} - checked={saveAsNew} - onChange={(e) => { - setSaveAsNew(e.target.checked); - }} - label={ - <span> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.saveAsNewPolicyMessage" - defaultMessage="Save as new policy" - /> - </span> - } /> - </EuiFormRow> - </Fragment> - )} - - {saveAsNew || isNewPolicy ? ( - <EuiDescribedFormGroup - title={ - <div> - <span className="eui-displayInlineBlock eui-alignMiddle"> + </p> + </EuiText> + <EuiSpacer /> + + <EuiFormRow> + <EuiSwitch + data-test-subj="saveAsNewSwitch" + style={{ maxWidth: '100%' }} + checked={saveAsNew} + onChange={(e) => { + setSaveAsNew(e.target.checked); + }} + label={ + <span> <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.nameLabel" - defaultMessage="Name" + id="xpack.indexLifecycleMgmt.editPolicy.saveAsNewPolicyMessage" + defaultMessage="Save as new policy" /> </span> - </div> - } - titleSize="s" - fullWidth - > - <UseField<string, FormInternal> - path={policyNamePath} - config={{ - label: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameLabel', { - defaultMessage: 'Policy name', - }), - helpText: i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.validPolicyNameMessage', - { - defaultMessage: - 'A policy name cannot start with an underscore and cannot contain a comma or a space.', - } - ), - validations: policyNameValidations, - }} - component={TextField} - componentProps={{ - fullWidth: false, - euiFieldProps: { - 'data-test-subj': 'policyNameField', - }, - }} + } /> - </EuiDescribedFormGroup> - ) : null} - - <EuiHorizontalRule /> + </EuiFormRow> + </Fragment> + )} + + {saveAsNew || isNewPolicy ? ( + <UseField<string, FormInternal> + path={policyNamePath} + config={{ + label: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameLabel', { + defaultMessage: 'Policy name', + }), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.validPolicyNameMessage', + { + defaultMessage: + 'A policy name cannot start with an underscore and cannot contain a comma or a space.', + } + ), + validations: policyNameValidations, + }} + component={TextField} + componentProps={{ + fullWidth: false, + euiFieldProps: { + 'data-test-subj': 'policyNameField', + }, + }} + /> + ) : null} - <Timeline /> + <EuiHorizontalRule /> - <EuiSpacer size="l" /> + <Timeline /> - <HotPhase /> + <EuiSpacer size="l" /> - <EuiHorizontalRule /> + <HotPhase /> - <WarmPhase /> + <EuiSpacer /> - <EuiHorizontalRule /> + <WarmPhase /> - <ColdPhase /> + <EuiSpacer /> - <EuiHorizontalRule /> + <ColdPhase /> - <DeletePhase /> + <EuiSpacer /> - <EuiHorizontalRule /> + <DeletePhase /> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiButton - data-test-subj="savePolicyButton" - fill - iconType="check" - iconSide="left" - disabled={form.isValid === false || form.isSubmitting} - onClick={submit} - color="secondary" - > - {saveAsNew ? ( - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.saveAsNewButton" - defaultMessage="Save as new policy" - /> - ) : ( - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.saveButton" - defaultMessage="Save policy" - /> - )} - </EuiButton> - </EuiFlexItem> + <EuiHorizontalRule /> - <EuiFlexItem grow={false}> - <EuiButtonEmpty data-test-subj="cancelTestPolicy" onClick={backToPolicyList}> - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.cancelButton" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - - <EuiFlexItem grow={false}> - <EuiButtonEmpty onClick={togglePolicyJsonFlyout} data-test-subj="requestButton"> - {isShowingPolicyJsonFlyout ? ( - <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto" - defaultMessage="Hide request" - /> - ) : ( + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty onClick={togglePolicyJsonFlyout} data-test-subj="requestButton"> + {isShowingPolicyJsonFlyout ? ( + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto" + defaultMessage="Hide request" + /> + ) : ( + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.showPolicyJsonButto" + defaultMessage="Show request" + /> + )} + </EuiButtonEmpty> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiButtonEmpty data-test-subj="cancelTestPolicy" onClick={backToPolicyList}> <FormattedMessage - id="xpack.indexLifecycleMgmt.editPolicy.showPolicyJsonButto" - defaultMessage="Show request" + id="xpack.indexLifecycleMgmt.editPolicy.cancelButton" + defaultMessage="Cancel" /> - )} - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - - {isShowingPolicyJsonFlyout ? ( - <PolicyJsonFlyout - policyName={saveAsNew ? currentPolicyName : policyName} - close={() => setIsShowingPolicyJsonFlyout(false)} - /> - ) : null} - </Form> - </div> + </EuiButtonEmpty> + </EuiFlexItem> + + <EuiFlexItem grow={false}> + <EuiButton + data-test-subj="savePolicyButton" + fill + iconType="check" + iconSide="left" + disabled={form.isValid === false || form.isSubmitting} + onClick={submit} + > + {saveAsNew ? ( + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.saveAsNewButton" + defaultMessage="Save as new policy" + /> + ) : ( + <FormattedMessage + id="xpack.indexLifecycleMgmt.editPolicy.saveButton" + defaultMessage="Save policy" + /> + )} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + + {isShowingPolicyJsonFlyout ? ( + <PolicyJsonFlyout + policyName={saveAsNew ? currentPolicyName : policyName} + close={() => setIsShowingPolicyJsonFlyout(false)} + /> + ) : null} + </Form> </EuiPageContent> </EuiPageBody> </EuiPage> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts index b5abf51c29028..44512c2a511ec 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts @@ -260,15 +260,6 @@ describe('deserializer and serializer', () => { expect(result.phases.hot!.actions.readonly).toBeUndefined(); }); - it('removes min_age from warm when rollover is enabled', () => { - formInternal._meta.hot.customRollover.enabled = true; - formInternal._meta.warm.warmPhaseOnRollover = true; - - const result = serializer(formInternal); - - expect(result.phases.warm!.min_age).toBeUndefined(); - }); - it('adds default rollover configuration when enabled, but previously not configured', () => { delete formInternal.phases.hot!.actions.rollover; formInternal._meta.hot.isUsingDefaultRollover = true; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index 4bdf902d27b6d..9883c2de0685d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -7,13 +7,13 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../../../shared_imports'; -import { defaultSetPriority, defaultPhaseIndexPriority } from '../../../constants'; +import { defaultIndexPriority } from '../../../constants'; import { ROLLOVER_FORM_PATHS } from '../constants'; -const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); - import { FormInternal } from '../types'; +const rolloverFormPaths = Object.values(ROLLOVER_FORM_PATHS); + import { ifExistsNumberGreaterThanZero, ifExistsNumberNonNegative, @@ -54,7 +54,6 @@ export const schema: FormSchema<FormInternal> = { }, bestCompression: { label: i18nTexts.editPolicy.bestCompressionFieldLabel, - helpText: i18nTexts.editPolicy.bestCompressionFieldHelpText, }, readonlyEnabled: { defaultValue: false, @@ -69,18 +68,11 @@ export const schema: FormSchema<FormInternal> = { { defaultMessage: 'Activate warm phase' } ), }, - warmPhaseOnRollover: { - defaultValue: true, - label: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel', { - defaultMessage: 'Move to warm phase on rollover', - }), - }, minAgeUnit: { defaultValue: 'ms', }, bestCompression: { label: i18nTexts.editPolicy.bestCompressionFieldLabel, - helpText: i18nTexts.editPolicy.bestCompressionFieldHelpText, }, dataTierAllocationType: { label: i18nTexts.editPolicy.allocationTypeOptionsFieldLabel, @@ -218,9 +210,14 @@ export const schema: FormSchema<FormInternal> = { }, set_priority: { priority: { - defaultValue: defaultSetPriority as any, - label: i18nTexts.editPolicy.setPriorityFieldLabel, - validations: [{ validator: ifExistsNumberNonNegative }], + defaultValue: defaultIndexPriority.hot as any, + label: i18nTexts.editPolicy.indexPriorityFieldLabel, + validations: [ + { + validator: emptyField(i18nTexts.editPolicy.errors.numberRequired), + }, + { validator: ifExistsNumberNonNegative }, + ], serializer: serializers.stringToNumber, }, }, @@ -239,9 +236,12 @@ export const schema: FormSchema<FormInternal> = { allocate: { number_of_replicas: { label: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasLabel', { - defaultMessage: 'Number of replicas (optional)', + defaultMessage: 'Number of replicas', }), validations: [ + { + validator: emptyField(i18nTexts.editPolicy.errors.numberRequired), + }, { validator: ifExistsNumberNonNegative, }, @@ -289,9 +289,14 @@ export const schema: FormSchema<FormInternal> = { }, set_priority: { priority: { - defaultValue: defaultPhaseIndexPriority as any, - label: i18nTexts.editPolicy.setPriorityFieldLabel, - validations: [{ validator: ifExistsNumberNonNegative }], + defaultValue: defaultIndexPriority.warm as any, + label: i18nTexts.editPolicy.indexPriorityFieldLabel, + validations: [ + { + validator: emptyField(i18nTexts.editPolicy.errors.numberRequired), + }, + { validator: ifExistsNumberNonNegative }, + ], serializer: serializers.stringToNumber, }, }, @@ -310,9 +315,12 @@ export const schema: FormSchema<FormInternal> = { allocate: { number_of_replicas: { label: i18n.translate('xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel', { - defaultMessage: 'Number of replicas (optional)', + defaultMessage: 'Number of replicas', }), validations: [ + { + validator: emptyField(i18nTexts.editPolicy.errors.numberRequired), + }, { validator: ifExistsNumberNonNegative, }, @@ -322,9 +330,14 @@ export const schema: FormSchema<FormInternal> = { }, set_priority: { priority: { - defaultValue: '0' as any, - label: i18nTexts.editPolicy.setPriorityFieldLabel, - validations: [{ validator: ifExistsNumberNonNegative }], + defaultValue: defaultIndexPriority.cold as any, + label: i18nTexts.editPolicy.indexPriorityFieldLabel, + validations: [ + { + validator: emptyField(i18nTexts.editPolicy.errors.numberRequired), + }, + { validator: ifExistsNumberNonNegative }, + ], serializer: serializers.stringToNumber, }, }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts index f718073afa352..f7cdecdc352fb 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts @@ -20,9 +20,7 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( ): SerializedPolicy => { const { _meta, ...updatedPolicy } = data; - if (!updatedPolicy.phases || !updatedPolicy.phases.hot) { - updatedPolicy.phases = { hot: { actions: {} } }; - } + updatedPolicy.phases = { hot: { actions: {} }, ...updatedPolicy.phases }; return produce<SerializedPolicy>(originalPolicy ?? defaultPolicy, (draft) => { // Copy over all updated fields @@ -32,7 +30,7 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( * Important shared values for serialization */ const isUsingRollover = Boolean( - _meta.hot.isUsingDefaultRollover || _meta.hot.customRollover.enabled + _meta.hot?.isUsingDefaultRollover || _meta.hot?.customRollover.enabled ); // Next copy over all meta fields and delete any fields that have been removed @@ -53,7 +51,7 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( * HOT PHASE ROLLOVER */ if (isUsingRollover) { - if (_meta.hot.isUsingDefaultRollover) { + if (_meta.hot?.isUsingDefaultRollover) { hotPhaseActions.rollover = cloneDeep(defaultRolloverAction); } else { // Rollover may not exist if editing an existing policy with initially no rollover configured @@ -63,7 +61,7 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( // We are using user-defined, custom rollover settings. if (updatedPolicy.phases.hot!.actions.rollover?.max_age) { - hotPhaseActions.rollover.max_age = `${hotPhaseActions.rollover.max_age}${_meta.hot.customRollover.maxAgeUnit}`; + hotPhaseActions.rollover.max_age = `${hotPhaseActions.rollover.max_age}${_meta.hot?.customRollover.maxAgeUnit}`; } else { delete hotPhaseActions.rollover.max_age; } @@ -73,7 +71,7 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( } if (updatedPolicy.phases.hot!.actions.rollover?.max_size) { - hotPhaseActions.rollover.max_size = `${hotPhaseActions.rollover.max_size}${_meta.hot.customRollover.maxStorageSizeUnit}`; + hotPhaseActions.rollover.max_size = `${hotPhaseActions.rollover.max_size}${_meta.hot?.customRollover.maxStorageSizeUnit}`; } else { delete hotPhaseActions.rollover.max_size; } @@ -84,20 +82,20 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( */ if (!updatedPolicy.phases.hot!.actions?.forcemerge) { delete hotPhaseActions.forcemerge; - } else if (_meta.hot.bestCompression) { + } else if (_meta.hot?.bestCompression) { hotPhaseActions.forcemerge!.index_codec = 'best_compression'; } else { delete hotPhaseActions.forcemerge!.index_codec; } - if (_meta.hot.bestCompression && hotPhaseActions.forcemerge) { + if (_meta.hot?.bestCompression && hotPhaseActions.forcemerge) { hotPhaseActions.forcemerge.index_codec = 'best_compression'; } /** * HOT PHASE READ-ONLY */ - if (_meta.hot.readonlyEnabled) { + if (_meta.hot?.readonlyEnabled) { hotPhaseActions.readonly = hotPhaseActions.readonly ?? {}; } else { delete hotPhaseActions.readonly; @@ -140,17 +138,9 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( /** * WARM PHASE MIN AGE * - * If warm phase on rollover is enabled, delete min age field - * An index lifecycle switches to warm phase when rollover occurs, so you cannot specify a warm phase time - * They are mutually exclusive */ - if ( - (!isUsingRollover || !_meta.warm.warmPhaseOnRollover) && - updatedPolicy.phases.warm?.min_age - ) { + if (updatedPolicy.phases.warm?.min_age) { warmPhase.min_age = `${updatedPolicy.phases.warm!.min_age}${_meta.warm.minAgeUnit}`; - } else { - delete warmPhase.min_age; } /** diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index f30a40fdd2bb9..71085a6d7a2b8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -40,10 +40,10 @@ export const i18nTexts = { defaultMessage: 'Number of segments', } ), - setPriorityFieldLabel: i18n.translate( + indexPriorityFieldLabel: i18n.translate( 'xpack.indexLifecycleMgmt.editPolicy.indexPriorityLabel', { - defaultMessage: 'Index priority (optional)', + defaultMessage: 'Index priority', } ), bestCompressionFieldLabel: i18n.translate( @@ -170,5 +170,30 @@ export const i18nTexts = { } ), }, + titles: { + hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseTitle', { + defaultMessage: 'Hot phase', + }), + warm: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseTitle', { + defaultMessage: 'Warm phase', + }), + cold: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseTitle', { + defaultMessage: 'Cold phase', + }), + }, + descriptions: { + hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescription', { + defaultMessage: + 'This phase is required. You are actively querying and writing to your index. For faster updates, you can roll over the index when it gets too big or too old.', + }), + warm: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseDescription', { + defaultMessage: + 'You are still querying your index, but it is read-only. You can allocate shards to less performant hardware. For faster searches, you can reduce the number of shards and force merge segments.', + }), + cold: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescription', { + defaultMessage: + 'You are querying your index less frequently, so you can allocate shards on significantly less performant hardware. Because your queries are slower, you can reduce the number of replicas.', + }), + }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts index c77b171a56bed..2f37608b2d7ae 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts @@ -70,9 +70,10 @@ export interface PhaseAgeInMilliseconds { const phaseOrder: Phase[] = ['hot', 'warm', 'cold', 'delete']; const getMinAge = (phase: MinAgePhase, formData: FormInternal) => ({ - min_age: formData.phases[phase]?.min_age - ? formData.phases[phase]!.min_age! + formData._meta[phase].minAgeUnit - : '0ms', + min_age: + formData.phases && formData.phases[phase]?.min_age + ? formData.phases[phase]!.min_age! + formData._meta[phase].minAgeUnit + : '0ms', }); const formDataToAbsoluteTimings = (formData: FormInternal): AbsoluteTimings => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx index 09c81efe163b5..21f028d1fec60 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/policy_table/components/table_content.tsx @@ -301,7 +301,7 @@ export const TableContent: React.FunctionComponent<Props> = ({ style={{ width: 150 }} > <EuiPopover - id="contextMenuPolicy" + id={`contextMenuPolicy-${name}`} button={button} isOpen={isPolicyPopoverOpen(policy.name)} closePopover={closePolicyPopover} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts index 2f1c7798e7a4d..4b22d1c8448b5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.test.ts @@ -9,7 +9,7 @@ import { UIM_CONFIG_WARM_PHASE, UIM_CONFIG_SET_PRIORITY, UIM_CONFIG_FREEZE_INDEX, - defaultPhaseIndexPriority, + defaultIndexPriority, } from '../constants/'; import { getUiMetricsForPhases } from './ui_metric'; @@ -22,7 +22,7 @@ describe('getUiMetricsForPhases', () => { min_age: '0ms', actions: { set_priority: { - priority: parseInt(defaultPhaseIndexPriority, 10), + priority: parseInt(defaultIndexPriority.cold, 10), }, }, }, @@ -37,7 +37,7 @@ describe('getUiMetricsForPhases', () => { min_age: '0ms', actions: { set_priority: { - priority: parseInt(defaultPhaseIndexPriority, 10), + priority: parseInt(defaultIndexPriority.warm, 10), }, }, }, @@ -52,7 +52,7 @@ describe('getUiMetricsForPhases', () => { min_age: '0ms', actions: { set_priority: { - priority: parseInt(defaultPhaseIndexPriority, 10) + 1, + priority: parseInt(defaultIndexPriority.warm, 10) + 1, }, }, }, @@ -68,7 +68,7 @@ describe('getUiMetricsForPhases', () => { actions: { freeze: {}, set_priority: { - priority: parseInt(defaultPhaseIndexPriority, 10), + priority: parseInt(defaultIndexPriority.cold, 10), }, }, }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts index bcf4b6cf1da0d..ffdcf927a5b67 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/ui_metric.ts @@ -19,8 +19,7 @@ import { UIM_CONFIG_FREEZE_INDEX, UIM_CONFIG_SET_PRIORITY, UIM_CONFIG_WARM_PHASE, - defaultSetPriority, - defaultPhaseIndexPriority, + defaultIndexPriority, } from '../constants'; import { Phases } from '../../../common/types'; @@ -50,17 +49,17 @@ export function getUiMetricsForPhases(phases: Phases): string[] { const isHotPhasePriorityChanged = phases.hot && phases.hot.actions.set_priority && - phases.hot.actions.set_priority.priority !== parseInt(defaultSetPriority, 10); + phases.hot.actions.set_priority.priority !== parseInt(defaultIndexPriority.hot, 10); const isWarmPhasePriorityChanged = phases.warm && phases.warm.actions.set_priority && - phases.warm.actions.set_priority.priority !== parseInt(defaultPhaseIndexPriority, 10); + phases.warm.actions.set_priority.priority !== parseInt(defaultIndexPriority.warm, 10); const isColdPhasePriorityChanged = phases.cold && phases.cold.actions.set_priority && - phases.cold.actions.set_priority.priority !== parseInt(defaultPhaseIndexPriority, 10); + phases.cold.actions.set_priority.priority !== parseInt(defaultIndexPriority.cold, 10); // If the priority is different than the default, we'll consider it a user interaction, // even if the user has set it to undefined. return ( diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index 4cb5d95239408..fdb25dec6f1fd 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -31,6 +31,7 @@ export { SuperSelectField, ComboBoxField, TextField, + CheckBoxField, } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { attemptToURIDecode } from '../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts index 62b76a0ae475e..614684d29ae76 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts @@ -7,48 +7,17 @@ import * as rt from 'io-ts'; import { timeRangeRT, routeTimingMetadataRT } from '../../shared'; +import { + logEntryAnomalyRT, + logEntryAnomalyDatasetsRT, + anomaliesSortRT, + paginationRT, + paginationCursorRT, +} from '../../../log_analysis'; export const LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH = '/api/infra/log_analysis/results/log_entry_anomalies'; -// [Sort field value, tiebreaker value] -const paginationCursorRT = rt.tuple([ - rt.union([rt.string, rt.number]), - rt.union([rt.string, rt.number]), -]); - -export type PaginationCursor = rt.TypeOf<typeof paginationCursorRT>; - -export const anomalyTypeRT = rt.keyof({ - logRate: null, - logCategory: null, -}); - -export type AnomalyType = rt.TypeOf<typeof anomalyTypeRT>; - -const logEntryAnomalyCommonFieldsRT = rt.type({ - id: rt.string, - anomalyScore: rt.number, - dataset: rt.string, - typical: rt.number, - actual: rt.number, - type: anomalyTypeRT, - duration: rt.number, - startTime: rt.number, - jobId: rt.string, -}); -const logEntrylogRateAnomalyRT = logEntryAnomalyCommonFieldsRT; -const logEntrylogCategoryAnomalyRT = rt.partial({ - categoryId: rt.string, -}); -const logEntryAnomalyRT = rt.intersection([ - logEntryAnomalyCommonFieldsRT, - logEntrylogRateAnomalyRT, - logEntrylogCategoryAnomalyRT, -]); - -export type LogEntryAnomaly = rt.TypeOf<typeof logEntryAnomalyRT>; - export const getLogEntryAnomaliesSuccessReponsePayloadRT = rt.intersection([ rt.type({ data: rt.intersection([ @@ -78,43 +47,6 @@ export type GetLogEntryAnomaliesSuccessResponsePayload = rt.TypeOf< typeof getLogEntryAnomaliesSuccessReponsePayloadRT >; -const sortOptionsRT = rt.keyof({ - anomalyScore: null, - dataset: null, - startTime: null, -}); - -const sortDirectionsRT = rt.keyof({ - asc: null, - desc: null, -}); - -const paginationPreviousPageCursorRT = rt.type({ - searchBefore: paginationCursorRT, -}); - -const paginationNextPageCursorRT = rt.type({ - searchAfter: paginationCursorRT, -}); - -const paginationRT = rt.intersection([ - rt.type({ - pageSize: rt.number, - }), - rt.partial({ - cursor: rt.union([paginationPreviousPageCursorRT, paginationNextPageCursorRT]), - }), -]); - -export type Pagination = rt.TypeOf<typeof paginationRT>; - -const sortRT = rt.type({ - field: sortOptionsRT, - direction: sortDirectionsRT, -}); - -export type Sort = rt.TypeOf<typeof sortRT>; - export const getLogEntryAnomaliesRequestPayloadRT = rt.type({ data: rt.intersection([ rt.type({ @@ -127,9 +59,9 @@ export const getLogEntryAnomaliesRequestPayloadRT = rt.type({ // Pagination properties pagination: paginationRT, // Sort properties - sort: sortRT, + sort: anomaliesSortRT, // Dataset filters - datasets: rt.array(rt.string), + datasets: logEntryAnomalyDatasetsRT, }), ]), }); diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts index 0554192398fc5..019ae01c1437c 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts @@ -13,6 +13,8 @@ import { routeTimingMetadataRT, } from '../../shared'; +import { logEntryCategoryRT, categoriesSortRT } from '../../../log_analysis'; + export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH = '/api/infra/log_analysis/results/log_entry_categories'; @@ -30,23 +32,6 @@ export type LogEntryCategoriesHistogramParameters = rt.TypeOf< typeof logEntryCategoriesHistogramParametersRT >; -const sortOptionsRT = rt.keyof({ - maximumAnomalyScore: null, - logEntryCount: null, -}); - -const sortDirectionsRT = rt.keyof({ - asc: null, - desc: null, -}); - -const categorySortRT = rt.type({ - field: sortOptionsRT, - direction: sortDirectionsRT, -}); - -export type CategorySort = rt.TypeOf<typeof categorySortRT>; - export const getLogEntryCategoriesRequestPayloadRT = rt.type({ data: rt.intersection([ rt.type({ @@ -59,7 +44,7 @@ export const getLogEntryCategoriesRequestPayloadRT = rt.type({ // a list of histograms to create histograms: rt.array(logEntryCategoriesHistogramParametersRT), // the criteria to the categories by - sort: categorySortRT, + sort: categoriesSortRT, }), rt.partial({ // the datasets to filter for (optional, unfiltered if not present) @@ -76,39 +61,6 @@ export type GetLogEntryCategoriesRequestPayload = rt.TypeOf< * response */ -export const logEntryCategoryHistogramBucketRT = rt.type({ - startTime: rt.number, - bucketDuration: rt.number, - logEntryCount: rt.number, -}); - -export type LogEntryCategoryHistogramBucket = rt.TypeOf<typeof logEntryCategoryHistogramBucketRT>; - -export const logEntryCategoryHistogramRT = rt.type({ - histogramId: rt.string, - buckets: rt.array(logEntryCategoryHistogramBucketRT), -}); - -export type LogEntryCategoryHistogram = rt.TypeOf<typeof logEntryCategoryHistogramRT>; - -export const logEntryCategoryDatasetRT = rt.type({ - name: rt.string, - maximumAnomalyScore: rt.number, -}); - -export type LogEntryCategoryDataset = rt.TypeOf<typeof logEntryCategoryDatasetRT>; - -export const logEntryCategoryRT = rt.type({ - categoryId: rt.number, - datasets: rt.array(logEntryCategoryDatasetRT), - histograms: rt.array(logEntryCategoryHistogramRT), - logEntryCount: rt.number, - maximumAnomalyScore: rt.number, - regularExpression: rt.string, -}); - -export type LogEntryCategory = rt.TypeOf<typeof logEntryCategoryRT>; - export const getLogEntryCategoriesSuccessReponsePayloadRT = rt.intersection([ rt.type({ data: rt.type({ diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts index e9e3c6e0ca3f9..3166d40d70392 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_category_examples.ts @@ -12,7 +12,7 @@ import { timeRangeRT, routeTimingMetadataRT, } from '../../shared'; -import { logEntryContextRT } from '../../log_entries'; +import { logEntryContextRT } from '../../../log_entry'; export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH = '/api/infra/log_analysis/results/log_entry_category_examples'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts index 1eed29cd37560..c061545ec09ed 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts @@ -5,7 +5,7 @@ */ import * as rt from 'io-ts'; - +import { logEntryExampleRT } from '../../../log_analysis'; import { badRequestErrorRT, forbiddenErrorRT, @@ -46,16 +46,6 @@ export type GetLogEntryExamplesRequestPayload = rt.TypeOf< * response */ -const logEntryExampleRT = rt.type({ - id: rt.string, - dataset: rt.string, - message: rt.string, - timestamp: rt.number, - tiebreaker: rt.number, -}); - -export type LogEntryExample = rt.TypeOf<typeof logEntryExampleRT>; - export const getLogEntryExamplesSuccessReponsePayloadRT = rt.intersection([ rt.type({ data: rt.type({ diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts index 31bc62f48791a..b4d9a5744d5ac 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts @@ -5,8 +5,7 @@ */ import * as rt from 'io-ts'; -import { logEntryCursorRT } from '../../log_entry'; -import { jsonArrayRT } from '../../typed_json'; +import { logEntryCursorRT, logEntryRT } from '../../log_entry'; import { logSourceColumnConfigurationRT } from '../log_sources'; export const LOG_ENTRIES_PATH = '/api/log_entries/entries'; @@ -52,54 +51,6 @@ export type LogEntriesAfterRequest = rt.TypeOf<typeof logEntriesAfterRequestRT>; export type LogEntriesCenteredRequest = rt.TypeOf<typeof logEntriesCenteredRequestRT>; export type LogEntriesRequest = rt.TypeOf<typeof logEntriesRequestRT>; -export const logMessageConstantPartRT = rt.type({ - constant: rt.string, -}); -export const logMessageFieldPartRT = rt.type({ - field: rt.string, - value: jsonArrayRT, - highlights: rt.array(rt.string), -}); - -export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); - -export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); -export const logFieldColumnRT = rt.type({ - columnId: rt.string, - field: rt.string, - value: jsonArrayRT, - highlights: rt.array(rt.string), -}); -export const logMessageColumnRT = rt.type({ - columnId: rt.string, - message: rt.array(logMessagePartRT), -}); - -export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); - -export const logEntryContextRT = rt.union([ - rt.type({}), - rt.type({ 'container.id': rt.string }), - rt.type({ 'host.name': rt.string, 'log.file.path': rt.string }), -]); - -export const logEntryRT = rt.type({ - id: rt.string, - cursor: logEntryCursorRT, - columns: rt.array(logColumnRT), - context: logEntryContextRT, -}); - -export type LogMessageConstantPart = rt.TypeOf<typeof logMessageConstantPartRT>; -export type LogMessageFieldPart = rt.TypeOf<typeof logMessageFieldPartRT>; -export type LogMessagePart = rt.TypeOf<typeof logMessagePartRT>; -export type LogTimestampColumn = rt.TypeOf<typeof logTimestampColumnRT>; -export type LogFieldColumn = rt.TypeOf<typeof logFieldColumnRT>; -export type LogMessageColumn = rt.TypeOf<typeof logMessageColumnRT>; -export type LogColumn = rt.TypeOf<typeof logColumnRT>; -export type LogEntryContext = rt.TypeOf<typeof logEntryContextRT>; -export type LogEntry = rt.TypeOf<typeof logEntryRT>; - export const logEntriesResponseRT = rt.type({ data: rt.intersection([ rt.type({ diff --git a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts index 648da43134a27..96bf8beb29021 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/highlights.ts @@ -5,13 +5,12 @@ */ import * as rt from 'io-ts'; -import { logEntryCursorRT } from '../../log_entry'; +import { logEntryCursorRT, logEntryRT } from '../../log_entry'; import { logEntriesBaseRequestRT, logEntriesBeforeRequestRT, logEntriesAfterRequestRT, logEntriesCenteredRequestRT, - logEntryRT, } from './entries'; export const LOG_ENTRIES_HIGHLIGHTS_PATH = '/api/log_entries/highlights'; diff --git a/x-pack/plugins/infra/common/http_api/shared/time_range.ts b/x-pack/plugins/infra/common/http_api/shared/time_range.ts index efda07423748b..07317092cdedb 100644 --- a/x-pack/plugins/infra/common/http_api/shared/time_range.ts +++ b/x-pack/plugins/infra/common/http_api/shared/time_range.ts @@ -4,11 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as rt from 'io-ts'; - -export const timeRangeRT = rt.type({ - startTime: rt.number, - endTime: rt.number, -}); - -export type TimeRange = rt.TypeOf<typeof timeRangeRT>; +export * from '../../time/time_range'; diff --git a/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_docker_overview.ts b/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_docker_overview.ts index d7026d7648d37..b8cf094d7bd4c 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_docker_overview.ts +++ b/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_docker_overview.ts @@ -15,7 +15,7 @@ export const hostDockerOverview: TSVBMetricModelCreator = ( index_pattern: indexPattern, interval, time_field: timeField, - type: 'gauge', + type: 'top_n', series: [ { id: 'total', diff --git a/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_k8s_overview.ts b/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_k8s_overview.ts index 86d615231f070..1488fe5504c0b 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_k8s_overview.ts +++ b/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_k8s_overview.ts @@ -16,7 +16,7 @@ export const hostK8sOverview: TSVBMetricModelCreator = ( index_pattern: indexPattern, interval, time_field: timeField, - type: 'gauge', + type: 'top_n', series: [ { id: 'cpucap', diff --git a/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_system_overview.ts b/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_system_overview.ts index 953c14ab2a9ce..cbd76dd8e9637 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_system_overview.ts +++ b/x-pack/plugins/infra/common/inventory_models/host/metrics/tsvb/host_system_overview.ts @@ -16,7 +16,7 @@ export const hostSystemOverview: TSVBMetricModelCreator = ( index_pattern: indexPattern, interval, time_field: timeField, - type: 'gauge', + type: 'top_n', series: [ { id: 'cpu', diff --git a/x-pack/plugins/infra/common/inventory_models/shared/metrics/tsvb/aws_overview.ts b/x-pack/plugins/infra/common/inventory_models/shared/metrics/tsvb/aws_overview.ts index 5ba61d1f92517..28e1b7860aab4 100644 --- a/x-pack/plugins/infra/common/inventory_models/shared/metrics/tsvb/aws_overview.ts +++ b/x-pack/plugins/infra/common/inventory_models/shared/metrics/tsvb/aws_overview.ts @@ -14,7 +14,7 @@ export const awsOverview: TSVBMetricModelCreator = (timeField, indexPattern): TS id_type: 'cloud', interval: '>=5m', time_field: timeField, - type: 'gauge', + type: 'top_n', series: [ { id: 'cpu-util', diff --git a/x-pack/plugins/infra/common/log_analysis/index.ts b/x-pack/plugins/infra/common/log_analysis/index.ts index 0b4fa374a5da9..f055f642c8d1b 100644 --- a/x-pack/plugins/infra/common/log_analysis/index.ts +++ b/x-pack/plugins/infra/common/log_analysis/index.ts @@ -10,3 +10,5 @@ export * from './log_analysis_results'; export * from './log_entry_rate_analysis'; export * from './log_entry_categories_analysis'; export * from './job_parameters'; +export * from './log_entry_anomalies'; +export * from './log_entry_examples'; diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts index f4497dbba5056..897a5a4bb84df 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import * as rt from 'io-ts'; + export const ML_SEVERITY_SCORES = { warning: 3, minor: 25, @@ -55,3 +57,44 @@ export const compareDatasetsByMaximumAnomalyScore = < firstDataset: Dataset, secondDataset: Dataset ) => firstDataset.maximumAnomalyScore - secondDataset.maximumAnomalyScore; + +// Generic Sort + +const sortDirectionsRT = rt.keyof({ + asc: null, + desc: null, +}); + +export const sortRT = <Fields extends rt.Mixed>(fields: Fields) => + rt.type({ + field: fields, + direction: sortDirectionsRT, + }); + +// Pagination +// [Sort field value, tiebreaker value] +export const paginationCursorRT = rt.tuple([ + rt.union([rt.string, rt.number]), + rt.union([rt.string, rt.number]), +]); + +export type PaginationCursor = rt.TypeOf<typeof paginationCursorRT>; + +const paginationPreviousPageCursorRT = rt.type({ + searchBefore: paginationCursorRT, +}); + +const paginationNextPageCursorRT = rt.type({ + searchAfter: paginationCursorRT, +}); + +export const paginationRT = rt.intersection([ + rt.type({ + pageSize: rt.number, + }), + rt.partial({ + cursor: rt.union([paginationPreviousPageCursorRT, paginationNextPageCursorRT]), + }), +]); + +export type Pagination = rt.TypeOf<typeof paginationRT>; diff --git a/x-pack/plugins/infra/common/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/common/log_analysis/log_entry_anomalies.ts new file mode 100644 index 0000000000000..c426646e8e847 --- /dev/null +++ b/x-pack/plugins/infra/common/log_analysis/log_entry_anomalies.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { sortRT } from './log_analysis_results'; + +export const anomalyTypeRT = rt.keyof({ + logRate: null, + logCategory: null, +}); + +export type AnomalyType = rt.TypeOf<typeof anomalyTypeRT>; + +export const logEntryAnomalyCommonFieldsRT = rt.type({ + id: rt.string, + anomalyScore: rt.number, + dataset: rt.string, + typical: rt.number, + actual: rt.number, + type: anomalyTypeRT, + duration: rt.number, + startTime: rt.number, + jobId: rt.string, +}); +export const logEntrylogRateAnomalyRT = logEntryAnomalyCommonFieldsRT; +export type RateAnomaly = rt.TypeOf<typeof logEntrylogRateAnomalyRT>; + +export const logEntrylogCategoryAnomalyRT = rt.intersection([ + logEntryAnomalyCommonFieldsRT, + rt.type({ + categoryId: rt.string, + categoryRegex: rt.string, + categoryTerms: rt.string, + }), +]); +export type CategoryAnomaly = rt.TypeOf<typeof logEntrylogCategoryAnomalyRT>; + +export const logEntryAnomalyRT = rt.union([logEntrylogRateAnomalyRT, logEntrylogCategoryAnomalyRT]); + +export type LogEntryAnomaly = rt.TypeOf<typeof logEntryAnomalyRT>; + +export const logEntryAnomalyDatasetsRT = rt.array(rt.string); +export type LogEntryAnomalyDatasets = rt.TypeOf<typeof logEntryAnomalyDatasetsRT>; + +export const isCategoryAnomaly = (anomaly: LogEntryAnomaly): anomaly is CategoryAnomaly => { + return anomaly.type === 'logCategory'; +}; + +const sortOptionsRT = rt.keyof({ + anomalyScore: null, + dataset: null, + startTime: null, +}); + +export const anomaliesSortRT = sortRT(sortOptionsRT); +export type AnomaliesSort = rt.TypeOf<typeof anomaliesSortRT>; diff --git a/x-pack/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts index 0957126ee52e3..4292eaeb5f98c 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts @@ -5,6 +5,7 @@ */ import * as rt from 'io-ts'; +import { sortRT } from './log_analysis_results'; export const logEntryCategoriesJobTypeRT = rt.keyof({ 'log-entry-categories-count': null, @@ -15,3 +16,44 @@ export type LogEntryCategoriesJobType = rt.TypeOf<typeof logEntryCategoriesJobTy export const logEntryCategoriesJobTypes: LogEntryCategoriesJobType[] = [ 'log-entry-categories-count', ]; + +export const logEntryCategoryDatasetRT = rt.type({ + name: rt.string, + maximumAnomalyScore: rt.number, +}); + +export type LogEntryCategoryDataset = rt.TypeOf<typeof logEntryCategoryDatasetRT>; + +export const logEntryCategoryHistogramBucketRT = rt.type({ + startTime: rt.number, + bucketDuration: rt.number, + logEntryCount: rt.number, +}); + +export type LogEntryCategoryHistogramBucket = rt.TypeOf<typeof logEntryCategoryHistogramBucketRT>; + +export const logEntryCategoryHistogramRT = rt.type({ + histogramId: rt.string, + buckets: rt.array(logEntryCategoryHistogramBucketRT), +}); + +export type LogEntryCategoryHistogram = rt.TypeOf<typeof logEntryCategoryHistogramRT>; + +export const logEntryCategoryRT = rt.type({ + categoryId: rt.number, + datasets: rt.array(logEntryCategoryDatasetRT), + histograms: rt.array(logEntryCategoryHistogramRT), + logEntryCount: rt.number, + maximumAnomalyScore: rt.number, + regularExpression: rt.string, +}); + +export type LogEntryCategory = rt.TypeOf<typeof logEntryCategoryRT>; + +const sortOptionsRT = rt.keyof({ + maximumAnomalyScore: null, + logEntryCount: null, +}); + +export const categoriesSortRT = sortRT(sortOptionsRT); +export type CategoriesSort = rt.TypeOf<typeof categoriesSortRT>; diff --git a/x-pack/plugins/infra/common/log_analysis/log_entry_examples.ts b/x-pack/plugins/infra/common/log_analysis/log_entry_examples.ts new file mode 100644 index 0000000000000..78d230e35dc74 --- /dev/null +++ b/x-pack/plugins/infra/common/log_analysis/log_entry_examples.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const logEntryExampleRT = rt.type({ + id: rt.string, + dataset: rt.string, + message: rt.string, + timestamp: rt.number, + tiebreaker: rt.number, +}); + +export type LogEntryExample = rt.TypeOf<typeof logEntryExampleRT>; diff --git a/x-pack/plugins/infra/common/log_entry/log_entry.ts b/x-pack/plugins/infra/common/log_entry/log_entry.ts index e02acebe27711..eec1fb59f3091 100644 --- a/x-pack/plugins/infra/common/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/common/log_entry/log_entry.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import * as rt from 'io-ts'; import { TimeKey } from '../time'; -import { InfraLogEntry } from '../graphql/types'; - -export type LogEntry = InfraLogEntry; +import { logEntryCursorRT } from './log_entry_cursor'; +import { jsonArrayRT } from '../typed_json'; export interface LogEntryOrigin { id: string; @@ -42,3 +42,51 @@ export function isLessOrEqual(time1: LogEntryTime, time2: LogEntryTime) { export function isBetween(min: LogEntryTime, max: LogEntryTime, operand: LogEntryTime) { return isLessOrEqual(min, operand) && isLessOrEqual(operand, max); } + +export const logMessageConstantPartRT = rt.type({ + constant: rt.string, +}); +export const logMessageFieldPartRT = rt.type({ + field: rt.string, + value: jsonArrayRT, + highlights: rt.array(rt.string), +}); + +export const logMessagePartRT = rt.union([logMessageConstantPartRT, logMessageFieldPartRT]); + +export const logTimestampColumnRT = rt.type({ columnId: rt.string, timestamp: rt.number }); +export const logFieldColumnRT = rt.type({ + columnId: rt.string, + field: rt.string, + value: jsonArrayRT, + highlights: rt.array(rt.string), +}); +export const logMessageColumnRT = rt.type({ + columnId: rt.string, + message: rt.array(logMessagePartRT), +}); + +export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]); + +export const logEntryContextRT = rt.union([ + rt.type({}), + rt.type({ 'container.id': rt.string }), + rt.type({ 'host.name': rt.string, 'log.file.path': rt.string }), +]); + +export const logEntryRT = rt.type({ + id: rt.string, + cursor: logEntryCursorRT, + columns: rt.array(logColumnRT), + context: logEntryContextRT, +}); + +export type LogMessageConstantPart = rt.TypeOf<typeof logMessageConstantPartRT>; +export type LogMessageFieldPart = rt.TypeOf<typeof logMessageFieldPartRT>; +export type LogMessagePart = rt.TypeOf<typeof logMessagePartRT>; +export type LogEntryContext = rt.TypeOf<typeof logEntryContextRT>; +export type LogEntry = rt.TypeOf<typeof logEntryRT>; +export type LogTimestampColumn = rt.TypeOf<typeof logTimestampColumnRT>; +export type LogFieldColumn = rt.TypeOf<typeof logFieldColumnRT>; +export type LogMessageColumn = rt.TypeOf<typeof logMessageColumnRT>; +export type LogColumn = rt.TypeOf<typeof logColumnRT>; diff --git a/x-pack/plugins/infra/common/time/index.ts b/x-pack/plugins/infra/common/time/index.ts index f49d46fa4920f..63bba2fa807ac 100644 --- a/x-pack/plugins/infra/common/time/index.ts +++ b/x-pack/plugins/infra/common/time/index.ts @@ -7,3 +7,4 @@ export * from './time_unit'; export * from './time_scale'; export * from './time_key'; +export * from './time_range'; diff --git a/x-pack/plugins/enterprise_search/common/version.ts b/x-pack/plugins/infra/common/time/time_range.ts similarity index 50% rename from x-pack/plugins/enterprise_search/common/version.ts rename to x-pack/plugins/infra/common/time/time_range.ts index e1a990e5c4710..efda07423748b 100644 --- a/x-pack/plugins/enterprise_search/common/version.ts +++ b/x-pack/plugins/infra/common/time/time_range.ts @@ -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'; +import * as rt from 'io-ts'; -export const CURRENT_VERSION = new SemVer(pkg.version as string); -export const CURRENT_MAJOR_VERSION = `${CURRENT_VERSION.major}.${CURRENT_VERSION.minor}`; +export const timeRangeRT = rt.type({ + startTime: rt.number, + endTime: rt.number, +}); + +export type TimeRange = rt.TypeOf<typeof timeRangeRT>; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_results/category_expression.tsx similarity index 95% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_results/category_expression.tsx index d5480977e7f9e..9684777ac9216 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_results/category_expression.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { memo } from 'react'; -import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; export const RegularExpressionRepresentation: React.FunctionComponent<{ maximumSegmentCount?: number; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts index 19e8108ee50e8..b0ff36574bede 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/item.ts @@ -7,7 +7,7 @@ import { bisector } from 'd3-array'; import { compareToTimeKey, TimeKey } from '../../../../common/time'; -import { LogEntry } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; export type StreamItem = LogEntryStreamItem; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx index 8de9e565b00be..2b30d43f8c38d 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.test.tsx @@ -7,7 +7,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; -import { LogFieldColumn } from '../../../../common/http_api'; +import { LogFieldColumn } from '../../../../common/log_entry'; import { LogEntryFieldColumn } from './log_entry_field_column'; describe('LogEntryFieldColumn', () => { diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx index 4a9b0d0906a76..0d295b4df5566 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_field_column.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { JsonValue } from '../../../../../../../src/plugins/kibana_utils/common'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { LogColumn } from '../../../../common/http_api'; +import { LogColumn } from '../../../../common/log_entry'; import { isFieldColumn, isHighlightFieldColumn } from '../../../utils/log_entry'; import { FieldValue } from './field_value'; import { LogEntryColumnContent } from './log_entry_column'; diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.test.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.test.tsx index 5d36e5cd47c59..00281c2df3133 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.test.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.test.tsx @@ -7,7 +7,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; -import { LogMessageColumn } from '../../../../common/http_api'; +import { LogMessageColumn } from '../../../../common/log_entry'; import { LogEntryMessageColumn } from './log_entry_message_column'; describe('LogEntryMessageColumn', () => { diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx index bfc160ada2e6a..92214dee9de22 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_message_column.tsx @@ -6,7 +6,7 @@ import React, { memo, useMemo } from 'react'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { LogColumn, LogMessagePart } from '../../../../common/http_api'; +import { LogColumn, LogMessagePart } from '../../../../common/log_entry'; import { isConstantSegment, isFieldSegment, diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 93c657fbdda97..1a472df2b5c90 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -17,7 +17,7 @@ import { LogEntryFieldColumn } from './log_entry_field_column'; import { LogEntryMessageColumn } from './log_entry_message_column'; import { LogEntryTimestampColumn } from './log_entry_timestamp_column'; import { monospaceTextStyle, hoveredContentStyle, highlightedContentStyle } from './text_styles'; -import { LogEntry, LogColumn } from '../../../../common/http_api'; +import { LogEntry, LogColumn } from '../../../../common/log_entry'; import { LogEntryContextMenu } from './log_entry_context_menu'; import { LogColumnRenderConfiguration, diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index d399e47a73562..8fb63533cf61b 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -25,7 +25,7 @@ import { MeasurableItemView } from './measurable_item_view'; import { VerticalScrollPanel } from './vertical_scroll_panel'; import { useColumnWidths, LogEntryColumnWidths } from './log_entry_column'; import { LogDateRow } from './log_date_row'; -import { LogEntry } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; import { LogColumnRenderConfiguration } from '../../../utils/log_column_render_configuration'; interface ScrollableLogTextStreamViewProps { diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index bf4c5fbe0b13b..f1b820857e340 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -10,10 +10,10 @@ import { pick, throttle } from 'lodash'; import { TimeKey, timeKeyIsBetween } from '../../../../common/time'; import { LogEntriesResponse, - LogEntry, LogEntriesRequest, LogEntriesBaseRequest, } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; import { fetchLogEntries } from './api/fetch_log_entries'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx index b4edebe8f8207..fb72874df5409 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_highlights/log_entry_highlights.tsx @@ -9,7 +9,8 @@ import { useEffect, useMemo, useState } from 'react'; import { TimeKey } from '../../../../common/time'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { fetchLogEntriesHighlights } from './api/fetch_log_entries_highlights'; -import { LogEntry, LogEntriesHighlightsResponse } from '../../../../common/http_api'; +import { LogEntriesHighlightsResponse } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; export const useLogEntryHighlights = ( diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index ff30e993aa3a9..da7176125dae4 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -10,8 +10,7 @@ import usePrevious from 'react-use/lib/usePrevious'; import { esKuery } from '../../../../../../../src/plugins/data/public'; import { fetchLogEntries } from '../log_entries/api/fetch_log_entries'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { LogEntry } from '../../../../common/http_api'; -import { LogEntryCursor } from '../../../../common/log_entry'; +import { LogEntryCursor, LogEntry } from '../../../../common/log_entry'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { LogSourceConfigurationProperties } from '../log_source'; diff --git a/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts b/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts index 61e1ea353880a..2888e5a2b3ac5 100644 --- a/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts +++ b/x-pack/plugins/infra/public/containers/logs/view_log_in_context/view_log_in_context.ts @@ -5,7 +5,7 @@ */ import { useState } from 'react'; import createContainer from 'constate'; -import { LogEntry } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; interface ViewLogInContextProps { sourceId: string; diff --git a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts index 2b8986820d5a4..89b5d993fa01e 100644 --- a/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts +++ b/x-pack/plugins/infra/public/containers/logs/with_stream_items.ts @@ -11,7 +11,7 @@ import { RendererFunction } from '../../utils/typed_react'; import { LogHighlightsState } from './log_highlights/log_highlights'; import { LogEntriesState, LogEntriesStateParams, LogEntriesCallbacks } from './log_entries'; import { UniqueTimeKey } from '../../../common/time'; -import { LogEntry } from '../../../common/http_api'; +import { LogEntry } from '../../../common/log_entry'; export const WithStreamItems: React.FunctionComponent<{ children: RendererFunction< diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index ecddd8a9aa5be..4445b735bedc9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -12,7 +12,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; -import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../common/time/time_range'; import { CategoryJobNoticesSection } from '../../../components/logging/log_analysis_job_status'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx index 3e1398c804686..8fe87c14c1a7c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/analyze_dataset_in_ml_action.tsx @@ -8,7 +8,7 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results'; import { useLinkProps } from '../../../../../hooks/use_link_props'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx index 47bb31ab4ae3e..20f0ee00bd505 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator_list.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; +import { LogEntryCategoryDataset } from '../../../../../../common/log_analysis'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx index de07f3eb02029..8b4f075b782a9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_details_row.tsx @@ -7,7 +7,7 @@ import React, { useEffect } from 'react'; import { useLogEntryCategoryExamples } from '../../use_log_entry_category_examples'; import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { CategoryExampleMessage } from './category_example_message'; const exampleCount = 5; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx index 84d7e198636e9..e24fdd06bc6d9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_example_message.tsx @@ -9,8 +9,8 @@ import { i18n } from '@kbn/i18n'; import { encode } from 'rison-node'; import moment from 'moment'; -import { LogEntry, LogEntryContext } from '../../../../../../common/http_api'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LogEntry, LogEntryContext } from '../../../../../../common/log_entry'; +import { TimeRange } from '../../../../../../common/time'; import { getFriendlyNameForPartitionId, partitionField, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx index 2321dafaead1c..6bbc640b5b007 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_action_list.tsx @@ -6,8 +6,8 @@ import React from 'react'; -import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LogEntryCategoryDataset } from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; import { AnalyzeCategoryDatasetInMlAction } from './analyze_dataset_in_ml_action'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx index 779ac3e8c3a07..78690285180d7 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { LogEntryCategoryDataset } from '../../../../../../common/http_api/log_analysis'; +import { LogEntryCategoryDataset } from '../../../../../../common/log_analysis'; import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; export const DatasetsList: React.FunctionComponent<{ diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx index 42d6509802ed4..d94dbb9d33556 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx @@ -6,8 +6,8 @@ import React, { useMemo } from 'react'; -import { LogEntryCategoryHistogram } from '../../../../../../common/http_api/log_analysis'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LogEntryCategoryHistogram } from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time'; import { SingleMetricComparison } from './single_metric_comparison'; import { SingleMetricSparkline } from './single_metric_sparkline'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx index 5fb8e3380f23f..c8453bdcdefbd 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx @@ -13,7 +13,7 @@ import { } from '@elastic/eui/dist/eui_charts_theme'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { TimeRange } from '../../../../../../common/time'; interface TimeSeriesPoint { timestamp: number; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx index c7a6c89012a3a..f810a675a18d1 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -8,8 +8,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiTitle } fro import { i18n } from '@kbn/i18n'; import React from 'react'; -import { LogEntryCategory } from '../../../../../../common/http_api/log_analysis'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LogEntryCategory } from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time'; import { BetaBadge } from '../../../../../components/beta_badge'; import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; import { RecreateJobButton } from '../../../../../components/logging/log_analysis_setup/create_job_button'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx index 954b6a9ab3ed3..834c99502a590 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx @@ -15,12 +15,12 @@ import { LogEntryCategory, LogEntryCategoryDataset, LogEntryCategoryHistogram, -} from '../../../../../../common/http_api/log_analysis'; -import { TimeRange } from '../../../../../../common/http_api/shared'; +} from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time'; import { RowExpansionButton } from '../../../../../components/basic_table'; import { AnomalySeverityIndicatorList } from './anomaly_severity_indicator_list'; import { CategoryDetailsRow } from './category_details_row'; -import { RegularExpressionRepresentation } from './category_expression'; +import { RegularExpressionRepresentation } from '../../../../../components/logging/log_analysis_results/category_expression'; import { DatasetActionsList } from './datasets_action_list'; import { DatasetsList } from './datasets_list'; import { LogEntryCountSparkline } from './log_entry_count_sparkline'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts index a0eaecf04fa4b..b25b6cbe6f631 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts @@ -10,8 +10,8 @@ import { getLogEntryCategoriesRequestPayloadRT, getLogEntryCategoriesSuccessReponsePayloadRT, LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, - CategorySort, } from '../../../../../common/http_api/log_analysis'; +import { CategoriesSort } from '../../../../../common/log_analysis'; import { decodeOrThrow } from '../../../../../common/runtime_types'; interface RequestArgs { @@ -20,7 +20,7 @@ interface RequestArgs { endTime: number; categoryCount: number; datasets?: string[]; - sort: CategorySort; + sort: CategoriesSort; } export const callGetTopLogEntryCategoriesAPI = async ( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts index a64b73dea25e6..e3fba92610955 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts @@ -9,8 +9,8 @@ import { useMemo, useState } from 'react'; import { GetLogEntryCategoriesSuccessResponsePayload, GetLogEntryCategoryDatasetsSuccessResponsePayload, - CategorySort, } from '../../../../common/http_api/log_analysis'; +import { CategoriesSort } from '../../../../common/log_analysis'; import { useTrackedPromise, CanceledPromiseError } from '../../../utils/use_tracked_promise'; import { callGetTopLogEntryCategoriesAPI } from './service_calls/get_top_log_entry_categories'; import { callGetLogEntryCategoryDatasetsAPI } from './service_calls/get_log_entry_category_datasets'; @@ -19,8 +19,8 @@ import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; type TopLogEntryCategories = GetLogEntryCategoriesSuccessResponsePayload['data']['categories']; type LogEntryCategoryDatasets = GetLogEntryCategoryDatasetsSuccessResponsePayload['data']['datasets']; -export type SortOptions = CategorySort; -export type ChangeSortOptions = (sortOptions: CategorySort) => void; +export type SortOptions = CategoriesSort; +export type ChangeSortOptions = (sortOptions: CategoriesSort) => void; export const useLogEntryCategoriesResults = ({ categoriesCount, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index 09d3746c6ace6..f5007a1d48c4a 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -13,7 +13,7 @@ import { encode, RisonValue } from 'rison-node'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useTrackPageview } from '../../../../../observability/public'; -import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../common/time/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; import { TimeKey } from '../../../../common/time'; import { diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index ae5c3b5b93b47..503d383201592 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx @@ -23,7 +23,7 @@ import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { MLSeverityScoreCategories, ML_SEVERITY_COLORS, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx index 37032a95e9640..39fb1a5e6ae19 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx @@ -10,8 +10,8 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import useMount from 'react-use/lib/useMount'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; -import { LogEntryAnomaly } from '../../../../../../common/http_api'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { LogEntryAnomaly, isCategoryAnomaly } from '../../../../../../common/log_analysis'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; import { useLogSourceContext } from '../../../../../containers/logs/log_source'; import { useLogEntryExamples } from '../../use_log_entry_examples'; @@ -40,7 +40,7 @@ export const AnomaliesTableExpandedRow: React.FunctionComponent<{ exampleCount: EXAMPLE_COUNT, sourceId, startTime: anomaly.startTime, - categoryId: anomaly.categoryId, + categoryId: isCategoryAnomaly(anomaly) ? anomaly.categoryId : undefined, }); useMount(() => { diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx index c89f0329e9f2e..780e8c7ec5ec9 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx @@ -16,7 +16,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { getAnnotationsForAll, getLogEntryRateCombinedSeries } from '../helpers/data_formatters'; import { AnomaliesChart } from './chart'; import { AnomaliesTable } from './table'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index ab3476cd78eb3..7446b3c348606 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -25,10 +25,10 @@ import { LogColumnHeader, } from '../../../../../components/logging/log_text_stream/column_headers'; import { useLinkProps } from '../../../../../hooks/use_link_props'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button'; -import { LogEntryExample } from '../../../../../../common/http_api/log_analysis/results'; +import { LogEntryExample, isCategoryAnomaly } from '../../../../../../common/log_analysis'; import { LogColumnConfiguration, isTimestampLogColumnConfiguration, @@ -36,7 +36,7 @@ import { isMessageLogColumnConfiguration, } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; -import { LogEntryAnomaly } from '../../../../../../common/http_api'; +import { LogEntryAnomaly } from '../../../../../../common/log_analysis'; import { useLogEntryFlyoutContext } from '../../../../../containers/logs/log_flyout'; export const exampleMessageScale = 'medium' as const; @@ -116,7 +116,7 @@ export const LogEntryExampleMessage: React.FunctionComponent<Props> = ({ const viewAnomalyInMachineLearningLinkProps = useLinkProps( getEntitySpecificSingleMetricViewerLink(anomaly.jobId, timeRange, { [partitionField]: dataset, - ...(anomaly.categoryId ? { mlcategory: anomaly.categoryId } : {}), + ...(isCategoryAnomaly(anomaly) ? { mlcategory: anomaly.categoryId } : {}), }) ); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 855113d66f510..4b8c2b02bb8af 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -18,16 +18,18 @@ import moment from 'moment'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; import useSet from 'react-use/lib/useSet'; -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { TimeRange } from '../../../../../../common/time/time_range'; import { + AnomalyType, formatAnomalyScore, getFriendlyNameForPartitionId, formatOneDecimalPlace, + isCategoryAnomaly, } from '../../../../../../common/log_analysis'; -import { AnomalyType } from '../../../../../../common/http_api/log_analysis'; import { RowExpansionButton } from '../../../../../components/basic_table'; import { AnomaliesTableExpandedRow } from './expanded_row'; import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; +import { RegularExpressionRepresentation } from '../../../../../components/logging/log_analysis_results/category_expression'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; import { Page, @@ -50,6 +52,7 @@ interface TableItem { typical: number; actual: number; type: AnomalyType; + categoryRegex?: string; } const anomalyScoreColumnName = i18n.translate( @@ -124,6 +127,7 @@ export const AnomaliesTable: React.FunctionComponent<{ type: anomaly.type, typical: anomaly.typical, actual: anomaly.actual, + categoryRegex: isCategoryAnomaly(anomaly) ? anomaly.categoryRegex : undefined, }; }); }, [results]); @@ -166,9 +170,7 @@ export const AnomaliesTable: React.FunctionComponent<{ { name: anomalyMessageColumnName, truncateText: true, - render: (item: TableItem) => ( - <AnomalyMessage actual={item.actual} typical={item.typical} type={item.type} /> - ), + render: (item: TableItem) => <AnomalyMessage anomaly={item} />, }, { field: 'startTime', @@ -226,15 +228,9 @@ export const AnomaliesTable: React.FunctionComponent<{ ); }; -const AnomalyMessage = ({ - actual, - typical, - type, -}: { - actual: number; - typical: number; - type: AnomalyType; -}) => { +const AnomalyMessage = ({ anomaly }: { anomaly: TableItem }) => { + const { type, actual, typical } = anomaly; + const moreThanExpectedAnomalyMessage = i18n.translate( 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage', { @@ -262,9 +258,20 @@ const AnomalyMessage = ({ const ratioMessage = useRatio ? `${formatOneDecimalPlace(ratio)}x` : ''; return ( - <span> - <EuiIcon type={icon} /> {`${ratioMessage} ${message}`} - </span> + <EuiFlexGroup gutterSize="s" responsive={false} alignItems="center"> + <EuiFlexItem grow={false} component="span"> + <EuiIcon type={icon} /> + </EuiFlexItem> + <EuiFlexItem component="span"> + {`${ratioMessage} ${message}`} + {anomaly.categoryRegex && ( + <> + {': '} + <RegularExpressionRepresentation regularExpression={anomaly.categoryRegex} /> + </> + )} + </EuiFlexItem> + </EuiFlexGroup> ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts index 7f90604bfefdd..f915b0d78c43d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts @@ -11,13 +11,13 @@ import { LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, } from '../../../../../common/http_api/log_analysis'; import { decodeOrThrow } from '../../../../../common/runtime_types'; -import { Sort, Pagination } from '../../../../../common/http_api/log_analysis'; +import { AnomaliesSort, Pagination } from '../../../../../common/log_analysis'; interface RequestArgs { sourceId: string; startTime: number; endTime: number; - sort: Sort; + sort: AnomaliesSort; pagination: Pagination; datasets?: string[]; } diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts index 396c1ad3e1857..fbfe76f1473f5 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts @@ -9,21 +9,21 @@ import useMount from 'react-use/lib/useMount'; import { useTrackedPromise, CanceledPromiseError } from '../../../utils/use_tracked_promise'; import { callGetLogEntryAnomaliesAPI } from './service_calls/get_log_entry_anomalies'; import { callGetLogEntryAnomaliesDatasetsAPI } from './service_calls/get_log_entry_anomalies_datasets'; +import { GetLogEntryAnomaliesDatasetsSuccessResponsePayload } from '../../../../common/http_api/log_analysis'; import { - Sort, + AnomaliesSort, Pagination, PaginationCursor, - GetLogEntryAnomaliesDatasetsSuccessResponsePayload, LogEntryAnomaly, -} from '../../../../common/http_api/log_analysis'; +} from '../../../../common/log_analysis'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; -export type SortOptions = Sort; +export type SortOptions = AnomaliesSort; export type PaginationOptions = Pick<Pagination, 'pageSize'>; export type Page = number; export type FetchNextPage = () => void; export type FetchPreviousPage = () => void; -export type ChangeSortOptions = (sortOptions: Sort) => void; +export type ChangeSortOptions = (sortOptions: AnomaliesSort) => void; export type ChangePaginationOptions = (paginationOptions: PaginationOptions) => void; export type LogEntryAnomalies = LogEntryAnomaly[]; type LogEntryAnomaliesDatasets = GetLogEntryAnomaliesDatasetsSuccessResponsePayload['data']['datasets']; @@ -38,7 +38,7 @@ interface ReducerState { paginationCursor: Pagination['cursor'] | undefined; hasNextPage: boolean; paginationOptions: PaginationOptions; - sortOptions: Sort; + sortOptions: AnomaliesSort; timeRange: { start: number; end: number; @@ -53,7 +53,7 @@ type ReducerStateDefaults = Pick< type ReducerAction = | { type: 'changePaginationOptions'; payload: { paginationOptions: PaginationOptions } } - | { type: 'changeSortOptions'; payload: { sortOptions: Sort } } + | { type: 'changeSortOptions'; payload: { sortOptions: AnomaliesSort } } | { type: 'fetchNextPage' } | { type: 'fetchPreviousPage' } | { type: 'changeHasNextPage'; payload: { hasNextPage: boolean } } @@ -144,7 +144,7 @@ export const useLogEntryAnomaliesResults = ({ endTime: number; startTime: number; sourceId: string; - defaultSortOptions: Sort; + defaultSortOptions: AnomaliesSort; defaultPaginationOptions: Pick<Pagination, 'pageSize'>; onGetLogEntryAnomaliesDatasetsError?: (error: Error) => void; filteredDatasets?: string[]; @@ -225,7 +225,7 @@ export const useLogEntryAnomaliesResults = ({ ); const changeSortOptions = useCallback( - (nextSortOptions: Sort) => { + (nextSortOptions: AnomaliesSort) => { dispatch({ type: 'changeSortOptions', payload: { sortOptions: nextSortOptions } }); }, [dispatch] diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts index e809ab9cd5a6f..90b8b03a81602 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts @@ -6,7 +6,7 @@ import { useMemo, useState } from 'react'; -import { LogEntryExample } from '../../../../common/http_api'; +import { LogEntryExample } from '../../../../common/log_analysis'; import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; import { callGetLogEntryExamplesAPI } from './service_calls/get_log_entry_examples'; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx index 3fa89da5b5e51..011653fd6eb47 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_view_log_in_context.tsx @@ -16,7 +16,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { isEmpty } from 'lodash'; import React, { useCallback, useContext, useMemo } from 'react'; -import { LogEntry } from '../../../../common/http_api'; +import { LogEntry } from '../../../../common/log_entry'; import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; import { useViewportDimensions } from '../../../utils/use_viewport_dimensions'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; diff --git a/x-pack/plugins/infra/public/test_utils/entries.ts b/x-pack/plugins/infra/public/test_utils/entries.ts index 04c87d5f73902..96737fb175365 100644 --- a/x-pack/plugins/infra/public/test_utils/entries.ts +++ b/x-pack/plugins/infra/public/test_utils/entries.ts @@ -5,7 +5,7 @@ */ import faker from 'faker'; -import { LogEntry } from '../../common/http_api'; +import { LogEntry } from '../../common/log_entry'; import { LogSourceConfiguration } from '../containers/logs/log_source'; export const ENTRIES_EMPTY = { diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts index bb528ee5b18c5..c69104ad6177e 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry.ts @@ -17,7 +17,7 @@ import { LogMessagePart, LogMessageFieldPart, LogMessageConstantPart, -} from '../../../common/http_api'; +} from '../../../common/log_entry'; export type LogEntryMessageSegment = InfraLogEntryFields.Message; export type LogEntryConstantMessageSegment = InfraLogEntryFields.InfraLogMessageConstantSegmentInlineFragment; diff --git a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts index abb004911214b..208316c693d4d 100644 --- a/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts +++ b/x-pack/plugins/infra/public/utils/log_entry/log_entry_highlight.ts @@ -12,7 +12,7 @@ import { LogFieldColumn, LogMessagePart, LogMessageFieldPart, -} from '../../../common/http_api'; +} from '../../../common/log_entry'; export type LogEntryHighlightColumn = InfraLogEntryHighlightFields.Columns; export type LogEntryHighlightMessageColumn = InfraLogEntryHighlightFields.InfraLogEntryMessageColumnInlineFragment; diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index 0b1df3abd465a..4c5debe58ed26 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -10,10 +10,9 @@ import type { InfraPluginRequestHandlerContext } from '../../../types'; import { LogEntriesSummaryBucket, LogEntriesSummaryHighlightsBucket, - LogEntry, - LogColumn, LogEntriesRequest, } from '../../../../common/http_api'; +import { LogEntry, LogColumn } from '../../../../common/log_entry'; import { InfraSourceConfiguration, InfraSources, diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts index 19ab82c9c5ac1..d04e036b33b21 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/message.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LogMessagePart } from '../../../../common/http_api/log_entries'; +import { LogMessagePart } from '../../../../common/log_entry'; import { JsonArray, JsonValue } from '../../../../../../../src/plugins/kibana_utils/common'; import { LogMessageFormattingCondition, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts index c6a4593912280..fbcc3671f08a2 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts @@ -12,12 +12,11 @@ import { logEntryCategoriesJobTypes, logEntryRateJobTypes, jobCustomSettingsRT, -} from '../../../common/log_analysis'; -import { - Sort, + LogEntryAnomalyDatasets, + AnomaliesSort, Pagination, - GetLogEntryAnomaliesRequestPayload, -} from '../../../common/http_api/log_analysis'; + isCategoryAnomaly, +} from '../../../common/log_analysis'; import type { MlSystem, MlAnomalyDetectors } from '../../types'; import { createLogEntryAnomaliesQuery, logEntryAnomaliesResponseRT } from './queries'; import { @@ -95,9 +94,9 @@ export async function getLogEntryAnomalies( sourceId: string, startTime: number, endTime: number, - sort: Sort, + sort: AnomaliesSort, pagination: Pagination, - datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] + datasets?: LogEntryAnomalyDatasets ) { const finalizeLogEntryAnomaliesSpan = startTracingSpan('get log entry anomalies'); @@ -131,7 +130,7 @@ export async function getLogEntryAnomalies( datasets ); - const data = anomalies.map((anomaly) => { + const parsedAnomalies = anomalies.map((anomaly) => { const { jobId } = anomaly; if (!anomaly.categoryId) { @@ -141,10 +140,41 @@ export async function getLogEntryAnomalies( } }); + const categoryIds = parsedAnomalies.reduce<number[]>((acc, anomaly) => { + return isCategoryAnomaly(anomaly) ? [...acc, parseInt(anomaly.categoryId, 10)] : acc; + }, []); + + const logEntryCategoriesCountJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { logEntryCategoriesById } = await fetchLogEntryCategories( + context, + logEntryCategoriesCountJobId, + categoryIds + ); + + const parsedAnomaliesWithExpandedCategoryInformation = parsedAnomalies.map((anomaly) => { + if (isCategoryAnomaly(anomaly)) { + if (logEntryCategoriesById[parseInt(anomaly.categoryId, 10)]) { + const { + _source: { regex, terms }, + } = logEntryCategoriesById[parseInt(anomaly.categoryId, 10)]; + return { ...anomaly, ...{ categoryRegex: regex, categoryTerms: terms } }; + } else { + return { ...anomaly, ...{ categoryRegex: '', categoryTerms: '' } }; + } + } else { + return anomaly; + } + }); + const logEntryAnomaliesSpan = finalizeLogEntryAnomaliesSpan(); return { - data, + data: parsedAnomaliesWithExpandedCategoryInformation, paginationCursors, hasMoreEntries, timing: { @@ -208,9 +238,9 @@ async function fetchLogEntryAnomalies( jobIds: string[], startTime: number, endTime: number, - sort: Sort, + sort: AnomaliesSort, pagination: Pagination, - datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] + datasets?: LogEntryAnomalyDatasets ) { // We'll request 1 extra entry on top of our pageSize to determine if there are // more entries to be fetched. This avoids scenarios where the client side can't diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index 7dd5aae9784f5..071a8a94e009b 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -5,14 +5,14 @@ */ import type { ILegacyScopedClusterClient } from 'src/core/server'; -import { LogEntryContext } from '../../../common/http_api'; +import { LogEntryContext } from '../../../common/log_entry'; import { compareDatasetsByMaximumAnomalyScore, getJobId, jobCustomSettingsRT, logEntryCategoriesJobTypes, + CategoriesSort, } from '../../../common/log_analysis'; -import { CategorySort } from '../../../common/http_api/log_analysis'; import { startTracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; @@ -51,7 +51,7 @@ export async function getTopLogEntryCategories( categoryCount: number, datasets: string[], histograms: HistogramParameters[], - sort: CategorySort + sort: CategoriesSort ) { const finalizeTopLogEntryCategoriesSpan = startTracingSpan('get top categories'); @@ -218,7 +218,7 @@ async function fetchTopLogEntryCategories( endTime: number, categoryCount: number, datasets: string[], - sort: CategorySort + sort: CategoriesSort ) { const finalizeEsSearchSpan = startTracingSpan('Fetch top categories from ES'); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts index e692ed019cf86..8e01cafcf62ae 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts @@ -14,10 +14,10 @@ import { createDatasetsFilters, } from './common'; import { - Sort, + AnomaliesSort, + LogEntryAnomalyDatasets, Pagination, - GetLogEntryAnomaliesRequestPayload, -} from '../../../../common/http_api/log_analysis'; +} from '../../../../common/log_analysis'; // TODO: Reassess validity of this against ML docs const TIEBREAKER_FIELD = '_doc'; @@ -32,9 +32,9 @@ export const createLogEntryAnomaliesQuery = ( jobIds: string[], startTime: number, endTime: number, - sort: Sort, + sort: AnomaliesSort, pagination: Pagination, - datasets: GetLogEntryAnomaliesRequestPayload['data']['datasets'] + datasets?: LogEntryAnomalyDatasets ) => { const { field } = sort; const { pageSize } = pagination; @@ -118,7 +118,7 @@ export const logEntryAnomaliesResponseRT = rt.intersection([ export type LogEntryAnomaliesResponseRT = rt.TypeOf<typeof logEntryAnomaliesResponseRT>; -const parsePaginationCursor = (sort: Sort, pagination: Pagination) => { +const parsePaginationCursor = (sort: AnomaliesSort, pagination: Pagination) => { const { cursor } = pagination; const { direction } = sort; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts index 057054b427227..f1363900d3696 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -14,13 +14,13 @@ import { createDatasetsFilters, } from './common'; -import { CategorySort } from '../../../../common/http_api/log_analysis'; +import { CategoriesSort } from '../../../../common/log_analysis'; type CategoryAggregationOrder = | 'filter_record>maximum_record_score' | 'filter_model_plot>sum_actual'; const getAggregationOrderForSortField = ( - field: CategorySort['field'] + field: CategoriesSort['field'] ): CategoryAggregationOrder => { switch (field) { case 'maximumAnomalyScore': @@ -40,7 +40,7 @@ export const createTopLogEntryCategoriesQuery = ( endTime: number, size: number, datasets: string[], - sort: CategorySort + sort: CategoriesSort ) => ({ ...defaultRequestParameters, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts index ec2bc6e5ed739..42d126d4ef036 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts @@ -11,9 +11,8 @@ import { getLogEntryAnomaliesSuccessReponsePayloadRT, getLogEntryAnomaliesRequestPayloadRT, GetLogEntryAnomaliesRequestPayload, - Sort, - Pagination, } from '../../../../common/http_api/log_analysis'; +import { AnomaliesSort, Pagination } from '../../../../common/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; import { getLogEntryAnomalies } from '../../../lib/log_analysis'; @@ -98,7 +97,7 @@ const getSortAndPagination = ( sort: Partial<GetLogEntryAnomaliesRequestPayload['data']['sort']> = {}, pagination: Partial<GetLogEntryAnomaliesRequestPayload['data']['pagination']> = {} ): { - sort: Sort; + sort: AnomaliesSort; pagination: Pagination; } => { const sortDefaults = { diff --git a/x-pack/plugins/lens/kibana.json b/x-pack/plugins/lens/kibana.json index 9df3f41fbd855..d473d728dc361 100644 --- a/x-pack/plugins/lens/kibana.json +++ b/x-pack/plugins/lens/kibana.json @@ -14,10 +14,26 @@ "dashboard", "uiActions", "embeddable", - "share" + "share", + "presentationUtil" ], - "optionalPlugins": ["usageCollection", "taskManager", "globalSearch", "savedObjectsTagging"], - "configPath": ["xpack", "lens"], - "extraPublicDirs": ["common/constants"], - "requiredBundles": ["savedObjects", "kibanaUtils", "kibanaReact", "embeddable", "presentationUtil"] + "optionalPlugins": [ + "usageCollection", + "taskManager", + "globalSearch", + "savedObjectsTagging" + ], + "configPath": [ + "xpack", + "lens" + ], + "extraPublicDirs": [ + "common/constants" + ], + "requiredBundles": [ + "savedObjects", + "kibanaUtils", + "kibanaReact", + "embeddable" + ] } diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 28e1f6da60742..2dcda656c779b 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -370,6 +370,11 @@ export function App({ state.persistedDoc?.state, ]); + const tagsIds = + state.persistedDoc && savedObjectsTagging + ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references) + : []; + const runSave = async ( saveProps: Omit<OnSaveProps, 'onTitleDuplicate' | 'newDescription'> & { returnToOrigin: boolean; @@ -385,8 +390,11 @@ export function App({ } let references = lastKnownDoc.references; - if (savedObjectsTagging && saveProps.newTags) { - references = savedObjectsTagging.ui.updateTagsReferences(references, saveProps.newTags); + if (savedObjectsTagging) { + references = savedObjectsTagging.ui.updateTagsReferences( + references, + saveProps.newTags || tagsIds + ); } const docToSave = { @@ -586,11 +594,6 @@ export function App({ }, }); - const tagsIds = - state.persistedDoc && savedObjectsTagging - ? savedObjectsTagging.ui.getTagIdsFromReferences(state.persistedDoc.references) - : []; - return ( <> <div className="lnsApp"> @@ -707,7 +710,6 @@ export function App({ isVisible={state.isSaveModalVisible} originatingApp={state.isLinkedToOriginatingApp ? incomingState?.originatingApp : undefined} allowByValueEmbeddables={dashboardFeatureFlag.allowByValueEmbeddables} - savedObjectsClient={savedObjectsClient} savedObjectsTagging={savedObjectsTagging} tagsIds={tagsIds} onSave={runSave} diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index e769e402ff0e1..c4961b80c5122 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React, { FC, useCallback } from 'react'; import { AppMountParameters, CoreSetup } from 'kibana/public'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; @@ -39,9 +39,15 @@ export async function mountApp( createEditorFrame: EditorFrameStart['createInstance']; getByValueFeatureFlag: () => Promise<DashboardFeatureFlagConfig>; attributeService: () => Promise<LensAttributeService>; + getPresentationUtilContext: () => Promise<FC>; } ) { - const { createEditorFrame, getByValueFeatureFlag, attributeService } = mountProps; + const { + createEditorFrame, + getByValueFeatureFlag, + attributeService, + getPresentationUtilContext, + } = mountProps; const [coreStart, startDependencies] = await core.getStartServices(); const { data, navigation, embeddable, savedObjectsTagging } = startDependencies; @@ -196,21 +202,26 @@ export async function mountApp( }); params.element.classList.add('lnsAppWrapper'); + + const PresentationUtilContext = await getPresentationUtilContext(); + render( <I18nProvider> <KibanaContextProvider services={lensServices}> - <HashRouter> - <Switch> - <Route exact path="/edit/:id" component={EditorRoute} /> - <Route - exact - path={`/${LENS_EDIT_BY_VALUE}`} - render={(routeProps) => <EditorRoute {...routeProps} editByValue />} - /> - <Route exact path="/" component={EditorRoute} /> - <Route path="/" component={NotFound} /> - </Switch> - </HashRouter> + <PresentationUtilContext> + <HashRouter> + <Switch> + <Route exact path="/edit/:id" component={EditorRoute} /> + <Route + exact + path={`/${LENS_EDIT_BY_VALUE}`} + render={(routeProps) => <EditorRoute {...routeProps} editByValue />} + /> + <Route exact path="/" component={EditorRoute} /> + <Route path="/" component={NotFound} /> + </Switch> + </HashRouter> + </PresentationUtilContext> </KibanaContextProvider> </I18nProvider>, params.element diff --git a/x-pack/plugins/lens/public/app_plugin/save_modal.tsx b/x-pack/plugins/lens/public/app_plugin/save_modal.tsx index 4fa35bd914889..a3ac7322db31f 100644 --- a/x-pack/plugins/lens/public/app_plugin/save_modal.tsx +++ b/x-pack/plugins/lens/public/app_plugin/save_modal.tsx @@ -7,8 +7,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { SavedObjectsStart } from '../../../../../src/core/public'; - import { Document } from '../persistence'; import type { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; @@ -29,8 +27,6 @@ export interface Props { originatingApp?: string; allowByValueEmbeddables: boolean; - savedObjectsClient: SavedObjectsStart['client']; - savedObjectsTagging?: SavedObjectTaggingPluginStart; tagsIds: string[]; @@ -51,7 +47,6 @@ export const SaveModal = (props: Props) => { const { originatingApp, savedObjectsTagging, - savedObjectsClient, tagsIds, lastKnownDoc, allowByValueEmbeddables, @@ -88,7 +83,6 @@ export const SaveModal = (props: Props) => { return ( <TagEnhancedSavedObjectSaveModalDashboard savedObjectsTagging={savedObjectsTagging} - savedObjectsClient={savedObjectsClient} initialTags={tagsIds} onSave={(saveProps) => { const saveToLibrary = saveProps.dashboardId === null; diff --git a/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_dashboard_wrapper.tsx b/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_dashboard_wrapper.tsx index 087cfdc9f3a8a..b191b8829347c 100644 --- a/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_dashboard_wrapper.tsx +++ b/x-pack/plugins/lens/public/app_plugin/tags_saved_object_save_modal_dashboard_wrapper.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useMemo, useCallback } from 'react'; import { OnSaveProps } from '../../../../../src/plugins/saved_objects/public'; import { - DashboardSaveModalProps, + SaveModalDashboardProps, SavedObjectSaveModalDashboard, } from '../../../../../src/plugins/presentation_util/public'; import { SavedObjectTaggingPluginStart } from '../../../saved_objects_tagging/public'; @@ -19,7 +19,7 @@ export type DashboardSaveProps = OnSaveProps & { }; export type TagEnhancedSavedObjectSaveModalDashboardProps = Omit< - DashboardSaveModalProps, + SaveModalDashboardProps, 'onSave' > & { initialTags: string[]; @@ -48,7 +48,7 @@ export const TagEnhancedSavedObjectSaveModalDashboard: FC<TagEnhancedSavedObject const tagEnhancedOptions = <>{tagSelectorOption}</>; - const tagEnhancedOnSave: DashboardSaveModalProps['onSave'] = useCallback( + const tagEnhancedOnSave: SaveModalDashboardProps['onSave'] = useCallback( (saveOptions) => { onSave({ ...saveOptions, diff --git a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap deleted file mode 100644 index 23460d442cfa8..0000000000000 --- a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap +++ /dev/null @@ -1,119 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`datatable_expression DatatableComponent it renders actions column when there are row actions 1`] = ` -<VisualizationContainer - reportTitle="My fanci metric chart" -> - <EuiBasicTable - className="lnsDataTable" - columns={ - Array [ - Object { - "field": "a", - "name": "a", - "render": [Function], - "sortable": true, - }, - Object { - "field": "b", - "name": "b", - "render": [Function], - "sortable": true, - }, - Object { - "field": "c", - "name": "c", - "render": [Function], - "sortable": true, - }, - Object { - "actions": Array [ - Object { - "description": "Table row context menu", - "icon": [Function], - "name": "More", - "onClick": [Function], - "type": "icon", - }, - ], - "name": "Actions", - }, - ] - } - data-test-subj="lnsDataTable" - items={ - Array [ - Object { - "a": "shoes", - "b": 1588024800000, - "c": 3, - "rowIndex": 0, - }, - ] - } - noItemsMessage="No items found" - onChange={[Function]} - responsive={true} - sorting={ - Object { - "allowNeutralSort": true, - "sort": undefined, - } - } - tableLayout="auto" - /> -</VisualizationContainer> -`; - -exports[`datatable_expression DatatableComponent it renders the title and value 1`] = ` -<VisualizationContainer - reportTitle="My fanci metric chart" -> - <EuiBasicTable - className="lnsDataTable" - columns={ - Array [ - Object { - "field": "a", - "name": "a", - "render": [Function], - "sortable": true, - }, - Object { - "field": "b", - "name": "b", - "render": [Function], - "sortable": true, - }, - Object { - "field": "c", - "name": "c", - "render": [Function], - "sortable": true, - }, - ] - } - data-test-subj="lnsDataTable" - items={ - Array [ - Object { - "a": "shoes", - "b": 1588024800000, - "c": 3, - "rowIndex": 0, - }, - ] - } - noItemsMessage="No items found" - onChange={[Function]} - responsive={true} - sorting={ - Object { - "allowNeutralSort": true, - "sort": undefined, - } - } - tableLayout="auto" - /> -</VisualizationContainer> -`; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap new file mode 100644 index 0000000000000..a4eb99a972b9b --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/__snapshots__/table_basic.test.tsx.snap @@ -0,0 +1,534 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DatatableComponent it renders actions column when there are row actions 1`] = ` +<VisualizationContainer + className="lnsDataTableContainer" + reportTitle="My fanci metric chart" +> + <ContextProvider + value={ + Object { + "rowHasRowClickTriggerActions": Array [ + true, + true, + true, + ], + "table": Object { + "columns": Array [ + Object { + "id": "a", + "meta": Object { + "field": "a", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "terms", + }, + "type": "string", + }, + "name": "a", + }, + Object { + "id": "b", + "meta": Object { + "field": "b", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "date_histogram", + }, + "type": "date", + }, + "name": "b", + }, + Object { + "id": "c", + "meta": Object { + "field": "c", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "count", + }, + "type": "number", + }, + "name": "c", + }, + ], + "rows": Array [ + Object { + "a": "shoes", + "b": 1588024800000, + "c": 3, + }, + ], + "type": "datatable", + }, + } + } + > + <EuiDataGrid + aria-label="My fanci metric chart" + columnVisibility={ + Object { + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "a", + "b", + "c", + ], + } + } + columns={ + Array [ + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "a", + "displayAsText": "a", + "id": "a", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "b", + "displayAsText": "b", + "id": "b", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "c", + "displayAsText": "c", + "id": "c", + }, + ] + } + data-test-subj="lnsDataTable" + gridStyle={ + Object { + "border": "horizontal", + "header": "underline", + } + } + onColumnResize={[Function]} + renderCellValue={[Function]} + rowCount={1} + sorting={ + Object { + "columns": Array [], + "onSort": [Function], + } + } + toolbarVisibility={false} + trailingControlColumns={ + Array [ + Object { + "headerCellRender": [Function], + "id": "trailingControlColumn", + "rowCellRender": [Function], + "width": 40, + }, + ] + } + /> + </ContextProvider> +</VisualizationContainer> +`; + +exports[`DatatableComponent it renders the title and value 1`] = ` +<VisualizationContainer + className="lnsDataTableContainer" + reportTitle="My fanci metric chart" +> + <ContextProvider + value={ + Object { + "rowHasRowClickTriggerActions": undefined, + "table": Object { + "columns": Array [ + Object { + "id": "a", + "meta": Object { + "field": "a", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "terms", + }, + "type": "string", + }, + "name": "a", + }, + Object { + "id": "b", + "meta": Object { + "field": "b", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "date_histogram", + }, + "type": "date", + }, + "name": "b", + }, + Object { + "id": "c", + "meta": Object { + "field": "c", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "count", + }, + "type": "number", + }, + "name": "c", + }, + ], + "rows": Array [ + Object { + "a": "shoes", + "b": 1588024800000, + "c": 3, + }, + ], + "type": "datatable", + }, + } + } + > + <EuiDataGrid + aria-label="My fanci metric chart" + columnVisibility={ + Object { + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "a", + "b", + "c", + ], + } + } + columns={ + Array [ + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "a", + "displayAsText": "a", + "id": "a", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "b", + "displayAsText": "b", + "id": "b", + }, + Object { + "actions": Object { + "additional": Array [ + Object { + "color": "text", + "data-test-subj": "lensDatatableResetWidth", + "iconType": "empty", + "isDisabled": true, + "label": "Reset width", + "onClick": [Function], + "size": "xs", + }, + ], + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": Object { + "label": "Sort asc", + }, + "showSortDesc": Object { + "label": "Sort desc", + }, + }, + "cellActions": undefined, + "display": "c", + "displayAsText": "c", + "id": "c", + }, + ] + } + data-test-subj="lnsDataTable" + gridStyle={ + Object { + "border": "horizontal", + "header": "underline", + } + } + onColumnResize={[Function]} + renderCellValue={[Function]} + rowCount={1} + sorting={ + Object { + "columns": Array [], + "onSort": [Function], + } + } + toolbarVisibility={false} + trailingControlColumns={Array []} + /> + </ContextProvider> +</VisualizationContainer> +`; + +exports[`DatatableComponent it should not render actions on header when it is in read only mode 1`] = ` +<VisualizationContainer + className="lnsDataTableContainer" + reportTitle="My fanci metric chart" +> + <ContextProvider + value={ + Object { + "rowHasRowClickTriggerActions": Array [ + false, + false, + false, + ], + "table": Object { + "columns": Array [ + Object { + "id": "a", + "meta": Object { + "field": "a", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "terms", + }, + "type": "string", + }, + "name": "a", + }, + Object { + "id": "b", + "meta": Object { + "field": "b", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "date_histogram", + }, + "type": "date", + }, + "name": "b", + }, + Object { + "id": "c", + "meta": Object { + "field": "c", + "source": "esaggs", + "sourceParams": Object { + "indexPatternId": "indexPatternId", + "type": "count", + }, + "type": "number", + }, + "name": "c", + }, + ], + "rows": Array [ + Object { + "a": "shoes", + "b": 1588024800000, + "c": 3, + }, + ], + "type": "datatable", + }, + } + } + > + <EuiDataGrid + aria-label="My fanci metric chart" + columnVisibility={ + Object { + "setVisibleColumns": [Function], + "visibleColumns": Array [ + "a", + "b", + "c", + ], + } + } + columns={ + Array [ + Object { + "actions": Object { + "additional": undefined, + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": false, + "showSortDesc": false, + }, + "cellActions": undefined, + "display": "a", + "displayAsText": "a", + "id": "a", + }, + Object { + "actions": Object { + "additional": undefined, + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": false, + "showSortDesc": false, + }, + "cellActions": undefined, + "display": "b", + "displayAsText": "b", + "id": "b", + }, + Object { + "actions": Object { + "additional": undefined, + "showHide": false, + "showMoveLeft": false, + "showMoveRight": false, + "showSortAsc": false, + "showSortDesc": false, + }, + "cellActions": undefined, + "display": "c", + "displayAsText": "c", + "id": "c", + }, + ] + } + data-test-subj="lnsDataTable" + gridStyle={ + Object { + "border": "horizontal", + "header": "underline", + } + } + onColumnResize={[Function]} + renderCellValue={[Function]} + rowCount={1} + sorting={ + Object { + "columns": Array [], + "onSort": [Function], + } + } + toolbarVisibility={false} + trailingControlColumns={Array []} + /> + </ContextProvider> +</VisualizationContainer> +`; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx new file mode 100644 index 0000000000000..a8328f5eefdca --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiDataGridCellValueElementProps } from '@elastic/eui'; +import type { FormatFactory } from '../../types'; +import type { DataContextType } from './types'; + +export const createGridCell = ( + formatters: Record<string, ReturnType<FormatFactory>>, + DataContext: React.Context<DataContextType> +) => ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const { table } = useContext(DataContext); + const rowValue = table?.rows[rowIndex][columnId]; + const content = formatters[columnId]?.convert(rowValue, 'html'); + + return ( + <span + /* + * dangerouslySetInnerHTML is necessary because the field formatter might produce HTML markup + * which is produced in a safe way. + */ + dangerouslySetInnerHTML={{ __html: content }} // eslint-disable-line react/no-danger + data-test-subj="lnsTableCellContent" + className="lnsDataTableCellContent" + /> + ); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx new file mode 100644 index 0000000000000..83a8d026f1315 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/columns.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import type { Datatable, DatatableColumnMeta } from 'src/plugins/expressions'; +import type { FormatFactory } from '../../types'; +import type { DatatableColumns } from './types'; + +export const createGridColumns = ( + bucketColumns: string[], + table: Datatable, + handleFilterClick: ( + field: string, + value: unknown, + colIndex: number, + rowIndex: number, + negate?: boolean + ) => void, + isReadOnly: boolean, + columnConfig: DatatableColumns & { type: 'lens_datatable_columns' }, + visibleColumns: string[], + formatFactory: FormatFactory, + onColumnResize: (eventData: { columnId: string; width: number | undefined }) => void +) => { + const columnsReverseLookup = table.columns.reduce< + Record<string, { name: string; index: number; meta?: DatatableColumnMeta }> + >((memo, { id, name, meta }, i) => { + memo[id] = { name, index: i, meta }; + return memo; + }, {}); + + const bucketLookup = new Set(bucketColumns); + + const getContentData = ({ + rowIndex, + columnId, + }: Pick<EuiDataGridColumnCellActionProps, 'rowIndex' | 'columnId'>) => { + const rowValue = table.rows[rowIndex][columnId]; + const column = columnsReverseLookup[columnId]; + const contentsIsDefined = rowValue != null; + + const cellContent = formatFactory(column?.meta?.params).convert(rowValue); + return { rowValue, contentsIsDefined, cellContent }; + }; + + return visibleColumns.map((field) => { + const filterable = bucketLookup.has(field); + const { name, index: colIndex } = columnsReverseLookup[field]; + + const cellActions = filterable + ? [ + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const { rowValue, contentsIsDefined, cellContent } = getContentData({ + rowIndex, + columnId, + }); + + const filterForText = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterForValueText', + { + defaultMessage: 'Filter for value', + } + ); + const filterForAriaLabel = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterForValueAriaLabel', + { + defaultMessage: 'Filter for value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + <Component + aria-label={filterForAriaLabel} + data-test-subj="lensDatatableFilterFor" + onClick={() => { + handleFilterClick(field, rowValue, colIndex, rowIndex); + closePopover(); + }} + iconType="plusInCircle" + > + {filterForText} + </Component> + ) + ); + }, + ({ rowIndex, columnId, Component, closePopover }: EuiDataGridColumnCellActionProps) => { + const { rowValue, contentsIsDefined, cellContent } = getContentData({ + rowIndex, + columnId, + }); + + const filterOutText = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterOutValueText', + { + defaultMessage: 'Filter out value', + } + ); + const filterOutAriaLabel = i18n.translate( + 'xpack.lens.table.tableCellFilter.filterOutValueAriaLabel', + { + defaultMessage: 'Filter out value: {cellContent}', + values: { + cellContent, + }, + } + ); + + return ( + contentsIsDefined && ( + <Component + data-test-subj="lensDatatableFilterOut" + aria-label={filterOutAriaLabel} + onClick={() => { + handleFilterClick(field, rowValue, colIndex, rowIndex, true); + closePopover(); + }} + iconType="minusInCircle" + > + {filterOutText} + </Component> + ) + ); + }, + ] + : undefined; + + const initialWidth = columnConfig.columnWidth?.find(({ columnId }) => columnId === field) + ?.width; + + const columnDefinition: EuiDataGridColumn = { + id: field, + cellActions, + display: name, + displayAsText: name, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + showSortAsc: isReadOnly + ? false + : { + label: i18n.translate('xpack.lens.table.sort.ascLabel', { + defaultMessage: 'Sort asc', + }), + }, + showSortDesc: isReadOnly + ? false + : { + label: i18n.translate('xpack.lens.table.sort.descLabel', { + defaultMessage: 'Sort desc', + }), + }, + additional: isReadOnly + ? undefined + : [ + { + color: 'text', + size: 'xs', + onClick: () => onColumnResize({ columnId: field, width: undefined }), + iconType: 'empty', + label: i18n.translate('xpack.lens.table.resize.reset', { + defaultMessage: 'Reset width', + }), + 'data-test-subj': 'lensDatatableResetWidth', + isDisabled: initialWidth == null, + }, + ], + }, + }; + + if (initialWidth) { + columnDefinition.initialWidth = initialWidth; + } + + return columnDefinition; + }); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts new file mode 100644 index 0000000000000..4779d42859a79 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const LENS_EDIT_SORT_ACTION = 'sort'; +export const LENS_EDIT_RESIZE_ACTION = 'resize'; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts new file mode 100644 index 0000000000000..dad9aa30b7712 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; + +import { EuiDataGridSorting } from '@elastic/eui'; +import { Datatable } from 'src/plugins/expressions'; + +import { + createGridFilterHandler, + createGridResizeHandler, + createGridSortingConfig, +} from './table_actions'; +import { DatatableColumns, LensGridDirection } from './types'; + +function getDefaultConfig(): DatatableColumns & { + type: 'lens_datatable_columns'; +} { + return { + columnIds: [], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }; +} + +function createTableRef( + { withDate }: { withDate: boolean } = { withDate: false } +): React.MutableRefObject<Datatable> { + return { + current: { + type: 'datatable', + rows: [], + columns: [ + { + id: 'a', + name: 'field', + meta: { type: withDate ? 'date' : 'number', field: 'a' }, + }, + ], + }, + }; +} + +describe('Table actions', () => { + const onEditAction = jest.fn(); + + describe('Table filtering', () => { + it('should set a filter on click with the correct configuration', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef(); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: false, + timeFieldName: 'a', + }); + }); + + it('should set a negate filter on click with the correct confgiuration', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef(); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0, true); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: true, + timeFieldName: 'a', + }); + }); + + it('should set a time filter on click', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef({ withDate: true }); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: false, + timeFieldName: 'a', + }); + }); + + it('should set a negative time filter on click', () => { + const onClickValue = jest.fn(); + const tableRef = createTableRef({ withDate: true }); + const filterHandle = createGridFilterHandler(tableRef, onClickValue); + + filterHandle('a', 100, 0, 0, true); + expect(onClickValue).toHaveBeenCalledWith({ + data: [ + { + column: 0, + row: 0, + table: tableRef.current, + value: 100, + }, + ], + negate: true, + timeFieldName: undefined, + }); + }); + }); + describe('Table sorting', () => { + it('should create the right configuration for all types of sorting', () => { + const configs: Array<{ + input: { direction: LensGridDirection; sortBy: string }; + output: EuiDataGridSorting['columns']; + }> = [ + { input: { direction: 'asc', sortBy: 'a' }, output: [{ id: 'a', direction: 'asc' }] }, + { input: { direction: 'none', sortBy: 'a' }, output: [] }, + { input: { direction: 'asc', sortBy: '' }, output: [] }, + ]; + for (const { input, output } of configs) { + const { sortBy, direction } = input; + expect(createGridSortingConfig(sortBy, direction, onEditAction)).toMatchObject( + expect.objectContaining({ columns: output }) + ); + } + }); + + it('should return the correct next configuration value based on the current state', () => { + const sorter = createGridSortingConfig('a', 'none', onEditAction); + // Click on the 'a' column + sorter.onSort([{ id: 'a', direction: 'asc' }]); + + // Click on another column 'b' + sorter.onSort([ + { id: 'a', direction: 'asc' }, + { id: 'b', direction: 'asc' }, + ]); + + // Change the sorting of 'a' + sorter.onSort([{ id: 'a', direction: 'desc' }]); + + // Toggle the 'a' current sorting (remove sorting) + sorter.onSort([]); + + expect(onEditAction.mock.calls).toEqual([ + [ + { + action: 'sort', + columnId: 'a', + direction: 'asc', + }, + ], + [ + { + action: 'sort', + columnId: 'b', + direction: 'asc', + }, + ], + [ + { + action: 'sort', + columnId: 'a', + direction: 'desc', + }, + ], + [ + { + action: 'sort', + columnId: undefined, + direction: 'none', + }, + ], + ]); + }); + }); + describe('Table resize', () => { + const setColumnConfig = jest.fn(); + + it('should resize the table locally and globally with the given size', () => { + const columnConfig = getDefaultConfig(); + const resizer = createGridResizeHandler(columnConfig, setColumnConfig, onEditAction); + resizer({ columnId: 'a', width: 100 }); + + expect(setColumnConfig).toHaveBeenCalledWith({ + ...columnConfig, + columnWidth: [{ columnId: 'a', width: 100, type: 'lens_datatable_column_width' }], + }); + + expect(onEditAction).toHaveBeenCalledWith({ action: 'resize', columnId: 'a', width: 100 }); + }); + + it('should pull out the table custom width from the local state when passing undefined', () => { + const columnConfig = getDefaultConfig(); + columnConfig.columnWidth = [ + { columnId: 'a', width: 100, type: 'lens_datatable_column_width' }, + ]; + + const resizer = createGridResizeHandler(columnConfig, setColumnConfig, onEditAction); + resizer({ columnId: 'a', width: undefined }); + + expect(setColumnConfig).toHaveBeenCalledWith({ + ...columnConfig, + columnWidth: [], + }); + + expect(onEditAction).toHaveBeenCalledWith({ + action: 'resize', + columnId: 'a', + width: undefined, + }); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts new file mode 100644 index 0000000000000..38534482b81fa --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_actions.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import type { EuiDataGridSorting } from '@elastic/eui'; +import type { Datatable } from 'src/plugins/expressions'; +import type { LensFilterEvent } from '../../types'; +import type { + DatatableColumns, + LensGridDirection, + LensResizeAction, + LensSortAction, +} from './types'; + +import { desanitizeFilterContext } from '../../utils'; + +export const createGridResizeHandler = ( + columnConfig: DatatableColumns & { + type: 'lens_datatable_columns'; + }, + setColumnConfig: React.Dispatch< + React.SetStateAction< + DatatableColumns & { + type: 'lens_datatable_columns'; + } + > + >, + onEditAction: (data: LensResizeAction['data']) => void +) => (eventData: { columnId: string; width: number | undefined }) => { + // directly set the local state of the component to make sure the visualization re-renders immediately, + // re-layouting and taking up all of the available space. + setColumnConfig({ + ...columnConfig, + columnWidth: [ + ...(columnConfig.columnWidth || []).filter(({ columnId }) => columnId !== eventData.columnId), + ...(eventData.width !== undefined + ? [ + { + columnId: eventData.columnId, + width: eventData.width, + type: 'lens_datatable_column_width' as const, + }, + ] + : []), + ], + }); + return onEditAction({ + action: 'resize', + columnId: eventData.columnId, + width: eventData.width, + }); +}; + +export const createGridFilterHandler = ( + tableRef: React.MutableRefObject<Datatable>, + onClickValue: (data: LensFilterEvent['data']) => void +) => ( + field: string, + value: unknown, + colIndex: number, + rowIndex: number, + negate: boolean = false +) => { + const col = tableRef.current.columns[colIndex]; + const isDate = col.meta?.type === 'date'; + const timeFieldName = negate && isDate ? undefined : col?.meta?.field; + + const data: LensFilterEvent['data'] = { + negate, + data: [ + { + row: rowIndex, + column: colIndex, + value, + table: tableRef.current, + }, + ], + timeFieldName, + }; + + onClickValue(desanitizeFilterContext(data)); +}; + +export const createGridSortingConfig = ( + sortBy: string, + sortDirection: LensGridDirection, + onEditAction: (data: LensSortAction['data']) => void +): EuiDataGridSorting => ({ + columns: + !sortBy || sortDirection === 'none' + ? [] + : [ + { + id: sortBy, + direction: sortDirection, + }, + ], + onSort: (sortingCols) => { + const newSortValue: + | { + id: string; + direction: Exclude<LensGridDirection, 'none'>; + } + | undefined = sortingCols.length <= 1 ? sortingCols[0] : sortingCols[1]; + const isNewColumn = sortBy !== (newSortValue?.id || ''); + const nextDirection = newSortValue ? newSortValue.direction : 'none'; + + return onEditAction({ + action: 'sort', + columnId: nextDirection !== 'none' || isNewColumn ? newSortValue?.id : undefined, + direction: nextDirection, + }); + }, +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss new file mode 100644 index 0000000000000..5e5db2c645809 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss @@ -0,0 +1,3 @@ +.lnsDataTableContainer { + height: 100%; +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx new file mode 100644 index 0000000000000..df5dba749a60c --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -0,0 +1,425 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { mountWithIntl } from '@kbn/test/jest'; +import { EuiDataGrid } from '@elastic/eui'; +import { IAggType, IFieldFormat } from 'src/plugins/data/public'; +import { EmptyPlaceholder } from '../../shared_components'; +import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { DatatableComponent } from './table_basic'; +import { LensMultiTable } from '../../types'; +import { DatatableProps } from '../expression'; + +function sampleArgs() { + const indexPatternId = 'indexPatternId'; + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'string', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'terms', indexPatternId }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'date', + field: 'b', + source: 'esaggs', + sourceParams: { + type: 'date_histogram', + indexPatternId, + }, + }, + }, + { + id: 'c', + name: 'c', + meta: { + type: 'number', + source: 'esaggs', + field: 'c', + sourceParams: { indexPatternId, type: 'count' }, + }, + }, + ], + rows: [{ a: 'shoes', b: 1588024800000, c: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: 'My fanci metric chart', + columns: { + columnIds: ['a', 'b', 'c'], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }, + }; + + return { data, args }; +} + +function copyData(data: LensMultiTable): LensMultiTable { + return JSON.parse(JSON.stringify(data)); +} + +describe('DatatableComponent', () => { + let onDispatchEvent: jest.Mock; + + beforeEach(() => { + onDispatchEvent = jest.fn(); + }); + + test('it renders the title and value', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + <DatatableComponent + data={data} + args={args} + formatFactory={(x) => x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ) + ).toMatchSnapshot(); + }); + + test('it renders actions column when there are row actions', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + <DatatableComponent + data={data} + args={args} + formatFactory={(x) => x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + rowHasRowClickTriggerActions={[true, true, true]} + renderMode="edit" + /> + ) + ).toMatchSnapshot(); + }); + + test('it should not render actions on header when it is in read only mode', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + <DatatableComponent + data={data} + args={args} + formatFactory={(x) => x as IFieldFormat} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + rowHasRowClickTriggerActions={[false, false, false]} + renderMode="display" + /> + ) + ).toMatchSnapshot(); + }); + + test('it invokes executeTriggerActions with correct context on click on top value', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={{ + ...data, + dateRange: { + fromDate: new Date('2020-04-20T05:00:00.000Z'), + toDate: new Date('2020-05-03T05:00:00.000Z'), + }, + }} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterOut"]').first().simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 'shoes', + }, + ], + negate: true, + timeFieldName: 'a', + }, + }); + }); + + test('it invokes executeTriggerActions with correct context on click on timefield', () => { + const { args, data } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={{ + ...data, + dateRange: { + fromDate: new Date('2020-04-20T05:00:00.000Z'), + toDate: new Date('2020-05-03T05:00:00.000Z'), + }, + }} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(3).simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 1, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'b', + }, + }); + }); + + test('it invokes executeTriggerActions with correct context on click on timefield from range', () => { + const data: LensMultiTable = { + type: 'lens_multitable', + tables: { + l1: { + type: 'datatable', + columns: [ + { + id: 'a', + name: 'a', + meta: { + type: 'date', + source: 'esaggs', + field: 'a', + sourceParams: { type: 'date_range', indexPatternId: 'a' }, + }, + }, + { + id: 'b', + name: 'b', + meta: { + type: 'number', + source: 'esaggs', + sourceParams: { type: 'count', indexPatternId: 'a' }, + }, + }, + ], + rows: [{ a: 1588024800000, b: 3 }], + }, + }, + }; + + const args: DatatableProps['args'] = { + title: '', + columns: { + columnIds: ['a', 'b'], + sortBy: '', + sortDirection: 'none', + type: 'lens_datatable_columns', + }, + }; + + const wrapper = mountWithIntl( + <DatatableComponent + data={{ + ...data, + dateRange: { + fromDate: new Date('2020-04-20T05:00:00.000Z'), + toDate: new Date('2020-05-03T05:00:00.000Z'), + }, + }} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} + renderMode="edit" + /> + ); + + wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'filter', + data: { + data: [ + { + column: 0, + row: 0, + table: data.tables.l1, + value: 1588024800000, + }, + ], + negate: false, + timeFieldName: 'a', + }, + }); + }); + + test('it shows emptyPlaceholder for undefined bucketed data', () => { + const { args, data } = sampleArgs(); + const emptyData: LensMultiTable = { + ...data, + tables: { + l1: { + ...data.tables.l1, + rows: [{ a: undefined, b: undefined, c: 0 }], + }, + }, + }; + + const component = shallow( + <DatatableComponent + data={emptyData} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn((type) => + type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) + )} + renderMode="edit" + /> + ); + expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); + }); + + test('it renders the table with the given sorting', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={data} + args={{ + ...args, + columns: { + ...args.columns, + sortBy: 'b', + sortDirection: 'desc', + }, + }} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ); + + expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ + { id: 'b', direction: 'desc' }, + ]); + + wrapper.find(EuiDataGrid).prop('sorting')!.onSort([]); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'edit', + data: { + action: 'sort', + columnId: undefined, + direction: 'none', + }, + }); + + wrapper + .find(EuiDataGrid) + .prop('sorting')! + .onSort([{ id: 'a', direction: 'asc' }]); + + expect(onDispatchEvent).toHaveBeenCalledWith({ + name: 'edit', + data: { + action: 'sort', + columnId: 'a', + direction: 'asc', + }, + }); + }); + + test('it renders the table with the given sorting in readOnly mode', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={data} + args={{ + ...args, + columns: { + ...args.columns, + sortBy: 'b', + sortDirection: 'desc', + }, + }} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="display" + /> + ); + + expect(wrapper.find(EuiDataGrid).prop('sorting')!.columns).toEqual([ + { id: 'b', direction: 'desc' }, + ]); + }); + + test('it should refresh the table header when the datatable data changes', () => { + const { data, args } = sampleArgs(); + + const wrapper = mountWithIntl( + <DatatableComponent + data={data} + args={args} + formatFactory={() => ({ convert: (x) => x } as IFieldFormat)} + dispatchEvent={onDispatchEvent} + getType={jest.fn()} + renderMode="edit" + /> + ); + // mnake a copy of the data, changing only the name of the first column + const newData = copyData(data); + newData.tables.l1.columns[0].name = 'new a'; + wrapper.setProps({ data: newData }); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="dataGridHeader"]').children().first().text()).toEqual( + 'new a' + ); + }); +}); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx new file mode 100644 index 0000000000000..171074d6e6797 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './table_basic.scss'; + +import React, { useCallback, useMemo, useRef, useState, useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import useDeepCompareEffect from 'react-use/lib/useDeepCompareEffect'; +import { + EuiButtonIcon, + EuiDataGrid, + EuiDataGridControlColumn, + EuiDataGridColumn, + EuiDataGridSorting, + EuiDataGridStyle, +} from '@elastic/eui'; +import { FormatFactory, LensFilterEvent, LensTableRowContextMenuEvent } from '../../types'; +import { VisualizationContainer } from '../../visualization_container'; +import { EmptyPlaceholder } from '../../shared_components'; +import { LensIconChartDatatable } from '../../assets/chart_datatable'; +import { + DataContextType, + DatatableRenderProps, + LensSortAction, + LensResizeAction, + LensGridDirection, +} from './types'; +import { createGridColumns } from './columns'; +import { createGridCell } from './cell_value'; +import { + createGridFilterHandler, + createGridResizeHandler, + createGridSortingConfig, +} from './table_actions'; + +const DataContext = React.createContext<DataContextType>({}); + +const gridStyle: EuiDataGridStyle = { + border: 'horizontal', + header: 'underline', +}; + +export const DatatableComponent = (props: DatatableRenderProps) => { + const [firstTable] = Object.values(props.data.tables); + + const [columnConfig, setColumnConfig] = useState(props.args.columns); + const [firstLocalTable, updateTable] = useState(firstTable); + + useDeepCompareEffect(() => { + setColumnConfig(props.args.columns); + }, [props.args.columns]); + + useDeepCompareEffect(() => { + updateTable(firstTable); + }, [firstTable]); + + const firstTableRef = useRef(firstLocalTable); + firstTableRef.current = firstLocalTable; + + const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions?.some((x) => x); + + const { getType, dispatchEvent, renderMode, formatFactory } = props; + + const formatters: Record<string, ReturnType<FormatFactory>> = useMemo( + () => + firstLocalTable.columns.reduce( + (map, column) => ({ + ...map, + [column.id]: formatFactory(column.meta?.params), + }), + {} + ), + [firstLocalTable, formatFactory] + ); + + const onClickValue = useCallback( + (data: LensFilterEvent['data']) => { + dispatchEvent({ name: 'filter', data }); + }, + [dispatchEvent] + ); + + const onEditAction = useCallback( + (data: LensSortAction['data'] | LensResizeAction['data']) => { + if (renderMode === 'edit') { + dispatchEvent({ name: 'edit', data }); + } + }, + [dispatchEvent, renderMode] + ); + const onRowContextMenuClick = useCallback( + (data: LensTableRowContextMenuEvent['data']) => { + dispatchEvent({ name: 'tableRowContextMenuClick', data }); + }, + [dispatchEvent] + ); + + const handleFilterClick = useMemo(() => createGridFilterHandler(firstTableRef, onClickValue), [ + firstTableRef, + onClickValue, + ]); + + const bucketColumns = useMemo( + () => + columnConfig.columnIds.filter((_colId, index) => { + const col = firstTableRef.current.columns[index]; + return ( + col?.meta?.sourceParams?.type && + getType(col.meta.sourceParams.type as string)?.type === 'buckets' + ); + }), + [firstTableRef, columnConfig, getType] + ); + + const isEmpty = + firstLocalTable.rows.length === 0 || + (bucketColumns.length && + firstTable.rows.every((row) => bucketColumns.every((col) => row[col] == null))); + + const visibleColumns = useMemo(() => columnConfig.columnIds.filter((field) => !!field), [ + columnConfig, + ]); + + const { sortBy, sortDirection } = columnConfig; + + const isReadOnlySorted = renderMode !== 'edit'; + + const onColumnResize = useMemo( + () => createGridResizeHandler(columnConfig, setColumnConfig, onEditAction), + [onEditAction, setColumnConfig, columnConfig] + ); + + const columns: EuiDataGridColumn[] = useMemo( + () => + createGridColumns( + bucketColumns, + firstLocalTable, + handleFilterClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory, + onColumnResize + ), + [ + bucketColumns, + firstLocalTable, + handleFilterClick, + isReadOnlySorted, + columnConfig, + visibleColumns, + formatFactory, + onColumnResize, + ] + ); + + const trailingControlColumns: EuiDataGridControlColumn[] = useMemo(() => { + if (!hasAtLeastOneRowClickAction || !onRowContextMenuClick) { + return []; + } + return [ + { + headerCellRender: () => null, + width: 40, + id: 'trailingControlColumn', + rowCellRender: function RowCellRender({ rowIndex }) { + const { rowHasRowClickTriggerActions } = useContext(DataContext); + return ( + <EuiButtonIcon + aria-label={i18n.translate('xpack.lens.table.actionsLabel', { + defaultMessage: 'Show actions', + })} + iconType={ + !!rowHasRowClickTriggerActions && !rowHasRowClickTriggerActions[rowIndex] + ? 'empty' + : 'boxesVertical' + } + color="text" + onClick={() => { + onRowContextMenuClick({ + rowIndex, + table: firstTableRef.current, + columns: columnConfig.columnIds, + }); + }} + /> + ); + }, + }, + ]; + }, [firstTableRef, onRowContextMenuClick, columnConfig, hasAtLeastOneRowClickAction]); + + const renderCellValue = useMemo(() => createGridCell(formatters, DataContext), [formatters]); + + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns: () => {} }), [ + visibleColumns, + ]); + + const sorting = useMemo<EuiDataGridSorting>( + () => createGridSortingConfig(sortBy, sortDirection as LensGridDirection, onEditAction), + [onEditAction, sortBy, sortDirection] + ); + + if (isEmpty) { + return <EmptyPlaceholder icon={LensIconChartDatatable} />; + } + + const dataGridAriaLabel = + props.args.title || + i18n.translate('xpack.lens.table.defaultAriaLabel', { + defaultMessage: 'Data table visualization', + }); + + return ( + <VisualizationContainer + className="lnsDataTableContainer" + reportTitle={props.args.title} + reportDescription={props.args.description} + > + <DataContext.Provider + value={{ + table: firstLocalTable, + rowHasRowClickTriggerActions: props.rowHasRowClickTriggerActions, + }} + > + <EuiDataGrid + aria-label={dataGridAriaLabel} + data-test-subj="lnsDataTable" + columns={columns} + columnVisibility={columnVisibility} + trailingControlColumns={trailingControlColumns} + rowCount={firstLocalTable.rows.length} + renderCellValue={renderCellValue} + gridStyle={gridStyle} + sorting={sorting} + onColumnResize={onColumnResize} + toolbarVisibility={false} + /> + </DataContext.Provider> + </VisualizationContainer> + ); +}; diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/types.ts b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts new file mode 100644 index 0000000000000..4f1a1141fdaa8 --- /dev/null +++ b/x-pack/plugins/lens/public/datatable_visualization/components/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { Direction } from '@elastic/eui'; +import type { IAggType } from 'src/plugins/data/public'; +import type { Datatable, RenderMode } from 'src/plugins/expressions'; +import type { FormatFactory, ILensInterpreterRenderHandlers, LensEditEvent } from '../../types'; +import type { DatatableProps } from '../expression'; +import { LENS_EDIT_SORT_ACTION, LENS_EDIT_RESIZE_ACTION } from './constants'; + +export type LensGridDirection = 'none' | Direction; + +export interface LensSortActionData { + columnId: string | undefined; + direction: LensGridDirection; +} + +export interface LensResizeActionData { + columnId: string; + width: number | undefined; +} + +export type LensSortAction = LensEditEvent<typeof LENS_EDIT_SORT_ACTION>; +export type LensResizeAction = LensEditEvent<typeof LENS_EDIT_RESIZE_ACTION>; + +export interface DatatableColumns { + columnIds: string[]; + sortBy: string; + sortDirection: string; + columnWidth?: DatatableColumnWidthResult[]; +} + +export interface DatatableColumnWidth { + columnId: string; + width: number; +} + +export type DatatableColumnWidthResult = DatatableColumnWidth & { + type: 'lens_datatable_column_width'; +}; + +export type DatatableRenderProps = DatatableProps & { + formatFactory: FormatFactory; + dispatchEvent: ILensInterpreterRenderHandlers['event']; + getType: (name: string) => IAggType; + renderMode: RenderMode; + + /** + * A boolean for each table row, which is true if the row active + * ROW_CLICK_TRIGGER actions attached to it, otherwise false. + */ + rowHasRowClickTriggerActions?: boolean[]; +}; + +export interface DatatableRender { + type: 'render'; + as: 'lens_datatable_renderer'; + value: DatatableProps; +} + +export interface DataContextType { + table?: Datatable; + rowHasRowClickTriggerActions?: boolean[]; +} diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.scss b/x-pack/plugins/lens/public/datatable_visualization/expression.scss deleted file mode 100644 index 7d95d73143870..0000000000000 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.scss +++ /dev/null @@ -1,13 +0,0 @@ -.lnsDataTable { - align-self: flex-start; -} - -.lnsDataTable__filter { - opacity: 0; - transition: opacity $euiAnimSpeedNormal ease-in-out; -} - -.lnsDataTable__cell:hover .lnsDataTable__filter, -.lnsDataTable__filter:focus-within { - opacity: 1; -} diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx index d0811e0ad05a6..60d9461a5e0d9 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.test.tsx @@ -4,18 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { shallow } from 'enzyme'; -import { mountWithIntl } from '@kbn/test/jest'; -import { getDatatable, DatatableComponent } from './expression'; +import { DatatableProps, getDatatable } from './expression'; import { LensMultiTable } from '../types'; -import { DatatableProps } from './expression'; import { createMockExecutionContext } from '../../../../../src/plugins/expressions/common/mocks'; import { IFieldFormat } from '../../../../../src/plugins/data/public'; -import { IAggType } from 'src/plugins/data/public'; -import { EmptyPlaceholder } from '../shared_components'; -import { LensIconChartDatatable } from '../assets/chart_datatable'; -import { EuiBasicTable } from '@elastic/eui'; function sampleArgs() { const indexPatternId = 'indexPatternId'; @@ -78,14 +70,6 @@ function sampleArgs() { } describe('datatable_expression', () => { - let onClickValue: jest.Mock; - let onEditAction: jest.Mock; - - beforeEach(() => { - onClickValue = jest.fn(); - onEditAction = jest.fn(); - }); - describe('datatable renders', () => { test('it renders with the specified data and args', () => { const { data, args } = sampleArgs(); @@ -102,296 +86,4 @@ describe('datatable_expression', () => { }); }); }); - - describe('DatatableComponent', () => { - test('it renders the title and value', () => { - const { data, args } = sampleArgs(); - - expect( - shallow( - <DatatableComponent - data={data} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn()} - renderMode="edit" - /> - ) - ).toMatchSnapshot(); - }); - - test('it renders actions column when there are row actions', () => { - const { data, args } = sampleArgs(); - - expect( - shallow( - <DatatableComponent - data={data} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn()} - onRowContextMenuClick={() => undefined} - rowHasRowClickTriggerActions={[true, true, true]} - renderMode="edit" - /> - ) - ).toMatchSnapshot(); - }); - - test('it invokes executeTriggerActions with correct context on click on top value', () => { - const { args, data } = sampleArgs(); - - const wrapper = mountWithIntl( - <DatatableComponent - data={{ - ...data, - dateRange: { - fromDate: new Date('2020-04-20T05:00:00.000Z'), - toDate: new Date('2020-05-03T05:00:00.000Z'), - }, - }} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterOut"]').first().simulate('click'); - - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 0, - row: 0, - table: data.tables.l1, - value: 'shoes', - }, - ], - negate: true, - timeFieldName: 'a', - }); - }); - - test('it invokes executeTriggerActions with correct context on click on timefield', () => { - const { args, data } = sampleArgs(); - - const wrapper = mountWithIntl( - <DatatableComponent - data={{ - ...data, - dateRange: { - fromDate: new Date('2020-04-20T05:00:00.000Z'), - toDate: new Date('2020-05-03T05:00:00.000Z'), - }, - }} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(3).simulate('click'); - - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 1, - row: 0, - table: data.tables.l1, - value: 1588024800000, - }, - ], - negate: false, - timeFieldName: 'b', - }); - }); - - test('it invokes executeTriggerActions with correct context on click on timefield from range', () => { - const data: LensMultiTable = { - type: 'lens_multitable', - tables: { - l1: { - type: 'datatable', - columns: [ - { - id: 'a', - name: 'a', - meta: { - type: 'date', - source: 'esaggs', - field: 'a', - sourceParams: { type: 'date_range', indexPatternId: 'a' }, - }, - }, - { - id: 'b', - name: 'b', - meta: { - type: 'number', - source: 'esaggs', - sourceParams: { type: 'count', indexPatternId: 'a' }, - }, - }, - ], - rows: [{ a: 1588024800000, b: 3 }], - }, - }, - }; - - const args: DatatableProps['args'] = { - title: '', - columns: { - columnIds: ['a', 'b'], - sortBy: '', - sortDirection: 'none', - type: 'lens_datatable_columns', - }, - }; - - const wrapper = mountWithIntl( - <DatatableComponent - data={{ - ...data, - dateRange: { - fromDate: new Date('2020-04-20T05:00:00.000Z'), - toDate: new Date('2020-05-03T05:00:00.000Z'), - }, - }} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn(() => ({ type: 'buckets' } as IAggType))} - renderMode="edit" - /> - ); - - wrapper.find('[data-test-subj="lensDatatableFilterFor"]').at(1).simulate('click'); - - expect(onClickValue).toHaveBeenCalledWith({ - data: [ - { - column: 0, - row: 0, - table: data.tables.l1, - value: 1588024800000, - }, - ], - negate: false, - timeFieldName: 'a', - }); - }); - - test('it shows emptyPlaceholder for undefined bucketed data', () => { - const { args, data } = sampleArgs(); - const emptyData: LensMultiTable = { - ...data, - tables: { - l1: { - ...data.tables.l1, - rows: [{ a: undefined, b: undefined, c: 0 }], - }, - }, - }; - - const component = shallow( - <DatatableComponent - data={emptyData} - args={args} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - getType={jest.fn((type) => - type === 'count' ? ({ type: 'metrics' } as IAggType) : ({ type: 'buckets' } as IAggType) - )} - renderMode="edit" - /> - ); - expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); - }); - - test('it renders the table with the given sorting', () => { - const { data, args } = sampleArgs(); - - const wrapper = mountWithIntl( - <DatatableComponent - data={data} - args={{ - ...args, - columns: { - ...args.columns, - sortBy: 'b', - sortDirection: 'desc', - }, - }} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - onEditAction={onEditAction} - getType={jest.fn()} - renderMode="edit" - /> - ); - - // there's currently no way to detect the sorting column via DOM - expect( - wrapper.exists('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]') - ).toBe(true); - // check that the sorting is passing the right next state for the same column - wrapper - .find('[className*="isSorted"][data-test-subj="tableHeaderSortButton"]') - .first() - .simulate('click'); - - expect(onEditAction).toHaveBeenCalledWith({ - action: 'sort', - columnId: undefined, - direction: 'none', - }); - - // check that the sorting is passing the right next state for another column - wrapper - .find('[data-test-subj="tableHeaderSortButton"]') - .not('[className*="isSorted"]') - .first() - .simulate('click'); - - expect(onEditAction).toHaveBeenCalledWith({ - action: 'sort', - columnId: 'a', - direction: 'asc', - }); - }); - - test('it renders the table with the given sorting in readOnly mode', () => { - const { data, args } = sampleArgs(); - - const wrapper = mountWithIntl( - <DatatableComponent - data={data} - args={{ - ...args, - columns: { - ...args.columns, - sortBy: 'b', - sortDirection: 'desc', - }, - }} - formatFactory={(x) => x as IFieldFormat} - onClickValue={onClickValue} - onEditAction={onEditAction} - getType={jest.fn()} - renderMode="display" - /> - ); - - expect(wrapper.find(EuiBasicTable).prop('sorting')).toMatchObject({ - sort: undefined, - allowNeutralSort: true, - }); - }); - }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 57289fc0ac169..e8a0abb0316db 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -4,62 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import './expression.scss'; - -import React, { useMemo } from 'react'; +import React from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; -import { - EuiBasicTable, - EuiFlexGroup, - EuiButtonIcon, - EuiFlexItem, - EuiToolTip, - Direction, - EuiScreenReaderOnly, - EuiIcon, - EuiBasicTableColumn, - EuiTableActionsColumnType, -} from '@elastic/eui'; -import { IAggType } from 'src/plugins/data/public'; -import { Datatable, DatatableColumnMeta, RenderMode } from 'src/plugins/expressions'; -import { - FormatFactory, - ILensInterpreterRenderHandlers, - LensEditEvent, - LensFilterEvent, - LensMultiTable, - LensTableRowContextMenuEvent, -} from '../types'; -import { +import type { IAggType } from 'src/plugins/data/public'; +import type { + DatatableColumnMeta, ExpressionFunctionDefinition, ExpressionRenderDefinition, -} from '../../../../../src/plugins/expressions/public'; -import { VisualizationContainer } from '../visualization_container'; -import { EmptyPlaceholder } from '../shared_components'; -import { desanitizeFilterContext } from '../utils'; -import { LensIconChartDatatable } from '../assets/chart_datatable'; +} from 'src/plugins/expressions'; import { getSortingCriteria } from './sorting'; -export const LENS_EDIT_SORT_ACTION = 'sort'; - -export interface LensSortActionData { - columnId: string | undefined; - direction: 'asc' | 'desc' | 'none'; -} - -type LensSortAction = LensEditEvent<typeof LENS_EDIT_SORT_ACTION>; - -// This is a way to circumvent the explicit "any" forbidden type -type TableRowField = Datatable['rows'][number] & { rowIndex: number }; +import { DatatableComponent } from './components/table_basic'; -export interface DatatableColumns { - columnIds: string[]; - sortBy: string; - sortDirection: string; -} +import type { FormatFactory, ILensInterpreterRenderHandlers, LensMultiTable } from '../types'; +import type { + DatatableRender, + DatatableColumns, + DatatableColumnWidth, + DatatableColumnWidthResult, +} from './components/types'; interface Args { title: string; @@ -72,27 +38,6 @@ export interface DatatableProps { args: Args; } -type DatatableRenderProps = DatatableProps & { - formatFactory: FormatFactory; - onClickValue: (data: LensFilterEvent['data']) => void; - onEditAction?: (data: LensSortAction['data']) => void; - getType: (name: string) => IAggType; - renderMode: RenderMode; - onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void; - - /** - * A boolean for each table row, which is true if the row active - * ROW_CLICK_TRIGGER actions attached to it, otherwise false. - */ - rowHasRowClickTriggerActions?: boolean[]; -}; - -export interface DatatableRender { - type: 'render'; - as: 'lens_datatable_renderer'; - value: DatatableProps; -} - function isRange(meta: { params?: { id?: string } } | undefined) { return meta?.params?.id === 'range'; } @@ -191,6 +136,11 @@ export const datatableColumns: ExpressionFunctionDefinition< multi: true, help: '', }, + columnWidth: { + types: ['lens_datatable_column_width'], + multi: true, + help: '', + }, }, fn: function fn(input: unknown, args: DatatableColumns) { return { @@ -200,6 +150,35 @@ export const datatableColumns: ExpressionFunctionDefinition< }, }; +export const datatableColumnWidth: ExpressionFunctionDefinition< + 'lens_datatable_column_width', + null, + DatatableColumnWidth, + DatatableColumnWidthResult +> = { + name: 'lens_datatable_column_width', + aliases: [], + type: 'lens_datatable_column_width', + help: '', + inputTypes: ['null'], + args: { + columnId: { + types: ['string'], + help: '', + }, + width: { + types: ['number'], + help: '', + }, + }, + fn: function fn(input: unknown, args: DatatableColumnWidth) { + return { + type: 'lens_datatable_column_width', + ...args, + }; + }, +}; + export const getDatatableRenderer = (dependencies: { formatFactory: FormatFactory; getType: Promise<(name: string) => IAggType>; @@ -217,18 +196,6 @@ export const getDatatableRenderer = (dependencies: { handlers: ILensInterpreterRenderHandlers ) => { const resolvedGetType = await dependencies.getType; - const onClickValue = (data: LensFilterEvent['data']) => { - handlers.event({ name: 'filter', data }); - }; - - const onEditAction = (data: LensSortAction['data']) => { - if (handlers.getRenderMode() === 'edit') { - handlers.event({ name: 'edit', data }); - } - }; - const onRowContextMenuClick = (data: LensTableRowContextMenuEvent['data']) => { - handlers.event({ name: 'tableRowContextMenuClick', data }); - }; const { hasCompatibleActions } = handlers; // An entry for each table row, whether it has any actions attached to @@ -263,10 +230,8 @@ export const getDatatableRenderer = (dependencies: { <DatatableComponent {...config} formatFactory={dependencies.formatFactory} - onClickValue={onClickValue} - onEditAction={onEditAction} + dispatchEvent={handlers.event} renderMode={handlers.getRenderMode()} - onRowContextMenuClick={onRowContextMenuClick} getType={resolvedGetType} rowHasRowClickTriggerActions={rowHasRowClickTriggerActions} /> @@ -279,281 +244,3 @@ export const getDatatableRenderer = (dependencies: { handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); }, }); - -function getNextOrderValue(currentValue: LensSortAction['data']['direction']) { - const states: Array<LensSortAction['data']['direction']> = ['asc', 'desc', 'none']; - const newStateIndex = (1 + states.findIndex((state) => state === currentValue)) % states.length; - return states[newStateIndex]; -} - -function getDirectionLongLabel(sortDirection: LensSortAction['data']['direction']) { - if (sortDirection === 'none') { - return sortDirection; - } - return sortDirection === 'asc' ? 'ascending' : 'descending'; -} - -function getHeaderSortingCell( - name: string, - columnId: string, - sorting: Omit<LensSortAction['data'], 'action'>, - sortingLabel: string -) { - if (columnId !== sorting.columnId || sorting.direction === 'none') { - return name || ''; - } - // This is a workaround to hijack the title value of the header cell - return ( - <span aria-sort={getDirectionLongLabel(sorting.direction)}> - {name || ''} - <EuiScreenReaderOnly> - <span>{sortingLabel}</span> - </EuiScreenReaderOnly> - <EuiIcon - className="euiTableSortIcon" - type={sorting.direction === 'asc' ? 'sortUp' : 'sortDown'} - size="m" - aria-label={sortingLabel} - /> - </span> - ); -} - -export function DatatableComponent(props: DatatableRenderProps) { - const [firstTable] = Object.values(props.data.tables); - const formatters: Record<string, ReturnType<FormatFactory>> = {}; - - firstTable.columns.forEach((column) => { - formatters[column.id] = props.formatFactory(column.meta?.params); - }); - - const { onClickValue, onEditAction, onRowContextMenuClick } = props; - const handleFilterClick = useMemo( - () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { - const col = firstTable.columns[colIndex]; - const isDate = col.meta?.type === 'date'; - const timeFieldName = negate && isDate ? undefined : col?.meta?.field; - const rowIndex = firstTable.rows.findIndex((row) => row[field] === value); - - const data: LensFilterEvent['data'] = { - negate, - data: [ - { - row: rowIndex, - column: colIndex, - value, - table: firstTable, - }, - ], - timeFieldName, - }; - onClickValue(desanitizeFilterContext(data)); - }, - [firstTable, onClickValue] - ); - - const bucketColumns = firstTable.columns - .filter((col) => { - return ( - col?.meta?.sourceParams?.type && - props.getType(col.meta.sourceParams.type as string)?.type === 'buckets' - ); - }) - .map((col) => col.id); - - const isEmpty = - firstTable.rows.length === 0 || - (bucketColumns.length && - firstTable.rows.every((row) => - bucketColumns.every((col) => typeof row[col] === 'undefined') - )); - - if (isEmpty) { - return <EmptyPlaceholder icon={LensIconChartDatatable} />; - } - - const visibleColumns = props.args.columns.columnIds.filter((field) => !!field); - const columnsReverseLookup = firstTable.columns.reduce< - Record<string, { name: string; index: number; meta?: DatatableColumnMeta }> - >((memo, { id, name, meta }, i) => { - memo[id] = { name, index: i, meta }; - return memo; - }, {}); - - const { sortBy, sortDirection } = props.args.columns; - - const sortedRows: TableRowField[] = - firstTable?.rows.map((row, rowIndex) => ({ ...row, rowIndex })) || []; - const isReadOnlySorted = props.renderMode !== 'edit'; - - const sortedInLabel = i18n.translate('xpack.lens.datatableSortedInReadOnlyMode', { - defaultMessage: 'Sorted in {sortValue} order', - values: { - sortValue: sortDirection === 'asc' ? 'ascending' : 'descending', - }, - }); - - const tableColumns: Array<EuiBasicTableColumn<TableRowField>> = visibleColumns.map((field) => { - const filterable = bucketColumns.includes(field); - const { name, index: colIndex, meta } = columnsReverseLookup[field]; - const fieldName = meta?.field; - const nameContent = !isReadOnlySorted - ? name - : getHeaderSortingCell( - name, - field, - { - columnId: sortBy, - direction: sortDirection as LensSortAction['data']['direction'], - }, - sortedInLabel - ); - return { - field, - name: nameContent, - sortable: !isReadOnlySorted, - render: (value: unknown) => { - const formattedValue = formatters[field]?.convert(value); - - if (filterable) { - return ( - <EuiFlexGroup - className="lnsDataTable__cell" - data-test-subj="lnsDataTableCellValueFilterable" - gutterSize="xs" - > - <EuiFlexItem grow={false}>{formattedValue}</EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiFlexGroup - responsive={false} - gutterSize="none" - alignItems="center" - className="lnsDataTable__filter" - > - <EuiToolTip - position="bottom" - content={i18n.translate('xpack.lens.includeValueButtonTooltip', { - defaultMessage: 'Include value', - })} - > - <EuiButtonIcon - iconType="plusInCircle" - color="text" - aria-label={i18n.translate('xpack.lens.includeValueButtonAriaLabel', { - defaultMessage: `Include {value}`, - values: { - value: `${fieldName ? `${fieldName}: ` : ''}${formattedValue}`, - }, - })} - data-test-subj="lensDatatableFilterFor" - onClick={() => handleFilterClick(field, value, colIndex)} - /> - </EuiToolTip> - <EuiFlexItem grow={false}> - <EuiToolTip - position="bottom" - content={i18n.translate('xpack.lens.excludeValueButtonTooltip', { - defaultMessage: 'Exclude value', - })} - > - <EuiButtonIcon - iconType="minusInCircle" - color="text" - aria-label={i18n.translate('xpack.lens.excludeValueButtonAriaLabel', { - defaultMessage: `Exclude {value}`, - values: { - value: `${fieldName ? `${fieldName}: ` : ''}${formattedValue}`, - }, - })} - data-test-subj="lensDatatableFilterOut" - onClick={() => handleFilterClick(field, value, colIndex, true)} - /> - </EuiToolTip> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - ); - } - return <span data-test-subj="lnsDataTableCellValue">{formattedValue}</span>; - }, - }; - }); - - if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) { - const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x); - if (hasAtLeastOneRowClickAction) { - const actions: EuiTableActionsColumnType<TableRowField> = { - name: i18n.translate('xpack.lens.datatable.actionsColumnName', { - defaultMessage: 'Actions', - }), - actions: [ - { - name: i18n.translate('xpack.lens.tableRowMore', { - defaultMessage: 'More', - }), - description: i18n.translate('xpack.lens.tableRowMoreDescription', { - defaultMessage: 'Table row context menu', - }), - type: 'icon', - icon: ({ rowIndex }: { rowIndex: number }) => { - if ( - !!props.rowHasRowClickTriggerActions && - !props.rowHasRowClickTriggerActions[rowIndex] - ) - return 'empty'; - return 'boxesVertical'; - }, - onClick: ({ rowIndex }) => { - onRowContextMenuClick({ - rowIndex, - table: firstTable, - columns: props.args.columns.columnIds, - }); - }, - }, - ], - }; - tableColumns.push(actions); - } - } - - return ( - <VisualizationContainer - reportTitle={props.args.title} - reportDescription={props.args.description} - > - <EuiBasicTable - className="lnsDataTable" - data-test-subj="lnsDataTable" - tableLayout="auto" - sorting={{ - sort: - !sortBy || sortDirection === 'none' || isReadOnlySorted - ? undefined - : { - field: sortBy, - direction: sortDirection as Direction, - }, - allowNeutralSort: true, // this flag enables the 3rd Neutral state on the column header - }} - onChange={(event: { sort?: { field: string } }) => { - if (event.sort && onEditAction) { - const isNewColumn = sortBy !== event.sort.field; - // unfortunately the neutral state is not propagated and we need to manually handle it - const nextDirection = getNextOrderValue( - (isNewColumn ? 'none' : sortDirection) as LensSortAction['data']['direction'] - ); - return onEditAction({ - action: 'sort', - columnId: nextDirection !== 'none' || isNewColumn ? event.sort.field : undefined, - direction: nextDirection, - }); - } - }} - columns={tableColumns} - items={sortedRows} - /> - </VisualizationContainer> - ); -} diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index 42d2ff6a220c0..cf23d56adb915 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -29,12 +29,14 @@ export class DatatableVisualization { const { getDatatable, datatableColumns, + datatableColumnWidth, getDatatableRenderer, datatableVisualization, } = await import('../async_services'); const resolvedFormatFactory = await formatFactory; expressions.registerFunction(() => datatableColumns); + expressions.registerFunction(() => datatableColumnWidth); expressions.registerFunction(() => getDatatable({ formatFactory: resolvedFormatFactory })); expressions.registerRenderer(() => getDatatableRenderer({ diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 088246ccf4b9c..f067093891d29 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -408,6 +408,7 @@ describe('Datatable Visualization', () => { columnIds: ['c', 'b'], sortBy: [''], sortDirection: ['none'], + columnWidth: [], }); }); @@ -467,4 +468,80 @@ describe('Datatable Visualization', () => { expect(error).toBeUndefined(); }); }); + + describe('#onEditAction', () => { + it('should add a sort column to the state', () => { + const currentState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'sort', columnId: 'saved', direction: 'none' }, + }) + ).toEqual({ + ...currentState, + sorting: { + columnId: 'saved', + direction: 'none', + }, + }); + }); + + it('should add a custom width to a column in the state', () => { + const currentState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'resize', columnId: 'saved', width: 500 }, + }) + ).toEqual({ + ...currentState, + columnWidth: [ + { + columnId: 'saved', + width: 500, + }, + ], + }); + }); + + it('should clear custom width value for the column from the state', () => { + const currentState: DatatableVisualizationState = { + layers: [ + { + layerId: 'foo', + columns: ['saved'], + }, + ], + columnWidth: [ + { + columnId: 'saved', + width: 500, + }, + ], + }; + expect( + datatableVisualization.onEditAction!(currentState, { + name: 'edit', + data: { action: 'resize', columnId: 'saved', width: undefined }, + }) + ).toEqual({ + ...currentState, + columnWidth: [], + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index e4f787a265186..3df9e8a5145bc 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -6,13 +6,14 @@ import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; -import { +import type { SuggestionRequest, Visualization, VisualizationSuggestion, Operation, DatasourcePublicAPI, } from '../types'; +import type { DatatableColumnWidth } from './components/types'; import { LensIconChartDatatable } from '../assets/chart_datatable'; export interface LayerState { @@ -26,6 +27,7 @@ export interface DatatableVisualizationState { columnId: string | undefined; direction: 'asc' | 'desc' | 'none'; }; + columnWidth?: DatatableColumnWidth[]; } function newLayerState(layerId: string): LayerState { @@ -239,6 +241,19 @@ export const datatableVisualization: Visualization<DatatableVisualizationState> columnIds: operations.map((o) => o.columnId), sortBy: [state.sorting?.columnId || ''], sortDirection: [state.sorting?.direction || 'none'], + columnWidth: (state.columnWidth || []).map((columnWidth) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_datatable_column_width', + arguments: { + columnId: [columnWidth.columnId], + width: [columnWidth.width], + }, + }, + ], + })), }, }, ], @@ -255,16 +270,28 @@ export const datatableVisualization: Visualization<DatatableVisualizationState> }, onEditAction(state, event) { - if (event.data.action !== 'sort') { - return state; + switch (event.data.action) { + case 'sort': + return { + ...state, + sorting: { + columnId: event.data.columnId, + direction: event.data.direction, + }, + }; + case 'resize': + return { + ...state, + columnWidth: [ + ...(state.columnWidth || []).filter(({ columnId }) => columnId !== event.data.columnId), + ...(event.data.width !== undefined + ? [{ columnId: event.data.columnId, width: event.data.width }] + : []), + ], + }; + default: + return state; } - return { - ...state, - sorting: { - columnId: event.data.columnId, - direction: event.data.direction, - }, - }; }, }; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts index 94cf13a5c50a4..63f8bfb97d8f8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.test.ts @@ -2181,7 +2181,7 @@ describe('state_helpers', () => { expect(errors).toHaveLength(1); }); - it('should consider incompleteColumns before layer columns', () => { + it('should ignore incompleteColumns when checking for errors', () => { const savedRef = jest.fn().mockReturnValue(['error 1']); const incompleteRef = jest.fn(); operationDefinitionMap.testReference.getErrorMessage = savedRef; @@ -2206,9 +2206,9 @@ describe('state_helpers', () => { }, indexPattern ); - expect(savedRef).not.toHaveBeenCalled(); - expect(incompleteRef).toHaveBeenCalled(); - expect(errors).toBeUndefined(); + expect(savedRef).toHaveBeenCalled(); + expect(incompleteRef).not.toHaveBeenCalled(); + expect(errors).toHaveLength(1); delete operationDefinitionMap.testIncompleteReference; }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts index 10618cc754556..7c0036de62124 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/layer_helpers.ts @@ -870,12 +870,7 @@ export function getErrorMessages( ): string[] | undefined { const errors: string[] = Object.entries(layer.columns) .flatMap(([columnId, column]) => { - // If we're transitioning to another operation, check for "new" incompleteColumns rather - // than "old" saved operation on the layer - const columnFinalRef = - layer.incompleteColumns?.[columnId]?.operationType || column.operationType; - const def = operationDefinitionMap[columnFinalRef]; - + const def = operationDefinitionMap[column.operationType]; if (def.getErrorMessage) { return def.getErrorMessage(layer, columnId, indexPattern); } diff --git a/x-pack/plugins/lens/public/plugin.ts b/x-pack/plugins/lens/public/plugin.ts index 3fb7186aeac59..9848551e7873f 100644 --- a/x-pack/plugins/lens/public/plugin.ts +++ b/x-pack/plugins/lens/public/plugin.ts @@ -17,6 +17,7 @@ import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/ import { UrlForwardingSetup } from '../../../../src/plugins/url_forwarding/public'; import { GlobalSearchPluginSetup } from '../../global_search/public'; import { ChartsPluginSetup, ChartsPluginStart } from '../../../../src/plugins/charts/public'; +import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { EmbeddableStateTransfer } from '../../../../src/plugins/embeddable/public'; import { EditorFrameService } from './editor_frame_service'; import { @@ -71,6 +72,7 @@ export interface LensPluginStartDependencies { embeddable: EmbeddableStart; charts: ChartsPluginStart; savedObjectsTagging?: SavedObjectTaggingPluginStart; + presentationUtil: PresentationUtilPluginStart; } export interface LensPublicStart { @@ -172,6 +174,12 @@ export class LensPlugin { return deps.dashboard.dashboardFeatureFlagConfig; }; + const getPresentationUtilContext = async () => { + const [, deps] = await core.getStartServices(); + const { ContextProvider } = deps.presentationUtil; + return ContextProvider; + }; + core.application.register({ id: 'lens', title: NOT_INTERNATIONALIZED_PRODUCT_NAME, @@ -183,6 +191,7 @@ export class LensPlugin { createEditorFrame: this.createEditorFrame!, attributeService: this.attributeService!, getByValueFeatureFlag, + getPresentationUtilContext, }); }, }); diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 9feed918635b3..907ef3a700ce6 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 0000000000000..e6cb0101ee838 --- /dev/null +++ b/x-pack/plugins/license_management/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "public/**/*", + "server/**/*", + "common/**/*", + "__jest__/**/*", + "__mocks__/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/telemetry_management_section/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/telemetry/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../licensing/tsconfig.json"}, + { "path": "../features/tsconfig.json"}, + { "path": "../security/tsconfig.json"}, + ] +} diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index 2536601d0e6b1..744cc18c36f3e 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -2,7 +2,10 @@ "id": "maps", "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "maps"], + "configPath": [ + "xpack", + "maps" + ], "requiredPlugins": [ "licensing", "features", @@ -17,11 +20,21 @@ "mapsLegacy", "usageCollection", "savedObjects", - "share" + "share", + "presentationUtil" + ], + "optionalPlugins": [ + "home", + "savedObjectsTagging" ], - "optionalPlugins": ["home", "savedObjectsTagging"], "ui": true, "server": true, - "extraPublicDirs": ["common/constants"], - "requiredBundles": ["kibanaReact", "kibanaUtils", "home", "presentationUtil"] + "extraPublicDirs": [ + "common/constants" + ], + "requiredBundles": [ + "kibanaReact", + "kibanaUtils", + "home" + ] } diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx index e2529fff66f3b..78a9f82bb698f 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx @@ -26,7 +26,7 @@ const ADD_LAYER_STEP_LABEL = i18n.translate('xpack.maps.addLayerPanel.addLayer', }); const SELECT_WIZARD_LABEL = ADD_LAYER_STEP_LABEL; -interface Props { +export interface Props { addPreviewLayers: (layerDescriptors: LayerDescriptor[]) => void; closeFlyout: () => void; hasPreviewLayers: boolean; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx index 1bcee961db9e1..d47f130d4ede3 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 33d684b320208..c0462f824cd06 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.tsx +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/layer_settings/layer_settings.tsx @@ -21,7 +21,7 @@ import { AlphaSlider } from '../../../components/alpha_slider'; import { ValidatedDualRange } from '../../../../../../../src/plugins/kibana_react/public'; import { ILayer } from '../../../classes/layers/layer'; -interface Props { +export interface Props { layer: ILayer; updateLabel: (layerId: string, label: string) => void; updateMinZoom: (layerId: string, minZoom: number) => void; diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 93476f6e14da5..36d07e3870818 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -35,7 +35,7 @@ import 'mapbox-gl/dist/mapbox-gl.css'; const RENDER_COMPLETE_EVENT = 'renderComplete'; -interface Props { +export interface Props { addFilters: ((filters: Filter[]) => Promise<void>) | null; getFilterActions?: () => Promise<Action[]>; getActionContext?: () => ActionExecutionContext; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx index 726e2c3be7846..9cbbdec5e3d17 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx @@ -23,7 +23,7 @@ import { SpatialFiltersPanel } from './spatial_filters_panel'; import { DisplayPanel } from './display_panel'; import { MapCenter } from '../../../common/descriptor_types'; -interface Props { +export interface Props { cancelChanges: () => void; center: MapCenter; hasMapSettingsChanges: boolean; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index 820453f166a46..21a8abcbaa4e9 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -49,7 +49,7 @@ import mbWorkerUrl from '!!file-loader!mapbox-gl/dist/mapbox-gl-csp-worker'; mapboxgl.workerUrl = mbWorkerUrl; mapboxgl.setRTLTextPlugin(mbRtlPlugin); -interface Props { +export interface Props { isMapReady: boolean; settings: MapSettings; layerList: ILayer[]; diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx index 3f56d8d50b0f0..edf626612cb69 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/fit_to_data/fit_to_data.tsx @@ -10,7 +10,7 @@ import { EuiButtonIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ILayer } from '../../../classes/layers/layer'; -interface Props { +export interface Props { layerList: ILayer[]; fitToBounds: () => void; } diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx index ef320c73bce2f..a0f3aa40e75dd 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/tools_control/tools_control.tsx @@ -50,7 +50,7 @@ const DRAW_DISTANCE_LABEL_SHORT = i18n.translate( } ); -interface Props { +export interface Props { cancelDraw: () => void; geoFields: GeoFieldWithIndex[]; initiateDraw: (drawState: DrawState) => void; diff --git a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx index 4d669dfbe235e..fd0a0d55d2c1b 100644 --- a/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx +++ b/x-pack/plugins/maps/public/connected_components/widget_overlay/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { ILayer } from '../../../../../../classes/layers/layer'; import { TOCEntryButton } from '../toc_entry_button'; -interface Props { +export interface Props { cloneLayer: (layerId: string) => void; displayName: string; editLayer: () => void; diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index 67981ab3aede5..bcdc23bddd2eb 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -68,7 +68,7 @@ import { MapEmbeddableInput, MapEmbeddableOutput, } from './types'; -export { MapEmbeddableInput }; +export { MapEmbeddableInput, MapEmbeddableOutput }; export class MapEmbeddable extends Embeddable<MapEmbeddableInput, MapEmbeddableOutput> diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 99c9311a2a454..56e342a95be51 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -49,6 +49,7 @@ export const getSearchService = () => pluginsStart.data.search; export const getEmbeddableService = () => pluginsStart.embeddable; export const getNavigateToApp = () => coreStart.application.navigateToApp; export const getSavedObjectsTagging = () => pluginsStart.savedObjectsTagging; +export const getPresentationUtilContext = () => pluginsStart.presentationUtil.ContextProvider; // xpack.maps.* kibana.yml settings from this plugin let mapAppConfig: MapsConfigType; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 4173328a41d57..5bd0bd7346ab1 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -55,6 +55,7 @@ import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/public'; import { StartContract as FileUploadStartContract } from '../../maps_file_upload/public'; import { SavedObjectsStart } from '../../../../src/plugins/saved_objects/public'; +import { PresentationUtilPluginStart } from '../../../../src/plugins/presentation_util/public'; import { getIsEnterprisePlus, registerLicensedFeatures, @@ -86,6 +87,7 @@ export interface MapsPluginStartDependencies { savedObjects: SavedObjectsStart; dashboard: DashboardStart; savedObjectsTagging?: SavedObjectTaggingPluginStart; + presentationUtil: PresentationUtilPluginStart; } /** diff --git a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx index 817fbf3656103..c0a378f38fc13 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_app/map_app.tsx @@ -52,7 +52,7 @@ import { unsavedChangesWarning, } from '../saved_map'; -interface Props { +export interface Props { savedMap: SavedMap; // saveCounter used to trigger MapApp render after SaveMap.save saveCounter: number; @@ -83,7 +83,7 @@ interface Props { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } -interface State { +export interface State { initialized: boolean; indexPatterns: IndexPattern[]; savedQuery?: SavedQuery; diff --git a/x-pack/plugins/maps/public/routes/map_page/top_nav_config.tsx b/x-pack/plugins/maps/public/routes/map_page/top_nav_config.tsx index 7010c281d24c6..803b9defe9a24 100644 --- a/x-pack/plugins/maps/public/routes/map_page/top_nav_config.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/top_nav_config.tsx @@ -16,6 +16,7 @@ import { getSavedObjectsClient, getCoreOverlays, getSavedObjectsTagging, + getPresentationUtilContext, } from '../../kibana_services'; import { checkForDuplicateTitle, @@ -185,7 +186,7 @@ export function getTopNavConfig({ defaultMessage: 'map', }), }; - + const PresentationUtilContext = getPresentationUtilContext(); const saveModal = savedMap.getOriginatingApp() || !getIsAllowByValueEmbeddables() ? ( <SavedObjectSaveModalOrigin @@ -195,14 +196,10 @@ export function getTopNavConfig({ options={tagSelector} /> ) : ( - <SavedObjectSaveModalDashboard - {...saveModalProps} - savedObjectsClient={getSavedObjectsClient()} - tagOptions={tagSelector} - /> + <SavedObjectSaveModalDashboard {...saveModalProps} tagOptions={tagSelector} /> ); - showSaveModal(saveModal, getCoreI18n().Context); + showSaveModal(saveModal, getCoreI18n().Context, PresentationUtilContext); }, }); diff --git a/x-pack/plugins/maps/public/routes/map_page/url_state/global_sync.ts b/x-pack/plugins/maps/public/routes/map_page/url_state/global_sync.ts index 7fefc6662ada7..398c05b8ed69a 100644 --- a/x-pack/plugins/maps/public/routes/map_page/url_state/global_sync.ts +++ b/x-pack/plugins/maps/public/routes/map_page/url_state/global_sync.ts @@ -30,6 +30,6 @@ export function updateGlobalState(newState: MapsGlobalState, flushUrlState = fal ...newState, }); if (flushUrlState) { - kbnUrlStateStorage.flush({ replace: true }); + kbnUrlStateStorage.kbnUrlControls.flush(true); } } diff --git a/x-pack/plugins/maps/public/url_generator.ts b/x-pack/plugins/maps/public/url_generator.ts index be6a7f5fe6fa7..7f4215f4b1275 100644 --- a/x-pack/plugins/maps/public/url_generator.ts +++ b/x-pack/plugins/maps/public/url_generator.ts @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import rison from 'rison-node'; import { TimeRange, diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json new file mode 100644 index 0000000000000..b70459c690c07 --- /dev/null +++ b/x-pack/plugins/maps/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "config.ts", + "../../../typings/**/*", + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/maps_legacy/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../maps_file_upload/tsconfig.json" }, + { "path": "../saved_objects_tagging/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/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 c5ec5571917bd..f068d62b71739 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 03c30c3c49fd3..90e8265515a16 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 4f4d9851c4957..d20ad4a368948 100644 --- a/x-pack/plugins/ml/common/util/job_utils.ts +++ b/x-pack/plugins/ml/common/util/job_utils.ts @@ -78,6 +78,18 @@ export function isTimeSeriesViewDetector(job: CombinedJob, detectorIndex: number ); } +// Returns a flag to indicate whether the specified job is suitable for embedded map viewing. +export function isMappableJob(job: CombinedJob, detectorIndex: number): boolean { + let isMappable = false; + const { detectors } = job.analysis_config; + if (detectorIndex >= 0 && detectorIndex < detectors.length) { + const dtr = detectors[detectorIndex]; + const functionName = dtr.function; + isMappable = functionName === ML_JOB_AGGREGATION.LAT_LONG; + } + return isMappable; +} + // Returns a flag to indicate whether the source data can be plotted in a time // series chart for the specified detector. export function isSourceDataChartableForDetector(job: CombinedJob, detectorIndex: number): boolean { diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index 8ec9b8ee976d4..1c47512e0b3de 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -24,7 +24,8 @@ "security", "spaces", "management", - "licenseManagement" + "licenseManagement", + "maps" ], "server": true, "ui": true, @@ -35,7 +36,8 @@ "dashboard", "savedObjects", "home", - "spaces" + "spaces", + "maps" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/ml/public/application/_index.scss b/x-pack/plugins/ml/public/application/_index.scss index 7512c180970ad..da1c226a665f6 100644 --- a/x-pack/plugins/ml/public/application/_index.scss +++ b/x-pack/plugins/ml/public/application/_index.scss @@ -30,6 +30,7 @@ @import 'components/navigation_menu/index'; @import 'components/rule_editor/index'; // SASSTODO: This file overwrites EUI directly @import 'components/stats_bar/index'; + @import 'components/ml_embedded_map/index'; // Hacks are last so they can overwrite anything above if needed @import 'hacks'; diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index d3a055f957c3a..68ee32e24391c 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -77,6 +77,8 @@ const App: FC<AppProps> = ({ coreStart, deps, appMountParams }) => { security: deps.security, licenseManagement: deps.licenseManagement, storage: localStorage, + embeddable: deps.embeddable, + maps: deps.maps, ...coreStart, }; @@ -118,6 +120,7 @@ export const renderApp = ( http: coreStart.http, security: deps.security, urlGenerators: deps.share.urlGenerators, + maps: deps.maps, }); appMountParams.onAppLeave((actions) => actions.default()); diff --git a/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js index 274a5ff0ffbb4..935f1f7f54df8 100644 --- a/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js +++ b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useEffect } from 'react'; -import { PropTypes } from 'prop-types'; +import React, { Fragment, useState, useEffect, useMemo } from 'react'; import { + htmlIdGenerator, EuiCheckbox, EuiSearchBar, EuiFlexGroup, @@ -25,6 +25,7 @@ import { EuiTableRowCellCheckbox, EuiTableHeaderMobile, } from '@elastic/eui'; +import { PropTypes } from 'prop-types'; import { Pager } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; @@ -179,6 +180,8 @@ export function CustomSelectionTable({ return indexOfUnselectedItem === -1; } + const selectAllCheckboxId = useMemo(() => htmlIdGenerator()(), []); + function renderSelectAll(mobile) { const selectAll = i18n.translate('xpack.ml.jobSelector.customTable.selectAllCheckboxLabel', { defaultMessage: 'Select all', @@ -186,7 +189,7 @@ export function CustomSelectionTable({ return ( <EuiCheckbox - id="selectAllCheckbox" + id={`${mobile ? `mobile-` : ''}${selectAllCheckboxId}`} label={mobile ? selectAll : null} checked={areAllItemsSelected()} onChange={toggleAll} diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/_index.scss b/x-pack/plugins/ml/public/application/components/ml_embedded_map/_index.scss new file mode 100644 index 0000000000000..6d0d30dae670e --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/_index.scss @@ -0,0 +1 @@ +@import 'ml_embedded_map'; diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/_ml_embedded_map.scss b/x-pack/plugins/ml/public/application/components/ml_embedded_map/_ml_embedded_map.scss new file mode 100644 index 0000000000000..495fc40ddb27c --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/_ml_embedded_map.scss @@ -0,0 +1,8 @@ +.mlEmbeddedMapContent { + width: 100%; + height: 100%; + display: flex; + flex: 1 1 100%; + z-index: 1; + min-height: 0; // Absolute must for Firefox to scroll contents +} diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/index.ts b/x-pack/plugins/ml/public/application/components/ml_embedded_map/index.ts new file mode 100644 index 0000000000000..ce5dfb5171a6d --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { MlEmbeddedMapComponent } from './ml_embedded_map'; diff --git a/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx new file mode 100644 index 0000000000000..12c7d6ac69bb1 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/ml_embedded_map/ml_embedded_map.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useRef, useState } from 'react'; + +import { htmlIdGenerator } from '@elastic/eui'; +import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { INITIAL_LOCATION } from '../../../../../maps/common/constants'; +import { + MapEmbeddable, + MapEmbeddableInput, + MapEmbeddableOutput, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../maps/public/embeddable'; +import { MAP_SAVED_OBJECT_TYPE, RenderTooltipContentParams } from '../../../../../maps/public'; +import { + EmbeddableFactory, + ErrorEmbeddable, + isErrorEmbeddable, + ViewMode, +} from '../../../../../../../src/plugins/embeddable/public'; +import { useMlKibana } from '../../contexts/kibana'; + +export function MlEmbeddedMapComponent({ + layerList, + mapEmbeddableInput, + renderTooltipContent, +}: { + layerList: LayerDescriptor[]; + mapEmbeddableInput?: MapEmbeddableInput; + renderTooltipContent?: (params: RenderTooltipContentParams) => JSX.Element; +}) { + const [embeddable, setEmbeddable] = useState<ErrorEmbeddable | MapEmbeddable | undefined>(); + + const embeddableRoot: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(null); + const baseLayers = useRef<LayerDescriptor[]>(); + + const { + services: { embeddable: embeddablePlugin, maps: mapsPlugin }, + } = useMlKibana(); + + const factory: + | EmbeddableFactory<MapEmbeddableInput, MapEmbeddableOutput, MapEmbeddable> + | undefined = embeddablePlugin + ? embeddablePlugin.getEmbeddableFactory(MAP_SAVED_OBJECT_TYPE) + : undefined; + + // Update the layer list with updated geo points upon refresh + useEffect(() => { + async function updateIndexPatternSearchLayer() { + if ( + embeddable && + !isErrorEmbeddable(embeddable) && + Array.isArray(layerList) && + Array.isArray(baseLayers.current) + ) { + embeddable.setLayerList([...baseLayers.current, ...layerList]); + } + } + updateIndexPatternSearchLayer(); + }, [embeddable, layerList]); + + useEffect(() => { + async function setupEmbeddable() { + if (!factory) { + // eslint-disable-next-line no-console + console.error('Map embeddable not found.'); + return; + } + const input: MapEmbeddableInput = { + id: htmlIdGenerator()(), + attributes: { title: '' }, + filters: [], + hidePanelTitles: true, + refreshConfig: { + value: 0, + pause: false, + }, + viewMode: ViewMode.VIEW, + isLayerTOCOpen: false, + hideFilterActions: true, + // can use mapSettings to center map on anomalies + mapSettings: { + disableInteractive: false, + hideToolbarOverlay: false, + hideLayerControl: false, + hideViewControl: false, + initialLocation: INITIAL_LOCATION.AUTO_FIT_TO_BOUNDS, // this will startup based on data-extent + autoFitToDataBounds: true, // this will auto-fit when there are changes to the filter and/or query + }, + }; + + const embeddableObject = await factory.create(input); + + if (embeddableObject && !isErrorEmbeddable(embeddableObject)) { + const basemapLayerDescriptor = mapsPlugin + ? await mapsPlugin.createLayerDescriptors.createBasemapLayerDescriptor() + : null; + + if (basemapLayerDescriptor) { + baseLayers.current = [basemapLayerDescriptor]; + await embeddableObject.setLayerList(baseLayers.current); + } + } + + setEmbeddable(embeddableObject); + } + + setupEmbeddable(); + // we want this effect to execute exactly once after the component mounts + }, []); + + useEffect(() => { + if (embeddable && !isErrorEmbeddable(embeddable) && mapEmbeddableInput !== undefined) { + embeddable.updateInput(mapEmbeddableInput); + } + }, [embeddable, mapEmbeddableInput]); + + useEffect(() => { + if (embeddable && !isErrorEmbeddable(embeddable) && renderTooltipContent !== undefined) { + embeddable.setRenderTooltipContent(renderTooltipContent); + } + }, [embeddable, renderTooltipContent]); + + // We can only render after embeddable has already initialized + useEffect(() => { + if (embeddableRoot.current && embeddable) { + embeddable.render(embeddableRoot.current); + } + }, [embeddable, embeddableRoot]); + + if (!embeddablePlugin) { + // eslint-disable-next-line no-console + console.error('Embeddable start plugin not found'); + return null; + } + if (!mapsPlugin) { + // eslint-disable-next-line no-console + console.error('Maps start plugin not found'); + return null; + } + + return ( + <div + data-test-subj="mlEmbeddedMapContent" + className="mlEmbeddedMapContent" + ref={embeddableRoot} + /> + ); +} diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index 4a7550788db56..a97fd513c9c99 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -15,12 +15,16 @@ import { LicenseManagementUIPluginSetup } from '../../../../../license_managemen import { SharePluginStart } from '../../../../../../../src/plugins/share/public'; import { MlServicesContext } from '../../app'; import { IStorageWrapper } from '../../../../../../../src/plugins/kibana_utils/public'; +import type { EmbeddableStart } from '../../../../../../../src/plugins/embeddable/public'; +import { MapsStartApi } from '../../../../../maps/public'; interface StartPlugins { data: DataPublicPluginStart; security?: SecurityPluginSetup; licenseManagement?: LicenseManagementUIPluginSetup; share: SharePluginStart; + embeddable: EmbeddableStart; + maps?: MapsStartApi; } export type StartServices = CoreStart & StartPlugins & { diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx index 77f31ae9c2322..a8d8eb18776ec 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/file_based_expanded_row.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { BooleanContent, DateContent, - GeoPointContent, IpContent, KeywordContent, OtherContent, TextContent, NumberContent, } from '../../../stats_table/components/field_data_expanded_row'; +import { GeoPointContent } from './geo_point_content/geo_point_content'; import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; import type { FileBasedFieldVisConfig } from '../../../stats_table/types/field_vis_config'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts new file mode 100644 index 0000000000000..8a442e345014d --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/format_utils.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Feature, Point } from 'geojson'; +import { euiPaletteColorBlind } from '@elastic/eui'; +import { DEFAULT_GEO_REGEX } from './geo_point_content'; +import { SOURCE_TYPES } from '../../../../../../../../maps/common/constants'; + +export const convertWKTGeoToLonLat = ( + value: string | number +): { lat: number; lon: number } | undefined => { + if (typeof value === 'string') { + const trimmedValue = value.trim().replace('POINT (', '').replace(')', ''); + const regExpSerializer = DEFAULT_GEO_REGEX; + const parsed = regExpSerializer.exec(trimmedValue.trim()); + + if (parsed?.groups?.lat != null && parsed?.groups?.lon != null) { + return { + lat: parseFloat(parsed.groups.lat.trim()), + lon: parseFloat(parsed.groups.lon.trim()), + }; + } + } +}; + +export const DEFAULT_POINT_COLOR = euiPaletteColorBlind()[0]; +export const getGeoPointsLayer = ( + features: Array<Feature<Point>>, + pointColor: string = DEFAULT_POINT_COLOR +) => { + return { + id: 'geo_points', + label: 'Geo points', + sourceDescriptor: { + type: SOURCE_TYPES.GEOJSON_FILE, + __featureCollection: { + features, + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: 'STATIC', + options: { + color: pointColor, + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx new file mode 100644 index 0000000000000..a498b786229bb --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/geo_point_content.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useMemo } from 'react'; + +import { EuiFlexItem } from '@elastic/eui'; +import { Feature, Point } from 'geojson'; +import type { FieldDataRowProps } from '../../../../stats_table/types/field_data_row'; +import { DocumentStatsTable } from '../../../../stats_table/components/field_data_expanded_row/document_stats'; +import { MlEmbeddedMapComponent } from '../../../../../components/ml_embedded_map'; +import { convertWKTGeoToLonLat, getGeoPointsLayer } from './format_utils'; +import { ExpandedRowContent } from '../../../../stats_table/components/field_data_expanded_row/expanded_row_content'; +import { ExamplesList } from '../../../../index_based/components/field_data_row/examples_list'; + +export const DEFAULT_GEO_REGEX = RegExp('(?<lat>.+) (?<lon>.+)'); + +export const GeoPointContent: FC<FieldDataRowProps> = ({ config }) => { + const formattedResults = useMemo(() => { + const { stats } = config; + + if (stats === undefined || stats.topValues === undefined) return null; + if (Array.isArray(stats.topValues)) { + const geoPointsFeatures: Array<Feature<Point>> = []; + + // reformatting the top values from POINT (-2.359207 51.37837) to (-2.359207, 51.37837) + const formattedExamples = []; + + for (let i = 0; i < stats.topValues.length; i++) { + const value = stats.topValues[i]; + const coordinates = convertWKTGeoToLonLat(value.key); + if (coordinates) { + const formattedGeoPoint = `(${coordinates.lat}, ${coordinates.lon})`; + formattedExamples.push(coordinates); + + geoPointsFeatures.push({ + type: 'Feature', + id: `ml-${config.fieldName}-${i}`, + geometry: { + type: 'Point', + coordinates: [coordinates.lat, coordinates.lon], + }, + properties: { + value: formattedGeoPoint, + count: value.doc_count, + }, + }); + } + } + + if (geoPointsFeatures.length > 0) { + return { + examples: formattedExamples, + layerList: [getGeoPointsLayer(geoPointsFeatures)], + }; + } + } + }, [config]); + return ( + <ExpandedRowContent dataTestSubj={'mlDVGeoPointContent'}> + <DocumentStatsTable config={config} /> + {formattedResults && Array.isArray(formattedResults.examples) && ( + <EuiFlexItem> + <ExamplesList examples={formattedResults.examples} /> + </EuiFlexItem> + )} + {formattedResults && Array.isArray(formattedResults.layerList) && ( + <EuiFlexItem + className={'mlDataVisualizerMapWrapper'} + data-test-subj={'mlDataVisualizerEmbeddedMap'} + > + <MlEmbeddedMapComponent layerList={formattedResults.layerList} /> + </EuiFlexItem> + )} + </ExpandedRowContent> + ); +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/index.ts new file mode 100644 index 0000000000000..d3a50db45ec57 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/expanded_row/geo_point_content/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { GeoPointContent } from './geo_point_content'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/_index.scss b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/_index.scss index 957ca0eec24d3..c1191ad270e4c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/_index.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/file_datavisualizer_view/_index.scss @@ -1 +1 @@ -@import 'file_datavisualizer_view' +@import 'file_datavisualizer_view'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx index 7018f73ff6c32..6d58efee36f36 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/expanded_row.tsx @@ -6,23 +6,31 @@ import React from 'react'; +import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; +import { LoadingIndicator } from '../field_data_row/loading_indicator'; +import { NotInDocsContent } from '../field_data_row/content_types'; import { FieldVisConfig } from '../../../stats_table/types'; import { BooleanContent, DateContent, - GeoPointContent, IpContent, KeywordContent, NumberContent, OtherContent, TextContent, } from '../../../stats_table/components/field_data_expanded_row'; +import { CombinedQuery, GeoPointContent } from './geo_point_content'; +import { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; -import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; -import { LoadingIndicator } from '../field_data_row/loading_indicator'; -import { NotInDocsContent } from '../field_data_row/content_types'; - -export const IndexBasedDataVisualizerExpandedRow = ({ item }: { item: FieldVisConfig }) => { +export const IndexBasedDataVisualizerExpandedRow = ({ + item, + indexPattern, + combinedQuery, +}: { + item: FieldVisConfig; + indexPattern: IndexPattern | undefined; + combinedQuery: CombinedQuery; +}) => { const config = item; const { loading, type, existsInDocs, fieldName } = config; @@ -42,7 +50,13 @@ export const IndexBasedDataVisualizerExpandedRow = ({ item }: { item: FieldVisCo return <DateContent config={config} />; case ML_JOB_FIELD_TYPES.GEO_POINT: - return <GeoPointContent config={config} />; + return ( + <GeoPointContent + config={config} + indexPattern={indexPattern} + combinedQuery={combinedQuery} + /> + ); case ML_JOB_FIELD_TYPES.IP: return <IpContent config={config} />; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx new file mode 100644 index 0000000000000..94db5d1ba686c --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/expanded_row/geo_point_content.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useEffect, useState } from 'react'; + +import { EuiFlexItem } from '@elastic/eui'; +import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; +import { FieldVisConfig } from '../../../stats_table/types'; +import { IndexPattern } from '../../../../../../../../../src/plugins/data/common/index_patterns/index_patterns'; +import { MlEmbeddedMapComponent } from '../../../../components/ml_embedded_map'; +import { ML_JOB_FIELD_TYPES } from '../../../../../../common/constants/field_types'; +import { ES_GEO_FIELD_TYPE } from '../../../../../../../maps/common/constants'; +import { LayerDescriptor } from '../../../../../../../maps/common/descriptor_types'; +import { useMlKibana } from '../../../../contexts/kibana'; +import { DocumentStatsTable } from '../../../stats_table/components/field_data_expanded_row/document_stats'; +import { ExpandedRowContent } from '../../../stats_table/components/field_data_expanded_row/expanded_row_content'; + +export interface CombinedQuery { + searchString: string | { [key: string]: any }; + searchQueryLanguage: string; +} +export const GeoPointContent: FC<{ + config: FieldVisConfig; + indexPattern: IndexPattern | undefined; + combinedQuery: CombinedQuery; +}> = ({ config, indexPattern, combinedQuery }) => { + const { stats } = config; + const [layerList, setLayerList] = useState<LayerDescriptor[]>([]); + const { + services: { maps: mapsPlugin }, + } = useMlKibana(); + + // Update the layer list with updated geo points upon refresh + useEffect(() => { + async function updateIndexPatternSearchLayer() { + if ( + indexPattern?.id !== undefined && + config !== undefined && + config.fieldName !== undefined && + config.type === ML_JOB_FIELD_TYPES.GEO_POINT + ) { + const params = { + indexPatternId: indexPattern.id, + geoFieldName: config.fieldName, + geoFieldType: config.type as ES_GEO_FIELD_TYPE.GEO_POINT, + query: { + query: combinedQuery.searchString, + language: combinedQuery.searchQueryLanguage, + }, + }; + const searchLayerDescriptor = mapsPlugin + ? await mapsPlugin.createLayerDescriptors.createESSearchSourceLayerDescriptor(params) + : null; + if (searchLayerDescriptor) { + setLayerList([...layerList, searchLayerDescriptor]); + } + } + } + updateIndexPatternSearchLayer(); + }, [indexPattern, config.fieldName, combinedQuery]); + + if (stats?.examples === undefined) return null; + return ( + <ExpandedRowContent dataTestSubj={'mlDVIndexBasedMapContent'}> + <DocumentStatsTable config={config} /> + + <EuiFlexItem> + <ExamplesList examples={stats.examples} /> + </EuiFlexItem> + <EuiFlexItem className={'mlDataVisualizerMapWrapper'}> + <MlEmbeddedMapComponent layerList={layerList} /> + </EuiFlexItem> + </ExpandedRowContent> + ); +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/examples_list/examples_list.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/examples_list/examples_list.tsx index 1e8f7586258d5..a59c6e0fe4183 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/examples_list/examples_list.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/examples_list/examples_list.tsx @@ -15,25 +15,29 @@ interface Props { } export const ExamplesList: FC<Props> = ({ examples }) => { - if ( - examples === undefined || - examples === null || - !Array.isArray(examples) || - examples.length === 0 - ) { + if (examples === undefined || examples === null || !Array.isArray(examples)) { return null; } - - const examplesContent = examples.map((example, i) => { - return ( - <EuiListGroupItem - className="mlFieldDataCard__codeContent" - size="s" - key={`example_${i}`} - label={typeof example === 'string' ? example : JSON.stringify(example)} + let examplesContent; + if (examples.length === 0) { + examplesContent = ( + <FormattedMessage + id="xpack.ml.fieldDataCard.examplesList.noExamplesMessage" + defaultMessage="No examples were obtained for this field" /> ); - }); + } else { + examplesContent = examples.map((example, i) => { + return ( + <EuiListGroupItem + className="mlFieldDataCard__codeContent" + size="s" + key={`example_${i}`} + label={typeof example === 'string' ? example : JSON.stringify(example)} + /> + ); + }); + } return ( <div data-test-subj="mlFieldDataExamplesList"> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/top_values/top_values.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/top_values/top_values.tsx index bd83aaa1f6149..8ad48a3e60d3a 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/top_values/top_values.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/components/field_data_row/top_values/top_values.tsx @@ -19,9 +19,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import classNames from 'classnames'; import { kibanaFieldFormat } from '../../../../../formatters/kibana_field_format'; import { roundToDecimalPlace } from '../../../../../formatters/round_to_decimal_place'; +import { ExpandedRowFieldHeader } from '../../../../stats_table/components/expanded_row_field_header'; +import { FieldVisStats } from '../../../../stats_table/types'; interface Props { - stats: any; + stats: FieldVisStats | undefined; fieldFormat?: any; barColor?: 'primary' | 'secondary' | 'danger' | 'subdued' | 'accent'; compressed?: boolean; @@ -37,6 +39,7 @@ function getPercentLabel(docCount: number, topValuesSampleSize: number): string } export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed }) => { + if (stats === undefined) return null; const { topValues, topValuesSampleSize, @@ -46,51 +49,64 @@ export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed } = stats; const progressBarMax = isTopValuesSampled === true ? topValuesSampleSize : count; return ( - <div data-test-subj="mlFieldDataTopValues" className={'mlFieldDataTopValuesContainer'}> - {Array.isArray(topValues) && - topValues.map((value: any) => ( - <EuiFlexGroup gutterSize="xs" alignItems="center" key={value.key}> - <EuiFlexItem - grow={false} - className={classNames( - 'eui-textTruncate', - 'mlTopValuesValueLabelContainer', - `mlTopValuesValueLabelContainer--${compressed === true ? 'small' : 'large'}` + <EuiFlexItem data-test-subj={'mlTopValues'}> + <ExpandedRowFieldHeader> + <FormattedMessage id="xpack.ml.fieldDataCard.topValuesLabel" defaultMessage="Top values" /> + </ExpandedRowFieldHeader> + + <div data-test-subj="mlFieldDataTopValues" className={'mlFieldDataTopValuesContainer'}> + {Array.isArray(topValues) && + topValues.map((value) => ( + <EuiFlexGroup gutterSize="xs" alignItems="center" key={value.key}> + <EuiFlexItem + grow={false} + className={classNames( + 'eui-textTruncate', + 'mlTopValuesValueLabelContainer', + `mlTopValuesValueLabelContainer--${compressed === true ? 'small' : 'large'}` + )} + > + <EuiToolTip content={kibanaFieldFormat(value.key, fieldFormat)} position="right"> + <EuiText size="xs" textAlign={'right'} color="subdued"> + {kibanaFieldFormat(value.key, fieldFormat)} + </EuiText> + </EuiToolTip> + </EuiFlexItem> + <EuiFlexItem data-test-subj="mlFieldDataTopValueBar"> + <EuiProgress + value={value.doc_count} + max={progressBarMax} + color={barColor} + size="m" + /> + </EuiFlexItem> + {progressBarMax !== undefined && ( + <EuiFlexItem + grow={false} + className={classNames('eui-textTruncate', 'mlTopValuesPercentLabelContainer')} + > + <EuiText size="xs" textAlign="left" color="subdued"> + {getPercentLabel(value.doc_count, progressBarMax)} + </EuiText> + </EuiFlexItem> )} - > - <EuiToolTip content={kibanaFieldFormat(value.key, fieldFormat)} position="right"> - <EuiText size="xs" textAlign={'right'} color="subdued"> - {kibanaFieldFormat(value.key, fieldFormat)} - </EuiText> - </EuiToolTip> - </EuiFlexItem> - <EuiFlexItem data-test-subj="mlFieldDataTopValueBar"> - <EuiProgress value={value.doc_count} max={progressBarMax} color={barColor} size="m" /> - </EuiFlexItem> - <EuiFlexItem - grow={false} - className={classNames('eui-textTruncate', 'mlTopValuesPercentLabelContainer')} - > - <EuiText size="xs" textAlign="left" color="subdued"> - {getPercentLabel(value.doc_count, progressBarMax)} - </EuiText> - </EuiFlexItem> - </EuiFlexGroup> - ))} - {isTopValuesSampled === true && ( - <Fragment> - <EuiSpacer size="xs" /> - <EuiText size="xs" textAlign={'left'}> - <FormattedMessage - id="xpack.ml.fieldDataCard.topValues.calculatedFromSampleDescription" - defaultMessage="Calculated from sample of {topValuesSamplerShardSize} documents per shard" - values={{ - topValuesSamplerShardSize, - }} - /> - </EuiText> - </Fragment> - )} - </div> + </EuiFlexGroup> + ))} + {isTopValuesSampled === true && ( + <Fragment> + <EuiSpacer size="xs" /> + <EuiText size="xs" textAlign={'left'}> + <FormattedMessage + id="xpack.ml.fieldDataCard.topValues.calculatedFromSampleDescription" + defaultMessage="Calculated from sample of {topValuesSamplerShardSize} documents per shard" + values={{ + topValuesSamplerShardSize, + }} + /> + </EuiText> + </Fragment> + )} + </div> + </EuiFlexItem> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index e5b243d524034..8cf5e28ad3b5b 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useMemo, useState } from 'react'; +import React, { FC, Fragment, useEffect, useMemo, useState, useCallback } from 'react'; import { merge } from 'rxjs'; import { EuiFlexGroup, @@ -112,19 +112,6 @@ export const getDefaultDataVisualizerListState = (): Required<DataVisualizerInde showEmptyFields: false, }); -function getItemIdToExpandedRowMap( - itemIds: string[], - items: FieldVisConfig[] -): ItemIdToExpandedRowMap { - return itemIds.reduce((m: ItemIdToExpandedRowMap, fieldName: string) => { - const item = items.find((fieldVisConfig) => fieldVisConfig.fieldName === fieldName); - if (item !== undefined) { - m[fieldName] = <IndexBasedDataVisualizerExpandedRow item={item} />; - } - return m; - }, {} as ItemIdToExpandedRowMap); -} - export const Page: FC = () => { const mlContext = useMlContext(); const restorableDefaults = getDefaultDataVisualizerListState(); @@ -678,6 +665,26 @@ export const Page: FC = () => { } return { visibleFieldsCount: _visibleFieldsCount, totalFieldsCount: _totalFieldsCount }; }, [overallStats, showEmptyFields]); + + const getItemIdToExpandedRowMap = useCallback( + function (itemIds: string[], items: FieldVisConfig[]): ItemIdToExpandedRowMap { + return itemIds.reduce((m: ItemIdToExpandedRowMap, fieldName: string) => { + const item = items.find((fieldVisConfig) => fieldVisConfig.fieldName === fieldName); + if (item !== undefined) { + m[fieldName] = ( + <IndexBasedDataVisualizerExpandedRow + item={item} + indexPattern={currentIndexPattern} + combinedQuery={{ searchQueryLanguage, searchString }} + /> + ); + } + return m; + }, {} as ItemIdToExpandedRowMap); + }, + [currentIndexPattern, searchQuery] + ); + const { services: { docLinks }, } = useMlKibana(); diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_field_data_row.scss b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_field_data_row.scss index 2ab26f1564a1f..1832b0f78b895 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_field_data_row.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_field_data_row.scss @@ -60,6 +60,21 @@ @include euiCodeFont; } + .mlFieldDataCard__geoContent { + z-index: auto; + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + position: relative; + .embPanel__content { + display: flex; + flex: 1 1 100%; + z-index: 1; + min-height: 0; // Absolute must for Firefox to scroll contents + } + } + .mlFieldDataCard__stats { padding: $euiSizeS $euiSizeS 0 $euiSizeS; text-align: center; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_index.scss b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_index.scss index 6e7e66db9e03a..9e838c180713f 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_index.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/_index.scss @@ -1,5 +1,6 @@ -@import 'components/field_data_expanded_row/number_content'; +@import 'components/field_data_expanded_row/index'; @import 'components/field_count_stats/index'; +@import 'components/field_data_row/index'; .mlDataVisualizerFieldExpandedRow { padding-left: $euiSize * 4; @@ -37,6 +38,7 @@ } .mlDataVisualizerSummaryTable { max-width: 350px; + min-width: 250px; .euiTableRow > .euiTableRowCell { border-bottom: 0; } @@ -45,6 +47,10 @@ } } .mlDataVisualizerSummaryTableWrapper { - max-width: 350px; + max-width: 300px; + } + .mlDataVisualizerMapWrapper { + min-height: 300px; + min-width: 600px; } } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/_index.scss b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/_index.scss index fdc591a140fea..799beec093cca 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/_index.scss +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/_index.scss @@ -1 +1,7 @@ @import 'number_content'; + +.mlDataVisualizerExpandedRow { + @include euiBreakpoint('xs', 's', 'm') { + flex-direction: column; + } +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/boolean_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/boolean_content.tsx index a75920dd09b34..70e98153fd55c 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/boolean_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/boolean_content.tsx @@ -5,7 +5,7 @@ */ import React, { FC, ReactNode, useMemo } from 'react'; -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiBasicTable, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { Axis, BarSeries, Chart, Settings } from '@elastic/charts'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -16,6 +16,7 @@ import { getTFPercentage } from '../../utils'; import { roundToDecimalPlace } from '../../../../formatters/round_to_decimal_place'; import { useDataVizChartTheme } from '../../hooks'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; function getPercentLabel(value: number): string { if (value === 0) { @@ -85,7 +86,7 @@ export const BooleanContent: FC<FieldDataRowProps> = ({ config }) => { ); return ( - <EuiFlexGroup data-test-subj={'mlDVBooleanContent'} gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVBooleanContent'}> <DocumentStatsTable config={config} /> <EuiFlexItem className={'mlDataVisualizerSummaryTableWrapper'}> @@ -138,6 +139,6 @@ export const BooleanContent: FC<FieldDataRowProps> = ({ config }) => { /> </Chart> </EuiFlexItem> - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/date_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/date_content.tsx index 8d122df628381..a6e7901df4e8e 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/date_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/date_content.tsx @@ -5,7 +5,7 @@ */ import React, { FC, ReactNode } from 'react'; -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiBasicTable, EuiFlexItem } from '@elastic/eui'; // @ts-ignore import { formatDate } from '@elastic/eui/lib/services/format'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -14,6 +14,7 @@ import { i18n } from '@kbn/i18n'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; const TIME_FORMAT = 'MMM D YYYY, HH:mm:ss.SSS'; interface SummaryTableItem { function: string; @@ -66,7 +67,7 @@ export const DateContent: FC<FieldDataRowProps> = ({ config }) => { ]; return ( - <EuiFlexGroup data-test-subj={'mlDVDateContent'} gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVDateContent'}> <DocumentStatsTable config={config} /> <EuiFlexItem className={'mlDataVisualizerSummaryTableWrapper'}> <ExpandedRowFieldHeader>{summaryTableTitle}</ExpandedRowFieldHeader> @@ -80,6 +81,6 @@ export const DateContent: FC<FieldDataRowProps> = ({ config }) => { tableLayout="auto" /> </EuiFlexItem> - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/document_stats.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/document_stats.tsx index 177ac722166f7..fd23e9d51bf4e 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/document_stats.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/document_stats.tsx @@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiFlexItem } from '@elastic/eui'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { FieldDataRowProps } from '../../types'; +import { roundToDecimalPlace } from '../../../../formatters/round_to_decimal_place'; const metaTableColumns = [ { @@ -59,7 +60,7 @@ export const DocumentStatsTable: FC<FieldDataRowProps> = ({ config }) => { defaultMessage="percentage" /> ), - value: `${(count / sampleCount) * 100}%`, + value: `${roundToDecimalPlace((count / sampleCount) * 100)}%`, }, { function: 'distinctValues', diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/expanded_row_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/expanded_row_content.tsx new file mode 100644 index 0000000000000..6c14bd7a64a94 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/expanded_row_content.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, ReactNode } from 'react'; +import { EuiFlexGroup } from '@elastic/eui'; + +interface Props { + children: ReactNode; + dataTestSubj: string; +} +export const ExpandedRowContent: FC<Props> = ({ children, dataTestSubj }) => { + return ( + <EuiFlexGroup + data-test-subj={dataTestSubj} + gutterSize={'xl'} + className={'mlDataVisualizerExpandedRow'} + > + {children} + </EuiFlexGroup> + ); +}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/geo_point_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/geo_point_content.tsx deleted file mode 100644 index 993c7a94f5e06..0000000000000 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/geo_point_content.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC } from 'react'; - -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import type { FieldDataRowProps } from '../../types/field_data_row'; -import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; -import { DocumentStatsTable } from './document_stats'; -import { TopValues } from '../../../index_based/components/field_data_row/top_values'; - -export const GeoPointContent: FC<FieldDataRowProps> = ({ config }) => { - const { stats } = config; - if (stats === undefined || (stats?.examples === undefined && stats?.topValues === undefined)) - return null; - - return ( - <EuiFlexGroup data-test-subj={'mlDVGeoPointContent'} gutterSize={'xl'}> - <DocumentStatsTable config={config} /> - {Array.isArray(stats.examples) && ( - <EuiFlexItem> - <ExamplesList examples={stats.examples!} /> - </EuiFlexItem> - )} - {Array.isArray(stats.topValues) && ( - <EuiFlexItem> - <TopValues stats={stats} barColor="secondary" /> - </EuiFlexItem> - )} - </EuiFlexGroup> - ); -}; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/index.ts index c6cd50f6bc2e9..b9b1e181343b7 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/index.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/index.ts @@ -6,7 +6,7 @@ export { BooleanContent } from './boolean_content'; export { DateContent } from './date_content'; -export { GeoPointContent } from './geo_point_content'; +export { GeoPointContent } from '../../../file_based/components/expanded_row/geo_point_content/geo_point_content'; export { KeywordContent } from './keyword_content'; export { IpContent } from './ip_content'; export { NumberContent } from './number_content'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/ip_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/ip_content.tsx index 79492bb44a2dc..183268d6a7ef8 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/ip_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/ip_content.tsx @@ -5,14 +5,10 @@ */ import React, { FC } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; - -import { FormattedMessage } from '@kbn/i18n/react'; - import type { FieldDataRowProps } from '../../types/field_data_row'; import { TopValues } from '../../../index_based/components/field_data_row/top_values'; -import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; export const IpContent: FC<FieldDataRowProps> = ({ config }) => { const { stats } = config; @@ -22,17 +18,9 @@ export const IpContent: FC<FieldDataRowProps> = ({ config }) => { const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; return ( - <EuiFlexGroup gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVIPContent'}> <DocumentStatsTable config={config} /> - <EuiFlexItem> - <ExpandedRowFieldHeader> - <FormattedMessage - id="xpack.ml.fieldDataCard.cardIp.topValuesLabel" - defaultMessage="Top values" - /> - </ExpandedRowFieldHeader> - <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" /> - </EuiFlexItem> - </EuiFlexGroup> + <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" /> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx index 634f5b55513a3..d11ecc7d8804b 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/keyword_content.tsx @@ -5,31 +5,20 @@ */ import React, { FC } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { TopValues } from '../../../index_based/components/field_data_row/top_values'; -import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; export const KeywordContent: FC<FieldDataRowProps> = ({ config }) => { const { stats } = config; const fieldFormat = 'fieldFormat' in config ? config.fieldFormat : undefined; return ( - <EuiFlexGroup data-test-subj={'mlDVKeywordContent'} gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVKeywordContent'}> <DocumentStatsTable config={config} /> - <EuiFlexItem> - <ExpandedRowFieldHeader> - <FormattedMessage - id="xpack.ml.fieldDataCard.cardKeyword.topValuesLabel" - defaultMessage="Top values" - /> - </ExpandedRowFieldHeader> - <EuiSpacer size="xs" /> - <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" /> - </EuiFlexItem> - </EuiFlexGroup> + <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" /> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/number_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/number_content.tsx index d05de26d3c5d4..56811e61ad891 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/number_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/number_content.tsx @@ -5,7 +5,7 @@ */ import React, { FC, ReactNode, useEffect, useState } from 'react'; -import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { EuiBasicTable, EuiFlexItem, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -20,6 +20,7 @@ import { import { TopValues } from '../../../index_based/components/field_data_row/top_values'; import { ExpandedRowFieldHeader } from '../expanded_row_field_header'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; const METRIC_DISTRIBUTION_CHART_WIDTH = 325; const METRIC_DISTRIBUTION_CHART_HEIGHT = 200; @@ -97,7 +98,7 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => { } ); return ( - <EuiFlexGroup data-test-subj={'mlDVNumberContent'} gutterSize={'xl'}> + <ExpandedRowContent dataTestSubj={'mlDVNumberContent'}> <DocumentStatsTable config={config} /> <EuiFlexItem className={'mlDataVisualizerSummaryTableWrapper'}> <ExpandedRowFieldHeader>{summaryTableTitle}</ExpandedRowFieldHeader> @@ -112,24 +113,7 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => { </EuiFlexItem> {stats && ( - <EuiFlexItem data-test-subj={'mlTopValues'}> - <EuiFlexItem grow={false}> - <ExpandedRowFieldHeader> - <FormattedMessage - id="xpack.ml.fieldDataCardExpandedRow.numberContent.topValuesTitle" - defaultMessage="Top values" - /> - </ExpandedRowFieldHeader> - </EuiFlexItem> - <EuiFlexItem> - <TopValues - stats={stats} - fieldFormat={fieldFormat} - barColor="secondary" - compressed={true} - /> - </EuiFlexItem> - </EuiFlexItem> + <TopValues stats={stats} fieldFormat={fieldFormat} barColor="secondary" compressed={true} /> )} {distribution && ( <EuiFlexItem data-test-subj={'mlMetricDistribution'}> @@ -164,6 +148,6 @@ export const NumberContent: FC<FieldDataRowProps> = ({ config }) => { </EuiFlexItem> </EuiFlexItem> )} - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/other_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/other_content.tsx index a6d7398990cd3..08884619db2d6 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/other_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/other_content.tsx @@ -5,18 +5,18 @@ */ import React, { FC } from 'react'; -import { EuiFlexGroup } from '@elastic/eui'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; import { DocumentStatsTable } from './document_stats'; +import { ExpandedRowContent } from './expanded_row_content'; export const OtherContent: FC<FieldDataRowProps> = ({ config }) => { const { stats } = config; if (stats === undefined) return null; return ( - <EuiFlexGroup gutterSize={'xl'} data-test-subj={'mlDVOtherContent'}> + <ExpandedRowContent dataTestSubj={'mlDVOtherContent'}> <DocumentStatsTable config={config} /> {Array.isArray(stats.examples) && <ExamplesList examples={stats.examples} />} - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/text_content.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/text_content.tsx index 55639ecc5761f..c0a5ca18d03b5 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/text_content.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_expanded_row/text_content.tsx @@ -5,13 +5,14 @@ */ import React, { FC, Fragment } from 'react'; -import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import type { FieldDataRowProps } from '../../types/field_data_row'; import { ExamplesList } from '../../../index_based/components/field_data_row/examples_list'; +import { ExpandedRowContent } from './expanded_row_content'; export const TextContent: FC<FieldDataRowProps> = ({ config }) => { const { stats } = config; @@ -23,7 +24,7 @@ export const TextContent: FC<FieldDataRowProps> = ({ config }) => { const numExamples = examples.length; return ( - <EuiFlexGroup gutterSize={'xl'} data-test-subj={'mlDVTextContent'}> + <ExpandedRowContent dataTestSubj={'mlDVTextContent'}> <EuiFlexItem> {numExamples > 0 && <ExamplesList examples={examples} />} {numExamples === 0 && ( @@ -59,6 +60,6 @@ export const TextContent: FC<FieldDataRowProps> = ({ config }) => { </Fragment> )} </EuiFlexItem> - </EuiFlexGroup> + </ExpandedRowContent> ); }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/_index.scss b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/_index.scss new file mode 100644 index 0000000000000..27483feb573b8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/_index.scss @@ -0,0 +1,3 @@ +.mlDataVisualizerColumnHeaderIcon { + max-width: $euiSizeM; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/distinct_values.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/distinct_values.tsx index 819d5278c0d78..44c028d1ba8b5 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/distinct_values.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/distinct_values.tsx @@ -12,7 +12,7 @@ export const DistinctValues = ({ cardinality }: { cardinality?: number }) => { if (cardinality === undefined) return null; return ( <EuiFlexGroup alignItems={'center'}> - <EuiFlexItem style={{ maxWidth: 10 }}> + <EuiFlexItem className={'mlDataVisualizerColumnHeaderIcon'}> <EuiIcon type="database" size={'s'} /> </EuiFlexItem> <EuiText size={'s'}> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/document_stats.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/document_stats.tsx index 9c89d74fa751b..b223fd5d98c1a 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/document_stats.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/document_stats.tsx @@ -21,7 +21,7 @@ export const DocumentStat = ({ config }: FieldDataRowProps) => { return ( <EuiFlexGroup alignItems={'center'}> - <EuiFlexItem style={{ maxWidth: 10 }}> + <EuiFlexItem className={'mlDataVisualizerColumnHeaderIcon'}> <EuiIcon type="document" size={'s'} /> </EuiFlexItem> <EuiText size={'s'}> diff --git a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/number_content_preview.tsx b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/number_content_preview.tsx index 3a84ae644cb4e..996fffd225f96 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/number_content_preview.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/stats_table/components/field_data_row/number_content_preview.tsx @@ -14,6 +14,7 @@ import { } from '../metric_distribution_chart'; import { formatSingleValue } from '../../../../formatters/format_value'; import { FieldVisConfig } from '../../types'; +import { kibanaFieldFormat } from '../../../../formatters/kibana_field_format'; const METRIC_DISTRIBUTION_CHART_WIDTH = 150; const METRIC_DISTRIBUTION_CHART_HEIGHT = 80; @@ -59,14 +60,16 @@ export const IndexBasedNumberContentPreview: FC<NumberContentPreviewProps> = ({ <> <EuiSpacer size="s" /> <EuiFlexGroup direction={'row'} data-test-subj={`${dataTestSubj}-legend`}> - <EuiFlexItem className={'mlDataGridChart__legend'}>{legendText.min}</EuiFlexItem> + <EuiFlexItem className={'mlDataGridChart__legend'}> + {kibanaFieldFormat(legendText.min, fieldFormat)} + </EuiFlexItem> <EuiFlexItem className={classNames( 'mlDataGridChart__legend', 'mlDataGridChart__legend--numeric' )} > - {legendText.max} + {kibanaFieldFormat(legendText.max, fieldFormat)} </EuiFlexItem> </EuiFlexGroup> </> diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx new file mode 100644 index 0000000000000..fc1621e962f36 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_embedded_map.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { Dictionary } from '../../../../common/types/common'; +import { LayerDescriptor } from '../../../../../maps/common/descriptor_types'; +import { getMLAnomaliesActualLayer, getMLAnomaliesTypicalLayer } from './map_config'; +import { MlEmbeddedMapComponent } from '../../components/ml_embedded_map'; +interface Props { + seriesConfig: Dictionary<any>; +} + +export function EmbeddedMapComponentWrapper({ seriesConfig }: Props) { + const [layerList, setLayerList] = useState<LayerDescriptor[]>([]); + + useEffect(() => { + if (seriesConfig.mapData && seriesConfig.mapData.length > 0) { + setLayerList([ + getMLAnomaliesActualLayer(seriesConfig.mapData), + getMLAnomaliesTypicalLayer(seriesConfig.mapData), + ]); + } + }, [seriesConfig]); + + return ( + <div data-test-subj="xpack.ml.explorer.embeddedMap" style={{ width: '100%', height: 300 }}> + <MlEmbeddedMapComponent layerList={layerList} /> + </div> + ); +} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 774372f678c9b..9921b5f991844 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -22,6 +22,7 @@ import { } from '../../util/chart_utils'; import { ExplorerChartDistribution } from './explorer_chart_distribution'; import { ExplorerChartSingleMetric } from './explorer_chart_single_metric'; +import { EmbeddedMapComponentWrapper } from './explorer_chart_embedded_map'; import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; @@ -30,6 +31,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MlTooltipComponent } from '../../components/chart_tooltip'; import { withKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ML_APP_URL_GENERATOR } from '../../../../common/constants/ml_url_generator'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { addItemToRecentlyAccessed } from '../../util/recently_accessed'; import { ExplorerChartsErrorCallOuts } from './explorer_charts_error_callouts'; @@ -43,6 +45,9 @@ const textViewButton = i18n.translate( defaultMessage: 'Open in Single Metric Viewer', } ); +const mapsPluginMessage = i18n.translate('xpack.ml.explorer.charts.mapsPluginMissingMessage', { + defaultMessage: 'maps or embeddable start plugin not found', +}); // create a somewhat unique ID // from charts metadata for React's key attribute @@ -67,8 +72,8 @@ function ExplorerChartContainer({ useEffect(() => { let isCancelled = false; const generateLink = async () => { - const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); - if (!isCancelled) { + if (!isCancelled && series.functionDescription !== ML_JOB_AGGREGATION.LAT_LONG) { + const singleMetricViewerLink = await getExploreSeriesLink(mlUrlGenerator, series); setExplorerSeriesLink(singleMetricViewerLink); } }; @@ -150,6 +155,18 @@ function ExplorerChartContainer({ </EuiFlexItem> </EuiFlexGroup> {(() => { + if (chartType === CHART_TYPE.GEO_MAP) { + return ( + <MlTooltipComponent> + {(tooltipService) => ( + <EmbeddedMapComponentWrapper + seriesConfig={series} + tooltipService={tooltipService} + /> + )} + </MlTooltipComponent> + ); + } if ( chartType === CHART_TYPE.EVENT_DISTRIBUTION || chartType === CHART_TYPE.POPULATION_DISTRIBUTION @@ -167,18 +184,20 @@ function ExplorerChartContainer({ </MlTooltipComponent> ); } - return ( - <MlTooltipComponent> - {(tooltipService) => ( - <ExplorerChartSingleMetric - tooManyBuckets={tooManyBuckets} - seriesConfig={series} - severity={severity} - tooltipService={tooltipService} - /> - )} - </MlTooltipComponent> - ); + if (chartType === CHART_TYPE.SINGLE_METRIC) { + return ( + <MlTooltipComponent> + {(tooltipService) => ( + <ExplorerChartSingleMetric + tooManyBuckets={tooManyBuckets} + seriesConfig={series} + severity={severity} + tooltipService={tooltipService} + /> + )} + </MlTooltipComponent> + ); + } })()} </React.Fragment> ); @@ -199,8 +218,31 @@ export const ExplorerChartsContainerUI = ({ share: { urlGenerators: { getUrlGenerator }, }, + embeddable: embeddablePlugin, + maps: mapsPlugin, }, } = kibana; + + let seriesToPlotFiltered; + + if (!embeddablePlugin || !mapsPlugin) { + seriesToPlotFiltered = []; + // Show missing plugin callout + seriesToPlot.forEach((series) => { + if (series.functionDescription === 'lat_long') { + if (errorMessages[mapsPluginMessage] === undefined) { + errorMessages[mapsPluginMessage] = new Set([series.jobId]); + } else { + errorMessages[mapsPluginMessage].add(series.jobId); + } + } else { + seriesToPlotFiltered.push(series); + } + }); + } + + const seriesToUse = seriesToPlotFiltered !== undefined ? seriesToPlotFiltered : seriesToPlot; + const mlUrlGenerator = useMemo(() => getUrlGenerator(ML_APP_URL_GENERATOR), [getUrlGenerator]); // <EuiFlexGrid> doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. @@ -208,13 +250,13 @@ export const ExplorerChartsContainerUI = ({ const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow; - const wrapLabel = seriesToPlot.some((series) => isLabelLengthAboveThreshold(series)); + const wrapLabel = seriesToUse.some((series) => isLabelLengthAboveThreshold(series)); return ( <> <ExplorerChartsErrorCallOuts errorMessagesByType={errorMessages} /> <EuiFlexGrid columns={chartsColumns}> - {seriesToPlot.length > 0 && - seriesToPlot.map((series) => ( + {seriesToUse.length > 0 && + seriesToUse.map((series) => ( <EuiFlexItem key={getChartId(series)} className="ml-explorer-chart-container" diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js index 3dc1c0234584d..077e60db4760a 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js @@ -22,12 +22,14 @@ import { isSourceDataChartableForDetector, isModelPlotChartableForDetector, isModelPlotEnabled, + isMappableJob, } from '../../../../common/util/job_utils'; import { mlResultsService } from '../../services/results_service'; import { mlJobService } from '../../services/job_service'; import { explorerService } from '../explorer_dashboard_service'; import { CHART_TYPE } from '../explorer_constants'; +import { ML_JOB_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { i18n } from '@kbn/i18n'; import { SWIM_LANE_LABEL_WIDTH } from '../swimlane_container'; @@ -77,7 +79,50 @@ export const anomalyDataChange = function ( // For now just take first 6 (or 8 if 4 charts per row). const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6); const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); + const hasGeoData = recordsToPlot.find( + (record) => + (record.function_description || recordsToPlot.function) === ML_JOB_AGGREGATION.LAT_LONG + ); + const seriesConfigs = recordsToPlot.map(buildConfig); + const seriesConfigsNoGeoData = []; + + // initialize the charts with loading indicators + data.seriesToPlot = seriesConfigs.map((config) => ({ + ...config, + loading: true, + chartData: null, + })); + + const mapData = []; + + if (hasGeoData !== undefined) { + for (let i = 0; i < seriesConfigs.length; i++) { + const config = seriesConfigs[i]; + let records; + if (config.detectorLabel.includes(ML_JOB_AGGREGATION.LAT_LONG)) { + if (config.entityFields.length) { + records = [ + recordsToPlot.find((record) => { + const entityFieldName = config.entityFields[0].fieldName; + const entityFieldValue = config.entityFields[0].fieldValue; + return (record[entityFieldName] && record[entityFieldName][0]) === entityFieldValue; + }), + ]; + } else { + records = recordsToPlot; + } + + mapData.push({ + ...config, + loading: false, + mapData: records, + }); + } else { + seriesConfigsNoGeoData.push(config); + } + } + } // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. data.tooManyBuckets = false; @@ -92,13 +137,6 @@ export const anomalyDataChange = function ( ); data.tooManyBuckets = tooManyBuckets; - // initialize the charts with loading indicators - data.seriesToPlot = seriesConfigs.map((config) => ({ - ...config, - loading: true, - chartData: null, - })); - data.errorMessages = errorMessages; explorerService.setCharts({ ...data }); @@ -269,22 +307,27 @@ export const anomalyDataChange = function ( // only after that trigger data processing and page render. // TODO - if query returns no results e.g. source data has been deleted, // display a message saying 'No data between earliest/latest'. - const seriesPromises = seriesConfigs.map((seriesConfig) => - Promise.all([ - getMetricData(seriesConfig, chartRange), - getRecordsForCriteria(seriesConfig, chartRange), - getScheduledEvents(seriesConfig, chartRange), - getEventDistribution(seriesConfig, chartRange), - ]) - ); + const seriesPromises = []; + // Use seriesConfigs list without geo data config so indices match up after seriesPromises are resolved and we map through the responses + const seriesCongifsForPromises = hasGeoData ? seriesConfigsNoGeoData : seriesConfigs; + seriesCongifsForPromises.forEach((seriesConfig) => { + seriesPromises.push( + Promise.all([ + getMetricData(seriesConfig, chartRange), + getRecordsForCriteria(seriesConfig, chartRange), + getScheduledEvents(seriesConfig, chartRange), + getEventDistribution(seriesConfig, chartRange), + ]) + ); + }); function processChartData(response, seriesIndex) { const metricData = response[0].results; const records = response[1].records; - const jobId = seriesConfigs[seriesIndex].jobId; + const jobId = seriesCongifsForPromises[seriesIndex].jobId; const scheduledEvents = response[2].events[jobId]; const eventDistribution = response[3]; - const chartType = getChartType(seriesConfigs[seriesIndex]); + const chartType = getChartType(seriesCongifsForPromises[seriesIndex]); // Sort records in ascending time order matching up with chart data records.sort((recordA, recordB) => { @@ -409,16 +452,25 @@ export const anomalyDataChange = function ( ); const overallChartLimits = chartLimits(allDataPoints); - data.seriesToPlot = response.map((d, i) => ({ - ...seriesConfigs[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: selectedEarliestMs, - selectedLatest: selectedLatestMs, - chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i]), - })); + data.seriesToPlot = response.map((d, i) => { + return { + ...seriesCongifsForPromises[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: selectedEarliestMs, + selectedLatest: selectedLatestMs, + chartLimits: USE_OVERALL_CHART_LIMITS + ? overallChartLimits + : chartLimits(processedData[i]), + }; + }); + + if (mapData.length) { + // push map data in if it's available + data.seriesToPlot.push(...mapData); + } explorerService.setCharts({ ...data }); }) .catch((error) => { @@ -447,7 +499,10 @@ function processRecordsForDisplay(anomalyRecords) { return; } - let isChartable = isSourceDataChartableForDetector(job, record.detector_index); + let isChartable = + isSourceDataChartableForDetector(job, record.detector_index) || + isMappableJob(job, record.detector_index); + if (isChartable === false) { if (isModelPlotChartableForDetector(job, record.detector_index)) { // Check if model plot is enabled for this job. diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts new file mode 100644 index 0000000000000..451fa602315d7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/map_config.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FIELD_ORIGIN, STYLE_TYPE } from '../../../../../maps/common/constants'; +import { ANOMALY_THRESHOLD, SEVERITY_COLORS } from '../../../../common'; + +const FEATURE = 'Feature'; +const POINT = 'Point'; +const SEVERITY_COLOR_RAMP = [ + { + stop: ANOMALY_THRESHOLD.LOW, + color: SEVERITY_COLORS.WARNING, + }, + { + stop: ANOMALY_THRESHOLD.MINOR, + color: SEVERITY_COLORS.MINOR, + }, + { + stop: ANOMALY_THRESHOLD.MAJOR, + color: SEVERITY_COLORS.MAJOR, + }, + { + stop: ANOMALY_THRESHOLD.CRITICAL, + color: SEVERITY_COLORS.CRITICAL, + }, +]; + +function getAnomalyFeatures(anomalies: any[], type: 'actual_point' | 'typical_point') { + const anomalyFeatures = []; + for (let i = 0; i < anomalies.length; i++) { + const anomaly = anomalies[i]; + const geoResults = anomaly.geo_results || (anomaly?.causes && anomaly?.causes[0]?.geo_results); + const coordinateStr = geoResults && geoResults[type]; + if (coordinateStr !== undefined) { + // Must reverse coordinates here. Map expects [lon, lat] - anomalies are stored as [lat, lon] for lat_lon jobs + const coordinates = coordinateStr + .split(',') + .map((point: string) => Number(point)) + .reverse(); + + anomalyFeatures.push({ + type: FEATURE, + geometry: { + type: POINT, + coordinates, + }, + properties: { + record_score: Math.floor(anomaly.record_score), + [type]: coordinates.map((point: number) => point.toFixed(2)), + }, + }); + } + } + return anomalyFeatures; +} + +export const getMLAnomaliesTypicalLayer = (anomalies: any) => { + return { + id: 'anomalies_typical_layer', + label: 'Typical', + sourceDescriptor: { + id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854e', + type: 'GEOJSON_FILE', + __featureCollection: { + features: getAnomalyFeatures(anomalies, 'typical_point'), + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: 'STATIC', + options: { + color: '#98A2B2', + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; + +export const getMLAnomaliesActualLayer = (anomalies: any) => { + return { + id: 'anomalies_actual_layer', + label: 'Actual', + sourceDescriptor: { + id: 'b7486535-171b-4d3b-bb2e-33c1a0a2854d', + type: 'GEOJSON_FILE', + __fields: [ + { + name: 'record_score', + type: 'number', + }, + ], + __featureCollection: { + features: getAnomalyFeatures(anomalies, 'actual_point'), + type: 'FeatureCollection', + }, + }, + visible: true, + style: { + type: 'VECTOR', + properties: { + fillColor: { + type: STYLE_TYPE.DYNAMIC, + options: { + customColorRamp: SEVERITY_COLOR_RAMP, + field: { + name: 'record_score', + origin: FIELD_ORIGIN.SOURCE, + }, + useCustomColorRamp: true, + }, + }, + lineColor: { + type: 'STATIC', + options: { + color: '#fff', + }, + }, + lineWidth: { + type: 'STATIC', + options: { + size: 2, + }, + }, + iconSize: { + type: 'STATIC', + options: { + size: 6, + }, + }, + }, + }, + type: 'VECTOR', + }; +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 3f5f016fc365a..2178c837458e9 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -48,6 +48,7 @@ export const CHART_TYPE = { EVENT_DISTRIBUTION: 'event_distribution', POPULATION_DISTRIBUTION: 'population_distribution', SINGLE_METRIC: 'single_metric', + GEO_MAP: 'geo_map', }; export const MAX_CATEGORY_EXAMPLES = 10; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index f6889c9a6f24c..4ba9d4ea14f10 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -511,6 +511,7 @@ export async function loadAnomaliesTableData( const entityFields = getEntityFieldList(anomaly.source); isChartable = isModelPlotEnabled(job, anomaly.detectorIndex, entityFields); } + anomaly.isTimeSeriesViewRecord = isChartable; if (mlJobService.customUrlsByJob[jobId] !== undefined) { diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx index 280ac85a5a2bc..470fe11759d27 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/descriptions.tsx @@ -46,7 +46,7 @@ export const FieldDescription: FC = memo(({ children }) => { description={ <FormattedMessage id="xpack.ml.newJob.wizard.pickFieldsStep.advancedDetectorModal.fieldSelect.description" - defaultMessage="Required for functions: sum, mean, median, max, min, info_content, distinct_count." + defaultMessage="Required for functions: sum, mean, median, max, min, info_content, distinct_count, lat_long." /> } > diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index 514449385bf0b..3747e84f43765 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -156,7 +156,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { } body.aggs.byTime.aggs = {}; - if (metricFieldName !== undefined && metricFieldName !== '') { + + if (metricFieldName !== undefined && metricFieldName !== '' && metricFunction) { const metricAgg: any = { [metricFunction]: {}, }; diff --git a/x-pack/plugins/ml/public/application/util/chart_config_builder.js b/x-pack/plugins/ml/public/application/util/chart_config_builder.js index a30280f1220c0..a306211defc87 100644 --- a/x-pack/plugins/ml/public/application/util/chart_config_builder.js +++ b/x-pack/plugins/ml/public/application/util/chart_config_builder.js @@ -24,7 +24,10 @@ export function buildConfigFromDetector(job, detectorIndex) { const config = { jobId: job.job_id, detectorIndex: detectorIndex, - metricFunction: mlFunctionToESAggregation(detector.function), + metricFunction: + detector.function === ML_JOB_AGGREGATION.LAT_LONG + ? ML_JOB_AGGREGATION.LAT_LONG + : mlFunctionToESAggregation(detector.function), timeField: job.data_description.time_field, interval: job.analysis_config.bucket_span, datafeedConfig: job.datafeed_config, diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.js b/x-pack/plugins/ml/public/application/util/chart_utils.js index 402c922a0034f..799187cc37dfd 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.js +++ b/x-pack/plugins/ml/public/application/util/chart_utils.js @@ -176,6 +176,11 @@ const POPULATION_DISTRIBUTION_ENABLED = true; // get the chart type based on its configuration export function getChartType(config) { let chartType = CHART_TYPE.SINGLE_METRIC; + + if (config.functionDescription === 'lat_long' || config.mapData !== undefined) { + return CHART_TYPE.GEO_MAP; + } + if ( EVENT_DISTRIBUTION_ENABLED && config.functionDescription === 'rare' && diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index d7ca0203ab69e..9aa16f521554c 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -21,6 +21,7 @@ import type { import type { IndexPatternsContract, DataPublicPluginStart } from 'src/plugins/data/public'; import type { SharePluginStart } from 'src/plugins/share/public'; import type { SecurityPluginSetup } from '../../../../security/public'; +import type { MapsStartApi } from '../../../../maps/public'; export interface DependencyCache { timefilter: DataPublicPluginSetup['query']['timefilter'] | null; @@ -40,6 +41,7 @@ export interface DependencyCache { security: SecurityPluginSetup | undefined | null; i18n: I18nStart | null; urlGenerators: SharePluginStart['urlGenerators'] | null; + maps: MapsStartApi | null; } const cache: DependencyCache = { @@ -60,6 +62,7 @@ const cache: DependencyCache = { security: null, i18n: null, urlGenerators: null, + maps: null, }; export function setDependencyCache(deps: Partial<DependencyCache>) { diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 7c32671be93c4..3ba79e0eb9187 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -25,7 +25,7 @@ import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import type { DataPublicPluginStart } from 'src/plugins/data/public'; import type { HomePublicPluginSetup } from 'src/plugins/home/public'; import type { IndexPatternManagementSetup } from 'src/plugins/index_pattern_management/public'; -import type { EmbeddableSetup } from 'src/plugins/embeddable/public'; +import type { EmbeddableSetup, EmbeddableStart } from 'src/plugins/embeddable/public'; import type { SpacesPluginStart } from '../../spaces/public'; import { AppStatus, AppUpdater, DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; @@ -45,6 +45,7 @@ import { setDependencyCache } from './application/util/dependency_cache'; import { registerFeature } from './register_feature'; // Not importing from `ml_url_generator/index` here to avoid importing unnecessary code import { registerUrlGenerator } from './ml_url_generator/ml_url_generator'; +import type { MapsStartApi } from '../../maps/public'; export interface MlStartDependencies { data: DataPublicPluginStart; @@ -52,6 +53,8 @@ export interface MlStartDependencies { kibanaLegacy: KibanaLegacyStart; uiActions: UiActionsStart; spaces?: SpacesPluginStart; + embeddable: EmbeddableStart; + maps?: MapsStartApi; } export interface MlSetupDependencies { security?: SecurityPluginSetup; @@ -102,7 +105,8 @@ export class MlPlugin implements Plugin<MlPluginSetup, MlPluginStart> { usageCollection: pluginsSetup.usageCollection, licenseManagement: pluginsSetup.licenseManagement, home: pluginsSetup.home, - embeddable: pluginsSetup.embeddable, + embeddable: { ...pluginsSetup.embeddable, ...pluginsStart.embeddable }, + maps: pluginsStart.maps, uiActions: pluginsStart.uiActions, kibanaVersion, }, diff --git a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts index 7b3f97b684edc..22e75ec694733 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts @@ -1189,7 +1189,8 @@ export class DataVisualizer { }); const searchBody = { - _source: field, + fields: [field], + _source: false, query: { bool: { filter: filterCriteria, @@ -1209,16 +1210,16 @@ export class DataVisualizer { if (body.hits.total.value > 0) { const hits = body.hits.hits; for (let i = 0; i < hits.length; i++) { - // Look in the _source for the field value. - // If the field is not in the _source (as will happen if the - // field is populated using copy_to in the index mapping), - // there will be no example to add. // Use lodash get() to support field names containing dots. - const example: any = get(hits[i]._source, field); - if (example !== undefined && stats.examples.indexOf(example) === -1) { - stats.examples.push(example); - if (stats.examples.length === maxExamples) { - break; + const doc: object[] | undefined = get(hits[i].fields, field); + // the results from fields query is always an array + if (Array.isArray(doc) && doc.length > 0) { + const example = doc[0]; + if (example !== undefined && stats.examples.indexOf(example) === -1) { + stats.examples.push(example); + if (stats.examples.length === maxExamples) { + break; + } } } } diff --git a/x-pack/plugins/osquery/README.md b/x-pack/plugins/osquery/README.md new file mode 100755 index 0000000000000..e0861fab2040b --- /dev/null +++ b/x-pack/plugins/osquery/README.md @@ -0,0 +1,9 @@ +# osquery + +This plugin adds extended support to Security Solution Fleet Osquery integration + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/x-pack/plugins/osquery/common/constants.ts b/x-pack/plugins/osquery/common/constants.ts new file mode 100644 index 0000000000000..f6027d416beb1 --- /dev/null +++ b/x-pack/plugins/osquery/common/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DEFAULT_MAX_TABLE_QUERY_SIZE = 10000; +export const DEFAULT_DARK_MODE = 'theme:darkMode'; diff --git a/x-pack/plugins/osquery/common/ecs/agent/index.ts b/x-pack/plugins/osquery/common/ecs/agent/index.ts new file mode 100644 index 0000000000000..6f29a2020c944 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/agent/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface AgentEcs { + type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/auditd/index.ts b/x-pack/plugins/osquery/common/ecs/auditd/index.ts new file mode 100644 index 0000000000000..7611e5424e297 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/auditd/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface AuditdEcs { + result?: string[]; + session?: string[]; + data?: AuditdDataEcs; + summary?: SummaryEcs; + sequence?: string[]; +} + +export interface AuditdDataEcs { + acct?: string[]; + terminal?: string[]; + op?: string[]; +} + +export interface SummaryEcs { + actor?: PrimarySecondaryEcs; + object?: PrimarySecondaryEcs; + how?: string[]; + message_type?: string[]; + sequence?: string[]; +} + +export interface PrimarySecondaryEcs { + primary?: string[]; + secondary?: string[]; + type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/cloud/index.ts b/x-pack/plugins/osquery/common/ecs/cloud/index.ts new file mode 100644 index 0000000000000..812b30bcc13f1 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/cloud/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface CloudEcs { + instance?: CloudInstanceEcs; + machine?: CloudMachineEcs; + provider?: string[]; + region?: string[]; +} + +export interface CloudMachineEcs { + type?: string[]; +} + +export interface CloudInstanceEcs { + id?: string[]; +} diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/index.ts b/x-pack/plugins/osquery/common/ecs/destination/index.ts similarity index 50% rename from x-pack/plugins/vis_type_timeseries_enhanced/server/index.ts rename to x-pack/plugins/osquery/common/ecs/destination/index.ts index d2665ec1e2813..be12e829108a9 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/index.ts +++ b/x-pack/plugins/osquery/common/ecs/destination/index.ts @@ -4,8 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'src/core/server'; -import { VisTypeTimeseriesEnhanced } from './plugin'; +import { GeoEcs } from '../geo'; -export const plugin = (initializerContext: PluginInitializerContext) => - new VisTypeTimeseriesEnhanced(initializerContext); +export interface DestinationEcs { + bytes?: number[]; + ip?: string[]; + port?: number[]; + domain?: string[]; + geo?: GeoEcs; + packets?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/dns/index.ts b/x-pack/plugins/osquery/common/ecs/dns/index.ts new file mode 100644 index 0000000000000..45192d03a10b6 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/dns/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface DnsEcs { + question?: DnsQuestionEcs; + resolved_ip?: string[]; + response_code?: string[]; +} + +export interface DnsQuestionEcs { + name?: string[]; + type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.test.ts b/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.test.ts new file mode 100644 index 0000000000000..9ba22e83b4b4d --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extendMap } from './extend_map'; + +describe('ecs_fields test', () => { + describe('extendMap', () => { + test('it should extend a record', () => { + const osFieldsMap: Readonly<Record<string, string>> = { + 'os.platform': 'os.platform', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', + }; + const expected: Record<string, string> = { + 'host.os.family': 'host.os.family', + 'host.os.full': 'host.os.full', + 'host.os.kernel': 'host.os.kernel', + 'host.os.platform': 'host.os.platform', + 'host.os.version': 'host.os.version', + }; + expect(extendMap('host', osFieldsMap)).toEqual(expected); + }); + + test('it should extend a sample hosts record', () => { + const hostMap: Record<string, string> = { + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.name': 'host.name', + }; + const osFieldsMap: Readonly<Record<string, string>> = { + 'os.platform': 'os.platform', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', + }; + const expected: Record<string, string> = { + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.name': 'host.name', + 'host.os.family': 'host.os.family', + 'host.os.full': 'host.os.full', + 'host.os.kernel': 'host.os.kernel', + 'host.os.platform': 'host.os.platform', + 'host.os.version': 'host.os.version', + }; + const output = { ...hostMap, ...extendMap('host', osFieldsMap) }; + expect(output).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.ts b/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.ts new file mode 100644 index 0000000000000..c25979cbcdcee --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/ecs_fields/extend_map.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const extendMap = ( + path: string, + map: Readonly<Record<string, string>> +): Readonly<Record<string, string>> => + Object.entries(map).reduce<Record<string, string>>((accum, [key, value]) => { + accum[`${path}.${key}`] = `${path}.${value}`; + return accum; + }, {}); diff --git a/x-pack/plugins/osquery/common/ecs/ecs_fields/index.ts b/x-pack/plugins/osquery/common/ecs/ecs_fields/index.ts new file mode 100644 index 0000000000000..19b16bd4bc6d2 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/ecs_fields/index.ts @@ -0,0 +1,358 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { extendMap } from './extend_map'; + +export const auditdMap: Readonly<Record<string, string>> = { + 'auditd.result': 'auditd.result', + 'auditd.session': 'auditd.session', + 'auditd.data.acct': 'auditd.data.acct', + 'auditd.data.terminal': 'auditd.data.terminal', + 'auditd.data.op': 'auditd.data.op', + 'auditd.summary.actor.primary': 'auditd.summary.actor.primary', + 'auditd.summary.actor.secondary': 'auditd.summary.actor.secondary', + 'auditd.summary.object.primary': 'auditd.summary.object.primary', + 'auditd.summary.object.secondary': 'auditd.summary.object.secondary', + 'auditd.summary.object.type': 'auditd.summary.object.type', + 'auditd.summary.how': 'auditd.summary.how', + 'auditd.summary.message_type': 'auditd.summary.message_type', + 'auditd.summary.sequence': 'auditd.summary.sequence', +}; + +export const cloudFieldsMap: Readonly<Record<string, string>> = { + 'cloud.account.id': 'cloud.account.id', + 'cloud.availability_zone': 'cloud.availability_zone', + 'cloud.instance.id': 'cloud.instance.id', + 'cloud.instance.name': 'cloud.instance.name', + 'cloud.machine.type': 'cloud.machine.type', + 'cloud.provider': 'cloud.provider', + 'cloud.region': 'cloud.region', +}; + +export const fileMap: Readonly<Record<string, string>> = { + 'file.name': 'file.name', + 'file.path': 'file.path', + 'file.target_path': 'file.target_path', + 'file.extension': 'file.extension', + 'file.type': 'file.type', + 'file.device': 'file.device', + 'file.inode': 'file.inode', + 'file.uid': 'file.uid', + 'file.owner': 'file.owner', + 'file.gid': 'file.gid', + 'file.group': 'file.group', + 'file.mode': 'file.mode', + 'file.size': 'file.size', + 'file.mtime': 'file.mtime', + 'file.ctime': 'file.ctime', +}; + +export const osFieldsMap: Readonly<Record<string, string>> = { + 'os.platform': 'os.platform', + 'os.name': 'os.name', + 'os.full': 'os.full', + 'os.family': 'os.family', + 'os.version': 'os.version', + 'os.kernel': 'os.kernel', +}; + +export const hostFieldsMap: Readonly<Record<string, string>> = { + 'host.architecture': 'host.architecture', + 'host.id': 'host.id', + 'host.ip': 'host.ip', + 'host.mac': 'host.mac', + 'host.name': 'host.name', + ...extendMap('host', osFieldsMap), +}; + +export const processFieldsMap: Readonly<Record<string, string>> = { + 'process.hash.md5': 'process.hash.md5', + 'process.hash.sha1': 'process.hash.sha1', + 'process.hash.sha256': 'process.hash.sha256', + 'process.pid': 'process.pid', + 'process.name': 'process.name', + 'process.ppid': 'process.ppid', + 'process.args': 'process.args', + 'process.entity_id': 'process.entity_id', + 'process.executable': 'process.executable', + 'process.title': 'process.title', + 'process.thread': 'process.thread', + 'process.working_directory': 'process.working_directory', +}; + +export const agentFieldsMap: Readonly<Record<string, string>> = { + 'agent.type': 'agent.type', +}; + +export const userFieldsMap: Readonly<Record<string, string>> = { + 'user.domain': 'user.domain', + 'user.id': 'user.id', + 'user.name': 'user.name', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.full_name': 'user.full_name', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.email': 'user.email', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.hash': 'user.hash', + // NOTE: This field is not tested and available from ECS. Please remove this tag once it is + 'user.group': 'user.group', +}; + +export const winlogFieldsMap: Readonly<Record<string, string>> = { + 'winlog.event_id': 'winlog.event_id', +}; + +export const suricataFieldsMap: Readonly<Record<string, string>> = { + 'suricata.eve.flow_id': 'suricata.eve.flow_id', + 'suricata.eve.proto': 'suricata.eve.proto', + 'suricata.eve.alert.signature': 'suricata.eve.alert.signature', + 'suricata.eve.alert.signature_id': 'suricata.eve.alert.signature_id', +}; + +export const tlsFieldsMap: Readonly<Record<string, string>> = { + 'tls.client_certificate.fingerprint.sha1': 'tls.client_certificate.fingerprint.sha1', + 'tls.fingerprints.ja3.hash': 'tls.fingerprints.ja3.hash', + 'tls.server_certificate.fingerprint.sha1': 'tls.server_certificate.fingerprint.sha1', +}; + +export const urlFieldsMap: Readonly<Record<string, string>> = { + 'url.original': 'url.original', + 'url.domain': 'url.domain', + 'user.username': 'user.username', + 'user.password': 'user.password', +}; + +export const httpFieldsMap: Readonly<Record<string, string>> = { + 'http.version': 'http.version', + 'http.request': 'http.request', + 'http.request.method': 'http.request.method', + 'http.request.body.bytes': 'http.request.body.bytes', + 'http.request.body.content': 'http.request.body.content', + 'http.request.referrer': 'http.request.referrer', + 'http.response.status_code': 'http.response.status_code', + 'http.response.body': 'http.response.body', + 'http.response.body.bytes': 'http.response.body.bytes', + 'http.response.body.content': 'http.response.body.content', +}; + +export const zeekFieldsMap: Readonly<Record<string, string>> = { + 'zeek.session_id': 'zeek.session_id', + 'zeek.connection.local_resp': 'zeek.connection.local_resp', + 'zeek.connection.local_orig': 'zeek.connection.local_orig', + 'zeek.connection.missed_bytes': 'zeek.connection.missed_bytes', + 'zeek.connection.state': 'zeek.connection.state', + 'zeek.connection.history': 'zeek.connection.history', + 'zeek.notice.suppress_for': 'zeek.notice.suppress_for', + 'zeek.notice.msg': 'zeek.notice.msg', + 'zeek.notice.note': 'zeek.notice.note', + 'zeek.notice.sub': 'zeek.notice.sub', + 'zeek.notice.dst': 'zeek.notice.dst', + 'zeek.notice.dropped': 'zeek.notice.dropped', + 'zeek.notice.peer_descr': 'zeek.notice.peer_descr', + 'zeek.dns.AA': 'zeek.dns.AA', + 'zeek.dns.qclass_name': 'zeek.dns.qclass_name', + 'zeek.dns.RD': 'zeek.dns.RD', + 'zeek.dns.qtype_name': 'zeek.dns.qtype_name', + 'zeek.dns.qtype': 'zeek.dns.qtype', + 'zeek.dns.query': 'zeek.dns.query', + 'zeek.dns.trans_id': 'zeek.dns.trans_id', + 'zeek.dns.qclass': 'zeek.dns.qclass', + 'zeek.dns.RA': 'zeek.dns.RA', + 'zeek.dns.TC': 'zeek.dns.TC', + 'zeek.http.resp_mime_types': 'zeek.http.resp_mime_types', + 'zeek.http.trans_depth': 'zeek.http.trans_depth', + 'zeek.http.status_msg': 'zeek.http.status_msg', + 'zeek.http.resp_fuids': 'zeek.http.resp_fuids', + 'zeek.http.tags': 'zeek.http.tags', + 'zeek.files.session_ids': 'zeek.files.session_ids', + 'zeek.files.timedout': 'zeek.files.timedout', + 'zeek.files.local_orig': 'zeek.files.local_orig', + 'zeek.files.tx_host': 'zeek.files.tx_host', + 'zeek.files.source': 'zeek.files.source', + 'zeek.files.is_orig': 'zeek.files.is_orig', + 'zeek.files.overflow_bytes': 'zeek.files.overflow_bytes', + 'zeek.files.sha1': 'zeek.files.sha1', + 'zeek.files.duration': 'zeek.files.duration', + 'zeek.files.depth': 'zeek.files.depth', + 'zeek.files.analyzers': 'zeek.files.analyzers', + 'zeek.files.mime_type': 'zeek.files.mime_type', + 'zeek.files.rx_host': 'zeek.files.rx_host', + 'zeek.files.total_bytes': 'zeek.files.total_bytes', + 'zeek.files.fuid': 'zeek.files.fuid', + 'zeek.files.seen_bytes': 'zeek.files.seen_bytes', + 'zeek.files.missing_bytes': 'zeek.files.missing_bytes', + 'zeek.files.md5': 'zeek.files.md5', + 'zeek.ssl.cipher': 'zeek.ssl.cipher', + 'zeek.ssl.established': 'zeek.ssl.established', + 'zeek.ssl.resumed': 'zeek.ssl.resumed', + 'zeek.ssl.version': 'zeek.ssl.version', +}; + +export const sourceFieldsMap: Readonly<Record<string, string>> = { + 'source.bytes': 'source.bytes', + 'source.ip': 'source.ip', + 'source.packets': 'source.packets', + 'source.port': 'source.port', + 'source.domain': 'source.domain', + 'source.geo.continent_name': 'source.geo.continent_name', + 'source.geo.country_name': 'source.geo.country_name', + 'source.geo.country_iso_code': 'source.geo.country_iso_code', + 'source.geo.city_name': 'source.geo.city_name', + 'source.geo.region_iso_code': 'source.geo.region_iso_code', + 'source.geo.region_name': 'source.geo.region_name', +}; + +export const destinationFieldsMap: Readonly<Record<string, string>> = { + 'destination.bytes': 'destination.bytes', + 'destination.ip': 'destination.ip', + 'destination.packets': 'destination.packets', + 'destination.port': 'destination.port', + 'destination.domain': 'destination.domain', + 'destination.geo.continent_name': 'destination.geo.continent_name', + 'destination.geo.country_name': 'destination.geo.country_name', + 'destination.geo.country_iso_code': 'destination.geo.country_iso_code', + 'destination.geo.city_name': 'destination.geo.city_name', + 'destination.geo.region_iso_code': 'destination.geo.region_iso_code', + 'destination.geo.region_name': 'destination.geo.region_name', +}; + +export const networkFieldsMap: Readonly<Record<string, string>> = { + 'network.bytes': 'network.bytes', + 'network.community_id': 'network.community_id', + 'network.direction': 'network.direction', + 'network.packets': 'network.packets', + 'network.protocol': 'network.protocol', + 'network.transport': 'network.transport', +}; + +export const geoFieldsMap: Readonly<Record<string, string>> = { + 'geo.region_name': 'destination.geo.region_name', + 'geo.country_iso_code': 'destination.geo.country_iso_code', +}; + +export const dnsFieldsMap: Readonly<Record<string, string>> = { + 'dns.question.name': 'dns.question.name', + 'dns.question.type': 'dns.question.type', + 'dns.resolved_ip': 'dns.resolved_ip', + 'dns.response_code': 'dns.response_code', +}; + +export const endgameFieldsMap: Readonly<Record<string, string>> = { + 'endgame.exit_code': 'endgame.exit_code', + 'endgame.file_name': 'endgame.file_name', + 'endgame.file_path': 'endgame.file_path', + 'endgame.logon_type': 'endgame.logon_type', + 'endgame.parent_process_name': 'endgame.parent_process_name', + 'endgame.pid': 'endgame.pid', + 'endgame.process_name': 'endgame.process_name', + 'endgame.subject_domain_name': 'endgame.subject_domain_name', + 'endgame.subject_logon_id': 'endgame.subject_logon_id', + 'endgame.subject_user_name': 'endgame.subject_user_name', + 'endgame.target_domain_name': 'endgame.target_domain_name', + 'endgame.target_logon_id': 'endgame.target_logon_id', + 'endgame.target_user_name': 'endgame.target_user_name', +}; + +export const eventBaseFieldsMap: Readonly<Record<string, string>> = { + 'event.action': 'event.action', + 'event.category': 'event.category', + 'event.code': 'event.code', + 'event.created': 'event.created', + 'event.dataset': 'event.dataset', + 'event.duration': 'event.duration', + 'event.end': 'event.end', + 'event.hash': 'event.hash', + 'event.id': 'event.id', + 'event.kind': 'event.kind', + 'event.module': 'event.module', + 'event.original': 'event.original', + 'event.outcome': 'event.outcome', + 'event.risk_score': 'event.risk_score', + 'event.risk_score_norm': 'event.risk_score_norm', + 'event.severity': 'event.severity', + 'event.start': 'event.start', + 'event.timezone': 'event.timezone', + 'event.type': 'event.type', +}; + +export const systemFieldsMap: Readonly<Record<string, string>> = { + 'system.audit.package.arch': 'system.audit.package.arch', + 'system.audit.package.entity_id': 'system.audit.package.entity_id', + 'system.audit.package.name': 'system.audit.package.name', + 'system.audit.package.size': 'system.audit.package.size', + 'system.audit.package.summary': 'system.audit.package.summary', + 'system.audit.package.version': 'system.audit.package.version', + 'system.auth.ssh.signature': 'system.auth.ssh.signature', + 'system.auth.ssh.method': 'system.auth.ssh.method', +}; + +export const signalFieldsMap: Readonly<Record<string, string>> = { + 'signal.original_time': 'signal.original_time', + 'signal.rule.id': 'signal.rule.id', + 'signal.rule.saved_id': 'signal.rule.saved_id', + 'signal.rule.timeline_id': 'signal.rule.timeline_id', + 'signal.rule.timeline_title': 'signal.rule.timeline_title', + 'signal.rule.output_index': 'signal.rule.output_index', + 'signal.rule.from': 'signal.rule.from', + 'signal.rule.index': 'signal.rule.index', + 'signal.rule.language': 'signal.rule.language', + 'signal.rule.query': 'signal.rule.query', + 'signal.rule.to': 'signal.rule.to', + 'signal.rule.filters': 'signal.rule.filters', + 'signal.rule.rule_id': 'signal.rule.rule_id', + 'signal.rule.false_positives': 'signal.rule.false_positives', + 'signal.rule.max_signals': 'signal.rule.max_signals', + 'signal.rule.risk_score': 'signal.rule.risk_score', + 'signal.rule.description': 'signal.rule.description', + 'signal.rule.name': 'signal.rule.name', + 'signal.rule.immutable': 'signal.rule.immutable', + 'signal.rule.references': 'signal.rule.references', + 'signal.rule.severity': 'signal.rule.severity', + 'signal.rule.tags': 'signal.rule.tags', + 'signal.rule.threat': 'signal.rule.threat', + 'signal.rule.type': 'signal.rule.type', + 'signal.rule.size': 'signal.rule.size', + 'signal.rule.enabled': 'signal.rule.enabled', + 'signal.rule.created_at': 'signal.rule.created_at', + 'signal.rule.updated_at': 'signal.rule.updated_at', + 'signal.rule.created_by': 'signal.rule.created_by', + 'signal.rule.updated_by': 'signal.rule.updated_by', + 'signal.rule.version': 'signal.rule.version', + 'signal.rule.note': 'signal.rule.note', + 'signal.rule.threshold': 'signal.rule.threshold', + 'signal.rule.exceptions_list': 'signal.rule.exceptions_list', +}; + +export const ruleFieldsMap: Readonly<Record<string, string>> = { + 'rule.reference': 'rule.reference', +}; + +export const eventFieldsMap: Readonly<Record<string, string>> = { + timestamp: '@timestamp', + '@timestamp': '@timestamp', + message: 'message', + ...{ ...agentFieldsMap }, + ...{ ...auditdMap }, + ...{ ...destinationFieldsMap }, + ...{ ...dnsFieldsMap }, + ...{ ...endgameFieldsMap }, + ...{ ...eventBaseFieldsMap }, + ...{ ...fileMap }, + ...{ ...geoFieldsMap }, + ...{ ...hostFieldsMap }, + ...{ ...networkFieldsMap }, + ...{ ...ruleFieldsMap }, + ...{ ...signalFieldsMap }, + ...{ ...sourceFieldsMap }, + ...{ ...suricataFieldsMap }, + ...{ ...systemFieldsMap }, + ...{ ...tlsFieldsMap }, + ...{ ...zeekFieldsMap }, + ...{ ...httpFieldsMap }, + ...{ ...userFieldsMap }, + ...{ ...winlogFieldsMap }, + ...{ ...processFieldsMap }, +}; diff --git a/x-pack/plugins/osquery/common/ecs/endgame/index.ts b/x-pack/plugins/osquery/common/ecs/endgame/index.ts new file mode 100644 index 0000000000000..d2fc5d61527a5 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/endgame/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface EndgameEcs { + exit_code?: number[]; + file_name?: string[]; + file_path?: string[]; + logon_type?: number[]; + parent_process_name?: string[]; + pid?: number[]; + process_name?: string[]; + subject_domain_name?: string[]; + subject_logon_id?: string[]; + subject_user_name?: string[]; + target_domain_name?: string[]; + target_logon_id?: string[]; + target_user_name?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/event/index.ts b/x-pack/plugins/osquery/common/ecs/event/index.ts new file mode 100644 index 0000000000000..c3b7b1d0b8436 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/event/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface EventEcs { + action?: string[]; + category?: string[]; + code?: string[]; + created?: string[]; + dataset?: string[]; + duration?: number[]; + end?: string[]; + hash?: string[]; + id?: string[]; + kind?: string[]; + module?: string[]; + original?: string[]; + outcome?: string[]; + risk_score?: number[]; + risk_score_norm?: number[]; + severity?: number[]; + start?: string[]; + timezone?: string[]; + type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/file/index.ts b/x-pack/plugins/osquery/common/ecs/file/index.ts new file mode 100644 index 0000000000000..b01e9514bf425 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/file/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface CodeSignature { + subject_name: string[]; + trusted: string[]; +} +export interface Ext { + code_signature: CodeSignature[] | CodeSignature; +} +export interface Hash { + sha256: string[]; +} + +export interface FileEcs { + name?: string[]; + path?: string[]; + target_path?: string[]; + extension?: string[]; + Ext?: Ext; + type?: string[]; + device?: string[]; + inode?: string[]; + uid?: string[]; + owner?: string[]; + gid?: string[]; + group?: string[]; + mode?: string[]; + size?: number[]; + mtime?: string[]; + ctime?: string[]; + hash?: Hash; +} diff --git a/x-pack/plugins/osquery/common/ecs/geo/index.ts b/x-pack/plugins/osquery/common/ecs/geo/index.ts new file mode 100644 index 0000000000000..4a4c76adb097b --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/geo/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface GeoEcs { + city_name?: string[]; + continent_name?: string[]; + country_iso_code?: string[]; + country_name?: string[]; + location?: Location; + region_iso_code?: string[]; + region_name?: string[]; +} + +export interface Location { + lon?: number[]; + lat?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/host/index.ts b/x-pack/plugins/osquery/common/ecs/host/index.ts new file mode 100644 index 0000000000000..27cbe433f9bf7 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/host/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface HostEcs { + architecture?: string[]; + id?: string[]; + ip?: string[]; + mac?: string[]; + name?: string[]; + os?: OsEcs; + type?: string[]; +} + +export interface OsEcs { + platform?: string[]; + name?: string[]; + full?: string[]; + family?: string[]; + version?: string[]; + kernel?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/http/index.ts b/x-pack/plugins/osquery/common/ecs/http/index.ts new file mode 100644 index 0000000000000..c5c5d1e140d0a --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/http/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface HttpEcs { + version?: string[]; + request?: HttpRequestData; + response?: HttpResponseData; +} + +export interface HttpRequestData { + method?: string[]; + body?: HttpBodyData; + referrer?: string[]; + bytes?: number[]; +} + +export interface HttpBodyData { + content?: string[]; + bytes?: number[]; +} + +export interface HttpResponseData { + status_code?: number[]; + body?: HttpBodyData; + bytes?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/index.ts b/x-pack/plugins/osquery/common/ecs/index.ts new file mode 100644 index 0000000000000..b8190463f5da5 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/index.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AgentEcs } from './agent'; +import { AuditdEcs } from './auditd'; +import { DestinationEcs } from './destination'; +import { DnsEcs } from './dns'; +import { EndgameEcs } from './endgame'; +import { EventEcs } from './event'; +import { FileEcs } from './file'; +import { GeoEcs } from './geo'; +import { HostEcs } from './host'; +import { NetworkEcs } from './network'; +import { RuleEcs } from './rule'; +import { SignalEcs } from './signal'; +import { SourceEcs } from './source'; +import { SuricataEcs } from './suricata'; +import { TlsEcs } from './tls'; +import { ZeekEcs } from './zeek'; +import { HttpEcs } from './http'; +import { UrlEcs } from './url'; +import { UserEcs } from './user'; +import { WinlogEcs } from './winlog'; +import { ProcessEcs } from './process'; +import { SystemEcs } from './system'; + +export interface Ecs { + _id: string; + _index?: string; + agent?: AgentEcs; + auditd?: AuditdEcs; + destination?: DestinationEcs; + dns?: DnsEcs; + endgame?: EndgameEcs; + event?: EventEcs; + geo?: GeoEcs; + host?: HostEcs; + network?: NetworkEcs; + rule?: RuleEcs; + signal?: SignalEcs; + source?: SourceEcs; + suricata?: SuricataEcs; + tls?: TlsEcs; + zeek?: ZeekEcs; + http?: HttpEcs; + url?: UrlEcs; + timestamp?: string; + message?: string[]; + user?: UserEcs; + winlog?: WinlogEcs; + process?: ProcessEcs; + file?: FileEcs; + system?: SystemEcs; +} diff --git a/x-pack/plugins/osquery/common/ecs/network/index.ts b/x-pack/plugins/osquery/common/ecs/network/index.ts new file mode 100644 index 0000000000000..18f7583d12231 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/network/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface NetworkEcs { + bytes?: number[]; + community_id?: string[]; + direction?: string[]; + packets?: number[]; + protocol?: string[]; + transport?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/process/index.ts b/x-pack/plugins/osquery/common/ecs/process/index.ts new file mode 100644 index 0000000000000..451f1455f55d4 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/process/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ProcessEcs { + entity_id?: string[]; + hash?: ProcessHashData; + pid?: number[]; + name?: string[]; + ppid?: number[]; + args?: string[]; + executable?: string[]; + title?: string[]; + thread?: Thread; + working_directory?: string[]; +} + +export interface ProcessHashData { + md5?: string[]; + sha1?: string[]; + sha256?: string[]; +} + +export interface Thread { + id?: number[]; + start?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/rule/index.ts b/x-pack/plugins/osquery/common/ecs/rule/index.ts new file mode 100644 index 0000000000000..47d1323371941 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/rule/index.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface RuleEcs { + id?: string[]; + rule_id?: string[]; + name?: string[]; + false_positives: string[]; + saved_id?: string[]; + timeline_id?: string[]; + timeline_title?: string[]; + max_signals?: number[]; + risk_score?: string[]; + output_index?: string[]; + description?: string[]; + from?: string[]; + immutable?: boolean[]; + index?: string[]; + interval?: string[]; + language?: string[]; + query?: string[]; + references?: string[]; + severity?: string[]; + tags?: string[]; + threat?: unknown; + threshold?: { + field: string; + value: number; + }; + type?: string[]; + size?: string[]; + to?: string[]; + enabled?: boolean[]; + filters?: unknown; + created_at?: string[]; + updated_at?: string[]; + created_by?: string[]; + updated_by?: string[]; + version?: string[]; + note?: string[]; + building_block_type?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/signal/index.ts b/x-pack/plugins/osquery/common/ecs/signal/index.ts new file mode 100644 index 0000000000000..6482b892bc18d --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/signal/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RuleEcs } from '../rule'; + +export interface SignalEcs { + rule?: RuleEcs; + original_time?: string[]; + status?: string[]; + group?: { + id?: string[]; + }; +} diff --git a/x-pack/plugins/osquery/common/ecs/source/index.ts b/x-pack/plugins/osquery/common/ecs/source/index.ts new file mode 100644 index 0000000000000..2c8618f4edcd0 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/source/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GeoEcs } from '../geo'; + +export interface SourceEcs { + bytes?: number[]; + ip?: string[]; + port?: number[]; + domain?: string[]; + geo?: GeoEcs; + packets?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/suricata/index.ts b/x-pack/plugins/osquery/common/ecs/suricata/index.ts new file mode 100644 index 0000000000000..0ef253ada2620 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/suricata/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface SuricataEcs { + eve?: SuricataEveData; +} + +export interface SuricataEveData { + alert?: SuricataAlertData; + flow_id?: number[]; + proto?: string[]; +} + +export interface SuricataAlertData { + signature?: string[]; + signature_id?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/system/index.ts b/x-pack/plugins/osquery/common/ecs/system/index.ts new file mode 100644 index 0000000000000..641a10209c150 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/system/index.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface SystemEcs { + audit?: AuditEcs; + auth?: AuthEcs; +} + +export interface AuditEcs { + package?: PackageEcs; +} + +export interface PackageEcs { + arch?: string[]; + entity_id?: string[]; + name?: string[]; + size?: number[]; + summary?: string[]; + version?: string[]; +} + +export interface AuthEcs { + ssh?: SshEcs; +} + +export interface SshEcs { + method?: string[]; + signature?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/tls/index.ts b/x-pack/plugins/osquery/common/ecs/tls/index.ts new file mode 100644 index 0000000000000..1533d46417d0a --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/tls/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface TlsEcs { + client_certificate?: TlsClientCertificateData; + fingerprints?: TlsFingerprintsData; + server_certificate?: TlsServerCertificateData; +} + +export interface TlsClientCertificateData { + fingerprint?: FingerprintData; +} + +export interface FingerprintData { + sha1?: string[]; +} + +export interface TlsFingerprintsData { + ja3?: TlsJa3Data; +} + +export interface TlsJa3Data { + hash?: string[]; +} + +export interface TlsServerCertificateData { + fingerprint?: FingerprintData; +} diff --git a/x-pack/plugins/osquery/common/ecs/url/index.ts b/x-pack/plugins/osquery/common/ecs/url/index.ts new file mode 100644 index 0000000000000..91d7958c813a3 --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/url/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface UrlEcs { + domain?: string[]; + original?: string[]; + username?: string[]; + password?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/user/index.ts b/x-pack/plugins/osquery/common/ecs/user/index.ts new file mode 100644 index 0000000000000..35de2e0459ceb --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/user/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface UserEcs { + domain?: string[]; + id?: string[]; + name?: string[]; + full_name?: string[]; + email?: string[]; + hash?: string[]; + group?: string[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/winlog/index.ts b/x-pack/plugins/osquery/common/ecs/winlog/index.ts new file mode 100644 index 0000000000000..a449fb9130e6f --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/winlog/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface WinlogEcs { + event_id?: number[]; +} diff --git a/x-pack/plugins/osquery/common/ecs/zeek/index.ts b/x-pack/plugins/osquery/common/ecs/zeek/index.ts new file mode 100644 index 0000000000000..2563612f09bfb --- /dev/null +++ b/x-pack/plugins/osquery/common/ecs/zeek/index.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ZeekEcs { + session_id?: string[]; + connection?: ZeekConnectionData; + notice?: ZeekNoticeData; + dns?: ZeekDnsData; + http?: ZeekHttpData; + files?: ZeekFileData; + ssl?: ZeekSslData; +} + +export interface ZeekConnectionData { + local_resp?: boolean[]; + local_orig?: boolean[]; + missed_bytes?: number[]; + state?: string[]; + history?: string[]; +} + +export interface ZeekNoticeData { + suppress_for?: number[]; + msg?: string[]; + note?: string[]; + sub?: string[]; + dst?: string[]; + dropped?: boolean[]; + peer_descr?: string[]; +} + +export interface ZeekDnsData { + AA?: boolean[]; + qclass_name?: string[]; + RD?: boolean[]; + qtype_name?: string[]; + rejected?: boolean[]; + qtype?: string[]; + query?: string[]; + trans_id?: number[]; + qclass?: string[]; + RA?: boolean[]; + TC?: boolean[]; +} + +export interface ZeekHttpData { + resp_mime_types?: string[]; + trans_depth?: string[]; + status_msg?: string[]; + resp_fuids?: string[]; + tags?: string[]; +} + +export interface ZeekFileData { + session_ids?: string[]; + timedout?: boolean[]; + local_orig?: boolean[]; + tx_host?: string[]; + source?: string[]; + is_orig?: boolean[]; + overflow_bytes?: number[]; + sha1?: string[]; + duration?: number[]; + depth?: number[]; + analyzers?: string[]; + mime_type?: string[]; + rx_host?: string[]; + total_bytes?: number[]; + fuid?: string[]; + seen_bytes?: number[]; + missing_bytes?: number[]; + md5?: string[]; +} + +export interface ZeekSslData { + cipher?: string[]; + established?: boolean[]; + resumed?: boolean[]; + version?: string[]; +} diff --git a/x-pack/plugins/osquery/common/index.ts b/x-pack/plugins/osquery/common/index.ts new file mode 100644 index 0000000000000..e4bbf4781e881 --- /dev/null +++ b/x-pack/plugins/osquery/common/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './constants'; + +export const PLUGIN_ID = 'osquery'; +export const PLUGIN_NAME = 'osquery'; diff --git a/x-pack/plugins/osquery/common/search_strategy/common/index.ts b/x-pack/plugins/osquery/common/search_strategy/common/index.ts new file mode 100644 index 0000000000000..0c1f13dac2e69 --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/common/index.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../src/plugins/data/common'; + +export type Maybe<T> = T | null; + +export type SearchHit = IEsSearchResponse<object>['rawResponse']['hits']['hits'][0]; + +export interface TotalValue { + value: number; + relation: string; +} + +export interface Inspect { + dsl: string[]; +} + +export interface PageInfoPaginated { + activePage: number; + fakeTotalCount: number; + showMorePagesIndicator: boolean; +} + +export interface CursorType { + value?: Maybe<string>; + tiebreaker?: Maybe<string>; +} + +export enum Direction { + asc = 'asc', + desc = 'desc', +} + +export interface SortField<Field = string> { + field: Field; + direction: Direction; +} + +export interface TimerangeInput { + /** The interval string to use for last bucket. The format is '{value}{unit}'. For example '5m' would return the metrics for the last 5 minutes of the timespan. */ + interval: string; + /** The end of the timerange */ + to: string; + /** The beginning of the timerange */ + from: string; +} + +export interface PaginationInput { + /** The limit parameter allows you to configure the maximum amount of items to be returned */ + limit: number; + /** The cursor parameter defines the next result you want to fetch */ + cursor?: Maybe<string>; + /** The tiebreaker parameter allow to be more precise to fetch the next item */ + tiebreaker?: Maybe<string>; +} + +export interface PaginationInputPaginated { + /** The activePage parameter defines the page of results you want to fetch */ + activePage: number; + /** The cursorStart parameter defines the start of the results to be displayed */ + cursorStart: number; + /** The fakePossibleCount parameter determines the total count in order to show 5 additional pages */ + fakePossibleCount: number; + /** The querySize parameter is the number of items to be returned */ + querySize: number; +} + +export interface DocValueFields { + field: string; + format?: string | null; +} + +export interface Explanation { + value: number; + description: string; + details: Explanation[]; +} + +export interface ShardsResponse { + total: number; + successful: number; + failed: number; + skipped: number; +} + +export interface TotalHit { + value: number; + relation: string; +} + +export interface Hit { + _index: string; + _type: string; + _id: string; + _score: number | null; +} + +export interface Hits<T, U> { + hits: { + total: T; + max_score: number | null; + hits: U[]; + }; +} + +export interface GenericBuckets { + key: string; + doc_count: number; +} + +export type StringOrNumber = string | number; + +export interface TimerangeFilter { + range: { + [timestamp: string]: { + gte: string; + lte: string; + format: string; + }; + }; +} diff --git a/x-pack/plugins/osquery/common/search_strategy/index.ts b/x-pack/plugins/osquery/common/search_strategy/index.ts new file mode 100644 index 0000000000000..ff9a8d1aa64c9 --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './common'; +export * from './osquery'; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts new file mode 100644 index 0000000000000..076fa02747573 --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/actions/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; +import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; + +import { Inspect, Maybe, PageInfoPaginated } from '../../common'; +import { RequestOptions, RequestOptionsPaginated } from '../..'; + +export type ActionEdges = SearchResponse<object>['hits']['hits']; + +export type ActionResultEdges = SearchResponse<object>['hits']['hits']; +export interface ActionsStrategyResponse extends IEsSearchResponse { + edges: ActionEdges; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export type ActionsRequestOptions = RequestOptionsPaginated<{}>; + +export interface ActionDetailsStrategyResponse extends IEsSearchResponse { + actionDetails: Record<string, any>; + inspect?: Maybe<Inspect>; +} + +export interface ActionDetailsRequestOptions extends RequestOptions { + actionId: string; +} + +export interface ActionResultsStrategyResponse extends IEsSearchResponse { + edges: ActionResultEdges; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export interface ActionResultsRequestOptions extends RequestOptionsPaginated { + actionId: string; +} diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts new file mode 100644 index 0000000000000..64a570ef5525b --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/agents/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; + +import { Inspect, Maybe, PageInfoPaginated } from '../../common'; +import { RequestOptionsPaginated } from '../..'; +import { Agent } from '../../../shared_imports'; + +export interface AgentsStrategyResponse extends IEsSearchResponse { + edges: Agent[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export type AgentsRequestOptions = RequestOptionsPaginated<{}>; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/common/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/common/index.ts new file mode 100644 index 0000000000000..fc58184f40afe --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/common/index.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CloudEcs } from '../../../ecs/cloud'; +import { HostEcs, OsEcs } from '../../../ecs/host'; +import { Hit, Hits, Maybe, SearchHit, StringOrNumber, TotalValue } from '../../common'; + +export enum HostPolicyResponseActionStatus { + success = 'success', + failure = 'failure', + warning = 'warning', +} + +export enum HostsFields { + lastSeen = 'lastSeen', + hostName = 'hostName', +} + +export interface EndpointFields { + endpointPolicy?: Maybe<string>; + sensorVersion?: Maybe<string>; + policyStatus?: Maybe<HostPolicyResponseActionStatus>; +} + +export interface HostItem { + _id?: Maybe<string>; + cloud?: Maybe<CloudEcs>; + endpoint?: Maybe<EndpointFields>; + host?: Maybe<HostEcs>; + lastSeen?: Maybe<string>; +} + +export interface HostValue { + value: number; + value_as_string: string; +} + +export interface HostBucketItem { + key: string; + doc_count: number; + timestamp: HostValue; +} + +export interface HostBuckets { + buckets: HostBucketItem[]; +} + +export interface HostOsHitsItem { + hits: { + total: TotalValue | number; + max_score: number | null; + hits: Array<{ + _source: { host: { os: Maybe<OsEcs> } }; + sort?: [number]; + _index?: string; + _type?: string; + _id?: string; + _score?: number | null; + }>; + }; +} + +export interface HostAggEsItem { + cloud_instance_id?: HostBuckets; + cloud_machine_type?: HostBuckets; + cloud_provider?: HostBuckets; + cloud_region?: HostBuckets; + firstSeen?: HostValue; + host_architecture?: HostBuckets; + host_id?: HostBuckets; + host_ip?: HostBuckets; + host_mac?: HostBuckets; + host_name?: HostBuckets; + host_os_name?: HostBuckets; + host_os_version?: HostBuckets; + host_type?: HostBuckets; + key?: string; + lastSeen?: HostValue; + os?: HostOsHitsItem; +} + +export interface HostEsData extends SearchHit { + sort: string[]; + aggregations: { + host_count: { + value: number; + }; + host_data: { + buckets: HostAggEsItem[]; + }; + }; +} + +export interface HostAggEsData extends SearchHit { + sort: string[]; + aggregations: HostAggEsItem; +} + +export interface HostHit extends Hit { + _source: { + '@timestamp'?: string; + host: HostEcs; + }; + cursor?: string; + firstSeen?: string; + sort?: StringOrNumber[]; +} + +export type HostHits = Hits<number, HostHit>; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts new file mode 100644 index 0000000000000..70882ffcc2e5c --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/index.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; +import { ESQuery } from '../../typed_json'; +import { + ActionsStrategyResponse, + ActionsRequestOptions, + ActionDetailsStrategyResponse, + ActionDetailsRequestOptions, + ActionResultsStrategyResponse, + ActionResultsRequestOptions, +} from './actions'; +import { AgentsStrategyResponse, AgentsRequestOptions } from './agents'; +import { ResultsStrategyResponse, ResultsRequestOptions } from './results'; + +import { DocValueFields, SortField, PaginationInputPaginated } from '../common'; + +export * from './actions'; +export * from './agents'; +export * from './results'; + +export enum OsqueryQueries { + actions = 'actions', + actionDetails = 'actionDetails', + actionResults = 'actionResults', + agents = 'agents', + results = 'results', +} + +export type FactoryQueryTypes = OsqueryQueries; + +export interface RequestBasicOptions extends IEsSearchRequest { + filterQuery: ESQuery | string | undefined; + docValueFields?: DocValueFields[]; + factoryQueryType?: FactoryQueryTypes; +} + +/** A mapping of semantic fields to their document counterparts */ + +export type RequestOptions = RequestBasicOptions; + +export interface RequestOptionsPaginated<Field = string> extends RequestBasicOptions { + pagination: PaginationInputPaginated; + sort: SortField<Field>; +} + +export type StrategyResponseType<T extends FactoryQueryTypes> = T extends OsqueryQueries.actions + ? ActionsStrategyResponse + : T extends OsqueryQueries.actionDetails + ? ActionDetailsStrategyResponse + : T extends OsqueryQueries.actionResults + ? ActionResultsStrategyResponse + : T extends OsqueryQueries.agents + ? AgentsStrategyResponse + : T extends OsqueryQueries.results + ? ResultsStrategyResponse + : never; + +export type StrategyRequestType<T extends FactoryQueryTypes> = T extends OsqueryQueries.actions + ? ActionsRequestOptions + : T extends OsqueryQueries.actionDetails + ? ActionDetailsRequestOptions + : T extends OsqueryQueries.actionResults + ? ActionResultsRequestOptions + : T extends OsqueryQueries.agents + ? AgentsRequestOptions + : T extends OsqueryQueries.results + ? ResultsRequestOptions + : never; diff --git a/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts b/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts new file mode 100644 index 0000000000000..65df2591338e4 --- /dev/null +++ b/x-pack/plugins/osquery/common/search_strategy/osquery/results/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; +import { IEsSearchResponse } from '../../../../../../../src/plugins/data/common'; + +import { Inspect, Maybe, PageInfoPaginated } from '../../common'; +import { RequestOptionsPaginated } from '../..'; + +export type ResultEdges = SearchResponse<unknown>['hits']['hits']; + +export interface ResultsStrategyResponse extends IEsSearchResponse { + edges: ResultEdges; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export interface ResultsRequestOptions extends RequestOptionsPaginated<{}> { + actionId: string; +} diff --git a/x-pack/plugins/osquery/common/shared_imports.ts b/x-pack/plugins/osquery/common/shared_imports.ts new file mode 100644 index 0000000000000..58133db6aa1b0 --- /dev/null +++ b/x-pack/plugins/osquery/common/shared_imports.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Agent } from '../../fleet/common'; diff --git a/x-pack/plugins/osquery/common/typed_json.ts b/x-pack/plugins/osquery/common/typed_json.ts new file mode 100644 index 0000000000000..0d6e3877eae55 --- /dev/null +++ b/x-pack/plugins/osquery/common/typed_json.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DslQuery, Filter } from 'src/plugins/data/common'; + +import { JsonObject } from '../../../../src/plugins/kibana_utils/common'; + +export type ESQuery = + | ESRangeQuery + | ESQueryStringQuery + | ESMatchQuery + | ESTermQuery + | ESBoolQuery + | JsonObject; + +export interface ESRangeQuery { + range: { + [name: string]: { + gte: number; + lte: number; + format: string; + }; + }; +} + +export interface ESMatchQuery { + match: { + [name: string]: { + query: string; + operator: string; + zero_terms_query: string; + }; + }; +} + +export interface ESQueryStringQuery { + query_string: { + query: string; + analyze_wildcard: boolean; + }; +} + +export interface ESTermQuery { + term: Record<string, string>; +} + +export interface ESBoolQuery { + bool: { + must: DslQuery[]; + filter: Filter[]; + should: never[]; + must_not: Filter[]; + }; +} diff --git a/x-pack/plugins/osquery/common/utility_types.ts b/x-pack/plugins/osquery/common/utility_types.ts new file mode 100644 index 0000000000000..4a7bd02d0442b --- /dev/null +++ b/x-pack/plugins/osquery/common/utility_types.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as runtimeTypes from 'io-ts'; +import { ReactNode } from 'react'; + +// This type is for typing EuiDescriptionList +export interface DescriptionList { + title: NonNullable<ReactNode>; + description: NonNullable<ReactNode>; +} + +export const unionWithNullType = <T extends runtimeTypes.Mixed>(type: T) => + runtimeTypes.union([type, runtimeTypes.null]); + +export const stringEnum = <T>(enumObj: T, enumName = 'enum') => + new runtimeTypes.Type<T[keyof T], string>( + enumName, + (u): u is T[keyof T] => Object.values(enumObj).includes(u), + (u, c) => + Object.values(enumObj).includes(u) + ? runtimeTypes.success(u as T[keyof T]) + : runtimeTypes.failure(u, c), + (a) => (a as unknown) as string + ); + +/** + * Unreachable Assertion helper for scenarios like exhaustive switches. + * For references see: https://stackoverflow.com/questions/39419170/how-do-i-check-that-a-switch-block-is-exhaustive-in-typescript + * This "x" should _always_ be a type of "never" and not change to "unknown" or any other type. See above link or the generic + * concept of exhaustive checks in switch blocks. + * + * Optionally you can avoid the use of this by using early returns and TypeScript will clear your type checking without complaints + * but there are situations and times where this function might still be needed. + * @param x Unreachable field + * @param message Message of error thrown + */ +export const assertUnreachable = ( + x: never, // This should always be a type of "never" + message = 'Unknown Field in switch statement' +): never => { + throw new Error(`${message}: ${x}`); +}; diff --git a/x-pack/plugins/osquery/common/utils/build_query/filters.ts b/x-pack/plugins/osquery/common/utils/build_query/filters.ts new file mode 100644 index 0000000000000..bde03be3f5edc --- /dev/null +++ b/x-pack/plugins/osquery/common/utils/build_query/filters.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, isString } from 'lodash/fp'; + +import { ESQuery } from '../../../common/typed_json'; + +export const createQueryFilterClauses = (filterQuery: ESQuery | string | undefined) => + !isEmpty(filterQuery) ? [isString(filterQuery) ? JSON.parse(filterQuery) : filterQuery] : []; diff --git a/x-pack/plugins/osquery/common/utils/build_query/index.ts b/x-pack/plugins/osquery/common/utils/build_query/index.ts new file mode 100644 index 0000000000000..05606d556528c --- /dev/null +++ b/x-pack/plugins/osquery/common/utils/build_query/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './filters'; + +export const inspectStringifyObject = (obj: unknown) => { + try { + return JSON.stringify(obj, null, 2); + } catch { + return 'Sorry about that, something went wrong.'; + } +}; diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/jest.config.js b/x-pack/plugins/osquery/jest.config.js similarity index 82% rename from x-pack/plugins/vis_type_timeseries_enhanced/jest.config.js rename to x-pack/plugins/osquery/jest.config.js index 17c5c87e3ccc2..8132491df8534 100644 --- a/x-pack/plugins/vis_type_timeseries_enhanced/jest.config.js +++ b/x-pack/plugins/osquery/jest.config.js @@ -7,5 +7,5 @@ module.exports = { preset: '@kbn/test', rootDir: '../../..', - roots: ['<rootDir>/x-pack/plugins/vis_type_timeseries_enhanced'], + roots: ['<rootDir>/x-pack/plugins/osquery'], }; diff --git a/x-pack/plugins/osquery/kibana.json b/x-pack/plugins/osquery/kibana.json new file mode 100644 index 0000000000000..f6e90b9460506 --- /dev/null +++ b/x-pack/plugins/osquery/kibana.json @@ -0,0 +1,29 @@ +{ + "configPath": [ + "xpack", + "osquery" + ], + "extraPublicDirs": [ + "common" + ], + "id": "osquery", + "kibanaVersion": "kibana", + "optionalPlugins": [ + "home" + ], + "requiredBundles": [ + "esUiShared", + "kibanaUtils", + "kibanaReact", + "kibanaUtils" + ], + "requiredPlugins": [ + "data", + "dataEnhanced", + "fleet", + "navigation" + ], + "server": true, + "ui": true, + "version": "8.0.0" +} diff --git a/x-pack/plugins/osquery/public/action_results/action_results_table.tsx b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx new file mode 100644 index 0000000000000..68424d848a9c7 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/action_results_table.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, isEqual, keys, map } from 'lodash/fp'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiDataGridSorting } from '@elastic/eui'; +import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; + +import { useAllResults } from './use_action_results'; +import { Direction, ResultEdges } from '../../common/search_strategy'; + +const DataContext = createContext<ResultEdges>([]); + +interface ActionResultsTableProps { + actionId: string; +} + +const ActionResultsTableComponent: React.FC<ActionResultsTableProps> = ({ actionId }) => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + const onChangeItemsPerPage = useCallback( + (pageSize) => + setPagination((currentPagination) => ({ + ...currentPagination, + pageSize, + pageIndex: 0, + })), + [setPagination] + ); + const onChangePage = useCallback( + (pageIndex) => setPagination((currentPagination) => ({ ...currentPagination, pageIndex })), + [setPagination] + ); + + const [columns, setColumns] = useState<EuiDataGridColumn[]>([]); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); + + const [, { results, totalCount }] = useAllResults({ + actionId, + activePage: pagination.pageIndex, + limit: pagination.pageSize, + direction: Direction.asc, + sortField: '@timestamp', + }); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState<string[]>([]); // initialize to the full set of columns + + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns }), [ + visibleColumns, + setVisibleColumns, + ]); + + const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo( + () => ({ rowIndex, columnId, setCellProps }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const data = useContext(DataContext); + const value = data[rowIndex].fields[columnId]; + + return !isEmpty(value) ? value : '-'; + }, + [] + ); + + const tableSorting: EuiDataGridSorting = useMemo( + () => ({ columns: sortingColumns, onSort: setSortingColumns }), + [sortingColumns] + ); + + const tablePagination = useMemo( + () => ({ + ...pagination, + pageSizeOptions: [10, 50, 100], + onChangeItemsPerPage, + onChangePage, + }), + [onChangeItemsPerPage, onChangePage, pagination] + ); + + useEffect(() => { + const newColumns = keys(results[0]?.fields) + .sort() + .map((fieldName) => ({ + id: fieldName, + displayAsText: fieldName.split('.')[1], + defaultSortDirection: Direction.asc, + })); + + if (!isEqual(columns, newColumns)) { + setColumns(newColumns); + setVisibleColumns(map('id', newColumns)); + } + }, [columns, results]); + + return ( + <DataContext.Provider value={results}> + <EuiDataGrid + aria-label="Osquery results" + columns={columns} + columnVisibility={columnVisibility} + rowCount={totalCount} + renderCellValue={renderCellValue} + sorting={tableSorting} + pagination={tablePagination} + /> + </DataContext.Provider> + ); +}; + +export const ActionResultsTable = React.memo(ActionResultsTableComponent); diff --git a/x-pack/plugins/osquery/public/action_results/helpers.ts b/x-pack/plugins/osquery/public/action_results/helpers.ts new file mode 100644 index 0000000000000..9f908e16c2eb2 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PaginationInputPaginated, + FactoryQueryTypes, + StrategyResponseType, + Inspect, +} from '../../common/search_strategy'; + +export type InspectResponse = Inspect & { response: string[] }; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number, + isBucketSort?: boolean +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: isBucketSort ? limit : limit + cursorStart, + }; +}; + +export const getInspectResponse = <T extends FactoryQueryTypes>( + response: StrategyResponseType<T>, + prevResponse: InspectResponse +): InspectResponse => ({ + dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, +}); diff --git a/x-pack/plugins/osquery/public/action_results/translations.ts b/x-pack/plugins/osquery/public/action_results/translations.ts new file mode 100644 index 0000000000000..54c8ecebc60c0 --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', { + defaultMessage: `An error has occurred on all results search`, +}); + +export const FAIL_ALL_RESULTS = i18n.translate('xpack.osquery.results.failSearchDescription', { + defaultMessage: `Failed to fetch results`, +}); diff --git a/x-pack/plugins/osquery/public/action_results/use_action_results.ts b/x-pack/plugins/osquery/public/action_results/use_action_results.ts new file mode 100644 index 0000000000000..2c54606bf3fbb --- /dev/null +++ b/x-pack/plugins/osquery/public/action_results/use_action_results.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + ResultEdges, + PageInfoPaginated, + DocValueFields, + OsqueryQueries, + ResultsRequestOptions, + ResultsStrategyResponse, + Direction, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'resultsAllQuery'; + +export interface ResultsArgs { + results: ResultEdges; + id: string; + inspect: InspectResponse; + isInspected: boolean; + pageInfo: PageInfoPaginated; + totalCount: number; +} + +interface UseAllResults { + actionId: string; + activePage: number; + direction: Direction; + limit: number; + sortField: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useAllResults = ({ + actionId, + activePage, + direction, + limit, + sortField, + docValueFields, + filterQuery, + skip = false, +}: UseAllResults): [boolean, ResultsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [resultsRequest, setHostRequest] = useState<ResultsRequestOptions | null>(null); + + const [resultsResponse, setResultsResponse] = useState<ResultsArgs>({ + results: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + totalCount: -1, + }); + + const resultsSearch = useCallback( + (request: ResultsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<ResultsRequestOptions, ResultsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setResultsResponse((prevResponse) => ({ + ...prevResponse, + results: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ALL_RESULTS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_ALL_RESULTS, text: msg.message }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + actionId, + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.actionResults, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: { + direction, + field: sortField, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [actionId, activePage, direction, docValueFields, filterQuery, limit, sortField]); + + useEffect(() => { + resultsSearch(resultsRequest); + }, [resultsRequest, resultsSearch]); + + return [loading, resultsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/actions/actions_table.tsx b/x-pack/plugins/osquery/public/actions/actions_table.tsx new file mode 100644 index 0000000000000..917e915d9d9dc --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/actions_table.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, isEqual, keys, map } from 'lodash/fp'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn, EuiDataGridSorting } from '@elastic/eui'; +import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; + +import { useAllActions } from './use_all_actions'; +import { ActionEdges, Direction } from '../../common/search_strategy'; + +const DataContext = createContext<ActionEdges>([]); + +const ActionsTableComponent = () => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + const onChangeItemsPerPage = useCallback( + (pageSize) => + setPagination((currentPagination) => ({ + ...currentPagination, + pageSize, + pageIndex: 0, + })), + [setPagination] + ); + const onChangePage = useCallback( + (pageIndex) => setPagination((currentPagination) => ({ ...currentPagination, pageIndex })), + [setPagination] + ); + + const [columns, setColumns] = useState<EuiDataGridColumn[]>([]); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); + + const [, { actions, totalCount }] = useAllActions({ + activePage: pagination.pageIndex, + limit: pagination.pageSize, + direction: Direction.asc, + sortField: '@timestamp', + }); + + // Column visibility + const [visibleColumns, setVisibleColumns] = useState<string[]>([]); // initialize to the full set of columns + + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns }), [ + visibleColumns, + setVisibleColumns, + ]); + + const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo(() => { + return ({ rowIndex, columnId, setCellProps }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const data = useContext(DataContext); + const value = data[rowIndex].fields[columnId]; + + return !isEmpty(value) ? value : '-'; + }; + }, []); + + const tableSorting: EuiDataGridSorting = useMemo( + () => ({ columns: sortingColumns, onSort: setSortingColumns }), + [setSortingColumns, sortingColumns] + ); + + const tablePagination = useMemo( + () => ({ + ...pagination, + pageSizeOptions: [10, 50, 100], + onChangeItemsPerPage, + onChangePage, + }), + [onChangeItemsPerPage, onChangePage, pagination] + ); + + useEffect(() => { + const newColumns = keys(actions[0]?.fields) + .sort() + .map((fieldName) => ({ + id: fieldName, + displayAsText: fieldName.split('.')[1], + defaultSortDirection: Direction.asc, + })); + + if (!isEqual(columns, newColumns)) { + setColumns(newColumns); + setVisibleColumns(map('id', newColumns)); + } + }, [columns, actions]); + + return ( + <DataContext.Provider value={actions}> + <EuiDataGrid + aria-label="Osquery actions" + columns={columns} + columnVisibility={columnVisibility} + rowCount={totalCount} + renderCellValue={renderCellValue} + sorting={tableSorting} + pagination={tablePagination} + /> + </DataContext.Provider> + ); +}; + +export const ActionsTable = React.memo(ActionsTableComponent); diff --git a/x-pack/plugins/osquery/public/actions/helpers.ts b/x-pack/plugins/osquery/public/actions/helpers.ts new file mode 100644 index 0000000000000..9f908e16c2eb2 --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PaginationInputPaginated, + FactoryQueryTypes, + StrategyResponseType, + Inspect, +} from '../../common/search_strategy'; + +export type InspectResponse = Inspect & { response: string[] }; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number, + isBucketSort?: boolean +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: isBucketSort ? limit : limit + cursorStart, + }; +}; + +export const getInspectResponse = <T extends FactoryQueryTypes>( + response: StrategyResponseType<T>, + prevResponse: InspectResponse +): InspectResponse => ({ + dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, +}); diff --git a/x-pack/plugins/osquery/public/actions/translations.ts b/x-pack/plugins/osquery/public/actions/translations.ts new file mode 100644 index 0000000000000..3bf2d81e5e092 --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/translations.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_ALL_ACTIONS = i18n.translate('xpack.osquery.actions.errorSearchDescription', { + defaultMessage: `An error has occurred on all actions search`, +}); + +export const FAIL_ALL_ACTIONS = i18n.translate('xpack.osquery.actions.failSearchDescription', { + defaultMessage: `Failed to fetch actions`, +}); + +export const ERROR_ACTION_DETAILS = i18n.translate( + 'xpack.osquery.actionDetails.errorSearchDescription', + { + defaultMessage: `An error has occurred on action details search`, + } +); + +export const FAIL_ACTION_DETAILS = i18n.translate( + 'xpack.osquery.actionDetails.failSearchDescription', + { + defaultMessage: `Failed to fetch action details`, + } +); + +export const ERROR_ACTION_RESULTS = i18n.translate( + 'xpack.osquery.actionResults.errorSearchDescription', + { + defaultMessage: `An error has occurred on action results search`, + } +); + +export const FAIL_ACTION_RESULTS = i18n.translate( + 'xpack.osquery.actionResults.failSearchDescription', + { + defaultMessage: `Failed to fetch action results`, + } +); diff --git a/x-pack/plugins/osquery/public/actions/use_action_details.ts b/x-pack/plugins/osquery/public/actions/use_action_details.ts new file mode 100644 index 0000000000000..3112d7cbf221e --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/use_action_details.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + DocValueFields, + OsqueryQueries, + ActionDetailsRequestOptions, + ActionDetailsStrategyResponse, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'actionDetailsQuery'; + +export interface ActionDetailsArgs { + actionDetails: Record<string, string>; + id: string; + inspect: InspectResponse; + isInspected: boolean; +} + +interface UseActionDetails { + actionId: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useActionDetails = ({ + actionId, + docValueFields, + filterQuery, + skip = false, +}: UseActionDetails): [boolean, ActionDetailsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [actionDetailsRequest, setHostRequest] = useState<ActionDetailsRequestOptions | null>(null); + + const [actionDetailsResponse, setActionDetailsResponse] = useState<ActionDetailsArgs>({ + actionDetails: {}, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + }); + + const actionDetailsSearch = useCallback( + (request: ActionDetailsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<ActionDetailsRequestOptions, ActionDetailsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setActionDetailsResponse((prevResponse) => ({ + ...prevResponse, + actionDetails: response.actionDetails, + inspect: getInspectResponse(response, prevResponse.inspect), + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ACTION_DETAILS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ + title: i18n.FAIL_ACTION_DETAILS, + text: msg.message, + }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + actionId, + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.actionDetails, + filterQuery: createFilter(filterQuery), + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [actionId, docValueFields, filterQuery]); + + useEffect(() => { + actionDetailsSearch(actionDetailsRequest); + }, [actionDetailsRequest, actionDetailsSearch]); + + return [loading, actionDetailsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/actions/use_all_actions.ts b/x-pack/plugins/osquery/public/actions/use_all_actions.ts new file mode 100644 index 0000000000000..192f5b1eb410c --- /dev/null +++ b/x-pack/plugins/osquery/public/actions/use_all_actions.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + ActionEdges, + PageInfoPaginated, + DocValueFields, + OsqueryQueries, + ActionsRequestOptions, + ActionsStrategyResponse, + Direction, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'actionsAllQuery'; + +export interface ActionsArgs { + actions: ActionEdges; + id: string; + inspect: InspectResponse; + isInspected: boolean; + pageInfo: PageInfoPaginated; + totalCount: number; +} + +interface UseAllActions { + activePage: number; + direction: Direction; + limit: number; + sortField: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useAllActions = ({ + activePage, + direction, + limit, + sortField, + docValueFields, + filterQuery, + skip = false, +}: UseAllActions): [boolean, ActionsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [actionsRequest, setHostRequest] = useState<ActionsRequestOptions | null>(null); + + const [actionsResponse, setActionsResponse] = useState<ActionsArgs>({ + actions: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + totalCount: -1, + }); + + const actionsSearch = useCallback( + (request: ActionsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<ActionsRequestOptions, ActionsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setActionsResponse((prevResponse) => ({ + ...prevResponse, + actions: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ALL_ACTIONS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_ALL_ACTIONS, text: msg.message }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.actions, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: { + direction, + field: sortField, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, direction, docValueFields, filterQuery, limit, sortField]); + + useEffect(() => { + actionsSearch(actionsRequest); + }, [actionsRequest, actionsSearch]); + + return [loading, actionsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/agents/agents_table.tsx b/x-pack/plugins/osquery/public/agents/agents_table.tsx new file mode 100644 index 0000000000000..1c0083b8252e8 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/agents_table.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { find } from 'lodash/fp'; +import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiBasicTableProps, + EuiTableSelectionType, + EuiHealth, +} from '@elastic/eui'; + +import { useAllAgents } from './use_all_agents'; +import { Direction } from '../../common/search_strategy'; +import { Agent } from '../../common/shared_imports'; + +interface AgentsTableProps { + selectedAgents: string[]; + onChange: (payload: string[]) => void; +} + +const AgentsTableComponent: React.FC<AgentsTableProps> = ({ selectedAgents, onChange }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState<keyof Agent>('id'); + const [sortDirection, setSortDirection] = useState<Direction>(Direction.asc); + const [selectedItems, setSelectedItems] = useState([]); + const tableRef = useRef<EuiBasicTable<Agent>>(null); + + const onTableChange: EuiBasicTableProps<Agent>['onChange'] = useCallback( + ({ page = {}, sort = {} }) => { + const { index: newPageIndex, size: newPageSize } = page; + + const { field: newSortField, direction: newSortDirection } = sort; + + setPageIndex(newPageIndex); + setPageSize(newPageSize); + setSortField(newSortField); + setSortDirection(newSortDirection); + }, + [] + ); + + const onSelectionChange: EuiTableSelectionType<{}>['onSelectionChange'] = useCallback( + (newSelectedItems) => { + setSelectedItems(newSelectedItems); + // @ts-expect-error + onChange(newSelectedItems.map((item) => item._id)); + }, + [onChange] + ); + + const renderStatus = (online: string) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return <EuiHealth color={color}>{label}</EuiHealth>; + }; + + const [, { agents, totalCount }] = useAllAgents({ + activePage: pageIndex, + limit: pageSize, + direction: sortDirection, + sortField, + }); + + const columns: Array<EuiBasicTableColumn<{}>> = useMemo( + () => [ + { + field: 'local_metadata.elastic.agent.id', + name: 'id', + sortable: true, + truncateText: true, + }, + { + field: 'local_metadata.host.name', + name: 'hostname', + truncateText: true, + }, + + { + field: 'active', + name: 'Online', + dataType: 'boolean', + render: (active: string) => renderStatus(active), + }, + ], + [] + ); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: totalCount, + pageSizeOptions: [3, 5, 8], + }), + [pageIndex, pageSize, totalCount] + ); + + const sorting = useMemo( + () => ({ + sort: { + field: sortField, + direction: sortDirection, + }, + }), + [sortDirection, sortField] + ); + + const selection: EuiBasicTableProps<Agent>['selection'] = useMemo( + () => ({ + selectable: (agent: Agent) => agent.active, + selectableMessage: (selectable: boolean) => (!selectable ? 'User is currently offline' : ''), + onSelectionChange, + initialSelected: selectedItems, + }), + [onSelectionChange, selectedItems] + ); + + useEffect(() => { + if (selectedAgents?.length && agents.length && selectedItems.length !== selectedAgents.length) { + tableRef?.current?.setSelection( + // @ts-expect-error + selectedAgents.map((agentId) => find({ _id: agentId }, agents)) + ); + } + }, [selectedAgents, agents, selectedItems.length]); + + return ( + <EuiBasicTable<Agent> + ref={tableRef} + items={agents} + itemId="_id" + columns={columns} + pagination={pagination} + sorting={sorting} + isSelectable={true} + selection={selection} + onChange={onTableChange} + rowHeader="firstName" + /> + ); +}; + +export const AgentsTable = React.memo(AgentsTableComponent); diff --git a/x-pack/plugins/osquery/public/agents/helpers.ts b/x-pack/plugins/osquery/public/agents/helpers.ts new file mode 100644 index 0000000000000..9f908e16c2eb2 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PaginationInputPaginated, + FactoryQueryTypes, + StrategyResponseType, + Inspect, +} from '../../common/search_strategy'; + +export type InspectResponse = Inspect & { response: string[] }; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number, + isBucketSort?: boolean +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: isBucketSort ? limit : limit + cursorStart, + }; +}; + +export const getInspectResponse = <T extends FactoryQueryTypes>( + response: StrategyResponseType<T>, + prevResponse: InspectResponse +): InspectResponse => ({ + dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, +}); diff --git a/x-pack/plugins/osquery/public/agents/translations.ts b/x-pack/plugins/osquery/public/agents/translations.ts new file mode 100644 index 0000000000000..a95ad5e4ce163 --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_ALL_AGENTS = i18n.translate('xpack.osquery.agents.errorSearchDescription', { + defaultMessage: `An error has occurred on all agents search`, +}); + +export const FAIL_ALL_AGENTS = i18n.translate('xpack.osquery.agents.failSearchDescription', { + defaultMessage: `Failed to fetch agents`, +}); diff --git a/x-pack/plugins/osquery/public/agents/use_all_agents.ts b/x-pack/plugins/osquery/public/agents/use_all_agents.ts new file mode 100644 index 0000000000000..ad1a09486961a --- /dev/null +++ b/x-pack/plugins/osquery/public/agents/use_all_agents.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + PageInfoPaginated, + DocValueFields, + OsqueryQueries, + AgentsRequestOptions, + AgentsStrategyResponse, + Direction, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; +import { Agent } from '../../common/shared_imports'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'agentsAllQuery'; + +export interface AgentsArgs { + agents: Agent[]; + id: string; + inspect: InspectResponse; + isInspected: boolean; + pageInfo: PageInfoPaginated; + totalCount: number; +} + +interface UseAllAgents { + activePage: number; + direction: Direction; + limit: number; + sortField: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useAllAgents = ({ + activePage, + direction, + limit, + sortField, + docValueFields, + filterQuery, + skip = false, +}: UseAllAgents): [boolean, AgentsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [agentsRequest, setHostRequest] = useState<AgentsRequestOptions | null>(null); + + const [agentsResponse, setAgentsResponse] = useState<AgentsArgs>({ + agents: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + totalCount: -1, + }); + + const agentsSearch = useCallback( + (request: AgentsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<AgentsRequestOptions, AgentsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setAgentsResponse((prevResponse) => ({ + ...prevResponse, + agents: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ALL_AGENTS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_ALL_AGENTS, text: msg.message }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.agents, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: { + direction, + field: sortField, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, direction, docValueFields, filterQuery, limit, sortField]); + + useEffect(() => { + agentsSearch(agentsRequest); + }, [agentsRequest, agentsSearch]); + + return [loading, agentsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/application.tsx b/x-pack/plugins/osquery/public/application.tsx new file mode 100644 index 0000000000000..1a5c826df3310 --- /dev/null +++ b/x-pack/plugins/osquery/public/application.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiErrorBoundary } from '@elastic/eui'; +import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React, { useMemo } from 'react'; +import ReactDOM from 'react-dom'; +import { Router } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { ThemeProvider } from 'styled-components'; + +import { useUiSetting$ } from '../../../../src/plugins/kibana_react/public'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; +import { AppMountParameters, CoreStart } from '../../../../src/core/public'; +import { AppPluginStartDependencies } from './types'; +import { OsqueryApp } from './components/app'; +import { DEFAULT_DARK_MODE, PLUGIN_NAME } from '../common'; +import { KibanaContextProvider } from './common/lib/kibana'; + +const OsqueryAppContext = () => { + const [darkMode] = useUiSetting$<boolean>(DEFAULT_DARK_MODE); + const theme = useMemo( + () => ({ + eui: darkMode ? euiDarkVars : euiLightVars, + darkMode, + }), + [darkMode] + ); + + return ( + <ThemeProvider theme={theme}> + <OsqueryApp /> + </ThemeProvider> + ); +}; + +export const renderApp = ( + core: CoreStart, + services: AppPluginStartDependencies, + { element, history }: AppMountParameters, + storage: Storage, + kibanaVersion: string +) => { + ReactDOM.render( + <KibanaContextProvider + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + services={{ + appName: PLUGIN_NAME, + ...core, + ...services, + storage, + }} + > + <EuiErrorBoundary> + <Router history={history}> + <I18nProvider> + <OsqueryAppContext /> + </I18nProvider> + </Router> + </EuiErrorBoundary> + </KibanaContextProvider>, + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/x-pack/plugins/osquery/public/common/helpers.test.ts b/x-pack/plugins/osquery/public/common/helpers.test.ts new file mode 100644 index 0000000000000..5d378d79acc7a --- /dev/null +++ b/x-pack/plugins/osquery/public/common/helpers.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESQuery } from '../../common/typed_json'; + +import { createFilter } from './helpers'; + +describe('Helpers', () => { + describe('#createFilter', () => { + test('if it is a string it returns untouched', () => { + const filter = createFilter('even invalid strings return the same'); + expect(filter).toBe('even invalid strings return the same'); + }); + + test('if it is an ESQuery object it will be returned as a string', () => { + const query: ESQuery = { term: { 'host.id': 'host-value' } }; + const filter = createFilter(query); + expect(filter).toBe(JSON.stringify(query)); + }); + + test('if it is undefined, then undefined is returned', () => { + const filter = createFilter(undefined); + expect(filter).toBe(undefined); + }); + }); +}); diff --git a/x-pack/plugins/osquery/public/common/helpers.ts b/x-pack/plugins/osquery/public/common/helpers.ts new file mode 100644 index 0000000000000..e922e030c9330 --- /dev/null +++ b/x-pack/plugins/osquery/public/common/helpers.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isString } from 'lodash/fp'; + +import { ESQuery } from '../../common/typed_json'; + +export const createFilter = (filterQuery: ESQuery | string | undefined) => + isString(filterQuery) ? filterQuery : JSON.stringify(filterQuery); diff --git a/x-pack/plugins/osquery/public/common/index.ts b/x-pack/plugins/osquery/public/common/index.ts new file mode 100644 index 0000000000000..d805555791e2a --- /dev/null +++ b/x-pack/plugins/osquery/public/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './helpers'; diff --git a/x-pack/plugins/osquery/public/common/lib/kibana/index.ts b/x-pack/plugins/osquery/public/common/lib/kibana/index.ts new file mode 100644 index 0000000000000..b9cb71d4adb47 --- /dev/null +++ b/x-pack/plugins/osquery/public/common/lib/kibana/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './kibana_react'; diff --git a/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts new file mode 100644 index 0000000000000..b4fb307a62b6c --- /dev/null +++ b/x-pack/plugins/osquery/public/common/lib/kibana/kibana_react.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + KibanaContextProvider, + KibanaReactContextValue, + useKibana, + useUiSetting, + useUiSetting$, + withKibana, +} from '../../../../../../../src/plugins/kibana_react/public'; +import { StartServices } from '../../../types'; + +export type KibanaContext = KibanaReactContextValue<StartServices>; +export interface WithKibanaProps { + kibana: KibanaContext; +} + +const useTypedKibana = () => useKibana<StartServices>(); + +export { + KibanaContextProvider, + useTypedKibana as useKibana, + useUiSetting, + useUiSetting$, + withKibana, +}; diff --git a/x-pack/plugins/osquery/public/components/app.tsx b/x-pack/plugins/osquery/public/components/app.tsx new file mode 100644 index 0000000000000..49ff7e2bfb4da --- /dev/null +++ b/x-pack/plugins/osquery/public/components/app.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Switch, Route } from 'react-router-dom'; + +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageHeader, + EuiTitle, + EuiSpacer, +} from '@elastic/eui'; + +import { PLUGIN_NAME } from '../../common'; +import { LiveQuery } from '../live_query'; + +export const OsqueryAppComponent = () => { + return ( + <EuiPage restrictWidth="1000px"> + <EuiPageBody> + <EuiPageHeader> + <EuiTitle size="l"> + <h1> + <FormattedMessage + id="xpack.osquery.helloWorldText" + defaultMessage="{name}" + // eslint-disable-next-line react-perf/jsx-no-new-object-as-prop + values={{ name: PLUGIN_NAME }} + /> + </h1> + </EuiTitle> + </EuiPageHeader> + <EuiPageContent> + <EuiPageContentBody> + <EuiSpacer /> + + <Switch> + <Route path={`/live_query`}> + <LiveQuery /> + </Route> + </Switch> + + <EuiSpacer /> + </EuiPageContentBody> + </EuiPageContent> + </EuiPageBody> + </EuiPage> + ); +}; + +export const OsqueryApp = React.memo(OsqueryAppComponent); diff --git a/x-pack/plugins/osquery/public/editor/index.tsx b/x-pack/plugins/osquery/public/editor/index.tsx new file mode 100644 index 0000000000000..a0e549e77467b --- /dev/null +++ b/x-pack/plugins/osquery/public/editor/index.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { EuiCodeEditor } from '@elastic/eui'; +import 'brace/mode/sql'; +import 'brace/theme/tomorrow'; +import 'brace/ext/language_tools'; + +const EDITOR_SET_OPTIONS = { + enableBasicAutocompletion: true, + enableLiveAutocompletion: true, +}; + +const EDITOR_PROPS = { + $blockScrolling: true, +}; + +interface OsqueryEditorProps { + defaultValue: string; + onChange: (newValue: string) => void; +} + +const OsqueryEditorComponent: React.FC<OsqueryEditorProps> = ({ defaultValue, onChange }) => { + const handleChange = useCallback( + (newValue) => { + onChange(newValue); + }, + [onChange] + ); + + return ( + <EuiCodeEditor + value={defaultValue} + mode="sql" + theme="tomorrow" + onChange={handleChange} + name="osquery_editor" + setOptions={EDITOR_SET_OPTIONS} + editorProps={EDITOR_PROPS} + height="200px" + /> + ); +}; + +export const OsqueryEditor = React.memo(OsqueryEditorComponent); diff --git a/x-pack/plugins/osquery/public/index.ts b/x-pack/plugins/osquery/public/index.ts new file mode 100644 index 0000000000000..32b0a30c24e7a --- /dev/null +++ b/x-pack/plugins/osquery/public/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { OsqueryPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin(initializerContext: PluginInitializerContext) { + return new OsqueryPlugin(initializerContext); +} +export { OsqueryPluginSetup, OsqueryPluginStart } from './types'; diff --git a/x-pack/plugins/osquery/public/live_query/edit/index.tsx b/x-pack/plugins/osquery/public/live_query/edit/index.tsx new file mode 100644 index 0000000000000..5626e78069d01 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/edit/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; +import { EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { useParams } from 'react-router-dom'; + +import { useActionDetails } from '../../actions/use_action_details'; +import { ResultTabs } from './tabs'; +import { LiveQueryForm } from '../form'; + +const EditLiveQueryPageComponent = () => { + const { actionId } = useParams<{ actionId: string }>(); + const [loading, { actionDetails }] = useActionDetails({ actionId }); + + const handleSubmit = useCallback(() => Promise.resolve(), []); + + if (loading) { + return <>{'Loading...'}</>; + } + + return ( + <> + {!isEmpty(actionDetails) && ( + <LiveQueryForm actionDetails={actionDetails} onSubmit={handleSubmit} /> + )} + <EuiSpacer /> + <ResultTabs /> + </> + ); +}; + +export const EditLiveQueryPage = React.memo(EditLiveQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/live_query/edit/tabs.tsx b/x-pack/plugins/osquery/public/live_query/edit/tabs.tsx new file mode 100644 index 0000000000000..564b91873e11d --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/edit/tabs.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiTabbedContent, EuiSpacer } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import { ResultsTable } from '../../results/results_table'; +import { ActionResultsTable } from '../../action_results/action_results_table'; + +const ResultTabsComponent = () => { + const { actionId } = useParams<{ actionId: string }>(); + const tabs = useMemo( + () => [ + { + id: 'status', + name: 'Status', + content: ( + <> + <EuiSpacer /> + <ActionResultsTable actionId={actionId} /> + </> + ), + }, + { + id: 'results', + name: 'Results', + content: ( + <> + <EuiSpacer /> + <ResultsTable actionId={actionId} /> + </> + ), + }, + ], + [actionId] + ); + + const handleTabClick = useCallback((tab) => { + // eslint-disable-next-line no-console + console.log('clicked tab', tab); + }, []); + + return ( + <EuiTabbedContent + tabs={tabs} + initialSelectedTab={tabs[0]} + autoFocus="selected" + onTabClick={handleTabClick} + /> + ); +}; + +export const ResultTabs = React.memo(ResultTabsComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx b/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx new file mode 100644 index 0000000000000..a6d7fbd404321 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/agents_table_field.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { FieldHook } from '../../shared_imports'; +import { AgentsTable } from '../../agents/agents_table'; + +interface AgentsTableFieldProps { + field: FieldHook<string[]>; +} + +const AgentsTableFieldComponent: React.FC<AgentsTableFieldProps> = ({ field }) => { + const { value, setValue } = field; + const handleChange = useCallback( + (props) => { + if (props !== value) { + return setValue(props); + } + }, + [value, setValue] + ); + + return <AgentsTable selectedAgents={value} onChange={handleChange} />; +}; + +export const AgentsTableField = React.memo(AgentsTableFieldComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx b/x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx new file mode 100644 index 0000000000000..458d2263fe9c2 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/code_editor_field.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; + +import { OsqueryEditor } from '../../editor'; +import { FieldHook } from '../../shared_imports'; + +interface CodeEditorFieldProps { + field: FieldHook<{ query: string }>; +} + +const CodeEditorFieldComponent: React.FC<CodeEditorFieldProps> = ({ field }) => { + const { value, setValue } = field; + const handleChange = useCallback( + (newQuery) => { + setValue({ + ...value, + query: newQuery, + }); + }, + [value, setValue] + ); + + return <OsqueryEditor defaultValue={value.query} onChange={handleChange} />; +}; + +export const CodeEditorField = React.memo(CodeEditorFieldComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/index.tsx b/x-pack/plugins/osquery/public/live_query/form/index.tsx new file mode 100644 index 0000000000000..23aa94b46a569 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/index.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { UseField, Form, useForm } from '../../shared_imports'; +import { AgentsTableField } from './agents_table_field'; +import { CodeEditorField } from './code_editor_field'; + +const FORM_ID = 'liveQueryForm'; + +interface LiveQueryFormProps { + actionDetails?: Record<string, string>; + onSubmit: (payload: Record<string, string>) => Promise<void>; +} + +const LiveQueryFormComponent: React.FC<LiveQueryFormProps> = ({ actionDetails, onSubmit }) => { + const handleSubmit = useCallback( + (payload) => { + onSubmit(payload); + return Promise.resolve(); + }, + [onSubmit] + ); + + const { form } = useForm({ + id: FORM_ID, + // schema: formSchema, + onSubmit: handleSubmit, + options: { + stripEmptyFields: false, + }, + defaultValue: actionDetails, + deserializer: ({ fields, _source }) => ({ + agents: fields?.agents, + command: _source?.data?.commands[0], + }), + }); + + const { submit } = form; + + return ( + <Form form={form}> + <UseField path="agents" component={AgentsTableField} /> + <EuiSpacer /> + <UseField path="command" component={CodeEditorField} /> + <EuiButton onClick={submit}>Send query</EuiButton> + </Form> + ); +}; + +export const LiveQueryForm = React.memo(LiveQueryFormComponent); diff --git a/x-pack/plugins/osquery/public/live_query/form/schema.ts b/x-pack/plugins/osquery/public/live_query/form/schema.ts new file mode 100644 index 0000000000000..e55b3d6cd6a5b --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/form/schema.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FIELD_TYPES, FormSchema } from '../../shared_imports'; + +export const formSchema: FormSchema = { + agents: { + type: FIELD_TYPES.MULTI_SELECT, + }, + command: { + type: FIELD_TYPES.TEXTAREA, + validations: [], + }, +}; diff --git a/x-pack/plugins/osquery/public/live_query/index.tsx b/x-pack/plugins/osquery/public/live_query/index.tsx new file mode 100644 index 0000000000000..646d2637a4c40 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Switch, Route, useRouteMatch } from 'react-router-dom'; + +import { QueriesPage } from './queries'; +import { NewLiveQueryPage } from './new'; +import { EditLiveQueryPage } from './edit'; + +const LiveQueryComponent = () => { + const match = useRouteMatch(); + + return ( + <Switch> + <Route path={`${match.url}/queries/new`}> + <NewLiveQueryPage /> + </Route> + <Route path={`${match.url}/queries/:actionId`}> + <EditLiveQueryPage /> + </Route> + <Route path={`${match.url}/queries`}> + <QueriesPage /> + </Route> + </Switch> + ); +}; + +export const LiveQuery = React.memo(LiveQueryComponent); diff --git a/x-pack/plugins/osquery/public/live_query/new/index.tsx b/x-pack/plugins/osquery/public/live_query/new/index.tsx new file mode 100644 index 0000000000000..40f934b4690f9 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/new/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { useKibana } from '../../common/lib/kibana'; +import { LiveQueryForm } from '../form'; + +const NewLiveQueryPageComponent = () => { + const { http } = useKibana().services; + const history = useHistory(); + + const handleSubmit = useCallback( + async (props) => { + const response = await http.post('/api/osquery/queries', { body: JSON.stringify(props) }); + const requestParamsActionId = JSON.parse(response.meta.request.params.body).action_id; + history.push(`/live_query/queries/${requestParamsActionId}`); + }, + [history, http] + ); + + return <LiveQueryForm onSubmit={handleSubmit} />; +}; + +export const NewLiveQueryPage = React.memo(NewLiveQueryPageComponent); diff --git a/x-pack/plugins/osquery/public/live_query/queries/index.tsx b/x-pack/plugins/osquery/public/live_query/queries/index.tsx new file mode 100644 index 0000000000000..5600284b8c147 --- /dev/null +++ b/x-pack/plugins/osquery/public/live_query/queries/index.tsx @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiSpacer, EuiTitle } from '@elastic/eui'; +import React from 'react'; + +import { ActionsTable } from '../../actions/actions_table'; + +const QueriesPageComponent = () => { + return ( + <> + <EuiTitle> + <h1>{'Queries'}</h1> + </EuiTitle> + <EuiSpacer /> + <ActionsTable /> + </> + ); +}; + +export const QueriesPage = React.memo(QueriesPageComponent); diff --git a/x-pack/plugins/osquery/public/plugin.ts b/x-pack/plugins/osquery/public/plugin.ts new file mode 100644 index 0000000000000..41698d3a1740d --- /dev/null +++ b/x-pack/plugins/osquery/public/plugin.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AppMountParameters, + CoreSetup, + Plugin, + PluginInitializerContext, + CoreStart, +} from 'src/core/public'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; +import { OsqueryPluginSetup, OsqueryPluginStart, AppPluginStartDependencies } from './types'; +import { PLUGIN_NAME } from '../common'; + +export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginStart> { + private kibanaVersion: string; + private storage = new Storage(localStorage); + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.kibanaVersion = this.initializerContext.env.packageInfo.version; + } + + public setup(core: CoreSetup): OsqueryPluginSetup { + const config = this.initializerContext.config.get<{ enabled: boolean }>(); + + if (!config.enabled) { + return {}; + } + + const storage = this.storage; + const kibanaVersion = this.kibanaVersion; + // Register an application into the side navigation menu + core.application.register({ + id: 'osquery', + title: PLUGIN_NAME, + async mount(params: AppMountParameters) { + // Get start services as specified in kibana.json + const [coreStart, depsStart] = await core.getStartServices(); + // Load application bundle + const { renderApp } = await import('./application'); + // Render the application + return renderApp( + coreStart, + depsStart as AppPluginStartDependencies, + params, + storage, + kibanaVersion + ); + }, + }); + + // Return methods that should be available to other plugins + return {}; + } + + public start(core: CoreStart): OsqueryPluginStart { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/osquery/public/results/helpers.ts b/x-pack/plugins/osquery/public/results/helpers.ts new file mode 100644 index 0000000000000..9f908e16c2eb2 --- /dev/null +++ b/x-pack/plugins/osquery/public/results/helpers.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PaginationInputPaginated, + FactoryQueryTypes, + StrategyResponseType, + Inspect, +} from '../../common/search_strategy'; + +export type InspectResponse = Inspect & { response: string[] }; + +export const generateTablePaginationOptions = ( + activePage: number, + limit: number, + isBucketSort?: boolean +): PaginationInputPaginated => { + const cursorStart = activePage * limit; + return { + activePage, + cursorStart, + fakePossibleCount: 4 <= activePage && activePage > 0 ? limit * (activePage + 2) : limit * 5, + querySize: isBucketSort ? limit : limit + cursorStart, + }; +}; + +export const getInspectResponse = <T extends FactoryQueryTypes>( + response: StrategyResponseType<T>, + prevResponse: InspectResponse +): InspectResponse => ({ + dsl: response?.inspect?.dsl ?? prevResponse?.dsl ?? [], + response: + response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response, +}); diff --git a/x-pack/plugins/osquery/public/results/results_table.tsx b/x-pack/plugins/osquery/public/results/results_table.tsx new file mode 100644 index 0000000000000..69b350e461a5c --- /dev/null +++ b/x-pack/plugins/osquery/public/results/results_table.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, isEqual, keys, map } from 'lodash/fp'; +import { EuiDataGrid, EuiDataGridProps, EuiDataGridColumn } from '@elastic/eui'; +import React, { createContext, useEffect, useState, useCallback, useContext, useMemo } from 'react'; + +import { EuiDataGridSorting } from '@elastic/eui'; +import { useAllResults } from './use_all_results'; +import { Direction, ResultEdges } from '../../common/search_strategy'; + +const DataContext = createContext<ResultEdges>([]); + +interface ResultsTableComponentProps { + actionId: string; +} + +const ResultsTableComponent: React.FC<ResultsTableComponentProps> = ({ actionId }) => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); + const onChangeItemsPerPage = useCallback( + (pageSize) => + setPagination((currentPagination) => ({ + ...currentPagination, + pageSize, + pageIndex: 0, + })), + [setPagination] + ); + const onChangePage = useCallback( + (pageIndex) => setPagination((currentPagination) => ({ ...currentPagination, pageIndex })), + [setPagination] + ); + + const [columns, setColumns] = useState<EuiDataGridColumn[]>([]); + + // ** Sorting config + const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]); + const onSort = useCallback( + (newSortingColumns) => { + setSortingColumns(newSortingColumns); + }, + [setSortingColumns] + ); + + const [, { results, totalCount }] = useAllResults({ + actionId, + activePage: pagination.pageIndex, + limit: pagination.pageSize, + direction: Direction.asc, + sortField: '@timestamp', + }); + + const [visibleColumns, setVisibleColumns] = useState<string[]>([]); + const columnVisibility = useMemo(() => ({ visibleColumns, setVisibleColumns }), [ + visibleColumns, + setVisibleColumns, + ]); + + const renderCellValue: EuiDataGridProps['renderCellValue'] = useMemo( + () => ({ rowIndex, columnId, setCellProps }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const data = useContext(DataContext); + + const value = data[rowIndex].fields[columnId]; + + return !isEmpty(value) ? value : '-'; + }, + [] + ); + + const tableSorting = useMemo(() => ({ columns: sortingColumns, onSort }), [ + onSort, + sortingColumns, + ]); + + const tablePagination = useMemo( + () => ({ + ...pagination, + pageSizeOptions: [10, 50, 100], + onChangeItemsPerPage, + onChangePage, + }), + [onChangeItemsPerPage, onChangePage, pagination] + ); + + useEffect(() => { + const newColumns: EuiDataGridColumn[] = keys(results[0]?.fields) + .sort() + .map((fieldName) => ({ + id: fieldName, + displayAsText: fieldName.split('.')[1], + defaultSortDirection: 'asc', + })); + + if (!isEqual(columns, newColumns)) { + setColumns(newColumns); + setVisibleColumns(map('id', newColumns)); + } + }, [columns, results]); + + return ( + <DataContext.Provider value={results}> + <EuiDataGrid + aria-label="Osquery results" + columns={columns} + columnVisibility={columnVisibility} + rowCount={totalCount} + renderCellValue={renderCellValue} + sorting={tableSorting} + pagination={tablePagination} + /> + </DataContext.Provider> + ); +}; + +export const ResultsTable = React.memo(ResultsTableComponent); diff --git a/x-pack/plugins/osquery/public/results/translations.ts b/x-pack/plugins/osquery/public/results/translations.ts new file mode 100644 index 0000000000000..54c8ecebc60c0 --- /dev/null +++ b/x-pack/plugins/osquery/public/results/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_ALL_RESULTS = i18n.translate('xpack.osquery.results.errorSearchDescription', { + defaultMessage: `An error has occurred on all results search`, +}); + +export const FAIL_ALL_RESULTS = i18n.translate('xpack.osquery.results.failSearchDescription', { + defaultMessage: `Failed to fetch results`, +}); diff --git a/x-pack/plugins/osquery/public/results/use_all_results.ts b/x-pack/plugins/osquery/public/results/use_all_results.ts new file mode 100644 index 0000000000000..2fc5f9ae869a7 --- /dev/null +++ b/x-pack/plugins/osquery/public/results/use_all_results.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import deepEqual from 'fast-deep-equal'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { createFilter } from '../common/helpers'; +import { useKibana } from '../common/lib/kibana'; +import { + ResultEdges, + PageInfoPaginated, + DocValueFields, + OsqueryQueries, + ResultsRequestOptions, + ResultsStrategyResponse, + Direction, +} from '../../common/search_strategy'; +import { ESTermQuery } from '../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../src/plugins/data/common'; +import { AbortError } from '../../../../../src/plugins/kibana_utils/common'; +import { generateTablePaginationOptions, getInspectResponse, InspectResponse } from './helpers'; + +const ID = 'resultsAllQuery'; + +export interface ResultsArgs { + results: ResultEdges; + id: string; + inspect: InspectResponse; + isInspected: boolean; + pageInfo: PageInfoPaginated; + totalCount: number; +} + +interface UseAllResults { + actionId: string; + activePage: number; + direction: Direction; + limit: number; + sortField: string; + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + skip?: boolean; +} + +export const useAllResults = ({ + actionId, + activePage, + direction, + limit, + sortField, + docValueFields, + filterQuery, + skip = false, +}: UseAllResults): [boolean, ResultsArgs] => { + const { data, notifications } = useKibana().services; + + const abortCtrl = useRef(new AbortController()); + const [loading, setLoading] = useState(false); + const [resultsRequest, setHostRequest] = useState<ResultsRequestOptions | null>(null); + + const [resultsResponse, setResultsResponse] = useState<ResultsArgs>({ + results: [], + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + totalCount: -1, + }); + + const resultsSearch = useCallback( + (request: ResultsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<ResultsRequestOptions, ResultsStrategyResponse>(request, { + strategy: 'osquerySearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + if (!didCancel) { + setLoading(false); + setResultsResponse((prevResponse) => ({ + ...prevResponse, + results: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (isErrorResponse(response)) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_ALL_RESULTS); + searchSubscription$.unsubscribe(); + } + }, + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ title: i18n.FAIL_ALL_RESULTS, text: msg.message }); + } + }, + }); + }; + abortCtrl.current.abort(); + asyncSearch(); + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts, skip] + ); + + useEffect(() => { + setHostRequest((prevRequest) => { + const myRequest = { + ...(prevRequest ?? {}), + actionId, + docValueFields: docValueFields ?? [], + factoryQueryType: OsqueryQueries.results, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + sort: { + direction, + field: sortField, + }, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [actionId, activePage, direction, docValueFields, filterQuery, limit, sortField]); + + useEffect(() => { + resultsSearch(resultsRequest); + }, [resultsRequest, resultsSearch]); + + return [loading, resultsResponse]; +}; diff --git a/x-pack/plugins/osquery/public/shared_imports.ts b/x-pack/plugins/osquery/public/shared_imports.ts new file mode 100644 index 0000000000000..5f7503a00702c --- /dev/null +++ b/x-pack/plugins/osquery/public/shared_imports.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + getUseField, + getFieldValidityAndErrorMessage, + FieldHook, + FieldValidateResponse, + FIELD_TYPES, + Form, + FormData, + FormDataProvider, + FormHook, + FormSchema, + UseField, + UseMultiFields, + useForm, + useFormContext, + useFormData, + ValidationError, + ValidationFunc, + VALIDATION_TYPES, +} from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; +export { Field, SelectField } from '../../../../src/plugins/es_ui_shared/static/forms/components'; +export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; +export { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; diff --git a/x-pack/plugins/osquery/public/types.ts b/x-pack/plugins/osquery/public/types.ts new file mode 100644 index 0000000000000..faaccfc29d5f1 --- /dev/null +++ b/x-pack/plugins/osquery/public/types.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { FleetStart } from '../../fleet/public'; +import { CoreStart } from '../../../../src/core/public'; +import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OsqueryPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OsqueryPluginStart {} + +export interface AppPluginStartDependencies { + navigation: NavigationPublicPluginStart; +} + +export interface StartPlugins { + data: DataPublicPluginStart; + fleet?: FleetStart; +} + +export type StartServices = CoreStart & StartPlugins; diff --git a/x-pack/plugins/osquery/server/config.ts b/x-pack/plugins/osquery/server/config.ts new file mode 100644 index 0000000000000..633a95b8f91a7 --- /dev/null +++ b/x-pack/plugins/osquery/server/config.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf, schema } from '@kbn/config-schema'; + +export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), +}); + +export type ConfigType = TypeOf<typeof ConfigSchema>; diff --git a/x-pack/plugins/osquery/server/create_config.ts b/x-pack/plugins/osquery/server/create_config.ts new file mode 100644 index 0000000000000..e46c71798eb9f --- /dev/null +++ b/x-pack/plugins/osquery/server/create_config.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map } from 'rxjs/operators'; +import { PluginInitializerContext } from 'kibana/server'; +import { Observable } from 'rxjs'; + +import { ConfigType } from './config'; + +export const createConfig$ = ( + context: PluginInitializerContext +): Observable<Readonly<ConfigType>> => { + return context.config.create<ConfigType>().pipe(map((config) => config)); +}; diff --git a/x-pack/plugins/osquery/server/index.ts b/x-pack/plugins/osquery/server/index.ts new file mode 100644 index 0000000000000..c74ef6c95a2e7 --- /dev/null +++ b/x-pack/plugins/osquery/server/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from '../../../../src/core/server'; +import { OsqueryPlugin } from './plugin'; +import { ConfigSchema } from './config'; + +export const config = { + schema: ConfigSchema, + exposeToBrowser: { + enabled: true, + }, +}; +export function plugin(initializerContext: PluginInitializerContext) { + return new OsqueryPlugin(initializerContext); +} + +export { OsqueryPluginSetup, OsqueryPluginStart } from './types'; diff --git a/x-pack/plugins/osquery/server/plugin.ts b/x-pack/plugins/osquery/server/plugin.ts new file mode 100644 index 0000000000000..3e59faa55d057 --- /dev/null +++ b/x-pack/plugins/osquery/server/plugin.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { first } from 'rxjs/operators'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../../src/core/server'; + +import { createConfig$ } from './create_config'; +import { OsqueryPluginSetup, OsqueryPluginStart, SetupPlugins, StartPlugins } from './types'; +import { defineRoutes } from './routes'; +import { osquerySearchStrategyProvider } from './search_strategy/osquery'; + +export class OsqueryPlugin implements Plugin<OsqueryPluginSetup, OsqueryPluginStart> { + private readonly logger: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup<StartPlugins, OsqueryPluginStart>, plugins: SetupPlugins) { + this.logger.debug('osquery: Setup'); + const config = await createConfig$(this.initializerContext).pipe(first()).toPromise(); + + if (!config.enabled) { + return {}; + } + + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router); + + core.getStartServices().then(([_, depsStart]) => { + const osquerySearchStrategy = osquerySearchStrategyProvider(depsStart.data); + + plugins.data.search.registerSearchStrategy('osquerySearchStrategy', osquerySearchStrategy); + }); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('osquery: Started'); + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/osquery/server/routes/index.ts b/x-pack/plugins/osquery/server/routes/index.ts new file mode 100644 index 0000000000000..f975865950a4d --- /dev/null +++ b/x-pack/plugins/osquery/server/routes/index.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { schema } from '@kbn/config-schema'; +import moment from 'moment'; + +import { IRouter } from '../../../../../src/core/server'; + +export function defineRoutes(router: IRouter) { + router.post( + { + path: '/api/osquery/queries', + validate: { + params: schema.object({}, { unknowns: 'allow' }), + body: schema.object({}, { unknowns: 'allow' }), + }, + }, + async (context, request, response) => { + const esClient = context.core.elasticsearch.client.asInternalUser; + const query = await esClient.index<{}, {}>({ + index: '.fleet-actions-new', + body: { + action_id: uuid.v4(), + '@timestamp': moment().toISOString(), + expiration: moment().add(2, 'days').toISOString(), + type: 'APP_ACTION', + input_id: 'osquery', + // @ts-expect-error + agents: request.body.agents, + data: { + commands: [ + { + id: uuid.v4(), + // @ts-expect-error + query: request.body.command.query, + }, + ], + }, + }, + }); + return response.ok({ + body: query, + }); + } + ); +} diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts new file mode 100644 index 0000000000000..75cdb67deed4d --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + ActionsStrategyResponse, + ActionsRequestOptions, + OsqueryQueries, +} from '../../../../../../common/search_strategy/osquery'; +import { inspectStringifyObject } from '../../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../../types'; +import { buildActionsQuery } from './query.all_actions.dsl'; + +export const allActions: OsqueryFactory<OsqueryQueries.actions> = { + buildDsl: (options: ActionsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildActionsQuery(options); + }, + parse: async ( + options: ActionsRequestOptions, + response: IEsSearchResponse<object> + ): Promise<ActionsStrategyResponse> => { + const { activePage } = options.pagination; + const inspect = { + dsl: [inspectStringifyObject(buildActionsQuery(options))], + }; + + return { + ...response, + inspect, + edges: response.rawResponse.hits.hits, + totalCount: response.rawResponse.hits.total, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts new file mode 100644 index 0000000000000..29af1df3a9e0c --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/all/query.all_actions.dsl.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; +import { AgentsRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../../common/utils/build_query'; + +export const buildActionsQuery = ({ + docValueFields, + filterQuery, + pagination: { activePage, querySize }, + sort, +}: AgentsRequestOptions): ISearchRequestParams => { + const filter = [...createQueryFilterClauses(filterQuery)]; + + const dslQuery = { + allowNoIndices: true, + index: '.fleet-actions', + ignoreUnavailable: true, + body: { + query: { bool: { filter } }, + from: activePage * querySize, + size: querySize, + track_total_hits: true, + fields: ['*'], + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/index.ts new file mode 100644 index 0000000000000..09e317786e20f --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + ActionDetailsStrategyResponse, + ActionDetailsRequestOptions, + OsqueryQueries, +} from '../../../../../../common/search_strategy/osquery'; + +import { inspectStringifyObject } from '../../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../../types'; +import { buildActionDetailsQuery } from './query.action_details.dsl'; + +export const actionDetails: OsqueryFactory<OsqueryQueries.actionDetails> = { + buildDsl: (options: ActionDetailsRequestOptions) => { + return buildActionDetailsQuery(options); + }, + parse: async ( + options: ActionDetailsRequestOptions, + response: IEsSearchResponse<unknown> + ): Promise<ActionDetailsStrategyResponse> => { + const inspect = { + dsl: [inspectStringifyObject(buildActionDetailsQuery(options))], + }; + + return { + ...response, + inspect, + actionDetails: response.rawResponse.hits.hits[0], + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts new file mode 100644 index 0000000000000..f22066134cbca --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/details/query.action_details.dsl.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; +import { ActionDetailsRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../../common/utils/build_query'; + +export const buildActionDetailsQuery = ({ + actionId, + docValueFields, + filterQuery, +}: ActionDetailsRequestOptions): ISearchRequestParams => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + match_phrase: { + action_id: actionId, + }, + }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: '.fleet-actions', + ignoreUnavailable: true, + body: { + query: { bool: { filter } }, + size: 1, + fields: ['*'], + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/index.ts new file mode 100644 index 0000000000000..c5a6342c180ed --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './all'; +export * from './details'; +export * from './results'; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts new file mode 100644 index 0000000000000..4a049ca670cc6 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/index.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + ActionResultsStrategyResponse, + ActionResultsRequestOptions, + OsqueryQueries, +} from '../../../../../../common/search_strategy/osquery'; + +import { inspectStringifyObject } from '../../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../../types'; +import { buildActionResultsQuery } from './query.action_results.dsl'; + +export const actionResults: OsqueryFactory<OsqueryQueries.actionResults> = { + buildDsl: (options: ActionResultsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildActionResultsQuery(options); + }, + parse: async ( + options: ActionResultsRequestOptions, + response: IEsSearchResponse<object> + ): Promise<ActionResultsStrategyResponse> => { + const { activePage } = options.pagination; + const inspect = { + dsl: [inspectStringifyObject(buildActionResultsQuery(options))], + }; + + return { + ...response, + inspect, + edges: response.rawResponse.hits.hits, + totalCount: response.rawResponse.hits.total, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts new file mode 100644 index 0000000000000..8b80427d4d0e0 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/actions/results/query.action_results.dsl.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; +import { ActionResultsRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../../common/utils/build_query'; + +export const buildActionResultsQuery = ({ + actionId, + docValueFields, + filterQuery, + pagination: { activePage, querySize }, + sort, +}: ActionResultsRequestOptions): ISearchRequestParams => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + match_phrase: { + action_id: actionId, + }, + }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: '.fleet-actions-results', + ignoreUnavailable: true, + body: { + query: { bool: { filter } }, + from: activePage * querySize, + size: querySize, + track_total_hits: true, + fields: ['*'], + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts new file mode 100644 index 0000000000000..615343c738d78 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/index.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; +import { + AgentsStrategyResponse, + AgentsRequestOptions, + OsqueryQueries, +} from '../../../../../common/search_strategy/osquery'; + +import { Agent } from '../../../../../common/shared_imports'; +import { inspectStringifyObject } from '../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../types'; +import { buildAgentsQuery } from './query.all_agents.dsl'; + +export const allAgents: OsqueryFactory<OsqueryQueries.agents> = { + buildDsl: (options: AgentsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildAgentsQuery(options); + }, + parse: async ( + options: AgentsRequestOptions, + response: IEsSearchResponse<Agent> + ): Promise<AgentsStrategyResponse> => { + const { activePage } = options.pagination; + const inspect = { + dsl: [inspectStringifyObject(buildAgentsQuery(options))], + }; + + return { + ...response, + inspect, + edges: response.rawResponse.hits.hits.map((hit) => ({ _id: hit._id, ...hit._source })), + totalCount: response.rawResponse.hits.total, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts new file mode 100644 index 0000000000000..935a6cd7b215e --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/agents/query.all_agents.dsl.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; +import { ISearchRequestParams } from '../../../../../../../../src/plugins/data/common'; +import { AgentsRequestOptions } from '../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../common/utils/build_query'; + +export const buildAgentsQuery = ({ + docValueFields, + filterQuery, + pagination: { querySize }, + sort, +}: AgentsRequestOptions): ISearchRequestParams => { + const filter = [...createQueryFilterClauses(filterQuery)]; + + const dslQuery = { + allowNoIndices: true, + index: '.fleet-agents', + ignoreUnavailable: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + query: { bool: { filter } }, + track_total_hits: true, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/index.ts new file mode 100644 index 0000000000000..dc2e741dbe3e4 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FactoryQueryTypes, OsqueryQueries } from '../../../../common/search_strategy/osquery'; + +import { allActions, actionDetails, actionResults } from './actions'; +import { allAgents } from './agents'; +import { allResults } from './results'; + +import { OsqueryFactory } from './types'; + +export const osqueryFactory: Record<FactoryQueryTypes, OsqueryFactory<FactoryQueryTypes>> = { + [OsqueryQueries.actions]: allActions, + [OsqueryQueries.actionDetails]: actionDetails, + [OsqueryQueries.actionResults]: actionResults, + [OsqueryQueries.agents]: allAgents, + [OsqueryQueries.results]: allResults, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts new file mode 100644 index 0000000000000..1460a0e5d331e --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; +import { + ResultsStrategyResponse, + ResultsRequestOptions, + OsqueryQueries, +} from '../../../../../common/search_strategy/osquery'; +import { inspectStringifyObject } from '../../../../../common/utils/build_query'; +import { OsqueryFactory } from '../types'; +import { buildResultsQuery } from './query.all_results.dsl'; + +export const allResults: OsqueryFactory<OsqueryQueries.results> = { + buildDsl: (options: ResultsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildResultsQuery(options); + }, + parse: async ( + options: ResultsRequestOptions, + response: IEsSearchResponse<unknown> + ): Promise<ResultsStrategyResponse> => { + const { activePage } = options.pagination; + const inspect = { + dsl: [inspectStringifyObject(buildResultsQuery(options))], + }; + + return { + ...response, + inspect, + edges: response.rawResponse.hits.hits, + totalCount: response.rawResponse.hits.total, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + }; + }, +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts new file mode 100644 index 0000000000000..c099e2762d741 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/results/query.all_results.dsl.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISearchRequestParams } from '../../../../../../../../src/plugins/data/common'; +import { ResultsRequestOptions } from '../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../common/utils/build_query'; + +export const buildResultsQuery = ({ + actionId, + filterQuery, + pagination: { activePage, querySize }, + sort, +}: ResultsRequestOptions): ISearchRequestParams => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + match_phrase: { + action_id: actionId, + }, + }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: 'logs-elastic_agent.osquery*', + ignoreUnavailable: true, + body: { + query: { bool: { filter } }, + from: activePage * querySize, + size: querySize, + track_total_hits: true, + fields: ['agent.*', 'osquery.*'], + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/factory/types.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/types.ts new file mode 100644 index 0000000000000..bc2bf63958a09 --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/factory/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IEsSearchResponse, + ISearchRequestParams, +} from '../../../../../../../src/plugins/data/common'; +import { + FactoryQueryTypes, + StrategyRequestType, + StrategyResponseType, +} from '../../../../common/search_strategy/osquery'; + +export interface OsqueryFactory<T extends FactoryQueryTypes> { + buildDsl: (options: StrategyRequestType<T>) => ISearchRequestParams; + parse: ( + options: StrategyRequestType<T>, + response: IEsSearchResponse + ) => Promise<StrategyResponseType<T>>; +} diff --git a/x-pack/plugins/osquery/server/search_strategy/osquery/index.ts b/x-pack/plugins/osquery/server/search_strategy/osquery/index.ts new file mode 100644 index 0000000000000..8d8a255f2fcdd --- /dev/null +++ b/x-pack/plugins/osquery/server/search_strategy/osquery/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map, mergeMap } from 'rxjs/operators'; +import { + ISearchStrategy, + PluginStart, + shimHitsTotal, +} from '../../../../../../src/plugins/data/server'; +import { ENHANCED_ES_SEARCH_STRATEGY } from '../../../../data_enhanced/common'; +import { + FactoryQueryTypes, + StrategyResponseType, + StrategyRequestType, +} from '../../../common/search_strategy/osquery'; +import { osqueryFactory } from './factory'; +import { OsqueryFactory } from './factory/types'; + +export const osquerySearchStrategyProvider = <T extends FactoryQueryTypes>( + data: PluginStart +): ISearchStrategy<StrategyRequestType<T>, StrategyResponseType<T>> => { + const es = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); + + return { + search: (request, options, deps) => { + if (request.factoryQueryType == null) { + throw new Error('factoryQueryType is required'); + } + const queryFactory: OsqueryFactory<T> = osqueryFactory[request.factoryQueryType]; + const dsl = queryFactory.buildDsl(request); + return es.search({ ...request, params: dsl }, options, deps).pipe( + map((response) => { + return { + ...response, + ...{ + rawResponse: shimHitsTotal(response.rawResponse), + }, + }; + }), + mergeMap((esSearchRes) => queryFactory.parse(request, esSearchRes)) + ); + }, + cancel: async (id, options, deps) => { + if (es.cancel) { + return es.cancel(id, options, deps); + } + }, + }; +}; diff --git a/x-pack/plugins/osquery/server/types.ts b/x-pack/plugins/osquery/server/types.ts new file mode 100644 index 0000000000000..51ef28b4c3478 --- /dev/null +++ b/x-pack/plugins/osquery/server/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + PluginSetup as DataPluginSetup, + PluginStart as DataPluginStart, +} from '../../../../src/plugins/data/server'; +import { FleetStartContract } from '../../fleet/server'; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OsqueryPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface OsqueryPluginStart {} + +export interface SetupPlugins { + data: DataPluginSetup; +} + +export interface StartPlugins { + data: DataPluginStart; + fleet?: FleetStartContract; +} diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index fafd0c2772842..7e2c634b2f1cf 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -17,7 +17,7 @@ import { PolicyData, SafeEndpointEvent, } from './types'; -import { factory as policyFactory } from './models/policy_config'; +import { policyFactory } from './models/policy_config'; import { ancestryArray, entityIDSafeVersion, diff --git a/x-pack/plugins/security_solution/common/endpoint/index_data.ts b/x-pack/plugins/security_solution/common/endpoint/index_data.ts index b3259b19cf2c0..b9d7f6dfe7a5a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/index_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/index_data.ts @@ -30,7 +30,7 @@ import { PostAgentAcksResponse, PostAgentAcksRequest, } from '../../../fleet/common'; -import { factory as policyConfigFactory } from './models/policy_config'; +import { policyFactory as policyConfigFactory } from './models/policy_config'; import { HostMetadata } from './types'; import { KbnClientWithApiKeySupport } from '../../scripts/endpoint/kbn_client_with_api_key_support'; diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts index 14941b019421b..614aac6ea8041 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config.ts @@ -7,9 +7,9 @@ import { PolicyConfig, ProtectionModes } from '../types'; /** - * Return a new default `PolicyConfig`. + * Return a new default `PolicyConfig` for platinum and above licenses */ -export const factory = (): PolicyConfig => { +export const policyFactory = (): PolicyConfig => { return { windows: { events: { @@ -24,11 +24,18 @@ export const factory = (): PolicyConfig => { malware: { mode: ProtectionModes.prevent, }, + ransomware: { + mode: ProtectionModes.prevent, + }, popup: { malware: { message: '', enabled: true, }, + ransomware: { + message: '', + enabled: true, + }, }, logging: { file: 'info', @@ -46,11 +53,18 @@ export const factory = (): PolicyConfig => { malware: { mode: ProtectionModes.prevent, }, + ransomware: { + mode: ProtectionModes.prevent, + }, popup: { malware: { message: '', enabled: true, }, + ransomware: { + message: '', + enabled: true, + }, }, logging: { file: 'info', @@ -69,6 +83,51 @@ export const factory = (): PolicyConfig => { }; }; +/** + * Strips paid features from an existing or new `PolicyConfig` for gold and below license + */ +export const policyFactoryWithoutPaidFeatures = ( + policy: PolicyConfig = policyFactory() +): PolicyConfig => { + return { + ...policy, + windows: { + ...policy.windows, + ransomware: { + mode: ProtectionModes.off, + }, + popup: { + ...policy.windows.popup, + malware: { + message: '', + enabled: true, + }, + ransomware: { + message: '', + enabled: false, + }, + }, + }, + mac: { + ...policy.mac, + ransomware: { + mode: ProtectionModes.off, + }, + popup: { + ...policy.mac.popup, + malware: { + message: '', + enabled: true, + }, + ransomware: { + message: '', + enabled: false, + }, + }, + }, + }; +}; + /** * Reflects what string the Endpoint will use when message field is default/empty */ diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.test.ts similarity index 98% rename from x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts rename to x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.test.ts index 1b70a13935b7d..932afc6af3f16 100644 --- a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.test.ts @@ -6,7 +6,7 @@ import { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from 'kibana/server'; import { PackagePolicy } from '../../../../../fleet/common'; -import { migratePackagePolicyToV7110 } from './to_v7_11.0'; +import { migratePackagePolicyToV7110 } from './to_v7_11_0'; describe('7.11.0 Endpoint Package Policy migration', () => { const migration = migratePackagePolicyToV7110; diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.ts similarity index 100% rename from x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.ts rename to x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11_0.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.test.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.test.ts new file mode 100644 index 0000000000000..2666b477921fc --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { PackagePolicy } from '../../../../../fleet/common'; +import { PolicyData, ProtectionModes } from '../../types'; +import { migratePackagePolicyToV7120 } from './to_v7_12_0'; + +describe('7.12.0 Endpoint Package Policy migration', () => { + const migration = migratePackagePolicyToV7120; + it('adds ransomware option and notification customization', () => { + const doc: SavedObjectUnsanitizedDoc<PolicyData> = { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + windows: { + // @ts-expect-error + popup: { + malware: { + message: '', + enabled: false, + }, + }, + }, + mac: { + // @ts-expect-error + popup: { + malware: { + message: '', + enabled: false, + }, + }, + }, + }, + }, + }, + }, + ], + }, + type: ' nested', + }; + + expect( + migration(doc, {} as SavedObjectMigrationContext) as SavedObjectUnsanitizedDoc<PolicyData> + ).toEqual({ + attributes: { + name: 'Some Policy Name', + package: { + name: 'endpoint', + title: '', + version: '', + }, + id: 'endpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'endpoint', + enabled: true, + streams: [], + config: { + policy: { + value: { + windows: { + ransomware: ProtectionModes.off, + popup: { + malware: { + message: '', + enabled: false, + }, + ransomware: { + message: '', + enabled: false, + }, + }, + }, + mac: { + ransomware: ProtectionModes.off, + popup: { + malware: { + message: '', + enabled: false, + }, + ransomware: { + message: '', + enabled: false, + }, + }, + }, + }, + }, + }, + }, + ], + }, + type: ' nested', + id: 'mock-saved-object-id', + }); + }); + + it('does not modify non-endpoint package policies', () => { + const doc: SavedObjectUnsanitizedDoc<PackagePolicy> = { + id: 'mock-saved-object-id', + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + }; + + expect( + migration(doc, {} as SavedObjectMigrationContext) as SavedObjectUnsanitizedDoc<PackagePolicy> + ).toEqual({ + attributes: { + name: 'Some Policy Name', + package: { + name: 'notEndpoint', + title: '', + version: '', + }, + id: 'notEndpoint', + policy_id: '', + enabled: true, + namespace: '', + output_id: '', + revision: 0, + updated_at: '', + updated_by: '', + created_at: '', + created_by: '', + inputs: [ + { + type: 'notEndpoint', + enabled: true, + streams: [], + config: {}, + }, + ], + }, + type: ' nested', + id: 'mock-saved-object-id', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.ts new file mode 100644 index 0000000000000..6004ef533d5ad --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_12_0.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectMigrationFn, SavedObjectUnsanitizedDoc } from 'kibana/server'; +import { cloneDeep } from 'lodash'; +import { PackagePolicy } from '../../../../../fleet/common'; +import { ProtectionModes } from '../../types'; + +export const migratePackagePolicyToV7120: SavedObjectMigrationFn<PackagePolicy, PackagePolicy> = ( + packagePolicyDoc +) => { + const updatedPackagePolicyDoc: SavedObjectUnsanitizedDoc<PackagePolicy> = cloneDeep( + packagePolicyDoc + ); + if (packagePolicyDoc.attributes.package?.name === 'endpoint') { + const input = updatedPackagePolicyDoc.attributes.inputs[0]; + const ransomware = { + message: '', + enabled: false, + }; + if (input && input.config) { + const policy = input.config.policy.value; + + policy.windows.ransomware = ProtectionModes.off; + policy.mac.ransomware = ProtectionModes.off; + policy.windows.popup.ransomware = ransomware; + policy.mac.popup.ransomware = ransomware; + } + } + + return updatedPackagePolicyDoc; +}; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index fab5bd9daae00..f72373a6544a0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -816,7 +816,8 @@ export interface PolicyConfig { registry: boolean; security: boolean; }; - malware: MalwareFields; + malware: ProtectionFields; + ransomware: ProtectionFields; logging: { file: string; }; @@ -825,6 +826,10 @@ export interface PolicyConfig { message: string; enabled: boolean; }; + ransomware: { + message: string; + enabled: boolean; + }; }; antivirus_registration: { enabled: boolean; @@ -837,12 +842,17 @@ export interface PolicyConfig { process: boolean; network: boolean; }; - malware: MalwareFields; + malware: ProtectionFields; + ransomware: ProtectionFields; popup: { malware: { message: string; enabled: boolean; }; + ransomware: { + message: string; + enabled: boolean; + }; }; logging: { file: string; @@ -870,20 +880,20 @@ export interface UIPolicyConfig { */ windows: Pick< PolicyConfig['windows'], - 'events' | 'malware' | 'popup' | 'antivirus_registration' | 'advanced' + 'events' | 'malware' | 'ransomware' | 'popup' | 'antivirus_registration' | 'advanced' >; /** * Mac-specific policy configuration that is supported via the UI */ - mac: Pick<PolicyConfig['mac'], 'malware' | 'events' | 'popup' | 'advanced'>; + mac: Pick<PolicyConfig['mac'], 'malware' | 'ransomware' | 'events' | 'popup' | 'advanced'>; /** * Linux-specific policy configuration that is supported via the UI */ linux: Pick<PolicyConfig['linux'], 'events' | 'advanced'>; } -/** Policy: Malware protection fields */ -export interface MalwareFields { +/** Policy: Protection fields */ +export interface ProtectionFields { mode: ProtectionModes; } diff --git a/x-pack/plugins/security_solution/common/license/policy_config.test.ts b/x-pack/plugins/security_solution/common/license/policy_config.test.ts index 6923bf00055f6..ef10bba7428ee 100644 --- a/x-pack/plugins/security_solution/common/license/policy_config.test.ts +++ b/x-pack/plugins/security_solution/common/license/policy_config.test.ts @@ -8,8 +8,13 @@ import { isEndpointPolicyValidForLicense, unsetPolicyFeaturesAboveLicenseLevel, } from './policy_config'; -import { DefaultMalwareMessage, factory } from '../endpoint/models/policy_config'; +import { + DefaultMalwareMessage, + policyFactory, + policyFactoryWithoutPaidFeatures, +} from '../endpoint/models/policy_config'; import { licenseMock } from '../../../licensing/common/licensing.mock'; +import { ProtectionModes } from '../endpoint/types'; describe('policy_config and licenses', () => { const Platinum = licenseMock.createLicense({ license: { type: 'platinum', mode: 'platinum' } }); @@ -18,13 +23,13 @@ describe('policy_config and licenses', () => { describe('isEndpointPolicyValidForLicense', () => { it('allows malware notification to be disabled with a Platinum license', () => { - const policy = factory(); + const policy = policyFactory(); policy.windows.popup.malware.enabled = false; // make policy change const valid = isEndpointPolicyValidForLicense(policy, Platinum); expect(valid).toBeTruthy(); }); it('blocks windows malware notification changes below Platinum licenses', () => { - const policy = factory(); + const policy = policyFactory(); policy.windows.popup.malware.enabled = false; // make policy change let valid = isEndpointPolicyValidForLicense(policy, Gold); expect(valid).toBeFalsy(); @@ -34,7 +39,7 @@ describe('policy_config and licenses', () => { }); it('blocks mac malware notification changes below Platinum licenses', () => { - const policy = factory(); + const policy = policyFactory(); policy.mac.popup.malware.enabled = false; // make policy change let valid = isEndpointPolicyValidForLicense(policy, Gold); expect(valid).toBeFalsy(); @@ -44,13 +49,13 @@ describe('policy_config and licenses', () => { }); it('allows malware notification message changes with a Platinum license', () => { - const policy = factory(); + const policy = policyFactory(); policy.windows.popup.malware.message = 'BOOM'; // make policy change const valid = isEndpointPolicyValidForLicense(policy, Platinum); expect(valid).toBeTruthy(); }); it('blocks windows malware notification message changes below Platinum licenses', () => { - const policy = factory(); + const policy = policyFactory(); policy.windows.popup.malware.message = 'BOOM'; // make policy change let valid = isEndpointPolicyValidForLicense(policy, Gold); expect(valid).toBeFalsy(); @@ -59,7 +64,7 @@ describe('policy_config and licenses', () => { expect(valid).toBeFalsy(); }); it('blocks mac malware notification message changes below Platinum licenses', () => { - const policy = factory(); + const policy = policyFactory(); policy.mac.popup.malware.message = 'BOOM'; // make policy change let valid = isEndpointPolicyValidForLicense(policy, Gold); expect(valid).toBeFalsy(); @@ -68,16 +73,71 @@ describe('policy_config and licenses', () => { expect(valid).toBeFalsy(); }); + it('allows ransomware to be turned on for Platinum licenses', () => { + const policy = policyFactoryWithoutPaidFeatures(); + policy.windows.ransomware.mode = ProtectionModes.prevent; + policy.mac.ransomware.mode = ProtectionModes.prevent; + + const valid = isEndpointPolicyValidForLicense(policy, Platinum); + expect(valid).toBeTruthy(); + }); + it('blocks ransomware to be turned on for Gold and below licenses', () => { + const policy = policyFactoryWithoutPaidFeatures(); + policy.windows.ransomware.mode = ProtectionModes.prevent; + policy.mac.ransomware.mode = ProtectionModes.prevent; + + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + + it('allows ransomware notification to be turned on with a Platinum license', () => { + const policy = policyFactoryWithoutPaidFeatures(); + policy.windows.popup.ransomware.enabled = true; + policy.mac.popup.ransomware.enabled = true; + const valid = isEndpointPolicyValidForLicense(policy, Platinum); + expect(valid).toBeTruthy(); + }); + it('blocks ransomware notification to be turned on for Gold and below licenses', () => { + const policy = policyFactoryWithoutPaidFeatures(); + policy.windows.popup.ransomware.enabled = true; + policy.mac.popup.ransomware.enabled = true; + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + + it('allows ransomware notification message changes with a Platinum license', () => { + const policy = policyFactory(); + policy.windows.popup.ransomware.message = 'BOOM'; + policy.mac.popup.ransomware.message = 'BOOM'; + const valid = isEndpointPolicyValidForLicense(policy, Platinum); + expect(valid).toBeTruthy(); + }); + it('blocks ransomware notification message changes for Gold and below licenses', () => { + const policy = policyFactory(); + policy.windows.popup.ransomware.message = 'BOOM'; + policy.mac.popup.ransomware.message = 'BOOM'; + let valid = isEndpointPolicyValidForLicense(policy, Gold); + expect(valid).toBeFalsy(); + + valid = isEndpointPolicyValidForLicense(policy, Basic); + expect(valid).toBeFalsy(); + }); + it('allows default policyConfig with Basic', () => { - const policy = factory(); + const policy = policyFactoryWithoutPaidFeatures(); const valid = isEndpointPolicyValidForLicense(policy, Basic); expect(valid).toBeTruthy(); }); }); describe('unsetPolicyFeaturesAboveLicenseLevel', () => { - it('does not change any fields with a Platinum license', () => { - const policy = factory(); + it('does not change any malware fields with a Platinum license', () => { + const policy = policyFactory(); const popupMessage = 'WOOP WOOP'; policy.windows.popup.malware.message = popupMessage; policy.mac.popup.malware.message = popupMessage; @@ -88,14 +148,37 @@ describe('policy_config and licenses', () => { expect(retPolicy.windows.popup.malware.message).toEqual(popupMessage); expect(retPolicy.mac.popup.malware.message).toEqual(popupMessage); }); - it('resets Platinum-paid fields for lower license tiers', () => { - const defaults = factory(); // reference - const policy = factory(); // what we will modify, and should be reset + + it('does not change any ransomware fields with a Platinum license', () => { + const policy = policyFactory(); + const popupMessage = 'WOOP WOOP'; + policy.windows.ransomware.mode = ProtectionModes.detect; + policy.mac.ransomware.mode = ProtectionModes.detect; + policy.windows.popup.ransomware.enabled = false; + policy.mac.popup.ransomware.enabled = false; + policy.windows.popup.ransomware.message = popupMessage; + policy.mac.popup.ransomware.message = popupMessage; + + const retPolicy = unsetPolicyFeaturesAboveLicenseLevel(policy, Platinum); + expect(retPolicy.windows.ransomware.mode).toEqual(ProtectionModes.detect); + expect(retPolicy.mac.ransomware.mode).toEqual(ProtectionModes.detect); + expect(retPolicy.windows.popup.ransomware.enabled).toBeFalsy(); + expect(retPolicy.mac.popup.ransomware.enabled).toBeFalsy(); + expect(retPolicy.windows.popup.ransomware.message).toEqual(popupMessage); + expect(retPolicy.mac.popup.ransomware.message).toEqual(popupMessage); + }); + + it('resets Platinum-paid malware fields for lower license tiers', () => { + const defaults = policyFactory(); // reference + const policy = policyFactory(); // what we will modify, and should be reset const popupMessage = 'WOOP WOOP'; policy.windows.popup.malware.message = popupMessage; policy.mac.popup.malware.message = popupMessage; policy.windows.popup.malware.enabled = false; + policy.windows.popup.ransomware.message = popupMessage; + policy.mac.popup.ransomware.message = popupMessage; + policy.windows.popup.ransomware.enabled = false; const retPolicy = unsetPolicyFeaturesAboveLicenseLevel(policy, Gold); expect(retPolicy.windows.popup.malware.enabled).toEqual( defaults.windows.popup.malware.enabled @@ -106,5 +189,37 @@ describe('policy_config and licenses', () => { // need to invert the test, since it could be either value expect(['', DefaultMalwareMessage]).toContain(retPolicy.windows.popup.malware.message); }); + + it('resets Platinum-paid ransomware fields for lower license tiers', () => { + const defaults = policyFactoryWithoutPaidFeatures(); // reference + const policy = policyFactory(); // what we will modify, and should be reset + const popupMessage = 'WOOP WOOP'; + policy.windows.popup.ransomware.message = popupMessage; + policy.mac.popup.ransomware.message = popupMessage; + + const retPolicy = unsetPolicyFeaturesAboveLicenseLevel(policy, Gold); + + expect(retPolicy.windows.ransomware.mode).toEqual(defaults.windows.ransomware.mode); + expect(retPolicy.mac.ransomware.mode).toEqual(defaults.mac.ransomware.mode); + expect(retPolicy.windows.popup.ransomware.enabled).toEqual( + defaults.windows.popup.ransomware.enabled + ); + expect(retPolicy.mac.popup.ransomware.enabled).toEqual(defaults.mac.popup.ransomware.enabled); + expect(retPolicy.windows.popup.ransomware.message).not.toEqual(popupMessage); + expect(retPolicy.mac.popup.ransomware.message).not.toEqual(popupMessage); + + // need to invert the test, since it could be either value + expect(['', DefaultMalwareMessage]).toContain(retPolicy.windows.popup.ransomware.message); + expect(['', DefaultMalwareMessage]).toContain(retPolicy.mac.popup.ransomware.message); + }); + }); + + describe('policyFactoryWithoutPaidFeatures for gold and below license', () => { + it('preserves non license-gated features', () => { + const policy = policyFactory(); // what we will modify, and should be reset + policy.windows.events.file = false; + const retPolicy = policyFactoryWithoutPaidFeatures(policy); + expect(retPolicy.windows.events.file).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/license/policy_config.ts b/x-pack/plugins/security_solution/common/license/policy_config.ts index da2260ad55e8b..e791b68f12f40 100644 --- a/x-pack/plugins/security_solution/common/license/policy_config.ts +++ b/x-pack/plugins/security_solution/common/license/policy_config.ts @@ -7,7 +7,10 @@ import { ILicense } from '../../../licensing/common/types'; import { isAtLeast } from './license'; import { PolicyConfig } from '../endpoint/types'; -import { DefaultMalwareMessage, factory } from '../endpoint/models/policy_config'; +import { + DefaultMalwareMessage, + policyFactoryWithoutPaidFeatures, +} from '../endpoint/models/policy_config'; /** * Given an endpoint package policy, verifies that all enabled features that @@ -21,7 +24,7 @@ export const isEndpointPolicyValidForLicense = ( return true; // currently, platinum allows all features } - const defaults = factory(); + const defaults = policyFactoryWithoutPaidFeatures(); // only platinum or higher may disable malware notification if ( @@ -40,6 +43,32 @@ export const isEndpointPolicyValidForLicense = ( return false; } + // only platinum or higher may enable ransomware + if ( + policy.windows.ransomware.mode !== defaults.windows.ransomware.mode || + policy.mac.ransomware.mode !== defaults.mac.ransomware.mode + ) { + return false; + } + + // only platinum or higher may enable ransomware notification + if ( + policy.windows.popup.ransomware.enabled !== defaults.windows.popup.ransomware.enabled || + policy.mac.popup.ransomware.enabled !== defaults.mac.popup.ransomware.enabled + ) { + return false; + } + + // Only Platinum or higher may change the ransomware message (which can be blank or what Endpoint defaults) + if ( + [policy.windows, policy.mac].some( + (p) => + p.popup.ransomware.message !== '' && p.popup.ransomware.message !== DefaultMalwareMessage + ) + ) { + return false; + } + return true; }; @@ -55,12 +84,6 @@ export const unsetPolicyFeaturesAboveLicenseLevel = ( return policy; } - const defaults = factory(); // set any license-gated features back to the defaults - policy.windows.popup.malware.enabled = defaults.windows.popup.malware.enabled; - policy.mac.popup.malware.enabled = defaults.mac.popup.malware.enabled; - policy.windows.popup.malware.message = defaults.windows.popup.malware.message; - policy.mac.popup.malware.message = defaults.mac.popup.malware.message; - - return policy; + return policyFactoryWithoutPaidFeatures(policy); }; diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts index fb457933f4b54..92a736ae601df 100644 --- a/x-pack/plugins/security_solution/common/shared_exports.ts +++ b/x-pack/plugins/security_solution/common/shared_exports.ts @@ -16,4 +16,5 @@ export { exactCheck } from './exact_check'; export { getPaths, foldLeftRight, removeExternalLinkText } from './test_utils'; export { validate, validateEither } from './validate'; export { formatErrors } from './format_errors'; -export { migratePackagePolicyToV7110 } from './endpoint/policy/migrations/to_v7_11.0'; +export { migratePackagePolicyToV7110 } from './endpoint/policy/migrations/to_v7_11_0'; +export { migratePackagePolicyToV7120 } from './endpoint/policy/migrations/to_v7_12_0'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts index 8451eb0dfbe6c..716585071c3f6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts @@ -99,7 +99,7 @@ describe('helpers', () => { { ...mockThreat, tactic: { ...mockThreat.tactic, name: 'none' } }, ]; const result = filterEmptyThreats(threat); - const expected = [mockThreat]; + const expected: Threats = [mockThreat]; expect(result).toEqual(expected); }); }); @@ -112,8 +112,8 @@ describe('helpers', () => { }); test('returns formatted object as DefineStepRuleJson', () => { - const result: DefineStepRuleJson = formatDefineStepData(mockData); - const expected = { + const result = formatDefineStepData(mockData); + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -128,15 +128,15 @@ describe('helpers', () => { }); test('returns formatted object with no saved_id if no savedId provided', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, queryBar: { ...mockData.queryBar, saved_id: '', }, }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); - const expected = { + const result = formatDefineStepData(mockStepData); + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -151,15 +151,15 @@ describe('helpers', () => { }); test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, }; // @ts-expect-error delete mockStepData.timeline.id; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -172,16 +172,16 @@ describe('helpers', () => { }); test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, timeline: { ...mockData.timeline, id: '', }, }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -196,7 +196,7 @@ describe('helpers', () => { }); test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, timeline: { ...mockData.timeline, @@ -205,9 +205,9 @@ describe('helpers', () => { }; // @ts-expect-error delete mockStepData.timeline.title; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -220,16 +220,16 @@ describe('helpers', () => { }); test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { - const mockStepData = { + const mockStepData: DefineStepRule = { ...mockData, timeline: { ...mockData.timeline, title: '', }, }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { language: 'kuery', filters: mockQueryBar.filters, query: 'test query', @@ -250,9 +250,9 @@ describe('helpers', () => { anomalyThreshold: 44, machineLearningJobId: 'some_jobert_id', }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { type: 'machine_learning', anomaly_threshold: 44, machine_learning_job_id: 'some_jobert_id', @@ -276,9 +276,9 @@ describe('helpers', () => { }, }, }; - const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + const result = formatDefineStepData(mockStepData); - const expected = { + const expected: DefineStepRuleJson = { filters: mockStepData.queryBar.filters, index: mockStepData.index, language: 'eql', @@ -288,6 +288,70 @@ describe('helpers', () => { expect(result).toEqual(expect.objectContaining(expected)); }); + + test('returns expected indicator matching rule type if all fields are filled out', () => { + const threatFilters: DefineStepRule['threatQueryBar']['filters'] = [ + { + meta: { alias: '', disabled: false, negate: false }, + query: { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [{ exists: { field: 'host.name' } }], + }, + }, + {}, + ], + must: [], + must_not: [], + should: [], + }, + }, + }, + ]; + const threatMapping: DefineStepRule['threatMapping'] = [ + { + entries: [ + { + field: 'host.name', + type: 'mapping', + value: 'host.name', + }, + ], + }, + ]; + const mockStepData: DefineStepRule = { + ...mockData, + ruleType: 'threat_match', + threatIndex: ['index_1', 'index_2'], + threatQueryBar: { + query: { language: 'kql', query: 'threat_host: *' }, + filters: threatFilters, + }, + threatMapping, + }; + const result = formatDefineStepData(mockStepData); + + const expected: DefineStepRuleJson = { + language: 'kuery', + query: 'test query', + saved_id: 'test123', + type: 'threat_match', + threat_query: 'threat_host: *', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', + threat_mapping: threatMapping, + threat_language: mockStepData.threatQueryBar.query.language, + filters: mockStepData.queryBar.filters, + threat_index: mockStepData.threatIndex, + index: mockStepData.index, + threat_filters: threatFilters, + }; + + expect(result).toEqual(expected); + }); }); describe('formatScheduleStepData', () => { @@ -298,8 +362,8 @@ describe('helpers', () => { }); test('returns formatted object as ScheduleStepRuleJson', () => { - const result: ScheduleStepRuleJson = formatScheduleStepData(mockData); - const expected = { + const result = formatScheduleStepData(mockData); + const expected: ScheduleStepRuleJson = { from: 'now-660s', to: 'now', interval: '5m', @@ -312,12 +376,12 @@ describe('helpers', () => { }); test('returns formatted object with "to" as "now" if "to" not supplied', () => { - const mockStepData = { + const mockStepData: ScheduleStepRule = { ...mockData, }; delete mockStepData.to; - const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); - const expected = { + const result = formatScheduleStepData(mockStepData); + const expected: ScheduleStepRuleJson = { from: 'now-660s', to: 'now', interval: '5m', @@ -330,12 +394,12 @@ describe('helpers', () => { }); test('returns formatted object with "to" as "now" if "to" random string', () => { - const mockStepData = { + const mockStepData: ScheduleStepRule = { ...mockData, to: 'random', }; - const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); - const expected = { + const result = formatScheduleStepData(mockStepData); + const expected: ScheduleStepRuleJson = { from: 'now-660s', to: 'now', interval: '5m', @@ -348,12 +412,12 @@ describe('helpers', () => { }); test('returns formatted object if "from" random string', () => { - const mockStepData = { + const mockStepData: ScheduleStepRule = { ...mockData, from: 'random', }; - const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); - const expected = { + const result = formatScheduleStepData(mockStepData); + const expected: ScheduleStepRuleJson = { from: 'now-300s', to: 'now', interval: '5m', @@ -366,12 +430,12 @@ describe('helpers', () => { }); test('returns formatted object if "interval" random string', () => { - const mockStepData = { + const mockStepData: ScheduleStepRule = { ...mockData, interval: 'random', }; - const result: ScheduleStepRuleJson = formatScheduleStepData(mockStepData); - const expected = { + const result = formatScheduleStepData(mockStepData); + const expected: ScheduleStepRuleJson = { from: 'now-360s', to: 'now', interval: 'random', @@ -392,8 +456,8 @@ describe('helpers', () => { }); test('returns formatted object as AboutStepRuleJson', () => { - const result: AboutStepRuleJson = formatAboutStepData(mockData); - const expected = { + const result = formatAboutStepData(mockData); + const expected: AboutStepRuleJson = { author: ['Elastic'], description: '24/7', false_positives: ['test'], @@ -413,7 +477,7 @@ describe('helpers', () => { }); test('returns formatted object with endpoint exceptions_list', () => { - const result: AboutStepRuleJson = formatAboutStepData( + const result = formatAboutStepData( { ...mockData, isAssociatedToEndpointList: true, @@ -424,12 +488,12 @@ describe('helpers', () => { }); test('returns formatted object with detections exceptions_list', () => { - const result: AboutStepRuleJson = formatAboutStepData(mockData, [getListMock()]); + const result = formatAboutStepData(mockData, [getListMock()]); expect(result.exceptions_list).toEqual([getListMock()]); }); test('returns formatted object with both exceptions_lists', () => { - const result: AboutStepRuleJson = formatAboutStepData( + const result = formatAboutStepData( { ...mockData, isAssociatedToEndpointList: true, @@ -441,7 +505,7 @@ describe('helpers', () => { test('returns formatted object with pre-existing exceptions lists', () => { const exceptionsLists: List[] = [getEndpointListMock(), getListMock()]; - const result: AboutStepRuleJson = formatAboutStepData( + const result = formatAboutStepData( { ...mockData, isAssociatedToEndpointList: true, @@ -453,18 +517,18 @@ describe('helpers', () => { test('returns formatted object with pre-existing endpoint exceptions list disabled', () => { const exceptionsLists: List[] = [getEndpointListMock(), getListMock()]; - const result: AboutStepRuleJson = formatAboutStepData(mockData, exceptionsLists); + const result = formatAboutStepData(mockData, exceptionsLists); expect(result.exceptions_list).toEqual([getListMock()]); }); test('returns formatted object with empty falsePositive and references filtered out', () => { - const mockStepData = { + const mockStepData: AboutStepRule = { ...mockData, falsePositives: ['', 'test', ''], references: ['www.test.co', ''], }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { + const result = formatAboutStepData(mockStepData); + const expected: AboutStepRuleJson = { author: ['Elastic'], description: '24/7', false_positives: ['test'], @@ -484,12 +548,12 @@ describe('helpers', () => { }); test('returns formatted object without note if note is empty string', () => { - const mockStepData = { + const mockStepData: AboutStepRule = { ...mockData, note: '', }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { + const result = formatAboutStepData(mockStepData); + const expected: AboutStepRuleJson = { author: ['Elastic'], description: '24/7', false_positives: ['test'], @@ -508,7 +572,7 @@ describe('helpers', () => { }); test('returns formatted object with threats filtered out where tactic.name is "none"', () => { - const mockStepData = { + const mockStepData: AboutStepRule = { ...mockData, threat: [ ...getThreatMock(), @@ -530,8 +594,8 @@ describe('helpers', () => { }, ], }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { + const result = formatAboutStepData(mockStepData); + const expected: AboutStepRuleJson = { author: ['Elastic'], license: 'Elastic License', description: '24/7', @@ -551,7 +615,7 @@ describe('helpers', () => { }); test('returns formatted object with threats that contains no subtechniques', () => { - const mockStepData = { + const mockStepData: AboutStepRule = { ...mockData, threat: [ ...getThreatMock(), @@ -573,8 +637,8 @@ describe('helpers', () => { }, ], }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { + const result = formatAboutStepData(mockStepData); + const expected: AboutStepRuleJson = { author: ['Elastic'], license: 'Elastic License', description: '24/7', @@ -611,8 +675,8 @@ describe('helpers', () => { }); test('returns formatted object as ActionsStepRuleJson', () => { - const result: ActionsStepRuleJson = formatActionsStepData(mockData); - const expected = { + const result = formatActionsStepData(mockData); + const expected: ActionsStepRuleJson = { actions: [], enabled: false, meta: { @@ -625,12 +689,12 @@ describe('helpers', () => { }); test('returns proper throttle value for no_actions', () => { - const mockStepData = { + const mockStepData: ActionsStepRule = { ...mockData, throttle: 'no_actions', }; - const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); - const expected = { + const result = formatActionsStepData(mockStepData); + const expected: ActionsStepRuleJson = { actions: [], enabled: false, meta: { @@ -643,7 +707,7 @@ describe('helpers', () => { }); test('returns proper throttle value for rule', () => { - const mockStepData = { + const mockStepData: ActionsStepRule = { ...mockData, throttle: 'rule', actions: [ @@ -655,8 +719,8 @@ describe('helpers', () => { }, ], }; - const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); - const expected = { + const result = formatActionsStepData(mockStepData); + const expected: ActionsStepRuleJson = { actions: [ { group: mockStepData.actions[0].group, @@ -676,7 +740,7 @@ describe('helpers', () => { }); test('returns proper throttle value for interval', () => { - const mockStepData = { + const mockStepData: ActionsStepRule = { ...mockData, throttle: '1d', actions: [ @@ -688,8 +752,8 @@ describe('helpers', () => { }, ], }; - const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); - const expected = { + const result = formatActionsStepData(mockStepData); + const expected: ActionsStepRuleJson = { actions: [ { group: mockStepData.actions[0].group, @@ -716,12 +780,12 @@ describe('helpers', () => { actionTypeId: '.slack', }; - const mockStepData = { + const mockStepData: ActionsStepRule = { ...mockData, actions: [mockAction], }; - const result: ActionsStepRuleJson = formatActionsStepData(mockStepData); - const expected = { + const result = formatActionsStepData(mockStepData); + const expected: ActionsStepRuleJson = { actions: [ { group: mockAction.group, @@ -755,20 +819,20 @@ describe('helpers', () => { }); test('returns rule with type of saved_query when saved_id exists', () => { - const result: Rule = formatRule<Rule>(mockDefine, mockAbout, mockSchedule, mockActions); + const result = formatRule<Rule>(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.type).toEqual('saved_query'); }); test('returns rule with type of query when saved_id does not exist', () => { - const mockDefineStepRuleWithoutSavedId = { + const mockDefineStepRuleWithoutSavedId: DefineStepRule = { ...mockDefine, queryBar: { ...mockDefine.queryBar, saved_id: '', }, }; - const result: CreateRulesSchema = formatRule<CreateRulesSchema>( + const result = formatRule<CreateRulesSchema>( mockDefineStepRuleWithoutSavedId, mockAbout, mockSchedule, @@ -779,7 +843,7 @@ describe('helpers', () => { }); test('returns rule without id if ruleId does not exist', () => { - const result: CreateRulesSchema = formatRule<CreateRulesSchema>( + const result = formatRule<CreateRulesSchema>( mockDefine, mockAbout, mockSchedule, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 7952bd396b72a..34818e7f0e4e2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -232,6 +232,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep saved_id: ruleFields.queryBar?.saved_id, threat_index: ruleFields.threatIndex, threat_query: ruleFields.threatQueryBar?.query?.query as string, + threat_filters: ruleFields.threatQueryBar?.filters, threat_mapping: ruleFields.threatMapping, threat_language: ruleFields.threatQueryBar?.query?.language, } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index 59cc7fba017e2..00206279be229 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -159,6 +159,11 @@ export interface DefineStepRuleJson { field: string; value: number; }; + threat_query?: string; + threat_mapping?: ThreatMapping; + threat_language?: string; + threat_index?: string[]; + threat_filters?: Filter[]; timeline_id?: string; timeline_title?: string; type: Type; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts index 70ffc1f8a9fc4..bda268c1fad00 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/index.test.ts @@ -8,7 +8,7 @@ import { PolicyDetailsState } from '../../types'; import { applyMiddleware, createStore, Dispatch, Store } from 'redux'; import { policyDetailsReducer, PolicyDetailsAction, policyDetailsMiddlewareFactory } from './index'; import { policyConfig } from './selectors'; -import { factory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; +import { policyFactory } from '../../../../../../common/endpoint/models/policy_config'; import { PolicyData } from '../../../../../../common/endpoint/types'; import { createSpyMiddleware, @@ -54,7 +54,7 @@ describe('policy details: ', () => { }, }, policy: { - value: policyConfigFactory(), + value: policyFactory(), }, }, }, @@ -254,6 +254,7 @@ describe('policy details: ', () => { http.put.mock.calls.length - 1 ] as unknown) as [string, HttpFetchOptions])[1]; + // license is below platinum in this test, paid features are off expect(JSON.parse(lastPutCallPayload.body as string)).toEqual({ name: '', description: '', @@ -282,11 +283,16 @@ describe('policy details: ', () => { security: true, }, malware: { mode: 'prevent' }, + ransomware: { mode: 'off' }, popup: { malware: { enabled: true, message: '', }, + ransomware: { + enabled: false, + message: '', + }, }, logging: { file: 'info' }, antivirus_registration: { @@ -296,11 +302,16 @@ describe('policy details: ', () => { mac: { events: { process: true, file: true, network: true }, malware: { mode: 'prevent' }, + ransomware: { mode: 'off' }, popup: { malware: { enabled: true, message: '', }, + ransomware: { + enabled: false, + message: '', + }, }, logging: { file: 'info' }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts index cc286b4c478d3..4d54acb5eae13 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware.ts @@ -41,6 +41,10 @@ export const policyDetailsMiddlewareFactory: ImmutableMiddlewareFactory<PolicyDe policyItem.inputs[0].config.policy.value.windows.popup.malware.message = DefaultMalwareMessage; policyItem.inputs[0].config.policy.value.mac.popup.malware.message = DefaultMalwareMessage; } + if (policyItem.inputs[0].config.policy.value.windows.popup.ransomware.message === '') { + policyItem.inputs[0].config.policy.value.windows.popup.ransomware.message = DefaultMalwareMessage; + policyItem.inputs[0].config.policy.value.mac.popup.ransomware.message = DefaultMalwareMessage; + } } catch (error) { dispatch({ type: 'serverFailedToReturnPolicyDetailsData', diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts index 7fe9987e3b724..0e0b891629d5e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts @@ -16,7 +16,7 @@ import { PolicyData, UIPolicyConfig, } from '../../../../../../common/endpoint/types'; -import { factory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; +import { policyFactory as policyConfigFactory } from '../../../../../../common/endpoint/models/policy_config'; import { MANAGEMENT_ROUTING_POLICY_DETAILS_PATH } from '../../../../common/constants'; import { ManagementRoutePolicyDetailsParams } from '../../../../types'; @@ -32,10 +32,26 @@ export const licensedPolicy: ( licenseState, (policyData, license) => { if (policyData) { - unsetPolicyFeaturesAboveLicenseLevel( - policyData?.inputs[0]?.config.policy.value, + const policyValue = unsetPolicyFeaturesAboveLicenseLevel( + policyData.inputs[0].config.policy.value, license as ILicense ); + const newPolicyData: Immutable<PolicyData> = { + ...policyData, + inputs: [ + { + ...policyData.inputs[0], + config: { + ...policyData.inputs[0].config, + policy: { + ...policyData.inputs[0].config.policy, + value: policyValue, + }, + }, + }, + ], + }; + return newPolicyData; } return policyData; } @@ -167,6 +183,7 @@ export const policyConfig: (s: PolicyDetailsState) => UIPolicyConfig = createSel advanced: windows.advanced, events: windows.events, malware: windows.malware, + ransomware: windows.ransomware, popup: windows.popup, antivirus_registration: windows.antivirus_registration, }, @@ -174,6 +191,7 @@ export const policyConfig: (s: PolicyDetailsState) => UIPolicyConfig = createSel advanced: mac.advanced, events: mac.events, malware: mac.malware, + ransomware: mac.ransomware, popup: mac.popup, }, linux: { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts index 228e8cc1c4385..a37e404cdc522 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts @@ -8,7 +8,7 @@ import { ILicense } from '../../../../../licensing/common/types'; import { AppLocation, Immutable, - MalwareFields, + ProtectionFields, PolicyData, UIPolicyConfig, } from '../../../../common/endpoint/types'; @@ -108,7 +108,16 @@ export type KeysByValueCriteria<O, Criteria> = { }[keyof O]; /** Returns an array of the policy OSes that have a malware protection field */ -export type MalwareProtectionOSes = KeysByValueCriteria<UIPolicyConfig, { malware: MalwareFields }>; +export type MalwareProtectionOSes = KeysByValueCriteria< + UIPolicyConfig, + { malware: ProtectionFields } +>; + +/** Returns an array of the policy OSes that have a ransomware protection field */ +export type RansomwareProtectionOSes = KeysByValueCriteria< + UIPolicyConfig, + { ransomware: ProtectionFields } +>; export interface GetPolicyListResponse extends GetPackagePoliciesResponse { items: PolicyData[]; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx index ce5eb03d60cd0..aea4df5b2a6fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/components/config_form/index.tsx @@ -58,7 +58,7 @@ export const ConfigForm: FC<ConfigFormProps> = memo( <ConfigFormHeading>{TITLES.type}</ConfigFormHeading> <EuiText size="m">{type}</EuiText> </EuiFlexItem> - <EuiFlexItem> + <EuiFlexItem grow={2}> <ConfigFormHeading>{TITLES.os}</ConfigFormHeading> <EuiText>{supportedOss.map((os) => OS_TITLES[os]).join(', ')}</EuiText> </EuiFlexItem> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 1280f1c351c2b..55fc7703de44b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -301,11 +301,16 @@ describe('Policy Details', () => { const userNotificationCustomMessageTextArea = policyView.find( 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' ); - const tooltip = policyView.find('EuiIconTip'); + const tooltip = policyView.find('EuiIconTip[data-test-subj="malwareTooltip"]'); expect(userNotificationCheckbox).toHaveLength(1); expect(userNotificationCustomMessageTextArea).toHaveLength(1); expect(tooltip).toHaveLength(1); }); + + it('ransomware card is shown', () => { + const ransomware = policyView.find('EuiPanel[data-test-subj="ransomwareProtectionsForm"]'); + expect(ransomware).toHaveLength(1); + }); }); describe('when the subscription tier is gold or lower', () => { beforeEach(() => { @@ -325,6 +330,11 @@ describe('Policy Details', () => { expect(userNotificationCustomMessageTextArea).toHaveLength(0); expect(tooltip).toHaveLength(0); }); + + it('ransomware card is hidden', () => { + const ransomware = policyView.find('EuiPanel[data-test-subj="ransomwareProtectionsForm"]'); + expect(ransomware).toHaveLength(0); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx index a0bf2b37e8a12..8710f696fad41 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details_form.tsx @@ -11,12 +11,15 @@ import { MalwareProtections } from './policy_forms/protections/malware'; import { LinuxEvents, MacEvents, WindowsEvents } from './policy_forms/events'; import { AdvancedPolicyForms } from './policy_advanced'; import { AntivirusRegistrationForm } from './components/antivirus_registration_form'; +import { Ransomware } from './policy_forms/protections/ransomware'; +import { useLicense } from '../../../../common/hooks/use_license'; export const PolicyDetailsForm = memo(() => { const [showAdvancedPolicy, setShowAdvancedPolicy] = useState<boolean>(false); const handleAdvancedPolicyClick = useCallback(() => { setShowAdvancedPolicy(!showAdvancedPolicy); }, [showAdvancedPolicy]); + const isPlatinumPlus = useLicense().isPlatinumPlus(); return ( <> @@ -31,6 +34,8 @@ export const PolicyDetailsForm = memo(() => { <EuiSpacer size="xs" /> <MalwareProtections /> + <EuiSpacer size="m" /> + {isPlatinumPlus && <Ransomware />} <EuiSpacer size="l" /> <EuiText size="xs" color="subdued"> @@ -44,14 +49,14 @@ export const PolicyDetailsForm = memo(() => { <EuiSpacer size="xs" /> <WindowsEvents /> - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <MacEvents /> - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <LinuxEvents /> - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <AntivirusRegistrationForm /> - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <EuiButtonEmpty data-test-subj="advancedPolicyButton" onClick={handleAdvancedPolicyClick}> <FormattedMessage id="xpack.securitySolution.endpoint.policy.advanced.show" diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 8e631e497e57b..1bc49c716a3c3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -6,7 +6,6 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -23,9 +22,9 @@ import { EuiIconTip, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; +import styled from 'styled-components'; import { APP_ID } from '../../../../../../../common/constants'; import { SecurityPageName } from '../../../../../../app/types'; - import { Immutable, OperatingSystem, @@ -36,91 +35,77 @@ import { ConfigForm, ConfigFormHeading } from '../../components/config_form'; import { policyConfig } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; -import { popupVersionsMap } from './popup_options_to_versions'; import { useLicense } from '../../../../../../common/hooks/use_license'; +import { AppAction } from '../../../../../../common/store/actions'; +import { SupportedVersionNotice } from './supported_version'; -const ProtectionRadioGroup = styled.div` - display: flex; - .policyDetailsProtectionRadio { - margin-right: ${(props) => props.theme.eui.euiSizeXXL}; +export const RadioFlexGroup = styled(EuiFlexGroup)` + .no-right-margin-radio { + margin-right: 0; + } + .no-horizontal-margin-radio { + margin: ${(props) => props.theme.eui.ruleMargins.marginSmall} 0; } `; const OSes: Immutable<MalwareProtectionOSes[]> = [OS.windows, OS.mac]; const protection = 'malware'; -const ProtectionRadio = React.memo(({ id, label }: { id: ProtectionModes; label: string }) => { - const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); - const dispatch = useDispatch(); - const radioButtonId = useMemo(() => htmlIdGenerator()(), []); - // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value - const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; - const isPlatinumPlus = useLicense().isPlatinumPlus(); +const ProtectionRadio = React.memo( + ({ protectionMode, label }: { protectionMode: ProtectionModes; label: string }) => { + const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); + const dispatch = useDispatch<(action: AppAction) => void>(); + const radioButtonId = useMemo(() => htmlIdGenerator()(), []); + // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value + const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; + const isPlatinumPlus = useLicense().isPlatinumPlus(); - const handleRadioChange = useCallback(() => { - if (policyDetailsConfig) { - const newPayload = cloneDeep(policyDetailsConfig); - for (const os of OSes) { - newPayload[os][protection].mode = id; - if (isPlatinumPlus) { - if (id === ProtectionModes.prevent) { - newPayload[os].popup[protection].enabled = true; - } else { - newPayload[os].popup[protection].enabled = false; + const handleRadioChange = useCallback(() => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + for (const os of OSes) { + newPayload[os][protection].mode = protectionMode; + if (isPlatinumPlus) { + if (protectionMode === ProtectionModes.prevent) { + newPayload[os].popup[protection].enabled = true; + } else { + newPayload[os].popup[protection].enabled = false; + } } } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); } - dispatch({ - type: 'userChangedPolicyConfig', - payload: { policyConfig: newPayload }, - }); - } - }, [dispatch, id, policyDetailsConfig, isPlatinumPlus]); + }, [dispatch, protectionMode, policyDetailsConfig, isPlatinumPlus]); - /** - * Passing an arbitrary id because EuiRadio - * requires an id if label is passed - */ + /** + * Passing an arbitrary id because EuiRadio + * requires an id if label is passed + */ - return ( - <EuiRadio - className="policyDetailsProtectionRadio" - label={label} - id={radioButtonId} - checked={selected === id} - onChange={handleRadioChange} - disabled={selected === ProtectionModes.off} - /> - ); -}); - -ProtectionRadio.displayName = 'ProtectionRadio'; - -const SupportedVersionNotice = ({ optionName }: { optionName: string }) => { - const version = popupVersionsMap.get(optionName); - if (!version) { - return null; + return ( + <EuiRadio + className="policyDetailsProtectionRadio" + label={label} + id={radioButtonId} + checked={selected === protectionMode} + onChange={handleRadioChange} + disabled={selected === ProtectionModes.off} + /> + ); } +); - return ( - <EuiText color="subdued" size="xs"> - <i> - <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetails.supportedVersion" - defaultMessage="Agent version {version}" - values={{ version }} - /> - </i> - </EuiText> - ); -}; +ProtectionRadio.displayName = 'ProtectionRadio'; /** The Malware Protections form for policy details * which will configure for all relevant OSes. */ export const MalwareProtections = React.memo(() => { const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); - const dispatch = useDispatch(); + const dispatch = useDispatch<(action: AppAction) => void>(); // currently just taking windows.malware, but both windows.malware and mac.malware should be the same value const selected = policyDetailsConfig && policyDetailsConfig.windows.malware.mode; const userNotificationSelected = @@ -224,19 +209,25 @@ export const MalwareProtections = React.memo(() => { /> </ConfigFormHeading> <EuiSpacer size="xs" /> - <ProtectionRadioGroup> - {radios.map((radio) => { - return ( - <ProtectionRadio - id={radio.id} - key={radio.protection + radio.id} - label={radio.label} - /> - ); - })} - </ProtectionRadioGroup> + <RadioFlexGroup> + <EuiFlexItem className="no-right-margin-radio" grow={1}> + <ProtectionRadio + protectionMode={radios[0].id} + key={radios[0].protection + radios[0].id} + label={radios[0].label} + /> + </EuiFlexItem> + <EuiFlexItem className="no-horizontal-margin-radio" grow={4}> + <ProtectionRadio + protectionMode={radios[1].id} + key={radios[1].protection + radios[1].id} + label={radios[1].label} + /> + </EuiFlexItem> + </RadioFlexGroup> {isPlatinumPlus && ( <> + <EuiSpacer size="m" /> <ConfigFormHeading> <FormattedMessage id="xpack.securitySolution.endpoint.policyDetailsConfig.userNotification" @@ -277,15 +268,16 @@ export const MalwareProtections = React.memo(() => { <EuiFlexItem grow={false}> <EuiIconTip position="right" + data-test-subj="malwareTooltip" content={ <> <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification.tooltip.a" + id="xpack.securitySolution.endpoint.policyDetailsConfig.malware.notifyUserTooltip.a" defaultMessage="Selecting the user notification option will display a notification to the host user when malware is prevented or detected." /> <EuiSpacer size="m" /> <FormattedMessage - id="xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification.tooltip.b" + id="xpack.securitySolution.endpoint.policyDetailsConfig.malware.notifyUserTooltip.b" defaultMessage=" The user notification can be customized in the text box below. Bracketed tags can be used to dynamically populate the applicable action (such as prevented or detected) and the filename." /> @@ -327,7 +319,7 @@ export const MalwareProtections = React.memo(() => { label={i18n.translate( 'xpack.securitySolution.endpoint.policy.details.malwareProtectionsEnabled', { - defaultMessage: 'Malware Protections {mode, select, true {Enabled} false {Disabled}}', + defaultMessage: 'Malware protections {mode, select, true {enabled} false {disabled}}', values: { mode: selected !== ProtectionModes.off, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/popup_options_to_versions.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/popup_options_to_versions.ts index d4c7d0102ebd4..795f7dda52499 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/popup_options_to_versions.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/popup_options_to_versions.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -const popupVersions: Array<[string, string]> = [['malware', '7.11+']]; +const popupVersions: Array<[string, string]> = [ + ['malware', '7.11+'], + ['ransomware', '7.12+'], +]; export const popupVersionsMap: ReadonlyMap<string, string> = new Map<string, string>(popupVersions); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx new file mode 100644 index 0000000000000..eb2dd4b2fe8d8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/ransomware.tsx @@ -0,0 +1,346 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiCallOut, + EuiCheckbox, + EuiRadio, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTextArea, + htmlIdGenerator, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiCheckboxProps, + EuiRadioProps, + EuiSwitchProps, +} from '@elastic/eui'; +import { cloneDeep } from 'lodash'; +import { APP_ID } from '../../../../../../../common/constants'; +import { SecurityPageName } from '../../../../../../app/types'; +import { + Immutable, + OperatingSystem, + ProtectionModes, +} from '../../../../../../../common/endpoint/types'; +import { RansomwareProtectionOSes, OS } from '../../../types'; +import { ConfigForm, ConfigFormHeading } from '../../components/config_form'; +import { policyConfig } from '../../../store/policy_details/selectors'; +import { usePolicyDetailsSelector } from '../../policy_hooks'; +import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; +import { AppAction } from '../../../../../../common/store/actions'; +import { SupportedVersionNotice } from './supported_version'; +import { RadioFlexGroup } from './malware'; + +const OSes: Immutable<RansomwareProtectionOSes[]> = [OS.windows, OS.mac]; +const protection = 'ransomware'; + +const ProtectionRadio = React.memo( + ({ protectionMode, label }: { protectionMode: ProtectionModes; label: string }) => { + const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); + const dispatch = useDispatch<(action: AppAction) => void>(); + const radioButtonId = useMemo(() => htmlIdGenerator()(), []); + // currently just taking windows.ransomware, but both windows.ransomware and mac.ransomware should be the same value + const selected = policyDetailsConfig && policyDetailsConfig.windows.ransomware.mode; + + const handleRadioChange: EuiRadioProps['onChange'] = useCallback(() => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + for (const os of OSes) { + newPayload[os][protection].mode = protectionMode; + if (protectionMode === ProtectionModes.prevent) { + newPayload[os].popup[protection].enabled = true; + } else { + newPayload[os].popup[protection].enabled = false; + } + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, [dispatch, protectionMode, policyDetailsConfig]); + + /** + * Passing an arbitrary id because EuiRadio + * requires an id if label is passed + */ + + return ( + <EuiRadio + className="policyDetailsProtectionRadio" + label={label} + id={radioButtonId} + checked={selected === protectionMode} + onChange={handleRadioChange} + disabled={selected === ProtectionModes.off} + /> + ); + } +); + +ProtectionRadio.displayName = 'ProtectionRadio'; + +/** The Ransomware Protections form for policy details + * which will configure for all relevant OSes. + */ +export const Ransomware = React.memo(() => { + const policyDetailsConfig = usePolicyDetailsSelector(policyConfig); + const dispatch = useDispatch<(action: AppAction) => void>(); + // currently just taking windows.ransomware, but both windows.ransomware and mac.ransomware should be the same value + const selected = policyDetailsConfig && policyDetailsConfig.windows.ransomware.mode; + const userNotificationSelected = + policyDetailsConfig && policyDetailsConfig.windows.popup.ransomware.enabled; + const userNotificationMessage = + policyDetailsConfig && policyDetailsConfig.windows.popup.ransomware.message; + + const radios: Immutable< + Array<{ + id: ProtectionModes; + label: string; + protection: 'ransomware'; + }> + > = useMemo(() => { + return [ + { + id: ProtectionModes.detect, + label: i18n.translate('xpack.securitySolution.endpoint.policy.details.detect', { + defaultMessage: 'Detect', + }), + protection: 'ransomware', + }, + { + id: ProtectionModes.prevent, + label: i18n.translate('xpack.securitySolution.endpoint.policy.details.prevent', { + defaultMessage: 'Prevent', + }), + protection: 'ransomware', + }, + ]; + }, []); + + const handleSwitchChange: EuiSwitchProps['onChange'] = useCallback( + (event) => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + if (event.target.checked === false) { + for (const os of OSes) { + newPayload[os][protection].mode = ProtectionModes.off; + newPayload[os].popup[protection].enabled = event.target.checked; + } + } else { + for (const os of OSes) { + newPayload[os][protection].mode = ProtectionModes.prevent; + newPayload[os].popup[protection].enabled = event.target.checked; + } + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, + [dispatch, policyDetailsConfig] + ); + + const handleUserNotificationCheckbox: EuiCheckboxProps['onChange'] = useCallback( + (event) => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + for (const os of OSes) { + newPayload[os].popup[protection].enabled = event.target.checked; + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, + [policyDetailsConfig, dispatch] + ); + + const handleCustomUserNotification = useCallback( + (event) => { + if (policyDetailsConfig) { + const newPayload = cloneDeep(policyDetailsConfig); + for (const os of OSes) { + newPayload[os].popup[protection].message = event.target.value; + } + dispatch({ + type: 'userChangedPolicyConfig', + payload: { policyConfig: newPayload }, + }); + } + }, + [policyDetailsConfig, dispatch] + ); + + const radioButtons = useMemo(() => { + return ( + <> + <ConfigFormHeading> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.protectionLevel" + defaultMessage="Protection Level" + /> + </ConfigFormHeading> + <EuiSpacer size="xs" /> + <RadioFlexGroup> + <EuiFlexItem className="no-right-margin-radio" grow={1}> + <ProtectionRadio + protectionMode={radios[0].id} + key={radios[0].protection + radios[0].id} + label={radios[0].label} + /> + </EuiFlexItem> + <EuiFlexItem className="no-horizontal-margin-radio" grow={4}> + <ProtectionRadio + protectionMode={radios[1].id} + key={radios[1].protection + radios[1].id} + label={radios[1].label} + /> + </EuiFlexItem> + </RadioFlexGroup> + <EuiSpacer size="m" /> + <ConfigFormHeading> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.userNotification" + defaultMessage="User Notification" + /> + </ConfigFormHeading> + <SupportedVersionNotice optionName="ransomware" /> + <EuiSpacer size="s" /> + <EuiCheckbox + data-test-subj="ransomwareUserNotificationCheckbox" + id="xpack.securitySolution.endpoint.policyDetail.ransomware.userNotification" + onChange={handleUserNotificationCheckbox} + checked={userNotificationSelected} + disabled={selected === ProtectionModes.off} + label={i18n.translate( + 'xpack.securitySolution.endpoint.policyDetail.ransomware.notifyUser', + { + defaultMessage: 'Notify User', + } + )} + /> + {userNotificationSelected && ( + <> + <EuiSpacer size="s" /> + <EuiFlexGroup gutterSize="s"> + <EuiFlexItem grow={false}> + <EuiText size="xs"> + <h4> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification" + defaultMessage="Customize notification message" + /> + </h4> + </EuiText> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiIconTip + position="right" + data-test-subj="ransomwareTooltip" + content={ + <> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.ransomware.notifyUserTooltip.a" + defaultMessage="Selecting the user notification option will display a notification to the host user when ransomware is prevented or detected." + /> + <EuiSpacer size="m" /> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetailsConfig.ransomware.notifyUserTooltip.b" + defaultMessage=" + The user notification can be customized in the text box below. Bracketed tags can be used to dynamically populate the applicable action (such as prevented or detected) and the filename." + /> + </> + } + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="xs" /> + <EuiTextArea + placeholder={i18n.translate( + 'xpack.securitySolution.endpoint.policyDetails.ransomware.userNotification.placeholder', + { + defaultMessage: 'Input your custom notification message', + } + )} + value={userNotificationMessage} + onChange={handleCustomUserNotification} + fullWidth={true} + data-test-subj="ransomwareUserNotificationCustomMessage" + /> + </> + )} + </> + ); + }, [ + radios, + selected, + handleUserNotificationCheckbox, + userNotificationSelected, + userNotificationMessage, + handleCustomUserNotification, + ]); + + const protectionSwitch = useMemo(() => { + return ( + <EuiSwitch + label={i18n.translate( + 'xpack.securitySolution.endpoint.policy.details.ransomwareProtectionsEnabled', + { + defaultMessage: + 'Ransomware protections {mode, select, true {enabled} false {disabled}}', + values: { + mode: selected !== ProtectionModes.off, + }, + } + )} + checked={selected !== ProtectionModes.off} + onChange={handleSwitchChange} + /> + ); + }, [handleSwitchChange, selected]); + + return ( + <ConfigForm + type={i18n.translate('xpack.securitySolution.endpoint.policy.details.ransomware', { + defaultMessage: 'Ransomware', + })} + supportedOss={[OperatingSystem.WINDOWS, OperatingSystem.MAC]} + dataTestSubj="ransomwareProtectionsForm" + rightCorner={protectionSwitch} + > + {radioButtons} + <EuiSpacer size="m" /> + <EuiCallOut iconType="iInCircle"> + <FormattedMessage + id="xpack.securitySolution.endpoint.policy.details.detectionRulesMessage" + defaultMessage="View {detectionRulesLink}. Prebuilt rules are tagged “Elastic” on the Detection Rules page." + values={{ + detectionRulesLink: ( + <LinkToApp appId={`${APP_ID}:${SecurityPageName.detections}`} appPath={`/rules`}> + <FormattedMessage + id="xpack.securitySolution.endpoint.policy.details.detectionRulesLink" + defaultMessage="related detection rules" + /> + </LinkToApp> + ), + }} + /> + </EuiCallOut> + </ConfigForm> + ); +}); + +Ransomware.displayName = 'RansomwareProtections'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/supported_version.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/supported_version.tsx new file mode 100644 index 0000000000000..dee6418b4f3ff --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/supported_version.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText } from '@elastic/eui'; +import { popupVersionsMap } from './popup_options_to_versions'; + +export const SupportedVersionNotice = ({ optionName }: { optionName: string }) => { + const version = popupVersionsMap.get(optionName); + if (!version) { + return null; + } + + return ( + <EuiText color="subdued" size="xs"> + <i> + <FormattedMessage + id="xpack.securitySolution.endpoint.policyDetails.supportedVersion" + defaultMessage="Agent version {version}" + values={{ version }} + /> + </i> + </EuiText> + ); +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index ec3d35cbb6585..7f9e8b42490fa 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -124,6 +124,7 @@ export class EndpointAppContextService { dependencies.config.maxTimelineImportExportSize, dependencies.security, dependencies.alerts, + dependencies.licenseService, dependencies.exceptionListsClient ) ); diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts index d287ada74eebc..2710e4afb5968 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts @@ -6,7 +6,10 @@ import { httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { createNewPackagePolicyMock } from '../../../fleet/common/mocks'; -import { factory as policyConfigFactory } from '../../common/endpoint/models/policy_config'; +import { + policyFactory, + policyFactoryWithoutPaidFeatures, +} from '../../common/endpoint/models/policy_config'; import { getManifestManagerMock, ManifestManagerMockType, @@ -55,6 +58,9 @@ describe('ingest_integration tests ', () => { }); describe('ingest_integration sanity checks', () => { + beforeEach(() => { + licenseEmitter.next(Platinum); // set license level to platinum + }); test('policy is updated with initial manifest', async () => { const logger = loggingSystemMock.create().get('ingest_integration.test'); const manifestManager = getManifestManagerMock({ @@ -68,13 +74,14 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); // policy config without manifest const newPolicyConfig = await callback(policyConfig, ctx, req); // policy config WITH manifest expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual({ artifacts: { 'endpoint-exceptionlist-macos-v1': { @@ -146,13 +153,14 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); const newPolicyConfig = await callback(policyConfig, ctx, req); expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( lastComputed!.toEndpointFormat() ); @@ -174,13 +182,14 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); const newPolicyConfig = await callback(policyConfig, ctx, req); expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); }); test('subsequent policy creations succeed', async () => { @@ -196,13 +205,14 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); const newPolicyConfig = await callback(policyConfig, ctx, req); expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( lastComputed!.toEndpointFormat() ); @@ -221,6 +231,7 @@ describe('ingest_integration tests ', () => { maxTimelineImportExportSize, endpointAppContextMock.security, endpointAppContextMock.alerts, + licenseService, exceptionListClient ); const policyConfig = createNewPackagePolicyMock(); @@ -228,7 +239,7 @@ describe('ingest_integration tests ', () => { expect(exceptionListClient.createEndpointList).toHaveBeenCalled(); expect(newPolicyConfig.inputs[0]!.type).toEqual('endpoint'); - expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); + expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual( lastComputed!.toEndpointFormat() ); @@ -239,8 +250,7 @@ describe('ingest_integration tests ', () => { licenseEmitter.next(Gold); // set license level to gold }); it('returns an error if paid features are turned on in the policy', async () => { - const mockPolicy = policyConfigFactory(); - mockPolicy.windows.popup.malware.message = 'paid feature'; + const mockPolicy = policyFactory(); // defaults with paid features on const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyUpdateCallback(logger, licenseService); const policyConfig = generator.generatePolicyPackagePolicy(); @@ -250,7 +260,7 @@ describe('ingest_integration tests ', () => { ); }); it('updates successfully if no paid features are turned on in the policy', async () => { - const mockPolicy = policyConfigFactory(); + const mockPolicy = policyFactoryWithoutPaidFeatures(); mockPolicy.windows.malware.mode = ProtectionModes.detect; const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyUpdateCallback(logger, licenseService); @@ -265,7 +275,7 @@ describe('ingest_integration tests ', () => { licenseEmitter.next(Platinum); // set license level to platinum }); it('updates successfully when paid features are turned on', async () => { - const mockPolicy = policyConfigFactory(); + const mockPolicy = policyFactory(); mockPolicy.windows.popup.malware.message = 'paid feature'; const logger = loggingSystemMock.create().get('ingest_integration.test'); const callback = getPackagePolicyUpdateCallback(logger, licenseService); diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts index 1e7f440ed6788..114c6ba969227 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts @@ -10,7 +10,10 @@ import { SecurityPluginSetup } from '../../../security/server'; import { ExternalCallback } from '../../../fleet/server'; import { KibanaRequest, Logger, RequestHandlerContext } from '../../../../../src/core/server'; import { NewPackagePolicy, UpdatePackagePolicy } from '../../../fleet/common/types/models'; -import { factory as policyConfigFactory } from '../../common/endpoint/models/policy_config'; +import { + policyFactory as policyConfigFactory, + policyFactoryWithoutPaidFeatures as policyConfigFactoryWithoutPaidFeatures, +} from '../../common/endpoint/models/policy_config'; import { NewPolicyData } from '../../common/endpoint/types'; import { ManifestManager } from './services/artifacts'; import { Manifest } from './lib/artifacts'; @@ -22,7 +25,7 @@ import { createDetectionIndex } from '../lib/detection_engine/routes/index/creat import { createPrepackagedRules } from '../lib/detection_engine/routes/rules/add_prepackaged_rules_route'; import { buildFrameworkRequest } from '../lib/timeline/routes/utils/common'; import { isEndpointPolicyValidForLicense } from '../../common/license/policy_config'; -import { LicenseService } from '../../common/license/license'; +import { isAtLeast, LicenseService } from '../../common/license/license'; const getManifest = async (logger: Logger, manifestManager: ManifestManager): Promise<Manifest> => { let manifest: Manifest | null = null; @@ -86,6 +89,7 @@ export const getPackagePolicyCreateCallback = ( maxTimelineImportExportSize: number, securitySetup: SecurityPluginSetup, alerts: AlertsStartContract, + licenseService: LicenseService, exceptionsClient: ExceptionListClient | undefined ): ExternalCallback[1] => { const handlePackagePolicyCreate = async ( @@ -151,6 +155,12 @@ export const getPackagePolicyCreateCallback = ( // Until we get the Default Policy Configuration in the Endpoint package, // we will add it here manually at creation time. + + // generate the correct default policy depending on the license + const defaultPolicy = isAtLeast(licenseService.getLicenseInformation(), 'platinum') + ? policyConfigFactory() + : policyConfigFactoryWithoutPaidFeatures(); + updatedPackagePolicy = { ...newPackagePolicy, inputs: [ @@ -163,7 +173,7 @@ export const getPackagePolicyCreateCallback = ( value: serializedManifest, }, policy: { - value: policyConfigFactory(), + value: defaultPolicy, }, }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts index 225592fa8e686..8e7c4d2d4daf5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/policy/license_watch.test.ts @@ -18,7 +18,7 @@ import { licenseMock } from '../../../../../licensing/common/licensing.mock'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { PackagePolicy } from '../../../../../fleet/common'; import { createPackagePolicyMock } from '../../../../../fleet/common/mocks'; -import { factory } from '../../../../common/endpoint/models/policy_config'; +import { policyFactory } from '../../../../common/endpoint/models/policy_config'; import { PolicyConfig } from '../../../../common/endpoint/types'; const MockPPWithEndpointPolicy = (cb?: (p: PolicyConfig) => PolicyConfig): PackagePolicy => { @@ -27,7 +27,7 @@ const MockPPWithEndpointPolicy = (cb?: (p: PolicyConfig) => PolicyConfig): Packa // eslint-disable-next-line no-param-reassign cb = (p) => p; } - const policyConfig = cb(factory()); + const policyConfig = cb(policyFactory()); packagePolicy.inputs[0].config = { policy: { value: policyConfig } }; return packagePolicy; }; diff --git a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts new file mode 100644 index 0000000000000..d74edef896c65 --- /dev/null +++ b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.test.ts @@ -0,0 +1,398 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildSortedEventsQuery, BuildSortedEventsQuery } from './build_sorted_events_query'; +import type { Writable } from '@kbn/utility-types'; + +const DefaultQuery: Writable<Partial<BuildSortedEventsQuery>> = { + index: ['index-name'], + from: '2021-01-01T00:00:10.123Z', + to: '2021-01-23T12:00:50.321Z', + filter: {}, + size: 100, + timeField: 'timefield', +}; + +describe('buildSortedEventsQuery', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let query: any; + beforeEach(() => { + query = { ...DefaultQuery }; + }); + + test('it builds a filter with given date range', () => { + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + }, + }); + }); + + test('it does not include searchAfterSortId if it is an empty string', () => { + query.searchAfterSortId = ''; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + }, + }); + }); + + test('it includes searchAfterSortId if it is a valid string', () => { + const sortId = '123456789012'; + query.searchAfterSortId = sortId; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + search_after: [sortId], + }, + }); + }); + + test('it includes searchAfterSortId if it is a valid number', () => { + const sortId = 123456789012; + query.searchAfterSortId = sortId; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + search_after: [sortId], + }, + }); + }); + + test('it includes aggregations if provided', () => { + query.aggs = { + tags: { + terms: { + field: 'tag', + }, + }, + }; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + aggs: { + tags: { + terms: { + field: 'tag', + }, + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + }, + }); + }); + + test('it uses sortOrder if specified', () => { + query.sortOrder = 'desc'; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: false, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'desc', + }, + }, + ], + }, + }); + }); + + test('it uses track_total_hits if specified', () => { + query.track_total_hits = true; + expect(buildSortedEventsQuery(query)).toEqual({ + allowNoIndices: true, + index: ['index-name'], + size: 100, + ignoreUnavailable: true, + track_total_hits: true, + body: { + docvalue_fields: [ + { + field: 'timefield', + format: 'strict_date_optional_time', + }, + ], + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + range: { + timefield: { + gte: '2021-01-01T00:00:10.123Z', + lte: '2021-01-23T12:00:50.321Z', + format: 'strict_date_optional_time', + }, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + sort: [ + { + timefield: { + order: 'asc', + }, + }, + ], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts b/x-pack/plugins/stack_alerts/common/build_sorted_events_query.ts new file mode 100644 index 0000000000000..92425433bf814 --- /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 884d33ef669e5..80eb177f92024 100644 --- a/x-pack/plugins/stack_alerts/kibana.json +++ b/x-pack/plugins/stack_alerts/kibana.json @@ -5,5 +5,6 @@ "kibanaVersion": "kibana", "requiredPlugins": ["alerts", "features", "triggersActionsUi", "kibanaReact", "savedObjects", "data"], "configPath": ["xpack", "stack_alerts"], + "requiredBundles": ["esUiShared"], "ui": true } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx new file mode 100644 index 0000000000000..5dc7c9248135c --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { IndexSelectPopover } from './index_select_popover'; + +jest.mock('../../../../triggers_actions_ui/public', () => ({ + getIndexPatterns: () => { + return ['index1', 'index2']; + }, + firstFieldOption: () => { + return { text: 'Select a field', value: '' }; + }, + getTimeFieldOptions: () => { + return [ + { + text: '@timestamp', + value: '@timestamp', + }, + ]; + }, + getFields: () => { + return Promise.resolve([ + { + name: '@timestamp', + type: 'date', + }, + { + name: 'field', + type: 'text', + }, + ]); + }, + getIndexOptions: () => { + return Promise.resolve([ + { + label: 'indexOption', + options: [ + { + label: 'index1', + value: 'index1', + }, + { + label: 'index2', + value: 'index2', + }, + ], + }, + ]); + }, +})); + +describe('IndexSelectPopover', () => { + const props = { + index: [], + esFields: [], + timeField: undefined, + errors: { + index: [], + timeField: [], + }, + onIndexChange: jest.fn(), + onTimeFieldChange: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('renders closed popover initially and opens on click', async () => { + const wrapper = mountWithIntl(<IndexSelectPopover {...props} />); + + expect(wrapper.find('[data-test-subj="selectIndexExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="thresholdAlertTimeFieldSelect"]').exists()).toBeFalsy(); + + wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="thresholdAlertTimeFieldSelect"]').exists()).toBeTruthy(); + }); + + test('renders search input', async () => { + const wrapper = mountWithIntl(<IndexSelectPopover {...props} />); + + expect(wrapper.find('[data-test-subj="selectIndexExpression"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeTruthy(); + const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); + expect(indexSearchBoxValue.first().props().value).toEqual(''); + + const indexComboBox = wrapper.find('#indexSelectSearchBox'); + indexComboBox.first().simulate('click'); + const event = { target: { value: 'indexPattern1' } }; + indexComboBox.find('input').first().simulate('change', event); + + const updatedIndexSearchValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); + expect(updatedIndexSearchValue.first().props().value).toEqual('indexPattern1'); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx new file mode 100644 index 0000000000000..6fe61be024042 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.tsx @@ -0,0 +1,239 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { isString } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonIcon, + EuiComboBox, + EuiComboBoxOptionOption, + EuiExpression, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPopover, + EuiPopoverTitle, + EuiSelect, +} from '@elastic/eui'; +import { HttpSetup } from 'kibana/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { + firstFieldOption, + getFields, + getIndexOptions, + getIndexPatterns, + getTimeFieldOptions, + IErrorObject, +} from '../../../../triggers_actions_ui/public'; + +interface KibanaDeps { + http: HttpSetup; +} +interface Props { + index: string[]; + esFields: Array<{ + name: string; + type: string; + normalizedType: string; + searchable: boolean; + aggregatable: boolean; + }>; + timeField: string | undefined; + errors: IErrorObject; + onIndexChange: (indices: string[]) => void; + onTimeFieldChange: (timeField: string) => void; +} + +export const IndexSelectPopover: React.FunctionComponent<Props> = ({ + index, + esFields, + timeField, + errors, + onIndexChange, + onTimeFieldChange, +}) => { + const { http } = useKibana<KibanaDeps>().services; + + const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); + const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]); + const [indexPatterns, setIndexPatterns] = useState([]); + const [areIndicesLoading, setAreIndicesLoading] = useState<boolean>(false); + const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); + + useEffect(() => { + const indexPatternsFunction = async () => { + setIndexPatterns(await getIndexPatterns()); + }; + indexPatternsFunction(); + }, []); + + useEffect(() => { + const timeFields = getTimeFieldOptions(esFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + }, [esFields]); + + const renderIndices = (indices: string[]) => { + const rows = indices.map((indexName: string, idx: number) => { + return ( + <p key={idx}> + {indexName} + {idx < indices.length - 1 ? ',' : null} + </p> + ); + }); + return <div>{rows}</div>; + }; + + const closeIndexPopover = () => { + setIndexPopoverOpen(false); + if (timeField === undefined) { + onTimeFieldChange(''); + } + }; + + return ( + <EuiPopover + id="indexPopover" + button={ + <EuiExpression + display="columns" + data-test-subj="selectIndexExpression" + description={i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexLabel', { + defaultMessage: 'index', + })} + value={index && index.length > 0 ? renderIndices(index) : firstFieldOption.text} + isActive={indexPopoverOpen} + onClick={() => { + setIndexPopoverOpen(true); + }} + isInvalid={!(index && index.length > 0 && timeField !== '')} + /> + } + isOpen={indexPopoverOpen} + closePopover={closeIndexPopover} + ownFocus + anchorPosition="downLeft" + zIndex={8000} + display="block" + > + <div style={{ width: '450px' }}> + <EuiPopoverTitle> + <EuiFlexGroup alignItems="center" gutterSize="s"> + <EuiFlexItem> + {i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexButtonLabel', { + defaultMessage: 'index', + })} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButtonIcon + data-test-subj="closePopover" + iconType="cross" + color="danger" + aria-label={i18n.translate( + 'xpack.stackAlerts.components.ui.alertParams.closeIndexPopoverLabel', + { + defaultMessage: 'Close', + } + )} + onClick={closeIndexPopover} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiPopoverTitle> + <EuiFormRow + id="indexSelectSearchBox" + fullWidth + label={ + <FormattedMessage + id="xpack.stackAlerts.components.ui.alertParams.indicesToQueryLabel" + defaultMessage="Indices to query" + /> + } + isInvalid={errors.index.length > 0 && index != null && index.length > 0} + error={errors.index} + helpText={ + <FormattedMessage + id="xpack.stackAlerts.components.ui.alertParams.howToBroadenSearchQueryDescription" + defaultMessage="Use * to broaden your query." + /> + } + > + <EuiComboBox + fullWidth + async + isLoading={areIndicesLoading} + isInvalid={errors.index.length > 0 && index != null && index.length > 0} + noSuggestions={!indexOptions.length} + options={indexOptions} + data-test-subj="thresholdIndexesComboBox" + selectedOptions={(index || []).map((anIndex: string) => { + return { + label: anIndex, + value: anIndex, + }; + })} + onChange={async (selected: EuiComboBoxOptionOption[]) => { + const selectedIndices = selected + .map((aSelected) => aSelected.value) + .filter<string>(isString); + onIndexChange(selectedIndices); + + // reset time field if indices have been reset + if (selectedIndices.length === 0) { + setTimeFieldOptions([firstFieldOption]); + } else { + const currentEsFields = await getFields(http!, selectedIndices); + const timeFields = getTimeFieldOptions(currentEsFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + } + }} + onSearchChange={async (search) => { + setAreIndicesLoading(true); + setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); + setAreIndicesLoading(false); + }} + onBlur={() => { + if (!index) { + onIndexChange([]); + } + }} + /> + </EuiFormRow> + <EuiFormRow + id="thresholdTimeField" + fullWidth + label={ + <FormattedMessage + id="xpack.stackAlerts.components.ui.alertParams.timeFieldLabel" + defaultMessage="Time field" + /> + } + isInvalid={errors.timeField.length > 0 && timeField !== undefined} + error={errors.timeField} + > + <EuiSelect + options={timeFieldOptions} + isInvalid={errors.timeField.length > 0 && timeField !== undefined} + fullWidth + name="thresholdTimeField" + data-test-subj="thresholdAlertTimeFieldSelect" + value={timeField || ''} + onChange={(e) => { + onTimeFieldChange(e.target.value); + }} + onBlur={() => { + if (timeField === undefined) { + onTimeFieldChange(''); + } + }} + /> + </EuiFormRow> + </div> + </EuiPopover> + ); +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx new file mode 100644 index 0000000000000..96a45da3d0808 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import 'brace'; +import { of } from 'rxjs'; +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; +import EsQueryAlertTypeExpression from './expression'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { + DataPublicPluginStart, + IKibanaSearchResponse, + ISearchStart, +} from 'src/plugins/data/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { EsQueryAlertParams } from './types'; + +jest.mock('../../../../../../src/plugins/kibana_react/public'); +jest.mock('../../../../../../src/plugins/es_ui_shared/public'); +jest.mock('../../../../../../src/plugins/es_ui_shared/public', () => ({ + XJson: { + useXJsonMode: jest.fn().mockReturnValue({ + convertToJson: jest.fn(), + setXJson: jest.fn(), + xJson: jest.fn(), + }), + }, +})); +jest.mock(''); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + + return { + ...original, + // Mocking EuiCodeEditor, which uses React Ace under the hood + // eslint-disable-next-line @typescript-eslint/no-explicit-any + EuiCodeEditor: (props: any) => ( + <input + data-test-subj="mockCodeEditor" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange={(syntheticEvent: any) => { + props.onChange(syntheticEvent.jsonString); + }} + /> + ), + }; +}); +jest.mock('../../../../triggers_actions_ui/public', () => { + const original = jest.requireActual('../../../../triggers_actions_ui/public'); + return { + ...original, + getIndexPatterns: () => { + return ['index1', 'index2']; + }, + firstFieldOption: () => { + return { text: 'Select a field', value: '' }; + }, + getTimeFieldOptions: () => { + return [ + { + text: '@timestamp', + value: '@timestamp', + }, + ]; + }, + getFields: () => { + return Promise.resolve([ + { + name: '@timestamp', + type: 'date', + }, + { + name: 'field', + type: 'text', + }, + ]); + }, + getIndexOptions: () => { + return Promise.resolve([ + { + label: 'indexOption', + options: [ + { + label: 'index1', + value: 'index1', + }, + { + label: 'index2', + value: 'index2', + }, + ], + }, + ]); + }, + }; +}); + +const createDataPluginMock = () => { + const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + search: ISearchStart & { search: jest.MockedFunction<any> }; + }; + return dataMock; +}; + +const dataMock = createDataPluginMock(); +const chartsStartMock = chartPluginMock.createStartContract(); + +describe('EsQueryAlertTypeExpression', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + docLinks: { + ELASTIC_WEBSITE_URL: '', + DOC_LINK_VERSION: '', + }, + }, + }); + }); + + function getAlertParams(overrides = {}) { + return { + index: ['test-index'], + timeField: '@timestamp', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + thresholdComparator: '>', + threshold: [0], + timeWindowSize: 15, + timeWindowUnit: 's', + ...overrides, + }; + } + async function setup(alertParams: EsQueryAlertParams) { + const errors = { + index: [], + esQuery: [], + timeField: [], + timeWindowSize: [], + }; + + const wrapper = mountWithIntl( + <EsQueryAlertTypeExpression + alertInterval="1m" + alertThrottle="1m" + alertParams={alertParams} + setAlertParams={() => {}} + setAlertProperty={() => {}} + errors={errors} + data={dataMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + /> + ); + + const update = async () => + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + await update(); + return wrapper; + } + + test('should render EsQueryAlertTypeExpression with expected components', async () => { + const wrapper = await setup(getAlertParams()); + expect(wrapper.find('[data-test-subj="indexSelectPopover"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="queryJsonEditor"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy(); + + const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]'); + expect(testQueryButton.exists()).toBeTruthy(); + expect(testQueryButton.prop('disabled')).toBe(false); + }); + + test('should render Test Query button disabled if alert params are invalid', async () => { + const wrapper = await setup(getAlertParams({ timeField: null })); + const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]'); + expect(testQueryButton.exists()).toBeTruthy(); + expect(testQueryButton.prop('disabled')).toBe(true); + }); + + test('should show success message if Test Query is successful', async () => { + const searchResponseMock$ = of<IKibanaSearchResponse>({ + rawResponse: { + hits: { + total: 1234, + }, + }, + }); + dataMock.search.search.mockImplementation(() => searchResponseMock$); + const wrapper = await setup(getAlertParams()); + const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]'); + + testQueryButton.simulate('click'); + expect(dataMock.search.search).toHaveBeenCalled(); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy(); + expect(wrapper.find('EuiText[data-test-subj="testQuerySuccess"]').text()).toEqual( + `Query matched 1234 documents in the last 15s.` + ); + }); + + test('should show error message if Test Query is throws error', async () => { + dataMock.search.search.mockImplementation(() => { + throw new Error('What is this query'); + }); + const wrapper = await setup(getAlertParams()); + const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]'); + + testQueryButton.simulate('click'); + expect(dataMock.search.search).toHaveBeenCalled(); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx new file mode 100644 index 0000000000000..bba0e30978305 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.tsx @@ -0,0 +1,371 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, Fragment, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import 'brace/theme/github'; +import { XJsonMode } from '@kbn/ace'; + +import { + EuiButtonEmpty, + EuiCodeEditor, + EuiSpacer, + EuiFormRow, + EuiCallOut, + EuiText, + EuiTitle, + EuiLink, +} from '@elastic/eui'; +import { DocLinksStart, HttpSetup } from 'kibana/public'; +import { XJson } from '../../../../../../src/plugins/es_ui_shared/public'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { + getFields, + COMPARATORS, + ThresholdExpression, + ForLastExpression, + AlertTypeParamsExpressionProps, +} from '../../../../triggers_actions_ui/public'; +import { validateExpression } from './validation'; +import { parseDuration } from '../../../../alerts/common'; +import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query'; +import { EsQueryAlertParams } from './types'; +import { IndexSelectPopover } from '../components/index_select_popover'; + +const DEFAULT_VALUES = { + THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, + QUERY: `{ + "query":{ + "match_all" : {} + } +}`, + TIME_WINDOW_SIZE: 5, + TIME_WINDOW_UNIT: 'm', + THRESHOLD: [1000], +}; + +const expressionFieldsWithValidation = [ + 'index', + 'esQuery', + 'timeField', + 'threshold0', + 'threshold1', + 'timeWindowSize', +]; + +const { useXJsonMode } = XJson; +const xJsonMode = new XJsonMode(); + +interface KibanaDeps { + http: HttpSetup; + docLinks: DocLinksStart; +} + +export const EsQueryAlertTypeExpression: React.FunctionComponent< + AlertTypeParamsExpressionProps<EsQueryAlertParams> +> = ({ alertParams, setAlertParams, setAlertProperty, errors, data }) => { + const { + index, + timeField, + esQuery, + thresholdComparator, + threshold, + timeWindowSize, + timeWindowUnit, + } = alertParams; + + const getDefaultParams = () => ({ + ...alertParams, + esQuery: esQuery ?? DEFAULT_VALUES.QUERY, + timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT, + threshold: threshold ?? DEFAULT_VALUES.THRESHOLD, + thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR, + }); + + const { http, docLinks } = useKibana<KibanaDeps>().services; + + const [esFields, setEsFields] = useState< + Array<{ + name: string; + type: string; + normalizedType: string; + searchable: boolean; + aggregatable: boolean; + }> + >([]); + const { convertToJson, setXJson, xJson } = useXJsonMode(DEFAULT_VALUES.QUERY); + const [currentAlertParams, setCurrentAlertParams] = useState<EsQueryAlertParams>( + getDefaultParams() + ); + const [testQueryResult, setTestQueryResult] = useState<string | null>(null); + const [testQueryError, setTestQueryError] = useState<string | null>(null); + + const hasExpressionErrors = !!Object.keys(errors).find( + (errorKey) => + expressionFieldsWithValidation.includes(errorKey) && + errors[errorKey].length >= 1 && + alertParams[errorKey as keyof EsQueryAlertParams] !== undefined + ); + + const expressionErrorMessage = i18n.translate( + 'xpack.stackAlerts.esQuery.ui.alertParams.fixErrorInExpressionBelowValidationMessage', + { + defaultMessage: 'Expression contains errors.', + } + ); + + const setDefaultExpressionValues = async () => { + setAlertProperty('params', getDefaultParams()); + + setXJson(esQuery ?? DEFAULT_VALUES.QUERY); + + if (index && index.length > 0) { + await refreshEsFields(); + } + }; + + const setParam = (paramField: string, paramValue: unknown) => { + setCurrentAlertParams({ + ...currentAlertParams, + [paramField]: paramValue, + }); + setAlertParams(paramField, paramValue); + }; + + useEffect(() => { + setDefaultExpressionValues(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const refreshEsFields = async () => { + if (index) { + const currentEsFields = await getFields(http, index); + setEsFields(currentEsFields); + } + }; + + const hasValidationErrors = () => { + const { errors: validationErrors } = validateExpression(currentAlertParams); + return Object.keys(validationErrors).some( + (key) => validationErrors[key] && validationErrors[key].length + ); + }; + + const onTestQuery = async () => { + if (!hasValidationErrors()) { + setTestQueryError(null); + setTestQueryResult(null); + try { + const window = `${timeWindowSize}${timeWindowUnit}`; + const timeWindow = parseDuration(window); + const parsedQuery = JSON.parse(esQuery); + const now = Date.now(); + const { rawResponse } = await data.search + .search({ + params: buildSortedEventsQuery({ + index, + from: new Date(now - timeWindow).toISOString(), + to: new Date(now).toISOString(), + filter: parsedQuery.query, + size: 0, + searchAfterSortId: undefined, + timeField: timeField ? timeField : '', + track_total_hits: true, + }), + }) + .toPromise(); + + const hits = rawResponse.hits; + setTestQueryResult( + i18n.translate('xpack.stackAlerts.esQuery.ui.numQueryMatchesText', { + defaultMessage: 'Query matched {count} documents in the last {window}.', + values: { count: hits.total, window }, + }) + ); + } catch (err) { + const message = err?.body?.attributes?.error?.root_cause[0]?.reason || err?.body?.message; + setTestQueryError( + i18n.translate('xpack.stackAlerts.esQuery.ui.queryError', { + defaultMessage: 'Error testing query: {message}', + values: { message: message ? `${err.message}: ${message}` : err.message }, + }) + ); + } + } + }; + + return ( + <Fragment> + {hasExpressionErrors ? ( + <Fragment> + <EuiSpacer /> + <EuiCallOut color="danger" size="s" title={expressionErrorMessage} /> + <EuiSpacer /> + </Fragment> + ) : null} + <EuiTitle size="xs"> + <h5> + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.selectIndex" + defaultMessage="Select an index" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + <IndexSelectPopover + index={index} + data-test-subj="indexSelectPopover" + esFields={esFields} + timeField={timeField} + errors={errors} + onIndexChange={async (indices: string[]) => { + setParam('index', indices); + + // reset expression fields if indices are deleted + if (indices.length === 0) { + setAlertProperty('params', { + ...alertParams, + index: indices, + esQuery: DEFAULT_VALUES.QUERY, + thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, + timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, + threshold: DEFAULT_VALUES.THRESHOLD, + timeField: '', + }); + } else { + await refreshEsFields(); + } + }} + onTimeFieldChange={(updatedTimeField: string) => setParam('timeField', updatedTimeField)} + /> + <EuiSpacer /> + <EuiTitle size="xs"> + <h5> + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.queryPrompt" + defaultMessage="Define the ES query" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + <EuiFormRow + id="queryEditor" + fullWidth + label={ + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.queryPrompt.label" + defaultMessage="ES query" + /> + } + isInvalid={errors.esQuery.length > 0} + error={errors.esQuery} + helpText={ + <EuiLink + href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${docLinks.DOC_LINK_VERSION}/query-dsl.html`} + target="_blank" + > + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.queryPrompt.help" + defaultMessage="ES Query DSL documentation" + /> + </EuiLink> + } + > + <EuiCodeEditor + mode={xJsonMode} + width="100%" + height="200px" + theme="github" + data-test-subj="queryJsonEditor" + aria-label={i18n.translate('xpack.stackAlerts.esQuery.ui.queryEditor', { + defaultMessage: 'ES query editor', + })} + value={xJson} + onChange={(xjson: string) => { + setXJson(xjson); + setParam('esQuery', convertToJson(xjson)); + }} + /> + </EuiFormRow> + <EuiFormRow> + <EuiButtonEmpty + data-test-subj="testQuery" + color={'primary'} + iconSide={'left'} + flush={'left'} + iconType={'play'} + disabled={hasValidationErrors()} + onClick={onTestQuery} + > + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.testQuery" + defaultMessage="Test query" + /> + </EuiButtonEmpty> + </EuiFormRow> + {testQueryResult && ( + <EuiFormRow> + <EuiText data-test-subj="testQuerySuccess" color="subdued" size="s"> + <p>{testQueryResult}</p> + </EuiText> + </EuiFormRow> + )} + {testQueryError && ( + <EuiFormRow> + <EuiText data-test-subj="testQueryError" color="danger" size="s"> + <p>{testQueryError}</p> + </EuiText> + </EuiFormRow> + )} + <EuiSpacer /> + <EuiTitle size="xs"> + <h5> + <FormattedMessage + id="xpack.stackAlerts.esQuery.ui.conditionPrompt" + defaultMessage="When number of matches" + /> + </h5> + </EuiTitle> + <EuiSpacer size="s" /> + <ThresholdExpression + data-test-subj="thresholdExpression" + thresholdComparator={thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR} + threshold={threshold ?? DEFAULT_VALUES.THRESHOLD} + errors={errors} + display="fullWidth" + popupPosition={'upLeft'} + onChangeSelectedThreshold={(selectedThresholds) => + setParam('threshold', selectedThresholds) + } + onChangeSelectedThresholdComparator={(selectedThresholdComparator) => + setParam('thresholdComparator', selectedThresholdComparator) + } + /> + <ForLastExpression + data-test-subj="forLastExpression" + popupPosition={'upLeft'} + timeWindowSize={timeWindowSize} + timeWindowUnit={timeWindowUnit} + display="fullWidth" + errors={errors} + onChangeWindowSize={(selectedWindowSize: number | undefined) => + setParam('timeWindowSize', selectedWindowSize) + } + onChangeWindowUnit={(selectedWindowUnit: string) => + setParam('timeWindowUnit', selectedWindowUnit) + } + /> + <EuiSpacer /> + </Fragment> + ); +}; + +// eslint-disable-next-line import/no-default-export +export { EsQueryAlertTypeExpression as default }; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts new file mode 100644 index 0000000000000..62b343ffd6d2f --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { validateExpression } from './validation'; +import { EsQueryAlertParams } from './types'; +import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; + +export function getAlertType(): AlertTypeModel<EsQueryAlertParams> { + return { + id: '.es-query', + description: i18n.translate('xpack.stackAlerts.esQuery.ui.alertType.descriptionText', { + defaultMessage: 'Alert on matches against an ES query.', + }), + iconClass: 'logoElastic', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/alert-types.html#alert-type-es-query`; + }, + alertParamsExpression: lazy(() => import('./expression')), + validate: validateExpression, + defaultActionMessage: i18n.translate( + 'xpack.stackAlerts.esQuery.ui.alertType.defaultActionMessage', + { + defaultMessage: `ES query alert '\\{\\{alertName\\}\\}' is active: + +- Value: \\{\\{context.value\\}\\} +- Conditions Met: \\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\} +- Timestamp: \\{\\{context.date\\}\\}`, + } + ), + requiresAppContext: false, + }; +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts new file mode 100644 index 0000000000000..803c4bde873b4 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertTypeParams } from '../../../../alerts/common'; + +export interface Comparator { + text: string; + value: string; + requiredValues: number; +} + +export interface EsQueryAlertParams extends AlertTypeParams { + index: string[]; + timeField?: string; + esQuery: string; + thresholdComparator?: string; + threshold: number[]; + timeWindowSize: number; + timeWindowUnit: string; +} diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts new file mode 100644 index 0000000000000..15aff9c9a6495 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EsQueryAlertParams } from './types'; +import { validateExpression } from './validation'; + +describe('expression params validation', () => { + test('if index property is invalid should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: [], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + }; + expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.index[0]).toBe('Index is required.'); + }); + + test('if timeField property is not defined should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + }; + expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.'); + }); + + test('if esQuery property is invalid JSON should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + }; + expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.esQuery[0]).toBe('Query must be valid JSON.'); + }); + + test('if esQuery property is invalid should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"aggs\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 1, + timeWindowUnit: 's', + threshold: [0], + }; + expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.esQuery[0]).toBe(`Query field is required.`); + }); + + test('if threshold0 property is not set should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + threshold: [], + timeWindowSize: 1, + timeWindowUnit: 's', + thresholdComparator: '<', + }; + expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold0[0]).toBe('Threshold 0 is required.'); + }); + + test('if threshold1 property is needed by thresholdComparator but not set should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + threshold: [1], + timeWindowSize: 1, + timeWindowUnit: 's', + thresholdComparator: 'between', + }; + expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold1[0]).toBe('Threshold 1 is required.'); + }); + + test('if threshold0 property greater than threshold1 property should return proper error message', () => { + const initialParams: EsQueryAlertParams = { + index: ['test'], + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + threshold: [10, 1], + timeWindowSize: 1, + timeWindowUnit: 's', + thresholdComparator: 'between', + }; + expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0); + expect(validateExpression(initialParams).errors.threshold1[0]).toBe( + 'Threshold 1 must be > Threshold 0.' + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts new file mode 100644 index 0000000000000..d54e24e21d61e --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/validation.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { EsQueryAlertParams } from './types'; +import { ValidationResult, builtInComparators } from '../../../../triggers_actions_ui/public'; + +export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => { + const { index, timeField, esQuery, threshold, timeWindowSize, thresholdComparator } = alertParams; + const validationResult = { errors: {} }; + const errors = { + index: new Array<string>(), + timeField: new Array<string>(), + esQuery: new Array<string>(), + threshold0: new Array<string>(), + threshold1: new Array<string>(), + thresholdComparator: new Array<string>(), + timeWindowSize: new Array<string>(), + }; + validationResult.errors = errors; + if (!index || index.length === 0) { + errors.index.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredIndexText', { + defaultMessage: 'Index is required.', + }) + ); + } + if (!timeField) { + errors.timeField.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeFieldText', { + defaultMessage: 'Time field is required.', + }) + ); + } + if (!esQuery) { + errors.esQuery.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredQueryText', { + defaultMessage: 'ES query is required.', + }) + ); + } else { + try { + const parsedQuery = JSON.parse(esQuery); + if (!parsedQuery.query) { + errors.esQuery.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredEsQueryText', { + defaultMessage: `Query field is required.`, + }) + ); + } + } catch (err) { + errors.esQuery.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.jsonQueryText', { + defaultMessage: 'Query must be valid JSON.', + }) + ); + } + } + if (!threshold || threshold.length === 0 || threshold[0] === undefined) { + errors.threshold0.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold0Text', { + defaultMessage: 'Threshold 0 is required.', + }) + ); + } + if ( + thresholdComparator && + builtInComparators[thresholdComparator].requiredValues > 1 && + (!threshold || + threshold[1] === undefined || + (threshold && threshold.length < builtInComparators[thresholdComparator!].requiredValues)) + ) { + errors.threshold1.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold1Text', { + defaultMessage: 'Threshold 1 is required.', + }) + ); + } + if (threshold && threshold.length === 2 && threshold[0] > threshold[1]) { + errors.threshold1.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.greaterThenThreshold0Text', { + defaultMessage: 'Threshold 1 must be > Threshold 0.', + }) + ); + } + if (!timeWindowSize) { + errors.timeWindowSize.push( + i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeWindowSizeText', { + defaultMessage: 'Time window size is required.', + }) + ); + } + return validationResult; +}; diff --git a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts index d1f64c9298f15..c67043106c0d1 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/geo_containment/types.ts @@ -18,7 +18,6 @@ export interface GeoContainmentAlertParams extends AlertTypeParams { boundaryIndexId: string; boundaryGeoField: string; boundaryNameField?: string; - delayOffsetWithUnits?: string; indexQuery?: Query; boundaryIndexQuery?: Query; } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/index.ts b/x-pack/plugins/stack_alerts/public/alert_types/index.ts index 1a9710eb08eb0..654bf0a424f09 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/public/alert_types/index.ts @@ -7,6 +7,7 @@ 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'; @@ -22,4 +23,5 @@ export function registerAlertTypes({ alertTypeRegistry.register(getGeoContainmentAlertType()); } alertTypeRegistry.register(getThresholdAlertType()); + alertTypeRegistry.register(getEsQueryAlertType()); } diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx index 8348a797972ae..00c170e291504 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx @@ -7,33 +7,13 @@ import React, { useState, Fragment, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlexItem, - EuiFlexGroup, - EuiExpression, - EuiPopover, - EuiPopoverTitle, - EuiSelect, - EuiSpacer, - EuiComboBox, - EuiComboBoxOptionOption, - EuiFormRow, - EuiCallOut, - EuiEmptyPrompt, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import { EuiButtonIcon } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiEmptyPrompt, EuiText, EuiTitle } from '@elastic/eui'; import { HttpSetup } from 'kibana/public'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { - firstFieldOption, - getIndexPatterns, - getIndexOptions, getFields, COMPARATORS, builtInComparators, - getTimeFieldOptions, OfExpression, ThresholdExpression, ForLastExpression, @@ -45,6 +25,7 @@ import { import { ThresholdVisualization } from './visualization'; import { IndexThresholdAlertParams } from './types'; import './expression.scss'; +import { IndexSelectPopover } from '../components/index_select_popover'; const DEFAULT_VALUES = { AGGREGATION_TYPE: 'count', @@ -101,12 +82,15 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< const indexArray = indexParamToArray(index); const { http } = useKibana<KibanaDeps>().services; - const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); - const [indexPatterns, setIndexPatterns] = useState([]); - const [esFields, setEsFields] = useState<unknown[]>([]); - const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]); - const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); - const [isIndiciesLoading, setIsIndiciesLoading] = useState<boolean>(false); + const [esFields, setEsFields] = useState< + Array<{ + name: string; + type: string; + normalizedType: string; + searchable: boolean; + aggregatable: boolean; + }> + >([]); const hasExpressionErrors = !!Object.keys(errors).find( (errorKey) => @@ -139,153 +123,22 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< }); if (indexArray.length > 0) { - const currentEsFields = await getFields(http, indexArray); - const timeFields = getTimeFieldOptions(currentEsFields); - - setEsFields(currentEsFields); - setTimeFieldOptions([firstFieldOption, ...timeFields]); + await refreshEsFields(); } }; - const closeIndexPopover = () => { - setIndexPopoverOpen(false); - if (timeField === undefined) { - setAlertParams('timeField', ''); + const refreshEsFields = async () => { + if (indexArray.length > 0) { + const currentEsFields = await getFields(http, indexArray); + setEsFields(currentEsFields); } }; - useEffect(() => { - const indexPatternsFunction = async () => { - setIndexPatterns(await getIndexPatterns()); - }; - indexPatternsFunction(); - }, []); - useEffect(() => { setDefaultExpressionValues(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const indexPopover = ( - <Fragment> - <EuiFormRow - id="indexSelectSearchBox" - fullWidth - label={ - <FormattedMessage - id="xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel" - defaultMessage="Indices to query" - /> - } - isInvalid={errors.index.length > 0 && indexArray.length > 0} - error={errors.index} - helpText={ - <FormattedMessage - id="xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription" - defaultMessage="Use * to broaden your query." - /> - } - > - <EuiComboBox - fullWidth - async - isLoading={isIndiciesLoading} - isInvalid={errors.index.length > 0 && indexArray.length > 0} - noSuggestions={!indexOptions.length} - options={indexOptions} - data-test-subj="thresholdIndexesComboBox" - selectedOptions={indexArray.map((anIndex: string) => { - return { - label: anIndex, - value: anIndex, - }; - })} - onChange={async (selected: EuiComboBoxOptionOption[]) => { - const indicies: string[] = selected - .map((aSelected) => aSelected.value) - .filter<string>(isString); - setAlertParams('index', indicies); - const indices = selected.map((s) => s.value as string); - - // reset time field and expression fields if indices are deleted - if (indices.length === 0) { - setTimeFieldOptions([firstFieldOption]); - setAlertProperty('params', { - ...alertParams, - index: indices, - aggType: DEFAULT_VALUES.AGGREGATION_TYPE, - termSize: DEFAULT_VALUES.TERM_SIZE, - thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, - timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, - timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, - groupBy: DEFAULT_VALUES.GROUP_BY, - threshold: DEFAULT_VALUES.THRESHOLD, - timeField: '', - }); - return; - } - const currentEsFields = await getFields(http!, indices); - const timeFields = getTimeFieldOptions(currentEsFields); - - setEsFields(currentEsFields); - setTimeFieldOptions([firstFieldOption, ...timeFields]); - }} - onSearchChange={async (search) => { - setIsIndiciesLoading(true); - setIndexOptions(await getIndexOptions(http!, search, indexPatterns)); - setIsIndiciesLoading(false); - }} - onBlur={() => { - if (!index) { - setAlertParams('index', []); - } - }} - /> - </EuiFormRow> - <EuiFormRow - id="thresholdTimeField" - fullWidth - label={ - <FormattedMessage - id="xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel" - defaultMessage="Time field" - /> - } - isInvalid={errors.timeField.length > 0 && timeField !== undefined} - error={errors.timeField} - > - <EuiSelect - options={timeFieldOptions} - isInvalid={errors.timeField.length > 0 && timeField !== undefined} - fullWidth - name="thresholdTimeField" - data-test-subj="thresholdAlertTimeFieldSelect" - value={timeField || ''} - onChange={(e) => { - setAlertParams('timeField', e.target.value); - }} - onBlur={() => { - if (timeField === undefined) { - setAlertParams('timeField', ''); - } - }} - /> - </EuiFormRow> - </Fragment> - ); - - const renderIndices = (indices: string[]) => { - const rows = indices.map((s: string, i: number) => { - return ( - <p key={i}> - {s} - {i < indices.length - 1 ? ',' : null} - </p> - ); - }); - return <div>{rows}</div>; - }; - return ( <Fragment> {hasExpressionErrors ? ( @@ -304,58 +157,36 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< </h5> </EuiTitle> <EuiSpacer size="s" /> - <EuiPopover - id="indexPopover" - button={ - <EuiExpression - display="columns" - data-test-subj="selectIndexExpression" - description={i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexLabel', { - defaultMessage: 'index', - })} - value={indexArray.length > 0 ? renderIndices(indexArray) : firstFieldOption.text} - isActive={indexPopoverOpen} - onClick={() => { - setIndexPopoverOpen(true); - }} - isInvalid={!(indexArray.length > 0 && timeField !== '')} - /> - } - isOpen={indexPopoverOpen} - closePopover={closeIndexPopover} - ownFocus - anchorPosition="downLeft" - zIndex={8000} - display="block" - > - <div style={{ width: '450px' }}> - <EuiPopoverTitle> - <EuiFlexGroup alignItems="center" gutterSize="s"> - <EuiFlexItem> - {i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel', { - defaultMessage: 'index', - })} - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButtonIcon - data-test-subj="closePopover" - iconType="cross" - color="danger" - aria-label={i18n.translate( - 'xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel', - { - defaultMessage: 'Close', - } - )} - onClick={closeIndexPopover} - /> - </EuiFlexItem> - </EuiFlexGroup> - </EuiPopoverTitle> + <IndexSelectPopover + index={indexArray} + esFields={esFields} + timeField={timeField} + errors={errors} + onIndexChange={async (indices: string[]) => { + setAlertParams('index', indices); - {indexPopover} - </div> - </EuiPopover> + // reset expression fields if indices are deleted + if (indices.length === 0) { + setAlertProperty('params', { + ...alertParams, + index: indices, + aggType: DEFAULT_VALUES.AGGREGATION_TYPE, + termSize: DEFAULT_VALUES.TERM_SIZE, + thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, + timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, + groupBy: DEFAULT_VALUES.GROUP_BY, + threshold: DEFAULT_VALUES.THRESHOLD, + timeField: '', + }); + } else { + await refreshEsFields(); + } + }} + onTimeFieldChange={(updatedTimeField: string) => + setAlertParams('timeField', updatedTimeField) + } + /> <WhenExpression display="fullWidth" aggType={aggType ?? DEFAULT_VALUES.AGGREGATION_TYPE} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts new file mode 100644 index 0000000000000..882580a00e951 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EsQueryAlertActionContext, addMessages } from './action_context'; +import { EsQueryAlertParamsSchema } from './alert_type_params'; + +describe('ActionContext', () => { + it('generates expected properties', async () => { + const params = EsQueryAlertParamsSchema.validate({ + index: ['[index]'], + timeField: '[timeField]', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', + threshold: [4], + }); + const base: EsQueryAlertActionContext = { + date: '2020-01-01T00:00:00.000Z', + value: 42, + conditions: 'count greater than 4', + hits: [], + }; + const context = addMessages({ name: '[alert-name]' }, base, params); + expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`); + expect(context.message).toEqual( + `alert '[alert-name]' is active: + +- Value: 42 +- Conditions Met: count greater than 4 over 5m +- Timestamp: 2020-01-01T00:00:00.000Z` + ); + }); + + it('generates expected properties if comparator is between', async () => { + const params = EsQueryAlertParamsSchema.validate({ + index: ['[index]'], + timeField: '[timeField]', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: 'between', + threshold: [4, 5], + }); + const base: EsQueryAlertActionContext = { + date: '2020-01-01T00:00:00.000Z', + value: 4, + conditions: 'count between 4 and 5', + hits: [], + }; + const context = addMessages({ name: '[alert-name]' }, base, params); + expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`); + expect(context.message).toEqual( + `alert '[alert-name]' is active: + +- Value: 4 +- Conditions Met: count between 4 and 5 over 5m +- Timestamp: 2020-01-01T00:00:00.000Z` + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts new file mode 100644 index 0000000000000..67d0ac0df8ffe --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/action_context.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { AlertExecutorOptions, AlertInstanceContext } from '../../../../alerts/server'; +import { EsQueryAlertParams } from './alert_type_params'; +import { ESSearchHit } from '../../../../../typings/elasticsearch'; + +// alert type context provided to actions + +type AlertInfo = Pick<AlertExecutorOptions, 'name'>; + +export interface ActionContext extends EsQueryAlertActionContext { + // a short pre-constructed message which may be used in an action field + title: string; + // a longer pre-constructed message which may be used in an action field + message: string; +} + +export interface EsQueryAlertActionContext extends AlertInstanceContext { + // the date the alert was run as an ISO date + date: string; + // the value that met the threshold + value: number; + // threshold conditions + conditions: string; + // query matches + hits: ESSearchHit[]; +} + +export function addMessages( + alertInfo: AlertInfo, + baseContext: EsQueryAlertActionContext, + params: EsQueryAlertParams +): ActionContext { + const title = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle', { + defaultMessage: `alert '{name}' matched query`, + values: { + name: alertInfo.name, + }, + }); + + const window = `${params.timeWindowSize}${params.timeWindowUnit}`; + const message = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextMessageDescription', { + defaultMessage: `alert '{name}' is active: + +- Value: {value} +- Conditions Met: {conditions} over {window} +- Timestamp: {date}`, + values: { + name: alertInfo.name, + value: baseContext.value, + conditions: baseContext.conditions, + window, + date: baseContext.date, + }, + }); + + return { ...baseContext, title, message }; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts new file mode 100644 index 0000000000000..c5f57a056b002 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import type { Writable } from '@kbn/utility-types'; +import { loggingSystemMock } from '../../../../../../src/core/server/mocks'; +import { getAlertType } from './alert_type'; +import { EsQueryAlertParams } from './alert_type_params'; + +describe('alertType', () => { + const logger = loggingSystemMock.create().get(); + + const alertType = getAlertType(logger); + + it('alert type creation structure is the expected value', async () => { + expect(alertType.id).toBe('.es-query'); + expect(alertType.name).toBe('ES query'); + expect(alertType.actionGroups).toEqual([{ id: 'query matched', name: 'Query matched' }]); + + expect(alertType.actionVariables).toMatchInlineSnapshot(` + Object { + "context": Array [ + Object { + "description": "A message for the alert.", + "name": "message", + }, + Object { + "description": "A title for the alert.", + "name": "title", + }, + Object { + "description": "The date that the alert met the threshold condition.", + "name": "date", + }, + Object { + "description": "The value that met the threshold condition.", + "name": "value", + }, + Object { + "description": "The documents that met the threshold condition.", + "name": "hits", + }, + Object { + "description": "A string that describes the threshold condition.", + "name": "conditions", + }, + ], + "params": Array [ + Object { + "description": "The index the query was run against.", + "name": "index", + }, + Object { + "description": "The string representation of the ES query.", + "name": "esQuery", + }, + Object { + "description": "An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.", + "name": "threshold", + }, + Object { + "description": "A function to determine if the threshold has been met.", + "name": "thresholdComparator", + }, + ], + } + `); + }); + + it('validator succeeds with valid params', async () => { + const params: Partial<Writable<EsQueryAlertParams>> = { + index: ['index-name'], + timeField: 'time-field', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '<', + threshold: [0], + }; + + expect(alertType.validate?.params?.validate(params)).toBeTruthy(); + }); + + it('validator fails with invalid params - threshold', async () => { + const paramsSchema = alertType.validate?.params; + if (!paramsSchema) throw new Error('params validator not set'); + + const params: Partial<Writable<EsQueryAlertParams>> = { + index: ['index-name'], + timeField: 'time-field', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: 'between', + threshold: [0], + }; + + expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: must have two elements for the \\"between\\" comparator"` + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type.ts new file mode 100644 index 0000000000000..b8190340c4d68 --- /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 0000000000000..09ad66f248fee --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.test.ts @@ -0,0 +1,190 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import type { Writable } from '@kbn/utility-types'; +import { EsQueryAlertParamsSchema, EsQueryAlertParams } from './alert_type_params'; + +const DefaultParams: Writable<Partial<EsQueryAlertParams>> = { + index: ['index-name'], + timeField: 'time-field', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: '>', + threshold: [0], +}; + +describe('alertType Params validate()', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let params: any; + beforeEach(() => { + params = { ...DefaultParams }; + }); + + it('passes for valid input', async () => { + expect(validate()).toBeTruthy(); + }); + + it('fails for invalid index', async () => { + delete params.index; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: expected value of type [array] but got [undefined]"` + ); + + params.index = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: expected value of type [array] but got [number]"` + ); + + params.index = 'index-name'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: could not parse array value from json input"` + ); + + params.index = []; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: array size is [0], but cannot be smaller than [1]"` + ); + + params.index = ['', 'a']; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index.0]: value has length [0] but it must have a minimum length of [1]."` + ); + }); + + it('fails for invalid timeField', async () => { + delete params.timeField; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: expected value of type [string] but got [undefined]"` + ); + + params.timeField = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: expected value of type [string] but got [number]"` + ); + + params.timeField = ''; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: value has length [0] but it must have a minimum length of [1]."` + ); + }); + + it('fails for invalid esQuery', async () => { + delete params.esQuery; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: expected value of type [string] but got [undefined]"` + ); + + params.esQuery = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: expected value of type [string] but got [number]"` + ); + + params.esQuery = ''; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: value has length [0] but it must have a minimum length of [1]."` + ); + + params.esQuery = '{\n "query":{\n "match_all" : {}\n }\n'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(`"[esQuery]: must be valid JSON"`); + + params.esQuery = '{\n "aggs":{\n "match_all" : {}\n }\n}'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[esQuery]: must contain \\"query\\""` + ); + }); + + it('fails for invalid timeWindowSize', async () => { + delete params.timeWindowSize; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowSize]: expected value of type [number] but got [undefined]"` + ); + + params.timeWindowSize = 'foo'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowSize]: expected value of type [number] but got [string]"` + ); + + params.timeWindowSize = 0; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowSize]: Value must be equal to or greater than [1]."` + ); + }); + + it('fails for invalid timeWindowUnit', async () => { + delete params.timeWindowUnit; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowUnit]: expected value of type [string] but got [undefined]"` + ); + + params.timeWindowUnit = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowUnit]: expected value of type [string] but got [number]"` + ); + + params.timeWindowUnit = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeWindowUnit]: invalid timeWindowUnit: \\"x\\""` + ); + }); + + it('fails for invalid threshold', async () => { + params.threshold = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: expected value of type [array] but got [number]"` + ); + + params.threshold = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: could not parse array value from json input"` + ); + + params.threshold = []; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: array size is [0], but cannot be smaller than [1]"` + ); + + params.threshold = [1, 2, 3]; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: array size is [3], but cannot be greater than [2]"` + ); + + params.threshold = ['foo']; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold.0]: expected value of type [number] but got [string]"` + ); + }); + + it('fails for invalid thresholdComparator', async () => { + params.thresholdComparator = '[invalid-comparator]'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[thresholdComparator]: invalid thresholdComparator specified: [invalid-comparator]"` + ); + }); + + it('fails for invalid threshold length', async () => { + params.thresholdComparator = '<'; + params.threshold = [0, 1, 2]; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: array size is [3], but cannot be greater than [2]"` + ); + + params.thresholdComparator = 'between'; + params.threshold = [0]; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: must have two elements for the \\"between\\" comparator"` + ); + }); + + function onValidate(): () => void { + return () => validate(); + } + + function validate(): TypeOf<typeof EsQueryAlertParamsSchema> { + return EsQueryAlertParamsSchema.validate(params); + } +}); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts new file mode 100644 index 0000000000000..2e7cd15d323e7 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type_params.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { ComparatorFnNames } from '../lib'; +import { validateTimeWindowUnits } from '../../../../triggers_actions_ui/server'; +import { AlertTypeState } from '../../../../alerts/server'; + +// alert type parameters +export type EsQueryAlertParams = TypeOf<typeof EsQueryAlertParamsSchema>; +export interface EsQueryAlertState extends AlertTypeState { + latestTimestamp: string | undefined; +} + +export const EsQueryAlertParamsSchemaProperties = { + index: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), + timeField: schema.string({ minLength: 1 }), + esQuery: schema.string({ minLength: 1 }), + timeWindowSize: schema.number({ min: 1 }), + timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }), + threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), + thresholdComparator: schema.string({ validate: validateComparator }), +}; + +export const EsQueryAlertParamsSchema = schema.object(EsQueryAlertParamsSchemaProperties, { + validate: validateParams, +}); + +const betweenComparators = new Set(['between', 'notBetween']); + +// using direct type not allowed, circular reference, so body is typed to any +function validateParams(anyParams: unknown): string | undefined { + const { + esQuery, + thresholdComparator, + threshold, + }: EsQueryAlertParams = anyParams as EsQueryAlertParams; + + if (betweenComparators.has(thresholdComparator) && threshold.length === 1) { + return i18n.translate('xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage', { + defaultMessage: + '[threshold]: must have two elements for the "{thresholdComparator}" comparator', + values: { + thresholdComparator, + }, + }); + } + + try { + const parsedQuery = JSON.parse(esQuery); + + if (parsedQuery && !parsedQuery.query) { + return i18n.translate('xpack.stackAlerts.esQuery.missingEsQueryErrorMessage', { + defaultMessage: '[esQuery]: must contain "query"', + }); + } + } catch (err) { + return i18n.translate('xpack.stackAlerts.esQuery.invalidEsQueryErrorMessage', { + defaultMessage: '[esQuery]: must be valid JSON', + }); + } +} + +export function validateComparator(comparator: string): string | undefined { + if (ComparatorFnNames.has(comparator)) return; + + return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', { + defaultMessage: 'invalid thresholdComparator specified: {comparator}', + values: { + comparator, + }, + }); +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/es_query/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/es_query/index.ts new file mode 100644 index 0000000000000..2fa2bed9d8419 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/es_query/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger } from 'src/core/server'; +import { AlertingSetup } from '../../types'; +import { getAlertType } from './alert_type'; + +interface RegisterParams { + logger: Logger; + alerts: AlertingSetup; +} + +export function register(params: RegisterParams) { + const { logger, alerts } = params; + alerts.registerType(getAlertType(logger)); +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts index 85e02b21cc78c..6b6f17bf4ba2a 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/alert_type.ts @@ -98,7 +98,6 @@ export const ParamsSchema = schema.object({ boundaryIndexId: schema.string({ minLength: 1 }), boundaryGeoField: schema.string({ minLength: 1 }), boundaryNameField: schema.maybe(schema.string({ minLength: 1 })), - delayOffsetWithUnits: schema.maybe(schema.string({ minLength: 1 })), indexQuery: schema.maybe(schema.any({})), boundaryIndexQuery: schema.maybe(schema.any({})), }); @@ -114,7 +113,6 @@ export interface GeoContainmentParams extends AlertTypeParams { boundaryIndexId: string; boundaryGeoField: string; boundaryNameField?: string; - delayOffsetWithUnits?: string; indexQuery?: Query; boundaryIndexQuery?: Query; } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts index 24232e47225f0..1648ad9ad2a62 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/geo_containment.ts @@ -24,7 +24,7 @@ export function transformResults( results: SearchResponse<unknown> | undefined, dateField: string, geoField: string -): Map<string, LatestEntityLocation> { +): Map<string, LatestEntityLocation[]> { if (!results) { return new Map(); } @@ -64,12 +64,15 @@ export function transformResults( // Get unique .reduce( ( - accu: Map<string, LatestEntityLocation>, + accu: Map<string, LatestEntityLocation[]>, el: LatestEntityLocation & { entityName: string } ) => { const { entityName, ...locationData } = el; - if (!accu.has(entityName)) { - accu.set(entityName, locationData); + if (entityName) { + if (!accu.has(entityName)) { + accu.set(entityName, []); + } + accu.get(entityName)!.push(locationData); } return accu; }, @@ -78,26 +81,9 @@ export function transformResults( return orderedResults; } -function getOffsetTime(delayOffsetWithUnits: string, oldTime: Date): Date { - const timeUnit = delayOffsetWithUnits.slice(-1); - const time: number = +delayOffsetWithUnits.slice(0, -1); - - const adjustedDate = new Date(oldTime.getTime()); - if (timeUnit === 's') { - adjustedDate.setSeconds(adjustedDate.getSeconds() - time); - } else if (timeUnit === 'm') { - adjustedDate.setMinutes(adjustedDate.getMinutes() - time); - } else if (timeUnit === 'h') { - adjustedDate.setHours(adjustedDate.getHours() - time); - } else if (timeUnit === 'd') { - adjustedDate.setDate(adjustedDate.getDate() - time); - } - return adjustedDate; -} - export function getActiveEntriesAndGenerateAlerts( - prevLocationMap: Record<string, LatestEntityLocation>, - currLocationMap: Map<string, LatestEntityLocation>, + prevLocationMap: Map<string, LatestEntityLocation[]>, + currLocationMap: Map<string, LatestEntityLocation[]>, alertInstanceFactory: AlertServices< GeoContainmentInstanceState, GeoContainmentInstanceContext, @@ -106,32 +92,55 @@ export function getActiveEntriesAndGenerateAlerts( shapesIdsNamesMap: Record<string, unknown>, currIntervalEndTime: Date ) { - const allActiveEntriesMap: Map<string, LatestEntityLocation> = new Map([ - ...Object.entries(prevLocationMap || {}), + const allActiveEntriesMap: Map<string, LatestEntityLocation[]> = new Map([ + ...prevLocationMap, ...currLocationMap, ]); - allActiveEntriesMap.forEach(({ location, shapeLocationId, dateInShape, docId }, entityName) => { - const containingBoundaryName = shapesIdsNamesMap[shapeLocationId] || shapeLocationId; - const context = { - entityId: entityName, - entityDateTime: dateInShape ? new Date(dateInShape).toISOString() : null, - entityDocumentId: docId, - detectionDateTime: new Date(currIntervalEndTime).toISOString(), - entityLocation: `POINT (${location[0]} ${location[1]})`, - containingBoundaryId: shapeLocationId, - containingBoundaryName, - }; - const alertInstanceId = `${entityName}-${containingBoundaryName}`; - if (shapeLocationId === OTHER_CATEGORY) { + allActiveEntriesMap.forEach((locationsArr, entityName) => { + // Generate alerts + locationsArr.forEach(({ location, shapeLocationId, dateInShape, docId }) => { + const context = { + entityId: entityName, + entityDateTime: dateInShape ? new Date(dateInShape).toISOString() : null, + entityDocumentId: docId, + detectionDateTime: new Date(currIntervalEndTime).toISOString(), + entityLocation: `POINT (${location[0]} ${location[1]})`, + containingBoundaryId: shapeLocationId, + containingBoundaryName: shapesIdsNamesMap[shapeLocationId] || shapeLocationId, + }; + const alertInstanceId = `${entityName}-${context.containingBoundaryName}`; + if (shapeLocationId !== OTHER_CATEGORY) { + alertInstanceFactory(alertInstanceId).scheduleActions(ActionGroupId, context); + } + }); + + if (locationsArr[0].shapeLocationId === OTHER_CATEGORY) { allActiveEntriesMap.delete(entityName); + return; + } + + const otherCatIndex = locationsArr.findIndex( + ({ shapeLocationId }) => shapeLocationId === OTHER_CATEGORY + ); + if (otherCatIndex >= 0) { + const afterOtherLocationsArr = locationsArr.slice(0, otherCatIndex); + allActiveEntriesMap.set(entityName, afterOtherLocationsArr); } else { - alertInstanceFactory(alertInstanceId).scheduleActions(ActionGroupId, context); + allActiveEntriesMap.set(entityName, locationsArr); } }); return allActiveEntriesMap; } + export const getGeoContainmentExecutor = (log: Logger): GeoContainmentAlertType['executor'] => - async function ({ previousStartedAt, startedAt, services, params, alertId, state }) { + async function ({ + previousStartedAt: currIntervalStartTime, + startedAt: currIntervalEndTime, + services, + params, + alertId, + state, + }) { const { shapesFilters, shapesIdsNamesMap } = state.shapesFilters ? state : await getShapesFilters( @@ -147,15 +156,6 @@ export const getGeoContainmentExecutor = (log: Logger): GeoContainmentAlertType[ const executeEsQuery = await executeEsQueryFactory(params, services, log, shapesFilters); - let currIntervalStartTime = previousStartedAt; - let currIntervalEndTime = startedAt; - if (params.delayOffsetWithUnits) { - if (currIntervalStartTime) { - currIntervalStartTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalStartTime); - } - currIntervalEndTime = getOffsetTime(params.delayOffsetWithUnits, currIntervalEndTime); - } - // Start collecting data only on the first cycle let currentIntervalResults: SearchResponse<unknown> | undefined; if (!currIntervalStartTime) { @@ -169,14 +169,17 @@ export const getGeoContainmentExecutor = (log: Logger): GeoContainmentAlertType[ currentIntervalResults = await executeEsQuery(currIntervalStartTime, currIntervalEndTime); } - const currLocationMap: Map<string, LatestEntityLocation> = transformResults( + const currLocationMap: Map<string, LatestEntityLocation[]> = transformResults( currentIntervalResults, params.dateField, params.geoField ); + const prevLocationMap: Map<string, LatestEntityLocation[]> = new Map([ + ...Object.entries((state.prevLocationMap as Record<string, LatestEntityLocation[]>) || {}), + ]); const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( - state.prevLocationMap as Record<string, LatestEntityLocation>, + prevLocationMap, currLocationMap, services.alertInstanceFactory, shapesIdsNamesMap, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts index 98842c8cc2cba..1aba7d6fb1010 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/alert_type.test.ts @@ -38,7 +38,6 @@ describe('alertType', () => { boundaryIndexId: 'testIndex', boundaryGeoField: 'testField', boundaryNameField: 'testField', - delayOffsetWithUnits: 'testOffset', }; expect(alertType.validate?.params?.validate(params)).toBeTruthy(); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts index 26b51060c2e73..40ad6454c3673 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts @@ -27,39 +27,47 @@ describe('geo_containment', () => { new Map([ [ '936', - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'AAL2019', - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'AAL2323', - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'n-ng1XQB6yyY-xQxnGSM', + location: [-84.71324851736426, 41.6677269525826], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'ABD5250', - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.192Z', + docId: 'GOng1XQB6yyY-xQxnGWM', + location: [6.073727197945118, 39.07997465226799], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], ]) ); @@ -77,39 +85,47 @@ describe('geo_containment', () => { new Map([ [ '936', - { - dateInShape: '2020-09-28T18:01:41.190Z', - docId: 'N-ng1XQB6yyY-xQxnGSM', - location: [-82.8814151789993, 40.62806099653244], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.190Z', + docId: 'N-ng1XQB6yyY-xQxnGSM', + location: [-82.8814151789993, 40.62806099653244], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'AAL2019', - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'iOng1XQB6yyY-xQxnGSM', - location: [-82.22068064846098, 39.006176185794175], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'iOng1XQB6yyY-xQxnGSM', + location: [-82.22068064846098, 39.006176185794175], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'AAL2323', - { - dateInShape: '2020-09-28T18:01:41.191Z', - docId: 'n-ng1XQB6yyY-xQxnGSM', - location: [-84.71324851736426, 41.6677269525826], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.191Z', + docId: 'n-ng1XQB6yyY-xQxnGSM', + location: [-84.71324851736426, 41.6677269525826], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], [ 'ABD5250', - { - dateInShape: '2020-09-28T18:01:41.192Z', - docId: 'GOng1XQB6yyY-xQxnGWM', - location: [6.073727197945118, 39.07997465226799], - shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', - }, + [ + { + dateInShape: '2020-09-28T18:01:41.192Z', + docId: 'GOng1XQB6yyY-xQxnGWM', + location: [6.073727197945118, 39.07997465226799], + shapeLocationId: '0DrJu3QB6yyY-xQxv6Ip', + }, + ], ], ]) ); @@ -131,30 +147,36 @@ describe('geo_containment', () => { const currLocationMap = new Map([ [ 'a', - { - location: [0, 0], - shapeLocationId: '123', - dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId1', - }, + [ + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ], ], [ 'b', - { - location: [0, 0], - shapeLocationId: '456', - dateInShape: 'Wed Dec 09 2020 15:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId2', - }, + [ + { + location: [0, 0], + shapeLocationId: '456', + dateInShape: 'Wed Dec 16 2020 15:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId2', + }, + ], ], [ 'c', - { - location: [0, 0], - shapeLocationId: '789', - dateInShape: 'Wed Dec 09 2020 16:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId3', - }, + [ + { + location: [0, 0], + shapeLocationId: '789', + dateInShape: 'Wed Dec 23 2020 16:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId3', + }, + ], ], ]); @@ -215,7 +237,7 @@ describe('geo_containment', () => { const currentDateTime = new Date(); it('should use currently active entities if no older entity entries', () => { - const emptyPrevLocationMap = {}; + const emptyPrevLocationMap = new Map(); const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( emptyPrevLocationMap, currLocationMap, @@ -227,14 +249,19 @@ describe('geo_containment', () => { expect(testAlertActionArr).toMatchObject(expectedContext); }); it('should overwrite older identical entity entries', () => { - const prevLocationMapWithIdenticalEntityEntry = { - a: { - location: [0, 0], - shapeLocationId: '999', - dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId7', - }, - }; + const prevLocationMapWithIdenticalEntityEntry = new Map([ + [ + 'a', + [ + { + location: [0, 0], + shapeLocationId: '999', + dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId7', + }, + ], + ], + ]); const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( prevLocationMapWithIdenticalEntityEntry, currLocationMap, @@ -246,14 +273,19 @@ describe('geo_containment', () => { expect(testAlertActionArr).toMatchObject(expectedContext); }); it('should preserve older non-identical entity entries', () => { - const prevLocationMapWithNonIdenticalEntityEntry = { - d: { - location: [0, 0], - shapeLocationId: '999', - dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId7', - }, - }; + const prevLocationMapWithNonIdenticalEntityEntry = new Map([ + [ + 'd', + [ + { + location: [0, 0], + shapeLocationId: '999', + dateInShape: 'Wed Dec 09 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId7', + }, + ], + ], + ]); const expectedContextPlusD = [ { actionGroupId: 'Tracked entity contained', @@ -279,14 +311,17 @@ describe('geo_containment', () => { expect(allActiveEntriesMap.has('d')).toBeTruthy(); expect(testAlertActionArr).toMatchObject(expectedContextPlusD); }); + it('should remove "other" entries and schedule the expected number of actions', () => { - const emptyPrevLocationMap = {}; - const currLocationMapWithOther = new Map(currLocationMap).set('d', { - location: [0, 0], - shapeLocationId: OTHER_CATEGORY, - dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)', - docId: 'docId1', - }); + const emptyPrevLocationMap = new Map(); + const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: OTHER_CATEGORY, + dateInShape: 'Wed Dec 09 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ]); expect(currLocationMapWithOther).not.toEqual(currLocationMap); const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( emptyPrevLocationMap, @@ -298,5 +333,115 @@ describe('geo_containment', () => { expect(allActiveEntriesMap).toEqual(currLocationMap); expect(testAlertActionArr).toMatchObject(expectedContext); }); + + it('should generate multiple alerts per entity if found in multiple shapes in interval', () => { + const emptyPrevLocationMap = new Map(); + const currLocationMapWithThreeMore = new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: '789', + dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId2', + }, + { + location: [0, 0], + shapeLocationId: '456', + dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId3', + }, + ]); + getActiveEntriesAndGenerateAlerts( + emptyPrevLocationMap, + currLocationMapWithThreeMore, + alertInstanceFactory, + emptyShapesIdsNamesMap, + currentDateTime + ); + let numEntitiesInShapes = 0; + currLocationMapWithThreeMore.forEach((v) => { + numEntitiesInShapes += v.length; + }); + expect(testAlertActionArr.length).toEqual(numEntitiesInShapes); + }); + + it('should not return entity as active entry if most recent location is "other"', () => { + const emptyPrevLocationMap = new Map(); + const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: OTHER_CATEGORY, + dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: '456', + dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ]); + expect(currLocationMapWithOther).not.toEqual(currLocationMap); + const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( + emptyPrevLocationMap, + currLocationMapWithOther, + alertInstanceFactory, + emptyShapesIdsNamesMap, + currentDateTime + ); + expect(allActiveEntriesMap).toEqual(currLocationMap); + }); + + it('should return entity as active entry if "other" not the latest location but remove "other" and earlier entries', () => { + const emptyPrevLocationMap = new Map(); + const currLocationMapWithOther = new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: OTHER_CATEGORY, + dateInShape: 'Wed Dec 08 2020 12:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + { + location: [0, 0], + shapeLocationId: '456', + dateInShape: 'Wed Dec 07 2020 10:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ]); + const allActiveEntriesMap = getActiveEntriesAndGenerateAlerts( + emptyPrevLocationMap, + currLocationMapWithOther, + alertInstanceFactory, + emptyShapesIdsNamesMap, + currentDateTime + ); + expect(allActiveEntriesMap).toEqual( + new Map([...currLocationMap]).set('d', [ + { + location: [0, 0], + shapeLocationId: '123', + dateInShape: 'Wed Dec 10 2020 14:31:31 GMT-0700 (Mountain Standard Time)', + docId: 'docId1', + }, + ]) + ); + }); }); }); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/index.ts index 21a7ffc481323..2a343cb49a91b 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index.ts @@ -9,7 +9,7 @@ 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']>; @@ -20,4 +20,5 @@ export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) { registerIndexThreshold(params); registerGeoThreshold(params); registerGeoContainment(params); + registerEsQuery(params); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md index 9b0eb23950cc3..de5b57dfbffc6 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/README.md @@ -6,7 +6,7 @@ The index threshold alert type is designed to run an ES query over indices, aggregating field values from documents, comparing them to threshold values, and scheduling actions to run when the thresholds are met. -And example would be checking a monitoring index for percent cpu usage field +An example would be checking a monitoring index for percent cpu usage field values that are greater than some threshold, which could then be used to invoke an action (email, slack, etc) to notify interested parties when the threshold is exceeded. diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 2366a872b855b..10dfabffddfcf 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -14,30 +14,10 @@ import { CoreQueryParamsSchemaProperties, TimeSeriesQuery, } from '../../../../triggers_actions_ui/server'; +import { ComparatorFns, getHumanReadableComparator } from '../lib'; export const ID = '.index-threshold'; - -enum Comparator { - GT = '>', - LT = '<', - GT_OR_EQ = '>=', - LT_OR_EQ = '<=', - BETWEEN = 'between', - NOT_BETWEEN = 'notBetween', -} - -const humanReadableComparators = new Map<string, string>([ - [Comparator.LT, 'less than'], - [Comparator.LT_OR_EQ, 'less than or equal to'], - [Comparator.GT_OR_EQ, 'greater than or equal to'], - [Comparator.GT, 'greater than'], - [Comparator.BETWEEN, 'between'], - [Comparator.NOT_BETWEEN, 'not between'], -]); - const ActionGroupId = 'threshold met'; -const ComparatorFns = getComparatorFns(); -export const ComparatorFnNames = new Set(ComparatorFns.keys()); export function getAlertType( logger: Logger, @@ -155,7 +135,14 @@ export function getAlertType( const compareFn = ComparatorFns.get(params.thresholdComparator); if (compareFn == null) { - throw new Error(getInvalidComparatorMessage(params.thresholdComparator)); + throw new Error( + i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', { + defaultMessage: 'invalid thresholdComparator specified: {comparator}', + values: { + comparator: params.thresholdComparator, + }, + }) + ); } const callCluster = services.callCluster; @@ -210,40 +197,3 @@ export function getAlertType( } } } - -export function getInvalidComparatorMessage(comparator: string) { - return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', { - defaultMessage: 'invalid thresholdComparator specified: {comparator}', - values: { - comparator, - }, - }); -} - -type ComparatorFn = (value: number, threshold: number[]) => boolean; - -function getComparatorFns(): Map<string, ComparatorFn> { - const fns: Record<string, ComparatorFn> = { - [Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0], - [Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0], - [Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0], - [Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0], - [Comparator.BETWEEN]: (value: number, threshold: number[]) => - value >= threshold[0] && value <= threshold[1], - [Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) => - value < threshold[0] || value > threshold[1], - }; - - const result = new Map<string, ComparatorFn>(); - for (const key of Object.keys(fns)) { - result.set(key, fns[key]); - } - - return result; -} - -function getHumanReadableComparator(comparator: string) { - return humanReadableComparators.has(comparator) - ? humanReadableComparators.get(comparator) - : comparator; -} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts index b51545770dd7b..2c83d5edc255a 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type_params.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { schema, TypeOf } from '@kbn/config-schema'; -import { ComparatorFnNames, getInvalidComparatorMessage } from './alert_type'; +import { ComparatorFnNames } from '../lib'; import { CoreQueryParamsSchemaProperties, validateCoreQueryBody, @@ -54,5 +54,10 @@ function validateParams(anyParams: unknown): string | undefined { export function validateComparator(comparator: string): string | undefined { if (ComparatorFnNames.has(comparator)) return; - return getInvalidComparatorMessage(comparator); + return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', { + defaultMessage: 'invalid thresholdComparator specified: {comparator}', + values: { + comparator, + }, + }); } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts b/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts new file mode 100644 index 0000000000000..cfa824d159686 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/lib/comparator_types.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +enum Comparator { + GT = '>', + LT = '<', + GT_OR_EQ = '>=', + LT_OR_EQ = '<=', + BETWEEN = 'between', + NOT_BETWEEN = 'notBetween', +} + +const humanReadableComparators = new Map<string, string>([ + [Comparator.LT, 'less than'], + [Comparator.LT_OR_EQ, 'less than or equal to'], + [Comparator.GT_OR_EQ, 'greater than or equal to'], + [Comparator.GT, 'greater than'], + [Comparator.BETWEEN, 'between'], + [Comparator.NOT_BETWEEN, 'not between'], +]); + +export const ComparatorFns = getComparatorFns(); +export const ComparatorFnNames = new Set(ComparatorFns.keys()); + +type ComparatorFn = (value: number, threshold: number[]) => boolean; + +function getComparatorFns(): Map<string, ComparatorFn> { + const fns: Record<string, ComparatorFn> = { + [Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0], + [Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0], + [Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0], + [Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0], + [Comparator.BETWEEN]: (value: number, threshold: number[]) => + value >= threshold[0] && value <= threshold[1], + [Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) => + value < threshold[0] || value > threshold[1], + }; + + const result = new Map<string, ComparatorFn>(); + for (const key of Object.keys(fns)) { + result.set(key, fns[key]); + } + + return result; +} + +export function getHumanReadableComparator(comparator: string) { + return humanReadableComparators.has(comparator) + ? humanReadableComparators.get(comparator) + : comparator; +} diff --git a/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts b/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts new file mode 100644 index 0000000000000..7e40a7247a4c9 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/alert_types/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ComparatorFns, ComparatorFnNames, getHumanReadableComparator } from './comparator_types'; diff --git a/x-pack/plugins/stack_alerts/server/plugin.test.ts b/x-pack/plugins/stack_alerts/server/plugin.test.ts index 7226c2175a769..8d69fad4afa46 100644 --- a/x-pack/plugins/stack_alerts/server/plugin.test.ts +++ b/x-pack/plugins/stack_alerts/server/plugin.test.ts @@ -27,7 +27,7 @@ describe('AlertingBuiltins Plugin', () => { const featuresSetup = featuresPluginMock.createSetup(); await plugin.setup(coreSetup, { alerts: alertingSetup, features: featuresSetup }); - expect(alertingSetup.registerType).toHaveBeenCalledTimes(3); + expect(alertingSetup.registerType).toHaveBeenCalledTimes(4); const indexThresholdArgs = alertingSetup.registerType.mock.calls[0][0]; const testedIndexThresholdArgs = { @@ -67,6 +67,25 @@ describe('AlertingBuiltins Plugin', () => { } `); + const esQueryArgs = alertingSetup.registerType.mock.calls[3][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", + } + `); + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith(BUILT_IN_ALERTS_FEATURE); }); }); diff --git a/x-pack/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts index 81d72c68b3a9e..a2a0ee11380ff 100644 --- a/x-pack/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -269,12 +269,13 @@ describe('TaskStore', () => { opts = {}, hits = generateFakeTasks(1), claimingOpts, + versionConflicts = 2, }: { opts: Partial<StoreOpts>; hits?: unknown[]; claimingOpts: OwnershipClaimingOpts; + versionConflicts?: number; }) { - const versionConflicts = 2; const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; esClient.search.mockResolvedValue(asApiResponse({ hits: { hits } })); esClient.updateByQuery.mockResolvedValue( @@ -971,6 +972,77 @@ if (doc['task.runAt'].size()!=0) { ]); }); + test('it returns version_conflicts that do not include conflicts that were proceeded against', async () => { + const taskManagerId = uuid.v1(); + const claimOwnershipUntil = new Date(Date.now()); + const runAt = new Date(); + const tasks = [ + { + _id: 'task:aaa', + _source: { + type: 'task', + task: { + runAt, + taskType: 'foo', + schedule: undefined, + attempts: 0, + status: 'claiming', + params: '{ "hello": "world" }', + state: '{ "baby": "Henhen" }', + user: 'jimbo', + scope: ['reporting'], + ownerId: taskManagerId, + }, + }, + _seq_no: 1, + _primary_term: 2, + sort: ['a', 1], + }, + { + _id: 'task:bbb', + _source: { + type: 'task', + task: { + runAt, + taskType: 'bar', + schedule: { interval: '5m' }, + attempts: 2, + status: 'claiming', + params: '{ "shazm": 1 }', + state: '{ "henry": "The 8th" }', + user: 'dabo', + scope: ['reporting', 'ceo'], + ownerId: taskManagerId, + }, + }, + _seq_no: 3, + _primary_term: 4, + sort: ['b', 2], + }, + ]; + const maxDocs = 10; + const { + result: { stats: { tasksUpdated, tasksConflicted, tasksClaimed } = {} } = {}, + } = await testClaimAvailableTasks({ + opts: { + taskManagerId, + }, + claimingOpts: { + claimOwnershipUntil, + size: maxDocs, + }, + hits: tasks, + // assume there were 20 version conflists, but thanks to `conflicts="proceed"` + // we proceeded to claim tasks + versionConflicts: 20, + }); + + expect(tasksUpdated).toEqual(2); + // ensure we only count conflicts that *may* have counted against max_docs, no more than that + expect(tasksConflicted).toEqual(10 - tasksUpdated!); + expect(tasksClaimed).toEqual(2); + }); + test('pushes error from saved objects client to errors$', async () => { const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; const store = new TaskStore({ diff --git a/x-pack/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts index 5d17c6246088a..4b02e35c61582 100644 --- a/x-pack/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -529,7 +529,7 @@ export class TaskStore { private async updateByQuery( opts: UpdateByQuerySearchOpts = {}, // eslint-disable-next-line @typescript-eslint/naming-convention - { max_docs }: UpdateByQueryOpts = {} + { max_docs: max_docs }: UpdateByQueryOpts = {} ): Promise<UpdateByQueryResult> { const { query } = ensureQueryOnlyReturnsTaskObjects(opts); try { @@ -548,10 +548,22 @@ export class TaskStore { }, }); + /** + * When we run updateByQuery with conflicts='proceed', it's possible for the `version_conflicts` + * to count against the specified `max_docs`, as per https://github.com/elastic/elasticsearch/issues/63671 + * In order to correct for that happening, we only count `version_conflicts` if we haven't updated as + * many docs as we could have. + * This is still no more than an estimation, as there might have been less docuemnt to update that the + * `max_docs`, but we bias in favour of over zealous `version_conflicts` as that's the best indicator we + * have for an unhealthy cluster distribution of Task Manager polling intervals + */ + const conflictsCorrectedForContinuation = + max_docs && version_conflicts + updated > max_docs ? max_docs - updated : version_conflicts; + return { total, updated, - version_conflicts, + version_conflicts: conflictsCorrectedForContinuation, }; } catch (e) { this.errors$.next(e); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index da9228335a3f3..1c058245f04cd 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": "確認", @@ -9416,9 +9413,7 @@ "xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableTitle": "コールドティアに割り当てられているノードがありません", "xpack.indexLifecycleMgmt.coldPhase.dataTier.description": "頻度が低い読み取り専用アクセス用に最適化されたノードにデータを移動します。安価なハードウェアのコールドフェーズにデータを格納します。", "xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel": "インデックスを凍結", - "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasDescription": "レプリカの数を設定します。デフォルトでは前のフェーズと同じです。", "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel": "レプリカ数(任意)", - "xpack.indexLifecycleMgmt.coldPhase.replicasTitle": "レプリカ", "xpack.indexLifecycleMgmt.common.dataTier.title": "データ割り当て", "xpack.indexLifecycleMgmt.confirmDelete.cancelButton": "キャンセル", "xpack.indexLifecycleMgmt.confirmDelete.deleteButton": "削除", @@ -9431,11 +9426,8 @@ "xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.title": "コールドティアを作成", "xpack.indexLifecycleMgmt.editPolicy.cold.nodeAttributesMissingDescription": "属性に基づく割り当てを使用するには、elasticsearch.yml でカスタムノード属性を定義します。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.activateColdPhaseSwitchLabel": "コールドフェーズを有効にする", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescriptionText": "インデックスへのクエリの頻度を減らすことで、大幅に性能が低いハードウェアにシャードを割り当てることができます。クエリが遅いため、複製の数を減らすことができます。", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseLabel": "コールドフェーズ", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText": "インデックスを読み取り専用にし、メモリー消費量を最小化します。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeText": "凍結", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel": "レプリカを設定", "xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.helpText": "ノード属性に基づいてデータを移動します。", "xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.input": "カスタム", "xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.defaultOption.helpText": "コールドティアのノードにデータを移動します。", @@ -9452,13 +9444,6 @@ "xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage": "インデックスライフサイクルポリシーを作成します", "xpack.indexLifecycleMgmt.editPolicy.createSearchableSnapshotLink": "スナップショットリポジドリを作成", "xpack.indexLifecycleMgmt.editPolicy.createSnapshotRepositoryLink": "新しいスナップショットリポジドリを作成", - "xpack.indexLifecycleMgmt.editPolicy.creationDaysOptionLabel": "インデックスの作成からの経過日数", - "xpack.indexLifecycleMgmt.editPolicy.creationHoursOptionLabel": "インデックスの作成からの経過時間数", - "xpack.indexLifecycleMgmt.editPolicy.creationMicroSecondsOptionLabel": "インデックスの作成からの経過時間(マイクロ秒)", - "xpack.indexLifecycleMgmt.editPolicy.creationMilliSecondsOptionLabel": "インデックスの作成からの経過時間(ミリ秒)", - "xpack.indexLifecycleMgmt.editPolicy.creationMinutesOptionLabel": "インデックスの作成からの経過時間(分)", - "xpack.indexLifecycleMgmt.editPolicy.creationNanoSecondsOptionLabel": "インデックスの作成からの経過時間(ナノ秒)", - "xpack.indexLifecycleMgmt.editPolicy.creationSecondsOptionLabel": "インデックスの作成からの経過時間(秒)", "xpack.indexLifecycleMgmt.editPolicy.dataTierAllocation.allocationFieldLabel": "データティアオプション", "xpack.indexLifecycleMgmt.editPolicy.dataTierAllocation.nodeAllocationFieldLabel": "ノード属性を選択", "xpack.indexLifecycleMgmt.editPolicy.dataTierColdLabel": "コールド", @@ -9499,23 +9484,17 @@ "xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage": "このページのエラーを修正してください。", "xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto": "リクエストを非表示", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.enableRolloverTipContent": "現在のインデックスが定義された条件のいずれかを満たすときに、新しいインデックスにロールオーバーします。", - "xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescriptionMessage": "このフェーズは必須です。アクティブにクエリを実行しインデックスに書き込んでいます。 更新を高速化するため、大きくなりすぎたり古くなりすぎたりした際にインデックスをロールオーバーできます。", - "xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseLabel": "ホットフェーズ", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText": "詳細", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDefaultsTipContent": "インデックスが 30 日経過するか、50 GB に達したらロールオーバーします。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDescriptionMessage": "効率的なストレージと高いパフォーマンスのための時系列データの自動ロールアウト。", "xpack.indexLifecycleMgmt.editPolicy.indexPriorityLabel": "インデックス優先度(任意)", "xpack.indexLifecycleMgmt.editPolicy.indexPriorityText": "インデックスの優先順位", - "xpack.indexLifecycleMgmt.editPolicy.learnAboutIndexLifecycleManagementLinkText": "インデックスライフサイクルの詳細をご覧ください。", "xpack.indexLifecycleMgmt.editPolicy.learnAboutIndexTemplatesLink": "インデックステンプレートの詳細をご覧ください", "xpack.indexLifecycleMgmt.editPolicy.learnAboutShardAllocationLink": "シャード割り当ての詳細をご覧ください", - "xpack.indexLifecycleMgmt.editPolicy.learnAboutTimingText": "タイミングの詳細をご覧ください", "xpack.indexLifecycleMgmt.editPolicy.lifecyclePoliciesLoadingFailedTitle": "既存のライフサイクルポリシーを読み込めません", "xpack.indexLifecycleMgmt.editPolicy.lifecyclePoliciesReloadButton": "再試行", - "xpack.indexLifecycleMgmt.editPolicy.lifecyclePolicyDescriptionText": "インデックスへのアクティブな書き込みから削除までの、インデックスライフサイクルの 4 つのフェーズを自動化するには、インデックスポリシーを使用します。", "xpack.indexLifecycleMgmt.editPolicy.loadSnapshotRepositoriesErrorBody": "このフィールドを更新し、既存のスナップショットリポジトリの名前を入力します。", "xpack.indexLifecycleMgmt.editPolicy.loadSnapshotRepositoriesErrorTitle": "スナップショットリポジトリを読み込めません", - "xpack.indexLifecycleMgmt.editPolicy.nameLabel": "名前", "xpack.indexLifecycleMgmt.editPolicy.nodeAllocation.customOption.description": "ノード属性を使用して、シャード割り当てを制御します。{learnMoreLink}。", "xpack.indexLifecycleMgmt.editPolicy.nodeAllocation.doNotModifyAllocationOption": "割り当て構成を修正しない", "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesLoadingFailedTitle": "ノードデータを読み込めません", @@ -9542,13 +9521,6 @@ "xpack.indexLifecycleMgmt.editPolicy.readonlyDescription": "有効にすると、インデックスおよびインデックスメタデータを読み取り専用にします。無効にすると、書き込みとメタデータの変更を許可します。", "xpack.indexLifecycleMgmt.editPolicy.readonlyTitle": "読み取り専用", "xpack.indexLifecycleMgmt.editPolicy.reloadSnapshotRepositoriesLabel": "スナップショットリポジドリの再読み込み", - "xpack.indexLifecycleMgmt.editPolicy.rolloverDaysOptionLabel": "ロールオーバーからの経過日数", - "xpack.indexLifecycleMgmt.editPolicy.rolloverHoursOptionLabel": "ロールオーバーからの経過時間数", - "xpack.indexLifecycleMgmt.editPolicy.rolloverMicroSecondsOptionLabel": "ロールオーバーからの経過時間(マイクロ秒)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverMilliSecondsOptionLabel": "ロールオーバーからの経過時間(ミリ秒)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverMinutesOptionLabel": "ロールオーバーからの経過時間数(分)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverNanoSecondsOptionLabel": "ロールオーバーからの経過時間(ナノ秒)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverSecondsOptionLabel": "ロールオーバーからの経過時間(秒)", "xpack.indexLifecycleMgmt.editPolicy.saveAsNewButton": "新規ポリシーとして保存します", "xpack.indexLifecycleMgmt.editPolicy.saveAsNewPolicyMessage": "新規ポリシーとして保存します", "xpack.indexLifecycleMgmt.editPolicy.saveButton": "ポリシーを保存", @@ -9573,15 +9545,11 @@ "xpack.indexLifecycleMgmt.editPolicy.warm.nodeAttributesMissingDescription": "属性に基づく割り当てを使用するには、elasticsearch.yml でカスタムノード属性を定義します。", "xpack.indexLifecycleMgmt.editPolicy.warmPhase.activateWarmPhaseSwitchLabel": "ウォームフェーズを有効にする", "xpack.indexLifecycleMgmt.editPolicy.warmPhase.indexPriorityExplanationText": "ノードの再起動後にインデックスを復元する優先順位を設定します。優先順位の高いインデックスは優先順位の低いインデックスよりも先に復元されます。", - "xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel": "レプリカを設定", - "xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseDescriptionMessage": "まだインデックスにクエリを実行中ですが、読み取り専用です。性能の低いハードウェアにシャードを割り当てることができます。検索を高速化するために、シャードの数を減らしセグメントを結合することができます。", - "xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseLabel": "ウォームフェーズ", "xpack.indexLifecycleMgmt.featureCatalogueDescription": "ライフサイクルポリシーを定義し、インデックス年齢として自動的に処理を実行します。", "xpack.indexLifecycleMgmt.featureCatalogueTitle": "インデックスライフサイクルを管理", "xpack.indexLifecycleMgmt.forcemerge.bestCompressionLabel": "格納されたフィールドを圧縮", "xpack.indexLifecycleMgmt.forcemerge.enableLabel": "データを強制結合", "xpack.indexLifecycleMgmt.forceMerge.numberOfSegmentsLabel": "セグメントの数", - "xpack.indexLifecycleMgmt.hotPhase.advancedSettingsButton": "高度な設定", "xpack.indexLifecycleMgmt.hotPhase.bytesLabel": "バイト", "xpack.indexLifecycleMgmt.hotPhase.daysLabel": "日", "xpack.indexLifecycleMgmt.hotPhase.enableRolloverLabel": "ロールオーバーを有効にする", @@ -9705,7 +9673,6 @@ "xpack.indexLifecycleMgmt.shrink.indexFieldLabel": "インデックスを縮小", "xpack.indexLifecycleMgmt.shrink.numberOfPrimaryShardsLabel": "プライマリシャードの数", "xpack.indexLifecycleMgmt.templateNotFoundMessage": "テンプレート{name}が見つかりません。", - "xpack.indexLifecycleMgmt.warmPhase.advancedSettingsButton": "高度な設定", "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableBody": "ロールに基づく割り当てを使用するには、1つ以上のノードを、ウォームまたはホットティアに割り当てます。使用可能なノードがない場合、ポリシーは割り当てを完了できません。", "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableTitle": "ウォームティアに割り当てられているノードがありません", "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotice.cold": "このポリシーはコールドフェーズのデータを{tier}ティアノードに移動します。", @@ -9713,10 +9680,7 @@ "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotice.warm": "このポリシーはウォームフェーズのデータを{tier}ティアノードに移動します。", "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotice.warm.title": "ウォームティアに割り当てられているノードがありません", "xpack.indexLifecycleMgmt.warmPhase.dataTier.description": "頻度が低い読み取り専用アクセス用に最適化されたノードにデータを移動します。", - "xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel": "ロールオーバー時にウォームフェーズに変更", - "xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasDescription": "レプリカの数を設定します。デフォルトでは前のフェーズと同じです。", "xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasLabel": "レプリカ数(任意)", - "xpack.indexLifecycleMgmt.warmPhase.replicasTitle": "レプリカ", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "なし(グループなし)", "xpack.infra.alerting.alertFlyout.groupByLabel": "グループ分けの条件", "xpack.infra.alerting.alertsButton": "アラート", @@ -11202,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": "データベースレンダー", @@ -11212,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", @@ -11248,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": "分単位", @@ -11280,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": "利用可能なフィールド", @@ -11485,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": "ほとんどのユーザーに推奨されます。", @@ -13106,8 +13062,6 @@ "xpack.ml.featureRegistry.mlFeatureName": "機械学習", "xpack.ml.fieldDataCard.cardBoolean.valuesLabel": "値", "xpack.ml.fieldDataCard.cardDate.summaryTableTitle": "まとめ", - "xpack.ml.fieldDataCard.cardIp.topValuesLabel": "トップの値", - "xpack.ml.fieldDataCard.cardKeyword.topValuesLabel": "トップの値", "xpack.ml.fieldDataCard.cardText.fieldMayBePopulatedDescription": "たとえば、ドキュメントマッピングで {copyToParam} パラメーターを使ったり、{includesParam} と {excludesParam} パラメーターを使用してインデックスした後に {sourceParam} フィールドから切り取ったりして入力される場合があります。", "xpack.ml.fieldDataCard.cardText.fieldNotPresentDescription": "このフィールドはクエリが実行されたドキュメントの {sourceParam} フィールドにありませんでした。", "xpack.ml.fieldDataCard.cardText.noExamplesForFieldsTitle": "このフィールドの例が取得されませんでした", @@ -13125,7 +13079,6 @@ "xpack.ml.fieldDataCardExpandedRow.numberContent.medianLabel": "中間", "xpack.ml.fieldDataCardExpandedRow.numberContent.minLabel": "分", "xpack.ml.fieldDataCardExpandedRow.numberContent.summaryTableTitle": "まとめ", - "xpack.ml.fieldDataCardExpandedRow.numberContent.topValuesTitle": "トップの値", "xpack.ml.fieldTitleBar.documentCountLabel": "ドキュメントカウント", "xpack.ml.fieldTypeIcon.booleanTypeAriaLabel": "ブールタイプ", "xpack.ml.fieldTypeIcon.dateTypeAriaLabel": "日付タイプ", @@ -18765,8 +18718,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": "ネットワーク", @@ -20921,13 +20872,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": "条件を定義してください", @@ -21487,9 +21432,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 679edaf9e0cdd..e7dbc0c161a37 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": "确认", @@ -9440,9 +9437,7 @@ "xpack.indexLifecycleMgmt.coldPhase.dataTier.defaultAllocationNotAvailableTitle": "没有分配到冷层的节点", "xpack.indexLifecycleMgmt.coldPhase.dataTier.description": "将数据移到针对不太频繁的只读访问优化的节点。将处于冷阶段的数据存储在成本较低的硬件上。", "xpack.indexLifecycleMgmt.coldPhase.freezeIndexLabel": "冻结索引", - "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasDescription": "设置副本数目。默认情况下仍与上一阶段相同。", "xpack.indexLifecycleMgmt.coldPhase.numberOfReplicasLabel": "副本分片数(可选)", - "xpack.indexLifecycleMgmt.coldPhase.replicasTitle": "副本分片", "xpack.indexLifecycleMgmt.common.dataTier.title": "数据分配", "xpack.indexLifecycleMgmt.confirmDelete.cancelButton": "取消", "xpack.indexLifecycleMgmt.confirmDelete.deleteButton": "删除", @@ -9455,11 +9450,8 @@ "xpack.indexLifecycleMgmt.editPolicy.cloudDataTierCallout.title": "创建冷层", "xpack.indexLifecycleMgmt.editPolicy.cold.nodeAttributesMissingDescription": "在 elasticsearch.yml 中定义定制节点属性,以使用基于属性的分配。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.activateColdPhaseSwitchLabel": "激活冷阶段", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseDescriptionText": "您查询自己索引的频率较低,因此您可以在效率较低的硬件上分配分片。因为您的查询较为缓慢,所以您可以减少副本分片数目。", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseLabel": "冷阶段", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeIndexExplanationText": "使索引只读,并最大限度减小其内存占用。", "xpack.indexLifecycleMgmt.editPolicy.coldPhase.freezeText": "冻结", - "xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel": "设置副本", "xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.helpText": "根据节点属性移动数据。", "xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.customOption.input": "定制", "xpack.indexLifecycleMgmt.editPolicy.common.dataTierAllocation.cold.defaultOption.helpText": "将数据移到冷层中的节点。", @@ -9476,13 +9468,6 @@ "xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage": "创建索引生命周期策略", "xpack.indexLifecycleMgmt.editPolicy.createSearchableSnapshotLink": "创建快照库", "xpack.indexLifecycleMgmt.editPolicy.createSnapshotRepositoryLink": "创建新的快照库", - "xpack.indexLifecycleMgmt.editPolicy.creationDaysOptionLabel": "天(自索引创建)", - "xpack.indexLifecycleMgmt.editPolicy.creationHoursOptionLabel": "小时(自索引创建)", - "xpack.indexLifecycleMgmt.editPolicy.creationMicroSecondsOptionLabel": "微秒(自索引创建)", - "xpack.indexLifecycleMgmt.editPolicy.creationMilliSecondsOptionLabel": "毫秒(自索引创建)", - "xpack.indexLifecycleMgmt.editPolicy.creationMinutesOptionLabel": "分钟(自索引创建)", - "xpack.indexLifecycleMgmt.editPolicy.creationNanoSecondsOptionLabel": "纳秒(自索引创建)", - "xpack.indexLifecycleMgmt.editPolicy.creationSecondsOptionLabel": "秒(自索引创建)", "xpack.indexLifecycleMgmt.editPolicy.dataTierAllocation.allocationFieldLabel": "数据层选项", "xpack.indexLifecycleMgmt.editPolicy.dataTierAllocation.nodeAllocationFieldLabel": "选择节点属性", "xpack.indexLifecycleMgmt.editPolicy.dataTierColdLabel": "冷", @@ -9523,23 +9508,17 @@ "xpack.indexLifecycleMgmt.editPolicy.formErrorsMessage": "请修复此页面上的错误。", "xpack.indexLifecycleMgmt.editPolicy.hidePolicyJsonButto": "隐藏请求", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.enableRolloverTipContent": "在当前索引满足定义的条件之一时,滚动更新到新索引。", - "xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescriptionMessage": "此阶段为必需。您正频繁地查询并写到您的索引。 为了获取更快的更新,在索引变得过大或过旧时,您可以滚动更新索引。", - "xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseLabel": "热阶段", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.learnAboutRolloverLinkText": "了解详情", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDefaultsTipContent": "当索引已存在 30 天或达到 50 GB 时滚动更新。", "xpack.indexLifecycleMgmt.editPolicy.hotPhase.rolloverDescriptionMessage": "自动滚动更新时间序列数据,以实现高效存储和更高性能。", "xpack.indexLifecycleMgmt.editPolicy.indexPriorityLabel": "索引优先级(可选)", "xpack.indexLifecycleMgmt.editPolicy.indexPriorityText": "索引优先级", - "xpack.indexLifecycleMgmt.editPolicy.learnAboutIndexLifecycleManagementLinkText": "了解索引生命周期。", "xpack.indexLifecycleMgmt.editPolicy.learnAboutIndexTemplatesLink": "了解索引模板", "xpack.indexLifecycleMgmt.editPolicy.learnAboutShardAllocationLink": "了解分片分配", - "xpack.indexLifecycleMgmt.editPolicy.learnAboutTimingText": "了解计时", "xpack.indexLifecycleMgmt.editPolicy.lifecyclePoliciesLoadingFailedTitle": "无法加载现有生命周期策略", "xpack.indexLifecycleMgmt.editPolicy.lifecyclePoliciesReloadButton": "重试", - "xpack.indexLifecycleMgmt.editPolicy.lifecyclePolicyDescriptionText": "使用索引策略自动化索引生命周期的四个阶段,从频繁地写入到索引到删除索引。", "xpack.indexLifecycleMgmt.editPolicy.loadSnapshotRepositoriesErrorBody": "刷新此字段并输入现有快照储存库的名称。", "xpack.indexLifecycleMgmt.editPolicy.loadSnapshotRepositoriesErrorTitle": "无法加载快照存储库", - "xpack.indexLifecycleMgmt.editPolicy.nameLabel": "名称", "xpack.indexLifecycleMgmt.editPolicy.nodeAllocation.customOption.description": "使用节点属性控制分片分配。{learnMoreLink}。", "xpack.indexLifecycleMgmt.editPolicy.nodeAllocation.doNotModifyAllocationOption": "切勿修改分配配置", "xpack.indexLifecycleMgmt.editPolicy.nodeAttributesLoadingFailedTitle": "无法加载节点数据", @@ -9566,13 +9545,6 @@ "xpack.indexLifecycleMgmt.editPolicy.readonlyDescription": "启用以使索引及索引元数据只读;禁用以允许写入和元数据更改。", "xpack.indexLifecycleMgmt.editPolicy.readonlyTitle": "只读", "xpack.indexLifecycleMgmt.editPolicy.reloadSnapshotRepositoriesLabel": "重新加载快照存储库", - "xpack.indexLifecycleMgmt.editPolicy.rolloverDaysOptionLabel": "天(自滚动更新)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverHoursOptionLabel": "小时(自滚动更新)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverMicroSecondsOptionLabel": "微秒(自滚动更新)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverMilliSecondsOptionLabel": "毫秒(自滚动更新)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverMinutesOptionLabel": "分钟(自滚动更新)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverNanoSecondsOptionLabel": "纳秒(自滚动更新)", - "xpack.indexLifecycleMgmt.editPolicy.rolloverSecondsOptionLabel": "秒(自滚动更新)", "xpack.indexLifecycleMgmt.editPolicy.saveAsNewButton": "另存为新策略", "xpack.indexLifecycleMgmt.editPolicy.saveAsNewPolicyMessage": "另存为新策略", "xpack.indexLifecycleMgmt.editPolicy.saveButton": "保存策略", @@ -9597,15 +9569,11 @@ "xpack.indexLifecycleMgmt.editPolicy.warm.nodeAttributesMissingDescription": "在 elasticsearch.yml 中定义定制节点属性,以使用基于属性的分配。", "xpack.indexLifecycleMgmt.editPolicy.warmPhase.activateWarmPhaseSwitchLabel": "激活温阶段", "xpack.indexLifecycleMgmt.editPolicy.warmPhase.indexPriorityExplanationText": "设置在节点重新启动后恢复索引的优先级。较高优先级的索引会在较低优先级的索引之前恢复。", - "xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel": "设置副本", - "xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseDescriptionMessage": "您仍在查询自己的索引,但其为只读。您可以将分片分配给效率较低的硬件。为了获取更快的搜索,您可以减少分片数目并强制合并段。", - "xpack.indexLifecycleMgmt.editPolicy.warmPhase.warmPhaseLabel": "温阶段", "xpack.indexLifecycleMgmt.featureCatalogueDescription": "定义生命周期策略,以随着索引老化自动执行操作。", "xpack.indexLifecycleMgmt.featureCatalogueTitle": "管理索引生命周期", "xpack.indexLifecycleMgmt.forcemerge.bestCompressionLabel": "压缩已存储字段", "xpack.indexLifecycleMgmt.forcemerge.enableLabel": "强制合并数据", "xpack.indexLifecycleMgmt.forceMerge.numberOfSegmentsLabel": "分段数目", - "xpack.indexLifecycleMgmt.hotPhase.advancedSettingsButton": "高级设置", "xpack.indexLifecycleMgmt.hotPhase.bytesLabel": "字节", "xpack.indexLifecycleMgmt.hotPhase.daysLabel": "天", "xpack.indexLifecycleMgmt.hotPhase.enableRolloverLabel": "启用滚动更新", @@ -9731,7 +9699,6 @@ "xpack.indexLifecycleMgmt.shrink.indexFieldLabel": "缩小索引", "xpack.indexLifecycleMgmt.shrink.numberOfPrimaryShardsLabel": "主分片数目", "xpack.indexLifecycleMgmt.templateNotFoundMessage": "找不到模板 {name}。", - "xpack.indexLifecycleMgmt.warmPhase.advancedSettingsButton": "高级设置", "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableBody": "至少将一个节点分配到温层或冷层,以使用基于角色的分配。如果没有可用节点,则策略无法完成分配。", "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotAvailableTitle": "没有分配到温层的节点", "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotice.cold": "此策略会改为将冷阶段的数据移到{tier}层节点。", @@ -9739,10 +9706,7 @@ "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotice.warm": "此策略会改为将温阶段的数据移到{tier}层节点。", "xpack.indexLifecycleMgmt.warmPhase.dataTier.defaultAllocationNotice.warm.title": "没有分配到温层的节点", "xpack.indexLifecycleMgmt.warmPhase.dataTier.description": "将数据移到针对不太频繁的只读访问优化的节点。", - "xpack.indexLifecycleMgmt.warmPhase.moveToWarmPhaseOnRolloverLabel": "滚动更新时移到温阶段", - "xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasDescription": "设置副本数目。默认情况下仍与上一阶段相同。", "xpack.indexLifecycleMgmt.warmPhase.numberOfReplicasLabel": "副本分片数(可选)", - "xpack.indexLifecycleMgmt.warmPhase.replicasTitle": "副本分片", "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "无内容(未分组)", "xpack.infra.alerting.alertFlyout.groupByLabel": "分组依据", "xpack.infra.alerting.alertsButton": "告警", @@ -11231,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": "数据表呈现器", @@ -11241,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", @@ -11277,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": "每分钟", @@ -11309,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": "可用字段", @@ -11514,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": "适合绝大多数用户。", @@ -13137,8 +13093,6 @@ "xpack.ml.featureRegistry.mlFeatureName": "Machine Learning", "xpack.ml.fieldDataCard.cardBoolean.valuesLabel": "值", "xpack.ml.fieldDataCard.cardDate.summaryTableTitle": "摘要", - "xpack.ml.fieldDataCard.cardIp.topValuesLabel": "排名最前值", - "xpack.ml.fieldDataCard.cardKeyword.topValuesLabel": "排名最前值", "xpack.ml.fieldDataCard.cardText.fieldMayBePopulatedDescription": "例如,可以使用文档映射中的 {copyToParam} 参数进行填充,也可以在索引后通过使用 {includesParam} 和 {excludesParam} 参数从 {sourceParam} 字段中修剪。", "xpack.ml.fieldDataCard.cardText.fieldNotPresentDescription": "查询的文档的 {sourceParam} 字段中不存在此字段。", "xpack.ml.fieldDataCard.cardText.noExamplesForFieldsTitle": "没有获取此字段的示例", @@ -13156,7 +13110,6 @@ "xpack.ml.fieldDataCardExpandedRow.numberContent.medianLabel": "中值", "xpack.ml.fieldDataCardExpandedRow.numberContent.minLabel": "最小值", "xpack.ml.fieldDataCardExpandedRow.numberContent.summaryTableTitle": "摘要", - "xpack.ml.fieldDataCardExpandedRow.numberContent.topValuesTitle": "排名最前值", "xpack.ml.fieldTitleBar.documentCountLabel": "文档计数", "xpack.ml.fieldTypeIcon.booleanTypeAriaLabel": "布尔类型", "xpack.ml.fieldTypeIcon.dateTypeAriaLabel": "日期类型", @@ -18812,8 +18765,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": "网络", @@ -20969,13 +20920,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": "定义条件", @@ -21537,9 +21482,6 @@ "xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey": " 。", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey": "要创建告警,请在 kibana.yml 文件中设置以下项的值: ", "xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle": "必须设置加密密钥", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError": "必须在 Kibana 和 Elasticsearch 之间启用传输层安全并在 kibana.yml 文件中配置加密密钥。", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction": "了解操作方法", - "xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle": "需要其他设置", "xpack.triggersActionsUI.components.healthCheck.tlsError": "Alerting 功能依赖于 API 密钥,这需要在 Elasticsearch 与 Kibana 之间启用 TLS。", "xpack.triggersActionsUI.components.healthCheck.tlsErrorAction": "了解如何启用 TLS。", "xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle": "必须启用传输层安全", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx index be6c72eef6f9a..8c6a16dcd4a02 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.test.tsx @@ -56,9 +56,11 @@ describe('health check', () => { }); it('renders children if keys are enabled', async () => { - useKibanaMock().services.http.get = jest - .fn() - .mockResolvedValue({ isSufficientlySecure: true, hasPermanentEncryptionKey: true }); + useKibanaMock().services.http.get = jest.fn().mockResolvedValue({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + isAlertsAvailable: true, + }); const { queryByText } = render( <HealthContextProvider> <HealthCheck waitForCheck={true}> @@ -72,10 +74,11 @@ describe('health check', () => { expect(queryByText('should render')).toBeInTheDocument(); }); - test('renders warning if keys are disabled', async () => { - useKibanaMock().services.http.get = jest.fn().mockImplementationOnce(async () => ({ + test('renders warning if TLS is required', async () => { + useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ isSufficientlySecure: false, hasPermanentEncryptionKey: true, + isAlertsAvailable: true, })); const { queryAllByText } = render( <HealthContextProvider> @@ -104,9 +107,10 @@ describe('health check', () => { }); test('renders warning if encryption key is ephemeral', async () => { - useKibanaMock().services.http.get = jest.fn().mockImplementationOnce(async () => ({ + useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: false, + isAlertsAvailable: true, })); const { queryByText, queryByRole } = render( <HealthContextProvider> @@ -121,7 +125,7 @@ describe('health check', () => { const description = queryByRole(/banner/i); expect(description!.textContent).toMatchInlineSnapshot( - `"To create an alert, set a value for xpack.encryptedSavedObjects.encryptionKey in your kibana.yml file. Learn how.(opens in a new tab or window)"` + `"To create an alert, set a value for xpack.encryptedSavedObjects.encryptionKey in your kibana.yml file and ensure the Encrypted Saved Objects plugin is enabled. Learn how.(opens in a new tab or window)"` ); const action = queryByText(/Learn/i); @@ -132,9 +136,10 @@ describe('health check', () => { }); test('renders warning if encryption key is ephemeral and keys are disabled', async () => { - useKibanaMock().services.http.get = jest.fn().mockImplementationOnce(async () => ({ + useKibanaMock().services.http.get = jest.fn().mockImplementation(async () => ({ isSufficientlySecure: false, hasPermanentEncryptionKey: false, + isAlertsAvailable: true, })); const { queryByText } = render( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx index 66f7c1d36dfb2..3103d8f2a817c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/health_check.tsx @@ -14,18 +14,24 @@ import { i18n } from '@kbn/i18n'; import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; import { DocLinksStart } from 'kibana/public'; -import { AlertingFrameworkHealth } from '../../types'; -import { health } from '../lib/alert_api'; +import { alertingFrameworkHealth } from '../lib/alert_api'; import './health_check.scss'; import { useHealthContext } from '../context/health_context'; import { useKibana } from '../../common/lib/kibana'; import { CenterJustifiedSpinner } from './center_justified_spinner'; +import { triggersActionsUiHealth } from '../../common/lib/health_api'; interface Props { inFlyout?: boolean; waitForCheck: boolean; } +interface HealthStatus { + isAlertsAvailable: boolean; + isSufficientlySecure: boolean; + hasPermanentEncryptionKey: boolean; +} + export const HealthCheck: React.FunctionComponent<Props> = ({ children, waitForCheck, @@ -33,12 +39,24 @@ export const HealthCheck: React.FunctionComponent<Props> = ({ }) => { const { http, docLinks } = useKibana().services; const { setLoadingHealthCheck } = useHealthContext(); - const [alertingHealth, setAlertingHealth] = React.useState<Option<AlertingFrameworkHealth>>(none); + const [alertingHealth, setAlertingHealth] = React.useState<Option<HealthStatus>>(none); React.useEffect(() => { (async function () { setLoadingHealthCheck(true); - setAlertingHealth(some(await health({ http }))); + const triggersActionsUiHealthStatus = await triggersActionsUiHealth({ http }); + const healthStatus: HealthStatus = { + ...triggersActionsUiHealthStatus, + isSufficientlySecure: false, + hasPermanentEncryptionKey: false, + }; + if (healthStatus.isAlertsAvailable) { + const alertingHealthResult = await alertingFrameworkHealth({ http }); + healthStatus.isSufficientlySecure = alertingHealthResult.isSufficientlySecure; + healthStatus.hasPermanentEncryptionKey = alertingHealthResult.hasPermanentEncryptionKey; + } + + setAlertingHealth(some(healthStatus)); setLoadingHealthCheck(false); })(); }, [http, setLoadingHealthCheck]); @@ -60,6 +78,8 @@ export const HealthCheck: React.FunctionComponent<Props> = ({ (healthCheck) => { return healthCheck?.isSufficientlySecure && healthCheck?.hasPermanentEncryptionKey ? ( <Fragment>{children}</Fragment> + ) : !healthCheck.isAlertsAvailable ? ( + <AlertsError docLinks={docLinks} className={className} /> ) : !healthCheck.isSufficientlySecure && !healthCheck.hasPermanentEncryptionKey ? ( <TlsAndEncryptionError docLinks={docLinks} className={className} /> ) : !healthCheck.hasPermanentEncryptionKey ? ( @@ -77,7 +97,7 @@ interface PromptErrorProps { className?: string; } -const TlsAndEncryptionError = ({ +const EncryptionError = ({ // eslint-disable-next-line @typescript-eslint/naming-convention docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, className, @@ -90,27 +110,37 @@ const TlsAndEncryptionError = ({ title={ <h2> <FormattedMessage - id="xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle" - defaultMessage="Additional setup required" + id="xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle" + defaultMessage="Encrypted saved objects are not available" /> </h2> } body={ <div className={`${className}__body`}> <p role="banner"> - {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError', { - defaultMessage: - 'You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. ', - })} + {i18n.translate( + 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey', + { + defaultMessage: 'To create an alert, set a value for ', + } + )} + <EuiCode>{'xpack.encryptedSavedObjects.encryptionKey'}</EuiCode> + {i18n.translate( + 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey', + { + defaultMessage: + ' in your kibana.yml file and ensure the Encrypted Saved Objects plugin is enabled. ', + } + )} <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alerting-getting-started.html#alerting-setup-prerequisites`} + href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`} external target="_blank" > {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction', + 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAction', { - defaultMessage: 'Learn how', + defaultMessage: 'Learn how.', } )} </EuiLink> @@ -120,7 +150,7 @@ const TlsAndEncryptionError = ({ /> ); -const EncryptionError = ({ +const TlsError = ({ // eslint-disable-next-line @typescript-eslint/naming-convention docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, className, @@ -133,38 +163,26 @@ const EncryptionError = ({ title={ <h2> <FormattedMessage - id="xpack.triggersActionsUI.components.healthCheck.encryptionErrorTitle" - defaultMessage="You must set an encryption key" + id="xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle" + defaultMessage="You must enable Transport Layer Security" /> </h2> } body={ <div className={`${className}__body`}> <p role="banner"> - {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorBeforeKey', - { - defaultMessage: 'To create an alert, set a value for ', - } - )} - <EuiCode>{'xpack.encryptedSavedObjects.encryptionKey'}</EuiCode> - {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAfterKey', - { - defaultMessage: ' in your kibana.yml file. ', - } - )} + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsError', { + defaultMessage: + 'Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. ', + })} <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html#general-alert-action-settings`} + href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`} external target="_blank" > - {i18n.translate( - 'xpack.triggersActionsUI.components.healthCheck.encryptionErrorAction', - { - defaultMessage: 'Learn how.', - } - )} + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsErrorAction', { + defaultMessage: 'Learn how to enable TLS.', + })} </EuiLink> </p> </div> @@ -172,7 +190,46 @@ const EncryptionError = ({ /> ); -const TlsError = ({ +const AlertsError = ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, + className, +}: PromptErrorProps) => ( + <EuiEmptyPrompt + iconType="watchesApp" + data-test-subj="alertsNeededEmptyPrompt" + className={className} + titleSize="xs" + title={ + <h2> + <FormattedMessage + id="xpack.triggersActionsUI.components.healthCheck.alertsErrorTitle" + defaultMessage="You must enable Alerts and Actions" + /> + </h2> + } + body={ + <div className={`${className}__body`}> + <p role="banner"> + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.alertsError', { + defaultMessage: 'To create an alert, set alerts and actions plugins enabled. ', + })} + <EuiLink + href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alert-action-settings-kb.html`} + external + target="_blank" + > + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.alertsErrorAction', { + defaultMessage: 'Learn how to enable Alerts and Actions.', + })} + </EuiLink> + </p> + </div> + } + /> +); + +const TlsAndEncryptionError = ({ // eslint-disable-next-line @typescript-eslint/naming-convention docLinks: { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION }, className, @@ -185,26 +242,29 @@ const TlsError = ({ title={ <h2> <FormattedMessage - id="xpack.triggersActionsUI.components.healthCheck.tlsErrorTitle" - defaultMessage="You must enable Transport Layer Security" + id="xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorTitle" + defaultMessage="Additional setup required" /> </h2> } body={ <div className={`${className}__body`}> <p role="banner"> - {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsError', { + {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionError', { defaultMessage: - 'Alerting relies on API keys, which require TLS between Elasticsearch and Kibana. ', + 'You must enable Transport Layer Security between Kibana and Elasticsearch and configure an encryption key in your kibana.yml file. ', })} <EuiLink - href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/configuring-tls.html`} + href={`${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/alerting-getting-started.html#alerting-setup-prerequisites`} external target="_blank" > - {i18n.translate('xpack.triggersActionsUI.components.healthCheck.tlsErrorAction', { - defaultMessage: 'Learn how to enable TLS.', - })} + {i18n.translate( + 'xpack.triggersActionsUI.components.healthCheck.tlsAndEncryptionErrorAction', + { + defaultMessage: 'Learn how', + } + )} </EuiLink> </p> </div> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index ea654bb21e88b..f3d49c52855ab 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -25,7 +25,7 @@ import { updateAlert, muteAlertInstance, unmuteAlertInstance, - health, + alertingFrameworkHealth, mapFiltersToKql, } from './alert_api'; import uuid from 'uuid'; @@ -801,9 +801,9 @@ describe('unmuteAlerts', () => { }); }); -describe('health', () => { - test('should call health API', async () => { - const result = await health({ http }); +describe('alertingFrameworkHealth', () => { + test('should call alertingFrameworkHealth API', async () => { + const result = await alertingFrameworkHealth({ http }); expect(result).toEqual(undefined); expect(http.get.mock.calls).toMatchInlineSnapshot(` Array [ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index 52ab33566da74..f774b3d35bb29 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -282,6 +282,10 @@ export async function unmuteAlerts({ await Promise.all(ids.map((id) => unmuteAlert({ id, http }))); } -export async function health({ http }: { http: HttpSetup }): Promise<AlertingFrameworkHealth> { +export async function alertingFrameworkHealth({ + http, +}: { + http: HttpSetup; +}): Promise<AlertingFrameworkHealth> { return await http.get(`${BASE_ALERT_API_PATH}/_health`); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 889dfe8289b13..3c32b5bc729dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -25,7 +25,14 @@ jest.mock('../../../common/lib/kibana'); jest.mock('../../lib/alert_api', () => ({ loadAlertTypes: jest.fn(), - health: jest.fn(() => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true })), + alertingFrameworkHealth: jest.fn(() => ({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + })), +})); + +jest.mock('../../../common/lib/health_api', () => ({ + triggersActionsUiHealth: jest.fn(() => ({ isAlertsAvailable: true })), })); const actionTypeRegistry = actionTypeRegistryMock.create(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx index baf0f55c415db..df7729bb407b3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.test.tsx @@ -18,11 +18,25 @@ import { alertTypeRegistryMock } from '../../alert_type_registry.mock'; import { ReactWrapper } from 'enzyme'; import AlertEdit from './alert_edit'; import { useKibana } from '../../../common/lib/kibana'; +import { ALERTS_FEATURE_ID } from '../../../../../alerts/common'; jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>; +jest.mock('../../lib/alert_api', () => ({ + loadAlertTypes: jest.fn(), + updateAlert: jest.fn().mockRejectedValue({ body: { message: 'Fail message' } }), + alertingFrameworkHealth: jest.fn(() => ({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + })), +})); + +jest.mock('../../../common/lib/health_api', () => ({ + triggersActionsUiHealth: jest.fn(() => ({ isAlertsAvailable: true })), +})); + describe('alert_edit', () => { let wrapper: ReactWrapper<any>; let mockedCoreSetup: ReturnType<typeof coreMock.createSetup>; @@ -48,12 +62,32 @@ describe('alert_edit', () => { }, }; - // eslint-disable-next-line react-hooks/rules-of-hooks - useKibanaMock().services.http.get = jest.fn().mockResolvedValue({ - isSufficientlySecure: true, - hasPermanentEncryptionKey: true, - }); - + const { loadAlertTypes } = jest.requireMock('../../lib/alert_api'); + const alertTypes = [ + { + id: 'my-alert-type', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + ], + defaultActionGroupId: 'testActionGroup', + minimumLicenseRequired: 'basic', + recoveryActionGroup: { id: 'recovered', name: 'Recovered' }, + producer: ALERTS_FEATURE_ID, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, + }, + actionVariables: { + context: [], + state: [], + params: [], + }, + }, + ]; const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -79,7 +113,7 @@ describe('alert_edit', () => { }, actionConnectorFields: null, }); - + loadAlertTypes.mockResolvedValue(alertTypes); const alert: Alert = { id: 'ab5661e0-197e-45ee-b477-302d89193b5e', params: { @@ -145,19 +179,15 @@ describe('alert_edit', () => { }); } - it('renders alert add flyout', async () => { + it('renders alert edit flyout', async () => { await setup(); expect(wrapper.find('[data-test-subj="editAlertFlyoutTitle"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="saveEditedAlertButton"]').exists()).toBeTruthy(); }); it('displays a toast message on save for server errors', async () => { - useKibanaMock().services.http.get = jest.fn().mockResolvedValue([]); await setup(); - const err = new Error() as any; - err.body = {}; - err.body.message = 'Fail message'; - useKibanaMock().services.http.put = jest.fn().mockRejectedValue(err); + await act(async () => { wrapper.find('[data-test-subj="saveEditedAlertButton"]').first().simulate('click'); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index 8ca3edb1c68df..bd50bf3270f1a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -26,7 +26,13 @@ jest.mock('../../../lib/action_connector_api', () => ({ jest.mock('../../../lib/alert_api', () => ({ loadAlerts: jest.fn(), loadAlertTypes: jest.fn(), - health: jest.fn(() => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true })), + alertingFrameworkHealth: jest.fn(() => ({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + })), +})); +jest.mock('../../../../common/lib/health_api', () => ({ + triggersActionsUiHealth: jest.fn(() => ({ isAlertsAvailable: true })), })); jest.mock('react-router-dom', () => ({ useHistory: () => ({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index 5656aa9de7795..f44f6e87c7a19 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -29,7 +29,7 @@ import { loadAlertState, loadAlertInstanceSummary, loadAlertTypes, - health, + alertingFrameworkHealth, } from '../../../lib/alert_api'; import { useKibana } from '../../../../common/lib/kibana'; @@ -131,7 +131,7 @@ export function withBulkAlertOperations<T>( loadAlertInstanceSummary({ http, alertId }) } loadAlertTypes={async () => loadAlertTypes({ http })} - getHealth={async () => health({ http })} + getHealth={async () => alertingFrameworkHealth({ http })} /> ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.test.ts new file mode 100644 index 0000000000000..d22fd538ad0ca --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from 'src/core/public/mocks'; +import { triggersActionsUiHealth } from './health_api'; + +describe('triggersActionsUiHealth', () => { + const http = httpServiceMock.createStartContract(); + + test('should call triggersActionsUiHealth API', async () => { + const result = await triggersActionsUiHealth({ http }); + expect(result).toEqual(undefined); + expect(http.get.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/triggers_actions_ui/_health", + ], + ] + `); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.ts b/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.ts new file mode 100644 index 0000000000000..752f5b3e2ca08 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/common/lib/health_api.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { HttpSetup } from 'kibana/public'; + +const TRIGGERS_ACTIONS_UI_API_ROOT = '/api/triggers_actions_ui'; + +export async function triggersActionsUiHealth({ http }: { http: HttpSetup }): Promise<any> { + return await http.get(`${TRIGGERS_ACTIONS_UI_API_ROOT}/_health`); +} diff --git a/x-pack/plugins/triggers_actions_ui/server/data/index.ts b/x-pack/plugins/triggers_actions_ui/server/data/index.ts index 6ee2b4bb8a5fe..cc76af90bcde6 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/index.ts @@ -13,6 +13,7 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, } from './lib'; // future enhancement: make these configurable? diff --git a/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts b/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts index 096a928249fd5..a3fe2220a86fd 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/lib/index.ts @@ -9,4 +9,5 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, } from './core_query_types'; diff --git a/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts b/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts index 17a2b2929f0cf..2cda40c18db0c 100644 --- a/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts +++ b/x-pack/plugins/triggers_actions_ui/server/data/routes/fields.ts @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -// the business logic of this code is from watcher, in: -// x-pack/plugins/watcher/server/routes/api/register_list_fields_route.ts - import { schema, TypeOf } from '@kbn/config-schema'; import { IRouter, diff --git a/x-pack/plugins/triggers_actions_ui/server/index.ts b/x-pack/plugins/triggers_actions_ui/server/index.ts index abd61f2bd3541..5e35293419b17 100644 --- a/x-pack/plugins/triggers_actions_ui/server/index.ts +++ b/x-pack/plugins/triggers_actions_ui/server/index.ts @@ -14,6 +14,7 @@ export { CoreQueryParams, CoreQueryParamsSchemaProperties, validateCoreQueryBody, + validateTimeWindowUnits, MAX_INTERVALS, MAX_GROUPS, DEFAULT_GROUPS, diff --git a/x-pack/plugins/triggers_actions_ui/server/plugin.ts b/x-pack/plugins/triggers_actions_ui/server/plugin.ts index c0d29341e217b..69be37f665887 100644 --- a/x-pack/plugins/triggers_actions_ui/server/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/server/plugin.ts @@ -5,12 +5,21 @@ */ import { Logger, Plugin, CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { PluginSetupContract as AlertsPluginSetup } from '../../alerts/server'; +import { EncryptedSavedObjectsPluginSetup } from '../../encrypted_saved_objects/server'; import { getService, register as registerDataService } from './data'; +import { createHealthRoute } from './routes/health'; +const BASE_ROUTE = '/api/triggers_actions_ui'; export interface PluginStartContract { data: ReturnType<typeof getService>; } +interface PluginsSetup { + encryptedSavedObjects?: EncryptedSavedObjectsPluginSetup; + alerts?: AlertsPluginSetup; +} + export class TriggersActionsPlugin implements Plugin<void, PluginStartContract> { private readonly logger: Logger; private readonly data: PluginStartContract['data']; @@ -20,13 +29,16 @@ export class TriggersActionsPlugin implements Plugin<void, PluginStartContract> this.data = getService(); } - public async setup(core: CoreSetup): Promise<void> { + public async setup(core: CoreSetup, plugins: PluginsSetup): Promise<void> { + const router = core.http.createRouter(); registerDataService({ logger: this.logger, data: this.data, - router: core.http.createRouter(), - baseRoute: '/api/triggers_actions_ui', + router, + baseRoute: BASE_ROUTE, }); + + createHealthRoute(this.logger, router, BASE_ROUTE, plugins.alerts !== undefined); } public async start(): Promise<PluginStartContract> { diff --git a/x-pack/plugins/triggers_actions_ui/server/routes/health.ts b/x-pack/plugins/triggers_actions_ui/server/routes/health.ts new file mode 100644 index 0000000000000..1ea9cb748bcd7 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/server/routes/health.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'kibana/server'; +import { Logger } from '../../../../../src/core/server'; + +export function createHealthRoute( + logger: Logger, + router: IRouter, + baseRoute: string, + isAlertsAvailable: boolean +) { + const path = `${baseRoute}/_health`; + logger.debug(`registering triggers_actions_ui health route GET ${path}`); + router.get( + { + path, + validate: false, + }, + handler + ); + async function handler( + ctx: RequestHandlerContext, + req: KibanaRequest<unknown, unknown, unknown>, + res: KibanaResponseFactory + ): Promise<IKibanaResponse> { + const result = { isAlertsAvailable }; + + logger.debug(`route ${path} response: ${JSON.stringify(result)}`); + return res.ok({ body: result }); + } +} diff --git a/x-pack/plugins/upgrade_assistant/public/application/app.tsx b/x-pack/plugins/upgrade_assistant/public/application/app.tsx index 17eff71f1039b..2b245bceceb6c 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/app.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/app.tsx @@ -16,10 +16,10 @@ export interface AppDependencies extends ContextValue { i18n: I18nStart; } -export const RootComponent = ({ i18n, ...contexValue }: AppDependencies) => { +export const RootComponent = ({ i18n, ...contextValue }: AppDependencies) => { return ( <i18n.Context> - <AppContextProvider value={contexValue}> + <AppContextProvider value={contextValue}> <div data-test-subj="upgradeAssistantRoot"> <EuiPageHeader> <EuiPageHeaderSection> diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/latest_minor_banner.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/latest_minor_banner.tsx index 43d0364425cbb..d9ec183231739 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/latest_minor_banner.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/latest_minor_banner.tsx @@ -10,40 +10,49 @@ import { EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { CURRENT_MAJOR_VERSION, NEXT_MAJOR_VERSION } from '../../../common/version'; +import { useAppContext } from '../app_context'; -export const LatestMinorBanner: React.FunctionComponent = () => ( - <EuiCallOut - title={ - <FormattedMessage - id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutTitle" - defaultMessage="Issues list might be incomplete" - /> - } - color="warning" - iconType="help" - > - <p> - <FormattedMessage - id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.calloutDetail" - defaultMessage="The complete list of {breakingChangesDocButton} in Elasticsearch {nextEsVersion} +export const LatestMinorBanner: React.FunctionComponent = () => { + const { docLinks } = useAppContext(); + + const { ELASTIC_WEBSITE_URL } = docLinks; + const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference`; + + return ( + <EuiCallOut + title={ + <FormattedMessage + id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutTitle" + defaultMessage="Issues list might be incomplete" + /> + } + color="warning" + iconType="help" + > + <p> + <FormattedMessage + id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.calloutDetail" + defaultMessage="The complete list of {breakingChangesDocButton} in Elasticsearch {nextEsVersion} will be available in the final {currentEsVersion} minor release. When the list is complete, this warning will go away." - values={{ - breakingChangesDocButton: ( - <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/master/breaking-changes.html" - target="_blank" - > - <FormattedMessage - id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.breackingChangesDocButtonLabel" - defaultMessage="deprecations and breaking changes" - /> - </EuiLink> - ), - nextEsVersion: `${NEXT_MAJOR_VERSION}.x`, - currentEsVersion: `${CURRENT_MAJOR_VERSION}.x`, - }} - /> - </p> - </EuiCallOut> -); + values={{ + breakingChangesDocButton: ( + <EuiLink + href={`${esDocBasePath}/master/breaking-changes.html`} // Pointing to master here, as we want to direct users to breaking changes for the next major ES version + target="_blank" + external + > + <FormattedMessage + id="xpack.upgradeAssistant.tabs.incompleteCallout.calloutBody.breackingChangesDocButtonLabel" + defaultMessage="deprecations and breaking changes" + /> + </EuiLink> + ), + nextEsVersion: `${NEXT_MAJOR_VERSION}.x`, + currentEsVersion: `${CURRENT_MAJOR_VERSION}.x`, + }} + /> + </p> + </EuiCallOut> + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx index 463c0c9d016b3..6a99bd24ef26b 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs.test.tsx @@ -17,6 +17,19 @@ const promisesToResolve = () => new Promise((resolve) => setTimeout(resolve, 0)) const mockHttp = httpServiceMock.createSetupContract(); +jest.mock('../app_context', () => { + return { + useAppContext: () => { + return { + docLinks: { + DOC_LINK_VERSION: 'current', + ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', + }, + }; + }, + }; +}); + describe('UpgradeAssistantTabs', () => { test('renders loading state', async () => { mockHttp.get.mockReturnValue( diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap index dda051e715234..5aa4a469e4f02 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/__snapshots__/checkup_tab.test.tsx.snap @@ -40,7 +40,8 @@ exports[`CheckupTab render with deprecations 1`] = ` values={ Object { "snapshotRestoreDocsButton": <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html" + external={true} + href="https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshot-restore.html" target="_blank" > <FormattedMessage @@ -321,7 +322,8 @@ exports[`CheckupTab render with error 1`] = ` values={ Object { "snapshotRestoreDocsButton": <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html" + external={true} + href="https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshot-restore.html" target="_blank" > <FormattedMessage @@ -386,7 +388,8 @@ exports[`CheckupTab render without deprecations 1`] = ` values={ Object { "snapshotRestoreDocsButton": <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html" + external={true} + href="https://www.elastic.co/guide/en/elasticsearch/reference/current/snapshot-restore.html" target="_blank" > <FormattedMessage diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx index a4054bacba448..3a1e042a3aa5f 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.test.tsx @@ -20,6 +20,19 @@ const defaultProps = { setSelectedTabIndex: jest.fn(), }; +jest.mock('../../../app_context', () => { + return { + useAppContext: () => { + return { + docLinks: { + DOC_LINK_VERSION: 'current', + ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', + }, + }; + }, + }; +}); + /** * Mostly a dumb container with copy, test the three main states. */ diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.tsx index 5688903b8f7cd..02cbc87483e55 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/checkup_tab.tsx @@ -5,7 +5,7 @@ */ import { find } from 'lodash'; -import React, { Fragment } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { EuiCallOut, @@ -20,211 +20,65 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { NEXT_MAJOR_VERSION } from '../../../../../common/version'; import { LoadingErrorBanner } from '../../error_banner'; +import { useAppContext } from '../../../app_context'; import { GroupByOption, LevelFilterOption, LoadingState, - UpgradeAssistantTabComponent, UpgradeAssistantTabProps, } from '../../types'; import { CheckupControls } from './controls'; import { GroupedDeprecations } from './deprecations/grouped'; -interface CheckupTabProps extends UpgradeAssistantTabProps { +export interface CheckupTabProps extends UpgradeAssistantTabProps { checkupLabel: string; showBackupWarning?: boolean; } -interface CheckupTabState { - currentFilter: LevelFilterOption; - search: string; - currentGroupBy: GroupByOption; -} - /** * Displays a list of deprecations that filterable and groupable. Can be used for cluster, * nodes, or indices checkups. */ -export class CheckupTab extends UpgradeAssistantTabComponent<CheckupTabProps, CheckupTabState> { - constructor(props: CheckupTabProps) { - super(props); - - this.state = { - // initialize to all filters - currentFilter: LevelFilterOption.all, - search: '', - currentGroupBy: GroupByOption.message, - }; - } - - public render() { - const { - alertBanner, - checkupLabel, - deprecations, - loadingError, - loadingState, - refreshCheckupData, - setSelectedTabIndex, - showBackupWarning = false, - } = this.props; - const { currentFilter, currentGroupBy } = this.state; - - return ( - <Fragment> - <EuiSpacer /> - <EuiText grow={false}> - <p> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.tabDetail" - defaultMessage="These {strongCheckupLabel} issues need your attention. Resolve them before upgrading to Elasticsearch {nextEsVersion}." - values={{ - strongCheckupLabel: <strong>{checkupLabel}</strong>, - nextEsVersion: `${NEXT_MAJOR_VERSION}.x`, - }} - /> - </p> - </EuiText> - - <EuiSpacer /> - - {alertBanner && ( - <Fragment> - {alertBanner} - <EuiSpacer /> - </Fragment> - )} - - {showBackupWarning && ( - <Fragment> - <EuiCallOut - title={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutTitle" - defaultMessage="Back up your indices now" - /> - } - color="warning" - iconType="help" - > - <p> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.calloutDetail" - defaultMessage="Back up your data using the {snapshotRestoreDocsButton}." - values={{ - snapshotRestoreDocsButton: ( - <EuiLink - href="https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html" - target="_blank" - > - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.snapshotRestoreDocsButtonLabel" - defaultMessage="snapshot and restore APIs" - /> - </EuiLink> - ), - }} - /> - </p> - </EuiCallOut> - <EuiSpacer /> - </Fragment> - )} - - <EuiPageContent> - <EuiPageContentBody> - {loadingState === LoadingState.Error ? ( - <LoadingErrorBanner loadingError={loadingError} /> - ) : deprecations && deprecations.length > 0 ? ( - <Fragment> - <CheckupControls - allDeprecations={deprecations} - loadingState={loadingState} - loadData={refreshCheckupData} - currentFilter={currentFilter} - onFilterChange={this.changeFilter} - onSearchChange={this.changeSearch} - availableGroupByOptions={this.availableGroupByOptions()} - currentGroupBy={currentGroupBy} - onGroupByChange={this.changeGroupBy} - /> - <EuiSpacer /> - {this.renderCheckupData()} - </Fragment> - ) : ( - <EuiEmptyPrompt - iconType="faceHappy" - title={ - <h2> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle" - defaultMessage="All clear!" - /> - </h2> - } - body={ - <Fragment> - <p data-test-subj="upgradeAssistantIssueSummary"> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel" - defaultMessage="You have no {strongCheckupLabel} issues." - values={{ - strongCheckupLabel: <strong>{checkupLabel}</strong>, - }} - /> - </p> - <p> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail" - defaultMessage="Check the {overviewTabButton} for next steps." - values={{ - overviewTabButton: ( - <EuiLink onClick={() => setSelectedTabIndex(0)}> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail.overviewTabButtonLabel" - defaultMessage="Overview tab" - /> - </EuiLink> - ), - }} - /> - </p> - </Fragment> - } - /> - )} - </EuiPageContentBody> - </EuiPageContent> - </Fragment> - ); - } - - private changeFilter = (filter: LevelFilterOption) => { - this.setState({ currentFilter: filter }); +export const CheckupTab: FunctionComponent<CheckupTabProps> = ({ + alertBanner, + checkupLabel, + deprecations, + loadingError, + loadingState, + refreshCheckupData, + setSelectedTabIndex, + showBackupWarning = false, +}) => { + const [currentFilter, setCurrentFilter] = useState<LevelFilterOption>(LevelFilterOption.all); + const [search, setSearch] = useState<string>(''); + const [currentGroupBy, setCurrentGroupBy] = useState<GroupByOption>(GroupByOption.message); + + const { docLinks } = useAppContext(); + + const { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } = docLinks; + const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`; + + const changeFilter = (filter: LevelFilterOption) => { + setCurrentFilter(filter); }; - private changeSearch = (search: string) => { - this.setState({ search }); + const changeSearch = (newSearch: string) => { + setSearch(newSearch); }; - private changeGroupBy = (groupBy: GroupByOption) => { - this.setState({ currentGroupBy: groupBy }); + const changeGroupBy = (groupBy: GroupByOption) => { + setCurrentGroupBy(groupBy); }; - private availableGroupByOptions() { - const { deprecations } = this.props; - + const availableGroupByOptions = () => { if (!deprecations) { return []; } return Object.keys(GroupByOption).filter((opt) => find(deprecations, opt)) as GroupByOption[]; - } - - private renderCheckupData() { - const { deprecations } = this.props; - const { currentFilter, currentGroupBy, search } = this.state; + }; + const renderCheckupData = () => { return ( <GroupedDeprecations currentGroupBy={currentGroupBy} @@ -233,5 +87,134 @@ export class CheckupTab extends UpgradeAssistantTabComponent<CheckupTabProps, Ch allDeprecations={deprecations} /> ); - } -} + }; + + return ( + <> + <EuiSpacer /> + <EuiText grow={false}> + <p> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.tabDetail" + defaultMessage="These {strongCheckupLabel} issues need your attention. Resolve them before upgrading to Elasticsearch {nextEsVersion}." + values={{ + strongCheckupLabel: <strong>{checkupLabel}</strong>, + nextEsVersion: `${NEXT_MAJOR_VERSION}.x`, + }} + /> + </p> + </EuiText> + + <EuiSpacer /> + + {alertBanner && ( + <> + {alertBanner} + <EuiSpacer /> + </> + )} + + {showBackupWarning && ( + <> + <EuiCallOut + title={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutTitle" + defaultMessage="Back up your indices now" + /> + } + color="warning" + iconType="help" + > + <p> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.calloutDetail" + defaultMessage="Back up your data using the {snapshotRestoreDocsButton}." + values={{ + snapshotRestoreDocsButton: ( + <EuiLink + href={`${esDocBasePath}/snapshot-restore.html`} + target="_blank" + external + > + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.backUpCallout.calloutBody.snapshotRestoreDocsButtonLabel" + defaultMessage="snapshot and restore APIs" + /> + </EuiLink> + ), + }} + /> + </p> + </EuiCallOut> + <EuiSpacer /> + </> + )} + + <EuiPageContent> + <EuiPageContentBody> + {loadingState === LoadingState.Error ? ( + <LoadingErrorBanner loadingError={loadingError} /> + ) : deprecations && deprecations.length > 0 ? ( + <> + <CheckupControls + allDeprecations={deprecations} + loadingState={loadingState} + loadData={refreshCheckupData} + currentFilter={currentFilter} + onFilterChange={changeFilter} + onSearchChange={changeSearch} + availableGroupByOptions={availableGroupByOptions()} + currentGroupBy={currentGroupBy} + onGroupByChange={changeGroupBy} + /> + <EuiSpacer /> + {renderCheckupData()} + </> + ) : ( + <EuiEmptyPrompt + iconType="faceHappy" + title={ + <h2> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.noIssues.noIssuesTitle" + defaultMessage="All clear!" + /> + </h2> + } + body={ + <> + <p data-test-subj="upgradeAssistantIssueSummary"> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.noIssues.noIssuesLabel" + defaultMessage="You have no {strongCheckupLabel} issues." + values={{ + strongCheckupLabel: <strong>{checkupLabel}</strong>, + }} + /> + </p> + <p> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail" + defaultMessage="Check the {overviewTabButton} for next steps." + values={{ + overviewTabButton: ( + <EuiLink onClick={() => setSelectedTabIndex(0)}> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.noIssues.nextStepsDetail.overviewTabButtonLabel" + defaultMessage="Overview tab" + /> + </EuiLink> + ), + }} + /> + </p> + </> + } + /> + )} + </EuiPageContentBody> + </EuiPageContent> + </> + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx index 318d2bc7baffe..6428edfbe904d 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warning_step.test.tsx @@ -11,6 +11,19 @@ import React from 'react'; import { ReindexWarning } from '../../../../../../../../common/types'; import { idForWarning, WarningsFlyoutStep } from './warnings_step'; +jest.mock('../../../../../../app_context', () => { + return { + useAppContext: () => { + return { + docLinks: { + DOC_LINK_VERSION: 'current', + ELASTIC_WEBSITE_URL: 'https://www.elastic.co/', + }, + }; + }, + }; +}); + describe('WarningsFlyoutStep', () => { const defaultProps = { advanceNextStep: jest.fn(), diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx index c3ef0fde6e749..9f48c77ec38e1 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/checkup/deprecations/reindex/flyout/warnings_step.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { useState } from 'react'; import { EuiButton, @@ -21,6 +21,7 @@ import { EuiText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { useAppContext } from '../../../../../../app_context'; import { ReindexWarning } from '../../../../../../../../common/types'; interface CheckedIds { @@ -37,7 +38,7 @@ const WarningCheckbox: React.FunctionComponent<{ documentationUrl: string; onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; }> = ({ checkedIds, warning, label, onChange, description, documentationUrl }) => ( - <Fragment> + <> <EuiText> <EuiCheckbox id={idForWarning(warning)} @@ -48,7 +49,7 @@ const WarningCheckbox: React.FunctionComponent<{ <p className="upgWarningsStep__warningDescription"> {description} <br /> - <EuiLink href={documentationUrl} target="_blank"> + <EuiLink href={documentationUrl} target="_blank" external> <FormattedMessage id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.documentationLinkLabel" defaultMessage="Documentation" @@ -58,7 +59,7 @@ const WarningCheckbox: React.FunctionComponent<{ </EuiText> <EuiSpacer /> - </Fragment> + </> ); interface WarningsConfirmationFlyoutProps { @@ -68,175 +69,169 @@ interface WarningsConfirmationFlyoutProps { advanceNextStep: () => void; } -interface WarningsConfirmationFlyoutState { - checkedIds: CheckedIds; -} - /** * Displays warning text about destructive changes required to reindex this index. The user * must acknowledge each change before being allowed to proceed. */ -export class WarningsFlyoutStep extends React.Component< - WarningsConfirmationFlyoutProps, - WarningsConfirmationFlyoutState -> { - constructor(props: WarningsConfirmationFlyoutProps) { - super(props); - - this.state = { - checkedIds: props.warnings.reduce((checkedIds, warning) => { - checkedIds[idForWarning(warning)] = false; - return checkedIds; - }, {} as { [id: string]: boolean }), - }; - } - - public render() { - const { warnings, closeFlyout, advanceNextStep, renderGlobalCallouts } = this.props; - const { checkedIds } = this.state; - - // Do not allow to proceed until all checkboxes are checked. - const blockAdvance = Object.values(checkedIds).filter((v) => v).length < warnings.length; - - return ( - <Fragment> - <EuiFlyoutBody> - {renderGlobalCallouts()} - <EuiCallOut - title={ +export const WarningsFlyoutStep: React.FunctionComponent<WarningsConfirmationFlyoutProps> = ({ + warnings, + renderGlobalCallouts, + closeFlyout, + advanceNextStep, +}) => { + const [checkedIds, setCheckedIds] = useState<CheckedIds>( + warnings.reduce((initialCheckedIds, warning) => { + initialCheckedIds[idForWarning(warning)] = false; + return initialCheckedIds; + }, {} as { [id: string]: boolean }) + ); + + // Do not allow to proceed until all checkboxes are checked. + const blockAdvance = Object.values(checkedIds).filter((v) => v).length < warnings.length; + + const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const optionId = e.target.id; + + setCheckedIds((prev) => ({ + ...prev, + ...{ + [optionId]: !checkedIds[optionId], + }, + })); + }; + + const { docLinks } = useAppContext(); + const { ELASTIC_WEBSITE_URL } = docLinks; + const esDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference`; + const observabilityDocBasePath = `${ELASTIC_WEBSITE_URL}guide/en/observability`; + + // TODO: Revisit warnings returned for 8.0 upgrade; many of these are likely obselete now + return ( + <> + <EuiFlyoutBody> + {renderGlobalCallouts()} + <EuiCallOut + title={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutTitle" + defaultMessage="This index requires destructive changes that can't be undone" + /> + } + color="danger" + iconType="alert" + > + <p> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutDetail" + defaultMessage="Back up your index, then proceed with the reindex by accepting each breaking change." + /> + </p> + </EuiCallOut> + + <EuiSpacer /> + + {warnings.includes(ReindexWarning.allField) && ( + <WarningCheckbox + checkedIds={checkedIds} + onChange={onChange} + warning={ReindexWarning.allField} + label={ <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutTitle" - defaultMessage="This index requires destructive changes that can't be undone" + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.allFieldWarningTitle" + defaultMessage="{allField} will be removed" + values={{ + allField: <EuiCode>_all</EuiCode>, + }} /> } - color="danger" - iconType="alert" - > - <p> + description={ <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.destructiveCallout.calloutDetail" - defaultMessage="Back up your index, then proceed with the reindex by accepting each breaking change." - /> - </p> - </EuiCallOut> - - <EuiSpacer /> - - {warnings.includes(ReindexWarning.allField) && ( - <WarningCheckbox - checkedIds={checkedIds} - onChange={this.onChange} - warning={ReindexWarning.allField} - label={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.allFieldWarningTitle" - defaultMessage="{allField} will be removed" - values={{ - allField: <EuiCode>_all</EuiCode>, - }} - /> - } - description={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.allFieldWarningDetail" - defaultMessage="The {allField} meta field is no longer supported in 7.0. Reindexing removes + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.allFieldWarningDetail" + defaultMessage="The {allField} meta field is no longer supported in 7.0. Reindexing removes the {allField} field in the new index. Ensure that no application code or scripts reply on this field." - values={{ - allField: <EuiCode>_all</EuiCode>, - }} - /> - } - documentationUrl="https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_the_literal__all_literal_meta_field_is_now_disabled_by_default" - /> - )} - - {warnings.includes(ReindexWarning.apmReindex) && ( - <WarningCheckbox - checkedIds={checkedIds} - onChange={this.onChange} - warning={ReindexWarning.apmReindex} - label={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningTitle" - defaultMessage="This index will be converted to ECS format" - /> - } - description={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningDetail" - defaultMessage="Starting in version 7.0.0, APM data will be represented in the Elastic Common Schema. + values={{ + allField: <EuiCode>_all</EuiCode>, + }} + /> + } + documentationUrl={`${esDocBasePath}/6.0/breaking_60_mappings_changes.html#_the_literal__all_literal_meta_field_is_now_disabled_by_default`} + /> + )} + + {warnings.includes(ReindexWarning.apmReindex) && ( + <WarningCheckbox + checkedIds={checkedIds} + onChange={onChange} + warning={ReindexWarning.apmReindex} + label={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningTitle" + defaultMessage="This index will be converted to ECS format" + /> + } + description={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.apmReindexWarningDetail" + defaultMessage="Starting in version 7.0.0, APM data will be represented in the Elastic Common Schema. Historical APM data will not visible until it's reindexed." - /> - } - documentationUrl="https://www.elastic.co/guide/en/apm/get-started/master/apm-release-notes.html" - /> - )} - - {warnings.includes(ReindexWarning.booleanFields) && ( - <WarningCheckbox - checkedIds={checkedIds} - onChange={this.onChange} - warning={ReindexWarning.booleanFields} - label={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.booleanFieldsWarningTitle" - defaultMessage="Boolean data in {_source} might change" - values={{ _source: <EuiCode>_source</EuiCode> }} - /> - } - description={ - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.booleanFieldsWarningDetail" - defaultMessage="If a document contain a boolean field that is neither {true} or {false} + /> + } + documentationUrl={`${observabilityDocBasePath}/master/whats-new.html`} + /> + )} + + {warnings.includes(ReindexWarning.booleanFields) && ( + <WarningCheckbox + checkedIds={checkedIds} + onChange={onChange} + warning={ReindexWarning.booleanFields} + label={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.booleanFieldsWarningTitle" + defaultMessage="Boolean data in {_source} might change" + values={{ _source: <EuiCode>_source</EuiCode> }} + /> + } + description={ + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.warningsStep.booleanFieldsWarningDetail" + defaultMessage="If a document contain a boolean field that is neither {true} or {false} (for example, {yes}, {on}, {one}), reindexing converts these fields to {true} or {false}. Ensure that no application code or scripts rely on boolean fields in the deprecated format." - values={{ - true: <EuiCode>true</EuiCode>, - false: <EuiCode>false</EuiCode>, - yes: <EuiCode>"yes"</EuiCode>, - on: <EuiCode>"on"</EuiCode>, - one: <EuiCode>1</EuiCode>, - }} - /> - } - documentationUrl="https://www.elastic.co/guide/en/elasticsearch/reference/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_field" - /> - )} - </EuiFlyoutBody> - <EuiFlyoutFooter> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left"> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel" - defaultMessage="Cancel" - /> - </EuiButtonEmpty> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton fill color="danger" onClick={advanceNextStep} disabled={blockAdvance}> - <FormattedMessage - id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.continueButtonLabel" - defaultMessage="Continue with reindex" - /> - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlyoutFooter> - </Fragment> - ); - } - - private onChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const optionId = e.target.id; - const nextCheckedIds = { - ...this.state.checkedIds, - ...{ - [optionId]: !this.state.checkedIds[optionId], - }, - }; - - this.setState({ checkedIds: nextCheckedIds }); - }; -} + values={{ + true: <EuiCode>true</EuiCode>, + false: <EuiCode>false</EuiCode>, + yes: <EuiCode>"yes"</EuiCode>, + on: <EuiCode>"on"</EuiCode>, + one: <EuiCode>1</EuiCode>, + }} + /> + } + documentationUrl={`${esDocBasePath}/6.0/breaking_60_mappings_changes.html#_coercion_of_boolean_field`} + /> + )} + </EuiFlyoutBody> + <EuiFlyoutFooter> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left"> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.cancelButtonLabel" + defaultMessage="Cancel" + /> + </EuiButtonEmpty> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton fill color="danger" onClick={advanceNextStep} disabled={blockAdvance}> + <FormattedMessage + id="xpack.upgradeAssistant.checkupTab.reindexing.flyout.checklistStep.continueButtonLabel" + defaultMessage="Continue with reindex" + /> + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlyoutFooter> + </> + ); +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/tabs/overview/steps.tsx index 85d275b080e13..1a1ea48a350c8 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 @@ -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" @@ -104,7 +101,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 @@ -237,8 +237,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" @@ -270,7 +271,7 @@ 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), ]} /> ); diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/README.md b/x-pack/plugins/vis_type_timeseries_enhanced/README.md deleted file mode 100644 index 33aa16d8574ae..0000000000000 --- a/x-pack/plugins/vis_type_timeseries_enhanced/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# vis_type_timeseries_enhanced - -The `vis_type_timeseries_enhanced` plugin is the x-pack counterpart to the OSS `vis_type_timeseries` plugin. - -It exists to provide Elastic-licensed services, or parts of services, which -enhance existing OSS functionality from `vis_type_timeseries`. - -Currently the `vis_type_timeseries_enhanced` plugin doesn't return any APIs which you can -consume directly. - diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/kibana.json b/x-pack/plugins/vis_type_timeseries_enhanced/kibana.json deleted file mode 100644 index 4b296856c3f97..0000000000000 --- a/x-pack/plugins/vis_type_timeseries_enhanced/kibana.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "id": "visTypeTimeseriesEnhanced", - "version": "8.0.0", - "kibanaVersion": "kibana", - "server": true, - "ui": false, - "requiredPlugins": [ - "visTypeTimeseries" - ] -} diff --git a/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts b/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts deleted file mode 100644 index 0598a691ab7c5..0000000000000 --- a/x-pack/plugins/vis_type_timeseries_enhanced/server/plugin.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Plugin, PluginInitializerContext, Logger, CoreSetup } from 'src/core/server'; -import { VisTypeTimeseriesSetup } from 'src/plugins/vis_type_timeseries/server'; -import { RollupSearchStrategy } from './search_strategies/rollup_search_strategy'; - -interface VisTypeTimeseriesEnhancedSetupDependencies { - visTypeTimeseries: VisTypeTimeseriesSetup; -} - -export class VisTypeTimeseriesEnhanced - implements Plugin<void, void, VisTypeTimeseriesEnhancedSetupDependencies, any> { - private logger: Logger; - - constructor(initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get('vis_type_timeseries_enhanced'); - } - - public async setup( - core: CoreSetup, - { visTypeTimeseries }: VisTypeTimeseriesEnhancedSetupDependencies - ) { - this.logger.debug('Starting plugin'); - - visTypeTimeseries.addSearchStrategy(new RollupSearchStrategy()); - } - - public start() {} -} diff --git a/x-pack/plugins/watcher/public/application/models/watch/default_watch.js b/x-pack/plugins/watcher/public/application/models/watch/default_watch.js new file mode 100644 index 0000000000000..51b28f8bd0332 --- /dev/null +++ b/x-pack/plugins/watcher/public/application/models/watch/default_watch.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const defaultWatch = { + trigger: { + schedule: { + interval: '30m', + }, + }, + input: { + search: { + request: { + body: { + size: 0, + query: { + match_all: {}, + }, + }, + indices: ['*'], + }, + }, + }, + condition: { + compare: { + 'ctx.payload.hits.total': { + gte: 10, + }, + }, + }, + actions: { + 'my-logging-action': { + logging: { + text: 'There are {{ctx.payload.hits.total}} documents in your index. Threshold is 10.', + }, + }, + }, +}; diff --git a/x-pack/plugins/watcher/public/application/models/watch/default_watch.json b/x-pack/plugins/watcher/public/application/models/watch/default_watch.json deleted file mode 100644 index 22c78660a0bb0..0000000000000 --- a/x-pack/plugins/watcher/public/application/models/watch/default_watch.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "trigger": { - "schedule": { - "interval": "30m" - } - }, - "input": { - "search": { - "request": { - "body": { - "size": 0, - "query" : { - "match_all": {} - } - }, - "indices": [ "*" ] - } - } - }, - "condition": { - "compare": { - "ctx.payload.hits.total": { - "gte": 10 - } - }}, - "actions": { - "my-logging-action": { - "logging": { - "text": "There are {{ctx.payload.hits.total}} documents in your index. Threshold is 10." - } - } - } -} diff --git a/x-pack/plugins/watcher/public/application/models/watch/index.d.ts b/x-pack/plugins/watcher/public/application/models/watch/index.d.ts index 73ee2279d3912..b9158bdd9ed70 100644 --- a/x-pack/plugins/watcher/public/application/models/watch/index.d.ts +++ b/x-pack/plugins/watcher/public/application/models/watch/index.d.ts @@ -5,3 +5,4 @@ */ export const Watch: any; +export const defaultWatch: any; diff --git a/x-pack/plugins/watcher/public/application/models/watch/index.js b/x-pack/plugins/watcher/public/application/models/watch/index.js index 9a74f6e548409..0741e5c5400df 100644 --- a/x-pack/plugins/watcher/public/application/models/watch/index.js +++ b/x-pack/plugins/watcher/public/application/models/watch/index.js @@ -5,3 +5,4 @@ */ export { Watch } from './watch'; +export { defaultWatch } from './default_watch'; diff --git a/x-pack/plugins/watcher/public/application/models/watch/json_watch.js b/x-pack/plugins/watcher/public/application/models/watch/json_watch.js index 0e67c8b18ca5e..24378d42b7451 100644 --- a/x-pack/plugins/watcher/public/application/models/watch/json_watch.js +++ b/x-pack/plugins/watcher/public/application/models/watch/json_watch.js @@ -6,11 +6,12 @@ import uuid from 'uuid'; import { get } from 'lodash'; -import { BaseWatch } from './base_watch'; -import { ACTION_TYPES, WATCH_TYPES } from '../../../../common/constants'; -import defaultWatchJson from './default_watch.json'; import { i18n } from '@kbn/i18n'; +import { ACTION_TYPES, WATCH_TYPES } from '../../../../common/constants'; +import { BaseWatch } from './base_watch'; +import { defaultWatch } from './default_watch'; + /** * {@code JsonWatch} allows a user to create a Watch by writing the raw JSON. */ @@ -20,11 +21,11 @@ export class JsonWatch extends BaseWatch { props.id = typeof props.id === 'undefined' ? uuid.v4() : props.id; super(props); const existingWatch = get(props, 'watch'); - this.watch = existingWatch ? existingWatch : defaultWatchJson; + this.watch = existingWatch ? existingWatch : defaultWatch; this.watchString = get( props, 'watchString', - JSON.stringify(existingWatch ? existingWatch : defaultWatchJson, null, 2) + JSON.stringify(existingWatch ? existingWatch : defaultWatch, null, 2) ); this.id = props.id; } @@ -113,7 +114,6 @@ export class JsonWatch extends BaseWatch { return new JsonWatch(upstreamWatch); } - static defaultWatchJson = defaultWatchJson; static typeName = i18n.translate('xpack.watcher.models.jsonWatch.typeName', { defaultMessage: 'Advanced Watch', }); diff --git a/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts b/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts index 6f243e130c235..7b4876f542292 100644 --- a/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts +++ b/x-pack/plugins/watcher/tests_client_integration/helpers/jest_constants.ts @@ -8,4 +8,4 @@ import { getWatch } from '../../__fixtures__'; export const WATCH_ID = 'my-test-watch'; -export const WATCH = { watch: getWatch({ id: WATCH_ID }) }; +export const WATCH: any = { watch: getWatch({ id: WATCH_ID }) }; diff --git a/x-pack/plugins/watcher/tests_client_integration/watch_create_json.test.ts b/x-pack/plugins/watcher/tests_client_integration/watch_create_json.test.ts index b3fbb8235f251..9bd8f8bbd7d57 100644 --- a/x-pack/plugins/watcher/tests_client_integration/watch_create_json.test.ts +++ b/x-pack/plugins/watcher/tests_client_integration/watch_create_json.test.ts @@ -5,11 +5,12 @@ */ import { act } from 'react-dom/test-utils'; + +import { getExecuteDetails } from '../__fixtures__'; +import { defaultWatch } from '../public/application/models/watch'; import { setupEnvironment, pageHelpers, nextTick, wrapBodyResponse } from './helpers'; import { WatchCreateJsonTestBed } from './helpers/watch_create_json.helpers'; import { WATCH } from './helpers/jest_constants'; -import defaultWatchJson from '../public/application/models/watch/default_watch.json'; -import { getExecuteDetails } from '../__fixtures__'; const { setup } = pageHelpers.watchCreateJson; @@ -117,7 +118,7 @@ describe('<JsonWatchEdit /> create route', () => { }, }, ], - watch: defaultWatchJson, + watch: defaultWatch, }) ); }); @@ -172,7 +173,7 @@ describe('<JsonWatchEdit /> create route', () => { const latestRequest = server.requests[server.requests.length - 1]; - const actionModes = Object.keys(defaultWatchJson.actions).reduce( + const actionModes = Object.keys(defaultWatch.actions).reduce( (actionAccum: any, action) => { actionAccum[action] = 'simulate'; return actionAccum; @@ -186,7 +187,7 @@ describe('<JsonWatchEdit /> create route', () => { isNew: true, isActive: true, actions: [], - watch: defaultWatchJson, + watch: defaultWatch, }; expect(latestRequest.requestBody).toEqual( @@ -234,7 +235,7 @@ describe('<JsonWatchEdit /> create route', () => { const latestRequest = server.requests[server.requests.length - 1]; - const actionModes = Object.keys(defaultWatchJson.actions).reduce( + const actionModes = Object.keys(defaultWatch.actions).reduce( (actionAccum: any, action) => { actionAccum[action] = ACTION_MODE; return actionAccum; @@ -248,7 +249,7 @@ describe('<JsonWatchEdit /> create route', () => { isNew: true, isActive: true, actions: [], - watch: defaultWatchJson, + watch: defaultWatch, }; const triggeredTime = `now+${TRIGGERED_TIME}s`; diff --git a/x-pack/plugins/watcher/tests_client_integration/watch_edit.test.ts b/x-pack/plugins/watcher/tests_client_integration/watch_edit.test.ts index eefe9d03c05ef..c24d939c9237e 100644 --- a/x-pack/plugins/watcher/tests_client_integration/watch_edit.test.ts +++ b/x-pack/plugins/watcher/tests_client_integration/watch_edit.test.ts @@ -7,12 +7,13 @@ import { act } from 'react-dom/test-utils'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import axios from 'axios'; +import { getRandomString } from '@kbn/test/jest'; + +import { getWatch } from '../__fixtures__'; +import { defaultWatch } from '../public/application/models/watch'; import { setupEnvironment, pageHelpers, nextTick, wrapBodyResponse } from './helpers'; import { WatchEditTestBed } from './helpers/watch_edit.helpers'; import { WATCH } from './helpers/jest_constants'; -import defaultWatchJson from '../public/application/models/watch/default_watch.json'; -import { getWatch } from '../__fixtures__'; -import { getRandomString } from '@kbn/test/jest'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); @@ -69,7 +70,7 @@ describe('<WatchEdit />', () => { expect(exists('jsonWatchForm')).toBe(true); expect(find('nameInput').props().value).toBe(watch.name); expect(find('idInput').props().value).toBe(watch.id); - expect(JSON.parse(codeEditor.props().value as string)).toEqual(defaultWatchJson); + expect(JSON.parse(codeEditor.props().value as string)).toEqual(defaultWatch); // ID should not be editable expect(find('idInput').props().readOnly).toEqual(true); @@ -112,7 +113,7 @@ describe('<WatchEdit />', () => { }, }, ], - watch: defaultWatchJson, + watch: defaultWatch, }) ); }); diff --git a/x-pack/plugins/watcher/tsconfig.json b/x-pack/plugins/watcher/tsconfig.json new file mode 100644 index 0000000000000..4680847ba486d --- /dev/null +++ b/x-pack/plugins/watcher/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "server/**/*", + "public/**/*", + "common/**/*", + "tests_client_integration/**/*", + "__fixtures__/*", + "../../typings/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + { "path": "../../../src/plugins/management/tsconfig.json" }, + { "path": "../../../src/plugins/charts/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../licensing/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + ] +} diff --git a/x-pack/test/accessibility/apps/index_lifecycle_management.ts b/x-pack/test/accessibility/apps/index_lifecycle_management.ts new file mode 100644 index 0000000000000..744a21cf381a8 --- /dev/null +++ b/x-pack/test/accessibility/apps/index_lifecycle_management.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +const TEST_POLICY_NAME = 'ilm-a11y-test'; +const TEST_POLICY_ALL_PHASES = { + policy: { + phases: { + hot: { + actions: {}, + }, + warm: { + actions: {}, + }, + cold: { + actions: {}, + }, + delete: { + actions: {}, + }, + }, + }, +}; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const { common } = getPageObjects(['common']); + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const esClient = getService('es'); + const a11y = getService('a11y'); + + const findPolicyLinkInListView = async (policyName: string) => { + const links = await testSubjects.findAll('policyTablePolicyNameLink'); + for (const link of links) { + const name = await link.getVisibleText(); + if (name === policyName) { + return link; + } + } + throw new Error(`Could not find ${policyName} in policy table`); + }; + + describe('Index Lifecycle Management', async () => { + before(async () => { + await esClient.ilm.putLifecycle({ policy: TEST_POLICY_NAME, body: TEST_POLICY_ALL_PHASES }); + await common.navigateToApp('indexLifecycleManagement'); + }); + + after(async () => { + await esClient.ilm.deleteLifecycle({ policy: TEST_POLICY_NAME }); + }); + + it('List policies view', async () => { + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + await common.navigateToApp('indexLifecycleManagement'); + return testSubjects.exists('policyTablePolicyNameLink') ? true : false; + }); + await a11y.testAppSnapshot(); + }); + + it('Edit policy with all phases view', async () => { + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + await common.navigateToApp('indexLifecycleManagement'); + return testSubjects.exists('policyTablePolicyNameLink'); + }); + const link = await findPolicyLinkInListView(TEST_POLICY_NAME); + await link.click(); + await retry.waitFor('Index Lifecycle Policy create/edit view to be present', async () => { + return testSubjects.exists('policyTitle'); + }); + await a11y.testAppSnapshot(); + }); + }); +} diff --git a/x-pack/test/accessibility/apps/ml.ts b/x-pack/test/accessibility/apps/ml.ts index 799911cd77a9f..b1fd96c4d160f 100644 --- a/x-pack/test/accessibility/apps/ml.ts +++ b/x-pack/test/accessibility/apps/ml.ts @@ -12,8 +12,7 @@ export default function ({ getService }: FtrProviderContext) { const a11y = getService('a11y'); const ml = getService('ml'); - // flaky tests, see https://github.com/elastic/kibana/issues/88592 - describe.skip('ml', () => { + describe('ml', () => { const esArchiver = getService('esArchiver'); before(async () => { @@ -239,6 +238,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('data frame analytics create job select index pattern modal', async () => { + await ml.navigation.navigateToMl(); await ml.navigation.navigateToDataFrameAnalytics(); await ml.dataFrameAnalytics.startAnalyticsCreation(); await a11y.testAppSnapshot(); @@ -261,6 +261,8 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); await ml.testExecution.logTestStep('enables the source data preview histogram charts'); await ml.dataFrameAnalyticsCreation.enableSourceDataPreviewHistogramCharts(); + await ml.testExecution.logTestStep('displays the include fields selection'); + await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); await a11y.testAppSnapshot(); }); diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index cf13a009c2821..67bfdd7a07b9d 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -27,6 +27,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/roles'), require.resolve('./apps/kibana_overview'), require.resolve('./apps/ingest_node_pipelines'), + require.resolve('./apps/index_lifecycle_management'), require.resolve('./apps/ml'), require.resolve('./apps/lens'), ], diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index cf944008c08d6..4193843c63bce 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -17,6 +17,7 @@ interface CreateTestConfigOptions { disabledPlugins?: string[]; ssl?: boolean; enableActionsProxy: boolean; + rejectUnauthorized?: boolean; } // test.not-enabled is specifically not enabled @@ -39,7 +40,12 @@ const enabledActionTypes = [ ]; export function createTestConfig(name: string, options: CreateTestConfigOptions) { - const { license = 'trial', disabledPlugins = [], ssl = false } = options; + const { + license = 'trial', + disabledPlugins = [], + ssl = false, + rejectUnauthorized = true, + } = options; return async ({ readConfigFile }: FtrConfigProviderContext) => { const xPackApiIntegrationTestsConfig = await readConfigFile( @@ -95,6 +101,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', '--xpack.alerts.invalidateApiKeysTask.interval="15s"', `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, + `--xpack.actions.rejectUnauthorized=${rejectUnauthorized}`, ...actionsProxyUrl, '--xpack.eventLog.logEntries=true', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index e7ce0638c6319..18f3c83b00141 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -5,6 +5,7 @@ */ import http from 'http'; +import https from 'https'; import { Plugin, CoreSetup, IRouter } from 'kibana/server'; import { EncryptedSavedObjectsPluginStart } from '../../../../../../../plugins/encrypted_saved_objects/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../../plugins/features/server'; @@ -47,7 +48,13 @@ export function getAllExternalServiceSimulatorPaths(): string[] { } export async function getWebhookServer(): Promise<http.Server> { - return await initWebhook(); + const { httpServer } = await initWebhook(); + return httpServer; +} + +export async function getHttpsWebhookServer(): Promise<https.Server> { + const { httpsServer } = await initWebhook(); + return httpsServer; } export async function getSlackServer(): Promise<http.Server> { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts index a34293090d7af..116f0604a37c9 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts @@ -3,16 +3,35 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import fs from 'fs'; import expect from '@kbn/expect'; import http from 'http'; +import https from 'https'; +import { promisify } from 'util'; import { fromNullable, map, filter, getOrElse } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; import { constant } from 'fp-ts/lib/function'; +import { KBN_KEY_PATH, KBN_CERT_PATH } from '@kbn/dev-utils'; export async function initPlugin() { - const payloads: string[] = []; + const httpsServerKey = await promisify(fs.readFile)(KBN_KEY_PATH, 'utf8'); + const httpsServerCert = await promisify(fs.readFile)(KBN_CERT_PATH, 'utf8'); + + return { + httpServer: http.createServer(createServerCallback()), + httpsServer: https.createServer( + { + key: httpsServerKey, + cert: httpsServerCert, + }, + createServerCallback() + ), + }; +} - return http.createServer((request, response) => { +function createServerCallback() { + const payloads: string[] = []; + return (request: http.IncomingMessage, response: http.ServerResponse) => { const credentials = pipe( fromNullable(request.headers.authorization), map((authorization) => authorization.split(/\s+/)), @@ -77,7 +96,7 @@ export async function initPlugin() { return; }); } - }); + }; } function validateAuthentication(credentials: any, res: any) { diff --git a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts index 6336d834c3943..5b093dfb28eab 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts @@ -28,6 +28,7 @@ interface GetEventLogParams { id: string; provider: string; actions: Map<string, { gte: number } | { equal: number }>; + filter?: string; } // Return event log entries given the specified parameters; for the `actions` @@ -37,7 +38,9 @@ export async function getEventLog(params: GetEventLogParams): Promise<IValidated const supertest = getService('supertest'); const spacePrefix = getUrlPrefix(spaceId); - const url = `${spacePrefix}/api/event_log/${type}/${id}/_find?per_page=5000`; + const url = `${spacePrefix}/api/event_log/${type}/${id}/_find?per_page=5000${ + params.filter ? `&filter=${params.filter}` : '' + }`; const { body: result } = await supertest.get(url).expect(200); if (!result.total) { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts index 9a3b2e7c137a4..a4f80c62cf2e9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts @@ -519,6 +519,7 @@ export default function ({ getService }: FtrProviderContext) { id: actionId, provider: 'actions', actions: new Map([['execute', { equal: 1 }]]), + filter: 'event.action:(execute)', }); }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/config.ts b/x-pack/test/alerting_api_integration/spaces_only/config.ts index f9860b642f13a..2b770395786b3 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/config.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/config.ts @@ -11,4 +11,5 @@ export default createTestConfig('spaces_only', { disabledPlugins: ['security'], license: 'trial', enableActionsProxy: false, + rejectUnauthorized: false, }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts index acfbad007d722..1748e770929d6 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/builtin_action_types/webhook.ts @@ -5,11 +5,15 @@ */ import http from 'http'; +import https from 'https'; import getPort from 'get-port'; import expect from '@kbn/expect'; import { URL, format as formatUrl } from 'url'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; -import { getWebhookServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; +import { + getWebhookServer, + getHttpsWebhookServer, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; // eslint-disable-next-line import/no-default-export export default function webhookTest({ getService }: FtrProviderContext) { @@ -43,32 +47,65 @@ export default function webhookTest({ getService }: FtrProviderContext) { } describe('webhook action', () => { - let webhookSimulatorURL: string = ''; - let webhookServer: http.Server; - before(async () => { - webhookServer = await getWebhookServer(); - const availablePort = await getPort({ port: 9000 }); - webhookServer.listen(availablePort); - webhookSimulatorURL = `http://localhost:${availablePort}`; - }); + describe('with http endpoint', () => { + let webhookSimulatorURL: string = ''; + let webhookServer: http.Server; + before(async () => { + webhookServer = await getWebhookServer(); + const availablePort = await getPort({ port: 9000 }); + webhookServer.listen(availablePort); + webhookSimulatorURL = `http://localhost:${availablePort}`; + }); + + it('webhook can be executed without username and password', async () => { + const webhookActionId = await createWebhookAction(webhookSimulatorURL); + const { body: result } = await supertest + .post(`/api/actions/action/${webhookActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'success', + }, + }) + .expect(200); - it('webhook can be executed without username and password', async () => { - const webhookActionId = await createWebhookAction(webhookSimulatorURL); - const { body: result } = await supertest - .post(`/api/actions/action/${webhookActionId}/_execute`) - .set('kbn-xsrf', 'test') - .send({ - params: { - body: 'success', - }, - }) - .expect(200); + expect(result.status).to.eql('ok'); + }); - expect(result.status).to.eql('ok'); + after(() => { + webhookServer.close(); + }); }); - after(() => { - webhookServer.close(); + describe('with https endpoint and rejectUnauthorized=false', () => { + let webhookSimulatorURL: string = ''; + let webhookServer: https.Server; + + before(async () => { + webhookServer = await getHttpsWebhookServer(); + const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) }); + webhookServer.listen(availablePort); + webhookSimulatorURL = `https://localhost:${availablePort}`; + }); + + it('should support the POST method against webhook target', async () => { + const webhookActionId = await createWebhookAction(webhookSimulatorURL, { method: 'post' }); + const { body: result } = await supertest + .post(`/api/actions/action/${webhookActionId}/_execute`) + .set('kbn-xsrf', 'test') + .send({ + params: { + body: 'success_post_method', + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + }); + + after(() => { + webhookServer.close(); + }); }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts new file mode 100644 index 0000000000000..a1ae35a29bf23 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/alert.ts @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { Spaces } from '../../../../scenarios'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { + ESTestIndexTool, + ES_TEST_INDEX_NAME, + getUrlPrefix, + ObjectRemover, +} from '../../../../../common/lib'; +import { createEsDocuments } from './create_test_data'; + +const ALERT_TYPE_ID = '.es-query'; +const ACTION_TYPE_ID = '.index'; +const ES_TEST_INDEX_SOURCE = 'builtin-alert:es-query'; +const ES_TEST_INDEX_REFERENCE = '-na-'; +const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`; + +const ALERT_INTERVALS_TO_WRITE = 5; +const ALERT_INTERVAL_SECONDS = 3; +const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; +const ES_GROUPS_TO_WRITE = 3; + +// eslint-disable-next-line import/no-default-export +export default function alertTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('legacyEs'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME); + + describe('alert', async () => { + let endDate: string; + let actionId: string; + const objectRemover = new ObjectRemover(supertest); + + beforeEach(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + + await esTestIndexToolOutput.destroy(); + await esTestIndexToolOutput.setup(); + + actionId = await createAction(supertest, objectRemover); + + // write documents in the future, figure out the end date + const endDateMillis = Date.now() + (ALERT_INTERVALS_TO_WRITE - 1) * ALERT_INTERVAL_MILLIS; + endDate = new Date(endDateMillis).toISOString(); + + // write documents from now to the future end date in groups + createEsDocumentsInGroups(ES_GROUPS_TO_WRITE); + }); + + afterEach(async () => { + await objectRemover.removeAll(); + await esTestIndexTool.destroy(); + await esTestIndexToolOutput.destroy(); + }); + + it('runs correctly: threshold on hit count < >', async () => { + await createAlert({ + name: 'never fire', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + thresholdComparator: '<', + threshold: [0], + }); + + await createAlert({ + name: 'always fire', + esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`, + thresholdComparator: '>', + threshold: [-1], + }); + + const docs = await waitForDocs(2); + for (let i = 0; i < docs.length; i++) { + const doc = docs[i]; + const { previousTimestamp, hits } = doc._source; + const { name, title, message } = doc._source.params; + + expect(name).to.be('always fire'); + expect(title).to.be(`alert 'always fire' matched query`); + const messagePattern = /alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); + expect(hits).not.to.be.empty(); + + // during the first execution, the latestTimestamp value should be empty + // since this alert always fires, the latestTimestamp value should be updated each execution + if (!i) { + expect(previousTimestamp).to.be.empty(); + } else { + expect(previousTimestamp).not.to.be.empty(); + } + } + }); + + it('runs correctly with query: threshold on hit count < >', async () => { + const rangeQuery = (rangeThreshold: number) => { + return { + query: { + bool: { + filter: [ + { + range: { + testedValue: { + gte: rangeThreshold, + }, + }, + }, + ], + }, + }, + }; + }; + + await createAlert({ + name: 'never fire', + esQuery: JSON.stringify(rangeQuery(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1)), + thresholdComparator: '>=', + threshold: [0], + }); + + await createAlert({ + name: 'fires once', + esQuery: JSON.stringify( + rangeQuery(Math.floor((ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2)) + ), + thresholdComparator: '>=', + threshold: [0], + }); + + const docs = await waitForDocs(1); + for (const doc of docs) { + const { previousTimestamp, hits } = doc._source; + const { name, title, message } = doc._source.params; + + expect(name).to.be('fires once'); + expect(title).to.be(`alert 'fires once' matched query`); + const messagePattern = /alert 'fires once' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); + expect(hits).not.to.be.empty(); + expect(previousTimestamp).to.be.empty(); + } + }); + + async function createEsDocumentsInGroups(groups: number) { + await createEsDocuments( + es, + esTestIndexTool, + endDate, + ALERT_INTERVALS_TO_WRITE, + ALERT_INTERVAL_MILLIS, + groups + ); + } + + async function waitForDocs(count: number): Promise<any[]> { + return await esTestIndexToolOutput.waitForDocs( + ES_TEST_INDEX_SOURCE, + ES_TEST_INDEX_REFERENCE, + count + ); + } + + interface CreateAlertParams { + name: string; + timeField?: string; + esQuery: string; + thresholdComparator: string; + threshold: number[]; + timeWindowSize?: number; + } + + async function createAlert(params: CreateAlertParams): Promise<string> { + const action = { + id: actionId, + group: 'query matched', + params: { + documents: [ + { + source: ES_TEST_INDEX_SOURCE, + reference: ES_TEST_INDEX_REFERENCE, + params: { + name: '{{{alertName}}}', + value: '{{{context.value}}}', + title: '{{{context.title}}}', + message: '{{{context.message}}}', + }, + hits: '{{context.hits}}', + date: '{{{context.date}}}', + previousTimestamp: '{{{state.latestTimestamp}}}', + }, + ], + }, + }; + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send({ + name: params.name, + consumer: 'alerts', + enabled: true, + alertTypeId: ALERT_TYPE_ID, + schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` }, + actions: [action], + params: { + index: [ES_TEST_INDEX_NAME], + timeField: params.timeField || 'date', + esQuery: params.esQuery, + timeWindowSize: params.timeWindowSize || ALERT_INTERVAL_SECONDS * 5, + timeWindowUnit: 's', + thresholdComparator: params.thresholdComparator, + threshold: params.threshold, + }, + }) + .expect(200); + + const alertId = createdAlert.id; + objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts'); + + return alertId; + } + }); +} + +async function createAction(supertest: any, objectRemover: ObjectRemover): Promise<string> { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'index action for es query FT', + actionTypeId: ACTION_TYPE_ID, + config: { + index: ES_TEST_OUTPUT_INDEX_NAME, + }, + secrets: {}, + }) + .expect(200); + + const actionId = createdAction.id; + objectRemover.add(Spaces.space1.id, actionId, 'action', 'actions'); + + return actionId; +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts new file mode 100644 index 0000000000000..7299827a72253 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/create_test_data.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { times } from 'lodash'; +import { v4 as uuid } from 'uuid'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib'; + +// default end date +export const END_DATE = '2020-01-01T00:00:00Z'; + +export const DOCUMENT_SOURCE = 'queryDataEndpointTests'; +export const DOCUMENT_REFERENCE = '-na-'; + +export async function createEsDocuments( + es: any, + esTestIndexTool: ESTestIndexTool, + endDate: string = END_DATE, + intervals: number = 1, + intervalMillis: number = 1000, + groups: number = 2 +) { + const endDateMillis = Date.parse(endDate) - intervalMillis / 2; + + let testedValue = 0; + times(intervals, (interval) => { + const date = endDateMillis - interval * intervalMillis; + + // don't need await on these, wait at the end of the function + times(groups, () => { + createEsDocument(es, date, testedValue++); + }); + }); + + const totalDocuments = intervals * groups; + await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, totalDocuments); +} + +async function createEsDocument(es: any, epochMillis: number, testedValue: number) { + const document = { + source: DOCUMENT_SOURCE, + reference: DOCUMENT_REFERENCE, + date: new Date(epochMillis).toISOString(), + date_epoch_millis: epochMillis, + testedValue, + }; + + const response = await es.index({ + id: uuid(), + index: ES_TEST_INDEX_NAME, + body: document, + }); + + if (response.result !== 'created') { + throw new Error(`document not created: ${JSON.stringify(response)}`); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts new file mode 100644 index 0000000000000..574f35e123fe8 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/es_query/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function alertingTests({ loadTestFile }: FtrProviderContext) { + describe('es_query', () => { + loadTestFile(require.resolve('./alert')); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts index c0147cbedcdfe..f59ef6829f892 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts @@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context'; export default function alertingTests({ loadTestFile }: FtrProviderContext) { describe('builtin alertTypes', () => { loadTestFile(require.resolve('./index_threshold')); + loadTestFile(require.resolve('./es_query')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index d3e1370bef285..5ff7b0d45a019 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -84,6 +84,23 @@ export default function eventLogTests({ getService }: FtrProviderContext) { }); }); + // get the filtered events only with action 'new-instance' + const filteredEvents = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: alertId, + provider: 'alerting', + actions: new Map([['new-instance', { equal: 1 }]]), + filter: 'event.action:(new-instance)', + }); + }); + + expect(getEventsByAction(filteredEvents, 'execute').length).equal(0); + expect(getEventsByAction(filteredEvents, 'execute-action').length).equal(0); + expect(getEventsByAction(events, 'new-instance').length).equal(1); + const executeEvents = getEventsByAction(events, 'execute'); const executeActionEvents = getEventsByAction(events, 'execute-action'); const newInstanceEvents = getEventsByAction(events, 'new-instance'); diff --git a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts b/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts index 2d148f4c2c0f7..79d5e68344432 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/log_entries.ts @@ -13,10 +13,13 @@ import { LOG_ENTRIES_PATH, logEntriesRequestRT, logEntriesResponseRT, +} from '../../../../plugins/infra/common/http_api'; + +import { LogTimestampColumn, LogFieldColumn, LogMessageColumn, -} from '../../../../plugins/infra/common/http_api'; +} from '../../../../plugins/infra/common/log_entry'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/api_integration/apis/metrics_ui/metrics.ts b/x-pack/test/api_integration/apis/metrics_ui/metrics.ts index 23b0a96ecd401..b9cbc58bbd6f7 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metrics.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metrics.ts @@ -93,5 +93,30 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.metrics.length).to.equal(2); }); }); + + it('should return multiple values for hostSystemOverview metric', () => { + const data = fetchNodeDetails({ + sourceId: 'default', + metrics: ['hostSystemOverview'], + timerange: { + to: max, + from: min, + interval: '>=1m', + }, + nodeId: 'demo-stack-mysql-01', + nodeType: 'host' as InfraNodeType, + }); + return data.then((resp) => { + if (!resp) { + return; + } + + const hostSystemOverviewMetric = resp.metrics.find( + (metric) => metric.id === 'hostSystemOverview' + ); + + expect(hostSystemOverviewMetric?.series.length).to.be.greaterThan(1); + }); + }); }); } diff --git a/x-pack/test/apm_api_integration/common/registry.ts b/x-pack/test/apm_api_integration/common/registry.ts index 8c918eae5a5a8..ba43db2fd5835 100644 --- a/x-pack/test/apm_api_integration/common/registry.ts +++ b/x-pack/test/apm_api_integration/common/registry.ts @@ -36,55 +36,69 @@ let configName: APMFtrConfigName | undefined; let running: boolean = false; -export const registry = { - init: (config: APMFtrConfigName) => { - configName = config; - callbacks.length = 0; - running = false; - }, - when: ( - title: string, - conditions: RunCondition | RunCondition[], - callback: (condition: RunCondition) => void - ) => { - const allConditions = castArray(conditions); - - if (!allConditions.length) { - throw new Error('At least one condition should be defined'); - } +function when( + title: string, + conditions: RunCondition | RunCondition[], + callback: (condition: RunCondition) => void, + skip?: boolean +) { + const allConditions = castArray(conditions); + + if (!allConditions.length) { + throw new Error('At least one condition should be defined'); + } - if (running) { - throw new Error("Can't add tests when running"); - } + if (running) { + throw new Error("Can't add tests when running"); + } - const frame = maybe(callsites()[1]); + const frame = maybe(callsites()[1]); - const file = frame?.getFileName(); + const file = frame?.getFileName(); - if (!file) { - throw new Error('Could not infer file for suite'); - } + if (!file) { + throw new Error('Could not infer file for suite'); + } - allConditions.forEach((matchedCondition) => { - callbacks.push({ - ...matchedCondition, - runs: [ - { - cb: () => { - const suite = describe(title, () => { + allConditions.forEach((matchedCondition) => { + callbacks.push({ + ...matchedCondition, + runs: [ + { + cb: () => { + const suite: ReturnType<typeof describe> = (skip ? describe.skip : describe)( + title, + () => { callback(matchedCondition); - }); + } + ) as any; - suite.file = file; - suite.eachTest((test) => { - test.file = file; - }); - }, + suite.file = file; + suite.eachTest((test) => { + test.file = file; + }); }, - ], - }); + }, + ], }); + }); +} + +when.skip = ( + title: string, + conditions: RunCondition | RunCondition[], + callback: (condition: RunCondition) => void +) => { + when(title, conditions, callback, true); +}; + +export const registry = { + init: (config: APMFtrConfigName) => { + configName = config; + callbacks.length = 0; + running = false; }, + when, run: (context: FtrProviderContext) => { if (!configName) { throw new Error(`registry was not init() before running`); diff --git a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap index f23601fccb174..eee0ec7f9ad38 100644 --- a/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap +++ b/x-pack/test/apm_api_integration/tests/services/__snapshots__/throughput.snap @@ -8,7 +8,7 @@ Array [ }, Object { "x": 1607435880000, - "y": 4, + "y": 0.133333333333333, }, Object { "x": 1607435910000, @@ -16,7 +16,7 @@ Array [ }, Object { "x": 1607435940000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607435970000, @@ -24,11 +24,11 @@ Array [ }, Object { "x": 1607436000000, - "y": 3, + "y": 0.1, }, Object { "x": 1607436030000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436060000, @@ -40,7 +40,7 @@ Array [ }, Object { "x": 1607436120000, - "y": 4, + "y": 0.133333333333333, }, Object { "x": 1607436150000, @@ -56,7 +56,7 @@ Array [ }, Object { "x": 1607436240000, - "y": 6, + "y": 0.2, }, Object { "x": 1607436270000, @@ -68,15 +68,15 @@ Array [ }, Object { "x": 1607436330000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436360000, - "y": 5, + "y": 0.166666666666667, }, Object { "x": 1607436390000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436420000, @@ -88,11 +88,11 @@ Array [ }, Object { "x": 1607436480000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436510000, - "y": 5, + "y": 0.166666666666667, }, Object { "x": 1607436540000, @@ -104,11 +104,11 @@ Array [ }, Object { "x": 1607436600000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436630000, - "y": 7, + "y": 0.233333333333333, }, Object { "x": 1607436660000, @@ -124,7 +124,7 @@ Array [ }, Object { "x": 1607436750000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436780000, @@ -132,15 +132,15 @@ Array [ }, Object { "x": 1607436810000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436840000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607436870000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436900000, @@ -152,11 +152,11 @@ Array [ }, Object { "x": 1607436960000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607436990000, - "y": 4, + "y": 0.133333333333333, }, Object { "x": 1607437020000, @@ -168,11 +168,11 @@ Array [ }, Object { "x": 1607437080000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437110000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437140000, @@ -184,15 +184,15 @@ Array [ }, Object { "x": 1607437200000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607437230000, - "y": 7, + "y": 0.233333333333333, }, Object { "x": 1607437260000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437290000, @@ -200,11 +200,11 @@ Array [ }, Object { "x": 1607437320000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437350000, - "y": 2, + "y": 0.0666666666666667, }, Object { "x": 1607437380000, @@ -216,11 +216,11 @@ Array [ }, Object { "x": 1607437440000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437470000, - "y": 3, + "y": 0.1, }, Object { "x": 1607437500000, @@ -232,7 +232,7 @@ Array [ }, Object { "x": 1607437560000, - "y": 1, + "y": 0.0333333333333333, }, Object { "x": 1607437590000, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index c3c7ecd0aba81..00a20abe367ae 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -265,7 +265,7 @@ export default ({ getService }: FtrProviderContext) => { }; const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 10, [id]); const signalsOpen = await getSignalsByRuleIds(supertest, ['eql-rule']); const sequenceSignal = signalsOpen.hits.hits.find( (signal) => signal._source.signal.depth === 2 diff --git a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts index 112a855c61201..b5f55180419ef 100644 --- a/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts +++ b/x-pack/test/functional/apps/dashboard/feature_controls/dashboard_security.ts @@ -29,7 +29,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const queryBar = getService('queryBar'); const savedQueryManagementComponent = getService('savedQueryManagementComponent'); - describe('dashboard feature controls security', () => { + // FLAKY: https://github.com/elastic/kibana/issues/86950 + describe.skip('dashboard feature controls security', () => { before(async () => { await esArchiver.load('dashboard/feature_controls/security'); await esArchiver.loadIfNeeded('logstash_functional'); diff --git a/x-pack/test/functional/apps/lens/index.ts b/x-pack/test/functional/apps/lens/index.ts index 642526d74b687..db8ede58ca9d4 100644 --- a/x-pack/test/functional/apps/lens/index.ts +++ b/x-pack/test/functional/apps/lens/index.ts @@ -34,6 +34,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./chart_data')); loadTestFile(require.resolve('./drag_and_drop')); loadTestFile(require.resolve('./lens_reporting')); + loadTestFile(require.resolve('./lens_tagging')); // has to be last one in the suite because it overrides saved objects loadTestFile(require.resolve('./rollup')); diff --git a/x-pack/test/functional/apps/lens/lens_tagging.ts b/x-pack/test/functional/apps/lens/lens_tagging.ts new file mode 100644 index 0000000000000..970eaa89548d2 --- /dev/null +++ b/x-pack/test/functional/apps/lens/lens_tagging.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const listingTable = getService('listingTable'); + const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); + const retry = getService('retry'); + const find = getService('find'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const PageObjects = getPageObjects([ + 'common', + 'tagManagement', + 'header', + 'dashboard', + 'visualize', + 'lens', + ]); + + const lensTag = 'extreme-lens-tag'; + const lensTitle = 'lens tag test'; + + describe('lens tagging', () => { + before(async () => { + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + it('adds a new tag to a Lens visualization', async () => { + // create lens + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await PageObjects.visualize.clickLensWidget(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_xDimensionPanel > lns-empty-dimension', + operation: 'date_histogram', + field: '@timestamp', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_splitDimensionPanel > lns-empty-dimension', + operation: 'terms', + field: 'ip', + }); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.click('lnsApp_saveButton'); + + await PageObjects.visualize.setSaveModalValues(lensTitle, { + saveAsNew: false, + redirectToOrigin: true, + }); + await testSubjects.click('savedObjectTagSelector'); + await testSubjects.click(`tagSelectorOption-action__create`); + + expect(await PageObjects.tagManagement.tagModal.isOpened()).to.be(true); + + await PageObjects.tagManagement.tagModal.fillForm( + { + name: lensTag, + color: '#FFCC33', + description: '', + }, + { + submit: true, + } + ); + + expect(await PageObjects.tagManagement.tagModal.isOpened()).to.be(false); + await testSubjects.click('confirmSaveSavedObjectButton'); + await retry.waitForWithTimeout('Save modal to disappear', 1000, () => + testSubjects + .missingOrFail('confirmSaveSavedObjectButton') + .then(() => true) + .catch(() => false) + ); + }); + + it('retains its saved object tags after save and return', async () => { + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.saveAndReturn(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.waitUntilTableIsLoaded(); + + // open the filter dropdown + const filterButton = await find.byCssSelector('.euiFilterGroup .euiFilterButton'); + await filterButton.click(); + await testSubjects.click( + `tag-searchbar-option-${PageObjects.tagManagement.testSubjFriendly(lensTag)}` + ); + // click elsewhere to close the filter dropdown + const searchFilter = await find.byCssSelector('main .euiFieldSearch'); + await searchFilter.click(); + // wait until the table refreshes + await listingTable.waitUntilTableIsLoaded(); + const itemNames = await listingTable.getAllItemsNames(); + expect(itemNames).to.contain(lensTitle); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index f2d91c2ae577f..badcadedd7138 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -514,6 +514,28 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ); }); + it('should transition from unique count to last value', async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-empty-dimension', + operation: 'cardinality', + field: 'ip', + }); + await PageObjects.lens.configureDimension({ + dimension: 'lnsXY_yDimensionPanel > lns-dimensionTrigger', + operation: 'last_value', + field: 'bytes', + isPreviousIncompatible: true, + }); + + expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_yDimensionPanel')).to.eql( + 'Last value of bytes' + ); + }); + it('should allow to change index pattern', async () => { await PageObjects.lens.switchFirstLayerIndexPattern('log*'); expect(await PageObjects.lens.getFirstLayerIndexPattern()).to.equal('log*'); @@ -539,5 +561,40 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); expect(await testSubjects.isEnabled('lnsApp_downloadCSVButton')).to.eql(true); }); + + it('should able to sort a table by a column', async () => { + await PageObjects.visualize.gotoVisualizationLandingPage(); + await listingTable.searchForItemWithName('lnsXYvis'); + await PageObjects.lens.clickVisualizeListItemTitle('lnsXYvis'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + // Sort by number + await PageObjects.lens.changeTableSortingBy(2, 'asc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 2)).to.eql('17,246'); + // Now sort by IP + await PageObjects.lens.changeTableSortingBy(0, 'asc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('78.83.247.30'); + // Change the sorting + await PageObjects.lens.changeTableSortingBy(0, 'desc'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('169.228.188.120'); + // Remove the sorting + await PageObjects.lens.changeTableSortingBy(0, 'none'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect(await PageObjects.lens.isDatatableHeaderSorted(0)).to.eql(false); + }); + + it('should able to use filters cell actions in table', async () => { + const firstCellContent = await PageObjects.lens.getDatatableCellText(0, 0); + await PageObjects.lens.clickTableCellAction(0, 0, 'lensDatatableFilterOut'); + await PageObjects.header.waitUntilLoadingHasFinished(); + expect( + await find.existsByCssSelector( + `[data-test-subj*="filter-value-${firstCellContent}"][data-test-subj*="filter-negated"]` + ) + ).to.eql(true); + }); }); } diff --git a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts index 531eba54f931d..10926a831d36b 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/file_data_visualizer.ts @@ -112,6 +112,47 @@ export default function ({ getService }: FtrProviderContext) { fieldNameFiltersResultCount: 1, }, }, + { + suiteSuffix: 'with a file containing geo field', + filePath: path.join(__dirname, 'files_to_import', 'geo_file.csv'), + indexName: 'user-import_2', + createIndexPattern: false, + fieldTypeFilters: [ML_JOB_FIELD_TYPES.GEO_POINT], + fieldNameFilters: ['Coordinates'], + expected: { + results: { + title: 'geo_file.csv', + numberOfFields: 3, + }, + metricFields: [], + nonMetricFields: [ + { + fieldName: 'Context', + type: ML_JOB_FIELD_TYPES.UNKNOWN, + docCountFormatted: '0 (0%)', + exampleCount: 0, + }, + { + fieldName: 'Coordinates', + type: ML_JOB_FIELD_TYPES.GEO_POINT, + docCountFormatted: '13 (100%)', + exampleCount: 7, + }, + { + fieldName: 'Location', + type: ML_JOB_FIELD_TYPES.KEYWORD, + docCountFormatted: '13 (100%)', + exampleCount: 7, + }, + ], + visibleMetricFieldsCount: 0, + totalMetricFieldsCount: 0, + populatedFieldsCount: 3, + totalFieldsCount: 3, + fieldTypeFiltersResultCount: 1, + fieldNameFiltersResultCount: 1, + }, + }, ]; const testDataListNegative = [ diff --git a/x-pack/test/functional/apps/ml/data_visualizer/files_to_import/geo_file.csv b/x-pack/test/functional/apps/ml/data_visualizer/files_to_import/geo_file.csv new file mode 100644 index 0000000000000..df7417f474d83 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/files_to_import/geo_file.csv @@ -0,0 +1,14 @@ +Coordinates,Location,Context +POINT (-2.516919 51.423683),On or near A4175, +POINT (-2.515072 51.419357),On or near Stockwood Hill, +POINT (-2.509126 51.416137),On or near St Francis Road, +POINT (-2.509384 51.40959),On or near Barnard Walk, +POINT (-2.509126 51.416137),On or near St Francis Road, +POINT (-2.516919 51.423683),On or near A4175, +POINT (-2.511571 51.414895),On or near Orchard Close, +POINT (-2.534338 51.417697),On or near Scotland Lane, +POINT (-2.509384 51.40959),On or near Barnard Walk, +POINT (-2.495055 51.422132),On or near Cross Street, +POINT (-2.509384 51.40959),On or near Barnard Walk, +POINT (-2.495055 51.422132),On or near Cross Street, +POINT (-2.509126 51.416137),On or near St Francis Road, \ No newline at end of file diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts index 0833f84960ea6..01d7ca6af4cc3 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts @@ -24,6 +24,11 @@ interface TestData { sourceIndexOrSavedSearch: string; fieldNameFilters: string[]; fieldTypeFilters: string[]; + rowsPerPage?: 10 | 25 | 50; + sampleSizeValidations: Array<{ + size: number; + expected: { field: string; docCountFormatted: string }; + }>; expected: { totalDocCountFormatted: string; metricFields?: MetricFieldVisConfig[]; @@ -47,6 +52,10 @@ export default function ({ getService }: FtrProviderContext) { sourceIndexOrSavedSearch: 'ft_farequote', fieldNameFilters: ['airline', '@timestamp'], fieldTypeFilters: [ML_JOB_FIELD_TYPES.KEYWORD], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], expected: { totalDocCountFormatted: '86,274', metricFields: [ @@ -132,6 +141,10 @@ export default function ({ getService }: FtrProviderContext) { sourceIndexOrSavedSearch: 'ft_farequote_kuery', fieldNameFilters: ['@version'], fieldTypeFilters: [ML_JOB_FIELD_TYPES.DATE, ML_JOB_FIELD_TYPES.TEXT], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], expected: { totalDocCountFormatted: '34,415', metricFields: [ @@ -217,6 +230,10 @@ export default function ({ getService }: FtrProviderContext) { sourceIndexOrSavedSearch: 'ft_farequote_lucene', fieldNameFilters: ['@version.keyword', 'type'], fieldTypeFilters: [ML_JOB_FIELD_TYPES.NUMBER], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], expected: { totalDocCountFormatted: '34,416', metricFields: [ @@ -297,6 +314,41 @@ export default function ({ getService }: FtrProviderContext) { }, }; + const sampleLogTestData: TestData = { + suiteTitle: 'geo point field', + sourceIndexOrSavedSearch: 'ft_module_sample_logs', + fieldNameFilters: ['geo.coordinates'], + fieldTypeFilters: [ML_JOB_FIELD_TYPES.GEO_POINT], + rowsPerPage: 50, + expected: { + totalDocCountFormatted: '408', + metricFields: [], + // only testing the geo_point fields + nonMetricFields: [ + { + fieldName: 'geo.coordinates', + type: ML_JOB_FIELD_TYPES.GEO_POINT, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '408 (100%)', + exampleCount: 10, + }, + ], + emptyFields: [], + visibleMetricFieldsCount: 4, + totalMetricFieldsCount: 5, + populatedFieldsCount: 35, + totalFieldsCount: 36, + fieldNameFiltersResultCount: 1, + fieldTypeFiltersResultCount: 1, + }, + sampleSizeValidations: [ + { size: 1000, expected: { field: 'geo.coordinates', docCountFormatted: '408 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '408 (100%)' } }, + ], + }; + function runTests(testData: TestData) { it(`${testData.suiteTitle} loads the source data in the data visualizer`, async () => { await ml.testExecution.logTestStep( @@ -332,6 +384,10 @@ export default function ({ getService }: FtrProviderContext) { ); await ml.dataVisualizerIndexBased.assertDataVisualizerTableExist(); + if (testData.rowsPerPage) { + await ml.dataVisualizerTable.ensureNumRowsPerPage(testData.rowsPerPage); + } + await ml.dataVisualizerTable.assertSearchPanelExist(); await ml.dataVisualizerTable.assertSampleSizeInputExists(); await ml.dataVisualizerTable.assertFieldTypeInputExists(); @@ -376,8 +432,14 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep( `${testData.suiteTitle} sample size control changes non-metric fields` ); - await ml.dataVisualizerTable.setSampleSizeInputValue(1000, 'airline', '1000 (100%)'); - await ml.dataVisualizerTable.setSampleSizeInputValue(5000, '@timestamp', '5000 (100%)'); + for (const sampleSizeCase of testData.sampleSizeValidations) { + const { size, expected } = sampleSizeCase; + await ml.dataVisualizerTable.setSampleSizeInputValue( + size, + expected.field, + expected.docCountFormatted + ); + } await ml.testExecution.logTestStep('sets and resets field type filter correctly'); await ml.dataVisualizerTable.setFieldTypeFilter( @@ -411,7 +473,10 @@ export default function ({ getService }: FtrProviderContext) { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('ml/farequote'); + await esArchiver.loadIfNeeded('ml/module_sample_logs'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.createIndexPatternIfNeeded('ft_module_sample_logs', '@timestamp'); await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded(); await ml.testResources.createSavedSearchFarequoteKueryIfNeeded(); await ml.testResources.setKibanaTimeZoneToUTC(); @@ -447,5 +512,15 @@ export default function ({ getService }: FtrProviderContext) { runTests(farequoteLuceneSearchTestData); }); + + describe('with module_sample_logs ', function () { + // Run tests on full farequote index. + it(`${sampleLogTestData.suiteTitle} loads the data visualizer selector page`, async () => { + // Start navigation from the base of the ML app. + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataVisualizer(); + }); + runTests(sampleLogTestData); + }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index 31a4d6e29fc35..dabead6ffbdad 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/services/ml/data_visualizer_table.ts b/x-pack/test/functional/services/ml/data_visualizer_table.ts index ad4625ed4dcb4..4772b3c894471 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_table.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_table.ts @@ -288,6 +288,16 @@ export function MachineLearningDataVisualizerTableProvider( await this.ensureDetailsClosed(fieldName); } + public async assertExamplesList(fieldName: string, expectedExamplesCount: number) { + const examplesList = await testSubjects.find( + this.detailsSelector(fieldName, 'mlFieldDataExamplesList') + ); + const examplesListItems = await examplesList.findAllByTagName('li'); + expect(examplesListItems).to.have.length( + expectedExamplesCount, + `Expected example list item count for field '${fieldName}' to be '${expectedExamplesCount}' (got '${examplesListItems.length}')` + ); + } public async assertTextFieldContents( fieldName: string, docCountFormatted: string, @@ -297,14 +307,33 @@ export function MachineLearningDataVisualizerTableProvider( await this.assertFieldDocCount(fieldName, docCountFormatted); await this.ensureDetailsOpen(fieldName); - const examplesList = await testSubjects.find( - this.detailsSelector(fieldName, 'mlFieldDataExamplesList') - ); - const examplesListItems = await examplesList.findAllByTagName('li'); - expect(examplesListItems).to.have.length( - expectedExamplesCount, - `Expected example list item count for field '${fieldName}' to be '${expectedExamplesCount}' (got '${examplesListItems.length}')` - ); + await this.assertExamplesList(fieldName, expectedExamplesCount); + await this.ensureDetailsClosed(fieldName); + } + + public async assertGeoPointFieldContents( + fieldName: string, + docCountFormatted: string, + expectedExamplesCount: number + ) { + await this.assertRowExists(fieldName); + await this.assertFieldDocCount(fieldName, docCountFormatted); + await this.ensureDetailsOpen(fieldName); + + await this.assertExamplesList(fieldName, expectedExamplesCount); + + await testSubjects.existOrFail(this.detailsSelector(fieldName, 'mlEmbeddedMapContent')); + + await this.ensureDetailsClosed(fieldName); + } + + public async assertUnknownFieldContents(fieldName: string, docCountFormatted: string) { + await this.assertRowExists(fieldName); + await this.assertFieldDocCount(fieldName, docCountFormatted); + await this.ensureDetailsOpen(fieldName); + + await testSubjects.existOrFail(this.detailsSelector(fieldName, 'mlDVDocumentStatsContent')); + await this.ensureDetailsClosed(fieldName); } @@ -321,10 +350,14 @@ export function MachineLearningDataVisualizerTableProvider( await this.assertKeywordFieldContents(fieldName, docCountFormatted, exampleCount); } else if (fieldType === ML_JOB_FIELD_TYPES.TEXT) { await this.assertTextFieldContents(fieldName, docCountFormatted, exampleCount); + } else if (fieldType === ML_JOB_FIELD_TYPES.GEO_POINT) { + await this.assertGeoPointFieldContents(fieldName, docCountFormatted, exampleCount); + } else if (fieldType === ML_JOB_FIELD_TYPES.UNKNOWN) { + await this.assertUnknownFieldContents(fieldName, docCountFormatted); } } - public async ensureNumRowsPerPage(n: 10 | 25 | 100) { + public async ensureNumRowsPerPage(n: 10 | 25 | 50) { const paginationButton = 'mlDataVisualizerTable > tablePaginationPopoverButton'; await retry.tryForTime(10000, async () => { await testSubjects.existOrFail(paginationButton); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 352652d9601dc..52e9422da2da4 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -29,10 +29,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); } - async function defineAlert(alertName: string) { + async function defineAlert(alertName: string, alertType?: string) { + alertType = alertType || '.index-threshold'; await pageObjects.triggersActionsUI.clickCreateAlertButton(); await testSubjects.setValue('alertNameInput', alertName); - await testSubjects.click('.index-threshold-SelectOption'); + await testSubjects.click(`${alertType}-SelectOption`); await testSubjects.click('selectIndexExpression'); const comboBox = await find.byCssSelector('#indexSelectSearchBox'); await comboBox.click(); @@ -217,5 +218,26 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('confirmAlertCloseModal > confirmModalCancelButton'); await testSubjects.missingOrFail('confirmAlertCloseModal'); }); + + it('should successfully test valid es_query alert', async () => { + const alertName = generateUniqueKey(); + await defineAlert(alertName, '.es-query'); + + // Valid query + await testSubjects.setValue('queryJsonEditor', '{"query":{"match_all":{}}}', { + clearWithKeyboard: true, + }); + await testSubjects.click('testQuery'); + await testSubjects.existOrFail('testQuerySuccess'); + await testSubjects.missingOrFail('testQueryError'); + + // Invalid query + await testSubjects.setValue('queryJsonEditor', '{"query":{"foo":{}}}', { + clearWithKeyboard: true, + }); + await testSubjects.click('testQuery'); + await testSubjects.missingOrFail('testQuerySuccess'); + await testSubjects.existOrFail('testQueryError'); + }); }); }; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index f53c1c589daab..ce1b58433362b 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -255,11 +255,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { events: { file: false, network: true, process: true }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, }, windows: { @@ -274,11 +279,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, antivirus_registration: { enabled: false, @@ -399,11 +409,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { events: { file: true, network: true, process: true }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, }, windows: { @@ -418,11 +433,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, antivirus_registration: { enabled: false, @@ -536,11 +556,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { events: { file: true, network: true, process: true }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, }, windows: { @@ -555,11 +580,16 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, logging: { file: 'info' }, malware: { mode: 'prevent' }, + ransomware: { mode: 'prevent' }, popup: { malware: { enabled: true, message: 'Elastic Security {action} {filename}', }, + ransomware: { + enabled: true, + message: 'Elastic Security {action} {filename}', + }, }, antivirus_registration: { enabled: false, diff --git a/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts b/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts index 5f54ab2539c5d..82e47896ce411 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint_policy.ts @@ -16,7 +16,7 @@ import { GetFullAgentPolicyResponse, GetPackagesResponse, } from '../../../plugins/fleet/common'; -import { factory as policyConfigFactory } from '../../../plugins/security_solution/common/endpoint/models/policy_config'; +import { policyFactory } from '../../../plugins/security_solution/common/endpoint/models/policy_config'; import { Immutable } from '../../../plugins/security_solution/common/endpoint/types'; // NOTE: import path below should be the deep path to the actual module - else we get CI errors @@ -178,7 +178,7 @@ export function EndpointPolicyTestResourcesProvider({ getService }: FtrProviderC streams: [], config: { policy: { - value: policyConfigFactory(), + value: policyFactory(), }, }, }, diff --git a/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts b/x-pack/test/send_search_to_background_integration/tests/apps/dashboard/async_search/send_to_background.ts index 03635efb6113d..7e878e763bfc1 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/discover/async_search.ts b/x-pack/test/send_search_to_background_integration/tests/apps/discover/async_search.ts index d64df98c98601..b5e65158c573a 100644 --- a/x-pack/test/send_search_to_background_integration/tests/apps/discover/async_search.ts +++ b/x-pack/test/send_search_to_background_integration/tests/apps/discover/async_search.ts @@ -13,6 +13,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const browser = getService('browser'); const inspector = getService('inspector'); const PageObjects = getPageObjects(['discover', 'common', 'timePicker', 'header']); + const searchSessions = getService('searchSessions'); describe('discover async search', () => { before(async () => { @@ -31,18 +32,33 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(searchSessionId2).not.to.be(searchSessionId1); }); - it('search session id should be picked up from the URL, non existing session id errors out', async () => { - const url = await browser.getCurrentUrl(); + it('search session id should be picked up from the URL, non existing session id errors out, back button restores a session', async () => { + let url = await browser.getCurrentUrl(); const fakeSearchSessionId = '__test__'; const savedSessionURL = url + `&searchSessionId=${fakeSearchSessionId}`; await browser.navigateTo(savedSessionURL); await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('restored'); await testSubjects.existOrFail('discoverNoResultsError'); // expect error because of fake searchSessionId const searchSessionId1 = await getSearchSessionId(); expect(searchSessionId1).to.be(fakeSearchSessionId); await queryBar.clickQuerySubmitButton(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('completed'); const searchSessionId2 = await getSearchSessionId(); expect(searchSessionId2).not.to.be(searchSessionId1); + + // back button should restore the session: + url = await browser.getCurrentUrl(); + expect(url).not.to.contain('searchSessionId'); + + await browser.goBack(); + + url = await browser.getCurrentUrl(); + expect(url).to.contain('searchSessionId'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await searchSessions.expectState('restored'); + expect(await getSearchSessionId()).to.be(fakeSearchSessionId); }); }); diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 6368751fedf75..cc36a2c93b1a0 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -58,6 +58,8 @@ { "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/watcher/tsconfig.json" } ] } diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index a6eb098b5d678..956bd409f979d 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -19,6 +19,9 @@ "plugins/event_log/**/*", "plugins/licensing/**/*", "plugins/lens/**/*", + "plugins/maps/**/*", + "plugins/maps_file_upload/**/*", + "plugins/maps_legacy_licensing/**/*", "plugins/searchprofiler/**/*", "plugins/security_solution/cypress/**/*", "plugins/task_manager/**/*", @@ -26,7 +29,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 +37,8 @@ "plugins/cloud/**/*", "plugins/saved_objects_tagging/**/*", "plugins/global_search_bar/**/*", + "plugins/license_management/**/*", + "plugins/watcher/**/*", "test/**/*" ], "compilerOptions": { @@ -86,11 +90,13 @@ { "path": "./plugins/event_log/tsconfig.json" }, { "path": "./plugins/licensing/tsconfig.json" }, { "path": "./plugins/lens/tsconfig.json" }, + { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/maps_file_upload/tsconfig.json" }, + { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, { "path": "./plugins/searchprofiler/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" }, @@ -102,6 +108,8 @@ { "path": "./plugins/actions/tsconfig.json"}, { "path": "./plugins/alerts/tsconfig.json"}, { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, - { "path": "./plugins/stack_alerts/tsconfig.json"} + { "path": "./plugins/stack_alerts/tsconfig.json"}, + { "path": "./plugins/license_management/tsconfig.json" }, + { "path": "./plugins/watcher/tsconfig.json" }, ] } diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index 6a9e54e2e7adf..1724cb2afbffa 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -15,11 +15,13 @@ { "path": "./plugins/features/tsconfig.json" }, { "path": "./plugins/graph/tsconfig.json" }, { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, + { "path": "./plugins/maps/tsconfig.json" }, + { "path": "./plugins/maps_file_upload/tsconfig.json" }, + { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, { "path": "./plugins/searchprofiler/tsconfig.json" }, { "path": "./plugins/task_manager/tsconfig.json" }, { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, - { "path": "./plugins/vis_type_timeseries_enhanced/tsconfig.json" }, { "path": "./plugins/translations/tsconfig.json" }, { "path": "./plugins/triggers_actions_ui/tsconfig.json"}, { "path": "./plugins/spaces/tsconfig.json" }, @@ -29,6 +31,8 @@ { "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/watcher/tsconfig.json" } ] } diff --git a/x-pack/typings/elasticsearch/aggregations.d.ts b/x-pack/typings/elasticsearch/aggregations.d.ts index 0328877aae8fe..fcb32fa6c0372 100644 --- a/x-pack/typings/elasticsearch/aggregations.d.ts +++ b/x-pack/typings/elasticsearch/aggregations.d.ts @@ -7,7 +7,7 @@ import { Unionize, UnionToIntersection } from 'utility-types'; import { ESSearchHit, MaybeReadonlyArray, ESSourceOptions, ESHitsOf } from '.'; -type SortOrder = 'asc' | 'desc'; +export type SortOrder = 'asc' | 'desc'; type SortInstruction = Record<string, SortOrder | { order: SortOrder }>; export type SortOptions = SortOrder | SortInstruction | SortInstruction[]; diff --git a/x-pack/typings/elasticsearch/index.d.ts b/x-pack/typings/elasticsearch/index.d.ts index 049e1e52c66d9..81443947855bc 100644 --- a/x-pack/typings/elasticsearch/index.d.ts +++ b/x-pack/typings/elasticsearch/index.d.ts @@ -70,6 +70,7 @@ export interface ESSearchBody { aggs?: AggregationInputMap; track_total_hits?: boolean | number; collapse?: CollapseQuery; + search_after?: Array<string | number>; _source?: ESSourceOptions; } diff --git a/yarn.lock b/yarn.lock index ed861b58773b9..1b8cc2f8dc6e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1997,6 +1997,11 @@ resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.0.tgz#860ce718b0b73f4009e153541faff2cb6b85d047" integrity sha512-4Th98KlMHr5+JkxfcoDT//6vY8vM+iSPrLNpHhRyLx2CFYi8e2RfqPLdpbnpo0Q5lQC5hNB79yes07zb02fvCw== +"@bazel/ibazel@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@bazel/ibazel/-/ibazel-0.14.0.tgz#86fa0002bed2ce1123b7ad98d4dd4623a0d93244" + integrity sha512-s0gyec6lArcRDwVfIP6xpY8iEaFpzrSpyErSppd3r2O49pOEg7n6HGS/qJ8ncvme56vrDk6crl/kQ6VAdEO+rg== + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -3512,6 +3517,10 @@ version "0.0.0" uid "" +"@kbn/tinymath@link:packages/kbn-tinymath": + version "0.0.0" + uid "" + "@kbn/ui-framework@link:packages/kbn-ui-framework": version "0.0.0" uid "" @@ -4436,7 +4445,7 @@ core-js "^3.0.1" ts-dedent "^1.1.1" -"@storybook/addon-docs@6.0.26": +"@storybook/addon-docs@6.0.26", "@storybook/addon-docs@^6.0.26": version "6.0.26" resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-6.0.26.tgz#bd7fc1fcdc47bb7992fa8d3254367e8c3bba373d" integrity sha512-3t8AOPkp8ZW74h7FnzxF3wAeb1wRyYjMmgJZxqzgi/x7K0i1inbCq8MuJnytuTcZ7+EK4HR6Ih7o9tJuAtIBLw== @@ -8439,7 +8448,7 @@ axe-core@^4.0.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.0.2.tgz#c7cf7378378a51fcd272d3c09668002a4990b1cb" integrity sha512-arU1h31OGFu+LPrOLGZ7nB45v940NMDMEJeNmbutu57P+UFDVnkZg3e+J1I2HJRZ9hT7gO8J91dn/PMrAiKakA== -axios@^0.21.0, axios@^0.21.1: +axios@^0.21.1: version "0.21.1" resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== @@ -10228,13 +10237,13 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -chromedriver@^87.0.3: - version "87.0.5" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-87.0.5.tgz#5a56bae6e23fc5eaa0c5ac3b76f936e4dd0989a1" - integrity sha512-bWAKdZANrt3LXMUOKFP+DgW7DjVKfihCbjej6URkUcKsvbQBDYpf5YY5d/dXE3SOSzIFZ7fmLxogusxpsupCJg== +chromedriver@^88.0.0: + version "88.0.0" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-88.0.0.tgz#6833fffd516db23c811eeafa1ee1069b5a12fd2f" + integrity sha512-EE8rXh7mikxk3VWKjUsz0KCUX8d3HkQ4HgMNJhWrWjzju12dKPPVHO9MY+YaAI5ryXrXGNf0Y4HcNKgW36P/CA== dependencies: "@testim/chrome-version" "^1.0.7" - axios "^0.21.0" + axios "^0.21.1" del "^6.0.0" extract-zip "^2.0.1" https-proxy-agent "^5.0.0" @@ -28051,11 +28060,6 @@ tinygradient@0.4.3: "@types/tinycolor2" "^1.4.0" tinycolor2 "^1.0.0" -tinymath@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/tinymath/-/tinymath-1.2.1.tgz#f97ed66c588cdbf3c19dfba2ae266ee323db7e47" - integrity sha512-8CYutfuHR3ywAJus/3JUhaJogZap1mrUQGzNxdBiQDhP3H0uFdQenvaXvqI8lMehX4RsanRZzxVfjMBREFdQaA== - tinyqueue@^1.1.0: version "1.2.3" resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-1.2.3.tgz#b6a61de23060584da29f82362e45df1ec7353f3d"