diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index f07ac997e31c2..e070baa844ea9 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -26,6 +26,7 @@ disabled: - x-pack/test/security_solution_cypress/cases_cli_config.ts - x-pack/test/security_solution_cypress/ccs_config.ts - x-pack/test/security_solution_cypress/cli_config.ts + - x-pack/test/security_solution_cypress/cli_config_parallel.ts - x-pack/test/security_solution_cypress/config.firefox.ts - x-pack/test/security_solution_cypress/config.ts - x-pack/test/security_solution_cypress/response_ops_cli_config.ts diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml index 14bddc49059ac..606ec6c2e038f 100644 --- a/.buildkite/pipelines/artifacts.yml +++ b/.buildkite/pipelines/artifacts.yml @@ -62,6 +62,16 @@ steps: - exit_status: '*' limit: 1 + - command: KIBANA_DOCKER_CONTEXT=ubi .buildkite/scripts/steps/artifacts/docker_context.sh + label: 'Docker Context Verification' + agents: + queue: n2-2 + timeout_in_minutes: 30 + retry: + automatic: + - exit_status: '*' + limit: 1 + - command: .buildkite/scripts/steps/artifacts/cloud.sh label: 'Cloud Deployment' agents: diff --git a/.buildkite/pipelines/pull_request/security_solution.yml b/.buildkite/pipelines/pull_request/security_solution.yml index 974469a700715..5903aac568a83 100644 --- a/.buildkite/pipelines/pull_request/security_solution.yml +++ b/.buildkite/pipelines/pull_request/security_solution.yml @@ -5,6 +5,7 @@ steps: queue: ci-group-6 depends_on: build timeout_in_minutes: 120 + parallelism: 4 retry: automatic: - exit_status: '*' diff --git a/.buildkite/pull_requests.json b/.buildkite/pull_requests.json index d54f637b8f6d1..e0e7454127733 100644 --- a/.buildkite/pull_requests.json +++ b/.buildkite/pull_requests.json @@ -16,7 +16,25 @@ "trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))", "always_trigger_comment_regex": "^(?:(?:buildkite\\W+)?(?:build|test)\\W+(?:this|it))", "skip_ci_labels": ["skip-ci", "jenkins-ci"], - "skip_target_branches": ["6.8", "7.11", "7.12"] + "skip_target_branches": ["6.8", "7.11", "7.12"], + "skip_ci_on_only_changed": [ + "^docs/", + "^rfcs/", + "^.ci/.+\\.yml$", + "^.ci/es-snapshots/", + "^.ci/pipeline-library/", + "^.ci/Jenkinsfile_[^/]+$", + "^\\.github/", + "\\.md$", + "^\\.backportrc\\.json$", + "^nav-kibana-dev\\.docnav\\.json$", + "^src/dev/prs/kibana_qa_pr_list\\.json$", + "^\\.buildkite/pull_requests\\.json$" + ], + "always_require_ci_on_changed": [ + "^docs/developer/plugin-list.asciidoc$", + "/plugins/[^/]+/readme\\.(md|asciidoc)$" + ] } ] } diff --git a/.buildkite/scripts/common/env.sh b/.buildkite/scripts/common/env.sh index b8b9ef2ffb7de..344117b57c452 100755 --- a/.buildkite/scripts/common/env.sh +++ b/.buildkite/scripts/common/env.sh @@ -38,6 +38,7 @@ export TEST_BROWSER_HEADLESS=1 export ELASTIC_APM_ENVIRONMENT=ci export ELASTIC_APM_TRANSACTION_SAMPLE_RATE=0.1 export ELASTIC_APM_SERVER_URL=https://kibana-ci-apm.apm.us-central1.gcp.cloud.es.io +# Not really a secret, if APM supported public auth we would use it and APM requires that we use this name export ELASTIC_APM_SECRET_TOKEN=7YKhoXsO4MzjhXjx2c if is_pr; then diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.js b/.buildkite/scripts/pipelines/pull_request/pipeline.js index 6a4610284e400..c9f42dae1a776 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.js +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.js @@ -9,14 +9,11 @@ const execSync = require('child_process').execSync; const fs = require('fs'); const { areChangesSkippable, doAnyChangesMatch } = require('kibana-buildkite-library'); -const { SKIPPABLE_PR_MATCHERS } = require('./skippable_pr_matchers'); - -const REQUIRED_PATHS = [ - // this file is auto-generated and changes to it need to be validated with CI - /^docs\/developer\/plugin-list.asciidoc$/, - // don't skip CI on prs with changes to plugin readme files /i is for case-insensitive matching - /\/plugins\/[^\/]+\/readme\.(md|asciidoc)$/i, -]; +const prConfigs = require('../../../pull_requests.json'); +const prConfig = prConfigs.jobs.find((job) => job.pipelineSlug === 'kibana-pull-request'); + +const REQUIRED_PATHS = prConfig.always_require_ci_on_changed.map((r) => new RegExp(r, 'i')); +const SKIPPABLE_PR_MATCHERS = prConfig.skip_ci_on_only_changed.map((r) => new RegExp(r, 'i')); const getPipeline = (filename, removeSteps = true) => { const str = fs.readFileSync(filename).toString(); diff --git a/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js b/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js deleted file mode 100644 index 2a36e66e11cd6..0000000000000 --- a/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -module.exports = { - SKIPPABLE_PR_MATCHERS: [ - /^docs\//, - /^rfcs\//, - /^.ci\/.+\.yml$/, - /^.ci\/es-snapshots\//, - /^.ci\/pipeline-library\//, - /^.ci\/Jenkinsfile_[^\/]+$/, - /^\.github\//, - /\.md$/, - /^\.backportrc\.json$/, - /^nav-kibana-dev\.docnav\.json$/, - /^src\/dev\/prs\/kibana_qa_pr_list\.json$/, - /^\.buildkite\/scripts\/pipelines\/pull_request\/skippable_pr_matchers\.js$/, - ], -}; diff --git a/.buildkite/scripts/steps/artifacts/docker_context.sh b/.buildkite/scripts/steps/artifacts/docker_context.sh index d01cbccfc76c1..8076ebd043545 100755 --- a/.buildkite/scripts/steps/artifacts/docker_context.sh +++ b/.buildkite/scripts/steps/artifacts/docker_context.sh @@ -19,6 +19,8 @@ if [[ "$KIBANA_DOCKER_CONTEXT" == "default" ]]; then DOCKER_CONTEXT_FILE="kibana-$FULL_VERSION-docker-build-context.tar.gz" elif [[ "$KIBANA_DOCKER_CONTEXT" == "cloud" ]]; then DOCKER_CONTEXT_FILE="kibana-cloud-$FULL_VERSION-docker-build-context.tar.gz" +elif [[ "$KIBANA_DOCKER_CONTEXT" == "ubi" ]]; then + DOCKER_CONTEXT_FILE="kibana-ubi8-$FULL_VERSION-docker-build-context.tar.gz" fi tar -xf "target/$DOCKER_CONTEXT_FILE" -C "$DOCKER_BUILD_FOLDER" diff --git a/.buildkite/scripts/steps/code_coverage/jest_parallel.sh b/.buildkite/scripts/steps/code_coverage/jest_parallel.sh index dc8a67320c5ed..44ea80bf95257 100755 --- a/.buildkite/scripts/steps/code_coverage/jest_parallel.sh +++ b/.buildkite/scripts/steps/code_coverage/jest_parallel.sh @@ -2,8 +2,8 @@ set -uo pipefail -JOB=$BUILDKITE_PARALLEL_JOB -JOB_COUNT=$BUILDKITE_PARALLEL_JOB_COUNT +JOB=${BUILDKITE_PARALLEL_JOB:-0} +JOB_COUNT=${BUILDKITE_PARALLEL_JOB_COUNT:-1} # a jest failure will result in the script returning an exit code of 10 @@ -35,4 +35,4 @@ while read -r config; do # uses heredoc to avoid the while loop being in a sub-shell thus unable to overwrite exitCode done <<< "$(find src x-pack packages -name jest.config.js -not -path "*/__fixtures__/*" | sort)" -exit $exitCode \ No newline at end of file +exit $exitCode diff --git a/.buildkite/scripts/steps/es_snapshots/build.sh b/.buildkite/scripts/steps/es_snapshots/build.sh index cdc1750e59bfc..370ae275aa758 100755 --- a/.buildkite/scripts/steps/es_snapshots/build.sh +++ b/.buildkite/scripts/steps/es_snapshots/build.sh @@ -69,7 +69,6 @@ echo "--- Build Elasticsearch" :distribution:archives:darwin-aarch64-tar:assemble \ :distribution:archives:darwin-tar:assemble \ :distribution:docker:docker-export:assemble \ - :distribution:docker:cloud-docker-export:assemble \ :distribution:archives:linux-aarch64-tar:assemble \ :distribution:archives:linux-tar:assemble \ :distribution:archives:windows-zip:assemble \ @@ -86,19 +85,26 @@ docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}} docker images "docker.elastic.co/elasticsearch/elasticsearch" --format "{{.Tag}}" | xargs -n1 bash -c 'docker save docker.elastic.co/elasticsearch/elasticsearch:${0} | gzip > ../es-build/elasticsearch-${0}-docker-image.tar.gz' echo "--- Create kibana-ci docker cloud image archives" -ES_CLOUD_ID=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.ID}}") -ES_CLOUD_VERSION=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.Tag}}") -KIBANA_ES_CLOUD_VERSION="$ES_CLOUD_VERSION-$ELASTICSEARCH_GIT_COMMIT" -KIBANA_ES_CLOUD_IMAGE="docker.elastic.co/kibana-ci/elasticsearch-cloud:$KIBANA_ES_CLOUD_VERSION" - -docker tag "$ES_CLOUD_ID" "$KIBANA_ES_CLOUD_IMAGE" - -echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co -trap 'docker logout docker.elastic.co' EXIT -docker image push "$KIBANA_ES_CLOUD_IMAGE" - -export ELASTICSEARCH_CLOUD_IMAGE="$KIBANA_ES_CLOUD_IMAGE" -export ELASTICSEARCH_CLOUD_IMAGE_CHECKSUM="$(docker images "$KIBANA_ES_CLOUD_IMAGE" --format "{{.Digest}}")" +# Ignore build failures. This docker image downloads metricbeat and filebeat. +# When we bump versions, these dependencies may not exist yet, but we don't want to +# block the rest of the snapshot promotion process +set +e +./gradlew :distribution:docker:cloud-docker-export:assemble && { + ES_CLOUD_ID=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.ID}}") + ES_CLOUD_VERSION=$(docker images "docker.elastic.co/elasticsearch-ci/elasticsearch-cloud" --format "{{.Tag}}") + KIBANA_ES_CLOUD_VERSION="$ES_CLOUD_VERSION-$ELASTICSEARCH_GIT_COMMIT" + KIBANA_ES_CLOUD_IMAGE="docker.elastic.co/kibana-ci/elasticsearch-cloud:$KIBANA_ES_CLOUD_VERSION" + echo $ES_CLOUD_ID $ES_CLOUD_VERSION $KIBANA_ES_CLOUD_VERSION $KIBANA_ES_CLOUD_IMAGE + docker tag "$ES_CLOUD_ID" "$KIBANA_ES_CLOUD_IMAGE" + + echo "$KIBANA_DOCKER_PASSWORD" | docker login -u "$KIBANA_DOCKER_USERNAME" --password-stdin docker.elastic.co + trap 'docker logout docker.elastic.co' EXIT + docker image push "$KIBANA_ES_CLOUD_IMAGE" + + export ELASTICSEARCH_CLOUD_IMAGE="$KIBANA_ES_CLOUD_IMAGE" + export ELASTICSEARCH_CLOUD_IMAGE_CHECKSUM="$(docker images "$KIBANA_ES_CLOUD_IMAGE" --format "{{.Digest}}")" +} +set -e echo "--- Create checksums for snapshot files" cd "$destination" diff --git a/.buildkite/scripts/steps/functional/security_solution.sh b/.buildkite/scripts/steps/functional/security_solution.sh index ae81eaa4f48e2..5e3b1513826f9 100755 --- a/.buildkite/scripts/steps/functional/security_solution.sh +++ b/.buildkite/scripts/steps/functional/security_solution.sh @@ -5,11 +5,13 @@ set -euo pipefail source .buildkite/scripts/steps/functional/common.sh export JOB=kibana-security-solution-chrome +export CLI_NUMBER=$((BUILDKITE_PARALLEL_JOB+1)) +export CLI_COUNT=$BUILDKITE_PARALLEL_JOB_COUNT echo "--- Security Solution tests (Chrome)" -checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome)" \ +checks-reporter-with-killswitch "Security Solution Cypress Tests (Chrome) $CLI_NUMBER" \ node scripts/functional_tests \ --debug --bail \ --kibana-install-dir "$KIBANA_BUILD_LOCATION" \ - --config x-pack/test/security_solution_cypress/cli_config.ts + --config x-pack/test/security_solution_cypress/cli_config_parallel.ts diff --git a/.buildkite/scripts/steps/test/ftr_configs.sh b/.buildkite/scripts/steps/test/ftr_configs.sh index 244b108a269f8..447dc5bca9e6b 100755 --- a/.buildkite/scripts/steps/test/ftr_configs.sh +++ b/.buildkite/scripts/steps/test/ftr_configs.sh @@ -4,10 +4,10 @@ set -euo pipefail source .buildkite/scripts/steps/functional/common.sh -export JOB_NUM=$BUILDKITE_PARALLEL_JOB +export JOB_NUM=${BUILDKITE_PARALLEL_JOB:-0} export JOB=ftr-configs-${JOB_NUM} -FAILED_CONFIGS_KEY="${BUILDKITE_STEP_ID}${BUILDKITE_PARALLEL_JOB:-0}" +FAILED_CONFIGS_KEY="${BUILDKITE_STEP_ID}${JOB_NUM}" # a FTR failure will result in the script returning an exit code of 10 exitCode=0 diff --git a/.buildkite/scripts/steps/test/jest.sh b/.buildkite/scripts/steps/test/jest.sh index cbf8bce703cc6..7b09c3f0d788a 100755 --- a/.buildkite/scripts/steps/test/jest.sh +++ b/.buildkite/scripts/steps/test/jest.sh @@ -8,6 +8,8 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh +JOB=${BUILDKITE_PARALLEL_JOB:-0} + echo '--- Jest' -checks-reporter-with-killswitch "Jest Unit Tests $((BUILDKITE_PARALLEL_JOB+1))" \ +checks-reporter-with-killswitch "Jest Unit Tests $((JOB+1))" \ .buildkite/scripts/steps/test/jest_parallel.sh jest.config.js diff --git a/.buildkite/scripts/steps/test/jest_integration.sh b/.buildkite/scripts/steps/test/jest_integration.sh index 13412881cb6fa..2dce8fec0f26c 100755 --- a/.buildkite/scripts/steps/test/jest_integration.sh +++ b/.buildkite/scripts/steps/test/jest_integration.sh @@ -8,6 +8,8 @@ is_test_execution_step .buildkite/scripts/bootstrap.sh +JOB=${BUILDKITE_PARALLEL_JOB:-0} + echo '--- Jest Integration Tests' -checks-reporter-with-killswitch "Jest Integration Tests $((BUILDKITE_PARALLEL_JOB+1))" \ +checks-reporter-with-killswitch "Jest Integration Tests $((JOB+1))" \ .buildkite/scripts/steps/test/jest_parallel.sh jest.integration.config.js diff --git a/.buildkite/scripts/steps/test/jest_parallel.sh b/.buildkite/scripts/steps/test/jest_parallel.sh index 71ecf7a853d4a..8ca025a3e6516 100755 --- a/.buildkite/scripts/steps/test/jest_parallel.sh +++ b/.buildkite/scripts/steps/test/jest_parallel.sh @@ -2,7 +2,7 @@ set -euo pipefail -export JOB=$BUILDKITE_PARALLEL_JOB +export JOB=${BUILDKITE_PARALLEL_JOB:-0} # a jest failure will result in the script returning an exit code of 10 exitCode=0 diff --git a/api_docs/deprecations_by_plugin.mdx b/api_docs/deprecations_by_plugin.mdx index bc9d1dac3a021..4904da587db13 100644 --- a/api_docs/deprecations_by_plugin.mdx +++ b/api_docs/deprecations_by_plugin.mdx @@ -130,12 +130,12 @@ warning: This document is auto-generated and is meant to be viewed inside our ex | Deprecated API | Reference location(s) | Remove By | | ---------------|-----------|-----------| -| | [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | +| | [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [create_search_source.test.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.test.ts#:~:text=IndexPatternsContract)+ 19 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternsService), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IndexPatternsService) | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/types.ts#:~:text=IndexPattern), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/types.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPattern), [tabify_docs.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/tabify/tabify_docs.ts#:~:text=IndexPattern), [tabify_docs.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/tabify/tabify_docs.ts#:~:text=IndexPattern)+ 89 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IFieldType), [date_histogram.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/buckets/date_histogram.ts#:~:text=IFieldType), [date_histogram.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/buckets/date_histogram.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [generate_filters.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/query/filter_manager/lib/generate_filters.ts#:~:text=IFieldType), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IFieldType), [create_filters_from_range_select.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts#:~:text=IFieldType), [create_filters_from_range_select.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts#:~:text=IFieldType)+ 6 more | 8.2 | -| | [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [field.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/field.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | +| | [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [mapping.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/aggs/param_types/mapping.ts#:~:text=IndexPatternField), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [kibana_context_type.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/kibana_context_type.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IndexPatternField)+ 16 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [get_time.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/query/timefilter/get_time.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [normalize_sort_request.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/normalize_sort_request.ts#:~:text=IIndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IIndexPattern), [search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source.ts#:~:text=IIndexPattern)+ 23 more | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternAttributes), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/index.ts#:~:text=IndexPatternAttributes), [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/server/index.ts#:~:text=IndexPatternAttributes) | - | | | [index.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/index.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [create_search_source.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [search_source_service.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/search_source_service.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [esaggs_fn.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/expressions/esaggs/esaggs_fn.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [types.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/public/search/types.ts#:~:text=IndexPatternsContract), [create_search_source.test.ts](https://github.com/elastic/kibana/tree/master/src/plugins/data/common/search/search_source/create_search_source.test.ts#:~:text=IndexPatternsContract)+ 19 more | - | diff --git a/dev_docs/getting_started/troubleshooting.mdx b/dev_docs/getting_started/troubleshooting.mdx index e0adfbad86a84..db52830bbae4f 100644 --- a/dev_docs/getting_started/troubleshooting.mdx +++ b/dev_docs/getting_started/troubleshooting.mdx @@ -26,3 +26,17 @@ git clean -fdxn -e /config -e /.vscode # review the files which will be deleted, consider adding some more excludes (-e) # re-run without the dry-run (-n) flag to actually delete the files ``` + +### search.check_ccs_compatibility error + +If you run into an error that says something like: + +``` +[class org.elasticsearch.action.search.SearchRequest] is not compatible version 8.1.0 and the 'search.check_ccs_compatibility' setting is enabled. +``` + +it means you are using a new Elasticsearch feature that will not work in a CCS environment because the feature does not exist in older versions. If you are working on an experimental feature and are okay with this limitation, you will have to move the failing test into a special test suite that does not use this setting to get ci to pass. Take this path cautiously. If you do not remember to move the test back into the default test suite when the feature is GA'ed, it will not have proper CCS test coverage. + +We added this test coverage in version `8.1` because we accidentally broke core Kibana features (for example, when Discover started using the new fields parameter) for our CCS users. CCS is not a corner case and (excluding certain experimental features) Kibana should always work for our CCS users. This setting is our way of ensuring test coverage. + +Please reach out to the [Kibana Operations team](https://github.com/orgs/elastic/teams/kibana-operations) if you have further questions. diff --git a/dev_docs/operations/operations_landing.mdx b/dev_docs/operations/operations_landing.mdx index 31e996086dd0b..cda44a96fe4dd 100644 --- a/dev_docs/operations/operations_landing.mdx +++ b/dev_docs/operations/operations_landing.mdx @@ -43,5 +43,7 @@ layout: landing { pageId: "kibDevDocsToolingLog" }, { pageId: "kibDevDocsOpsJestSerializers"}, { pageId: "kibDevDocsOpsExpect" }, + { pageId: "kibDevDocsOpsAmbientStorybookTypes" }, + { pageId: "kibDevDocsOpsAmbientUiTypes"}, ]} /> \ No newline at end of file diff --git a/docs/api/cases/cases-api-add-comment.asciidoc b/docs/api/cases/cases-api-add-comment.asciidoc index 203492d6aa632..b179c9ac2e4fb 100644 --- a/docs/api/cases/cases-api-add-comment.asciidoc +++ b/docs/api/cases/cases-api-add-comment.asciidoc @@ -120,6 +120,7 @@ The API returns details about the case and its comments. For example: }, "owner": "cases", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-create.asciidoc b/docs/api/cases/cases-api-create.asciidoc index 73c89937466b3..b39125cf7538e 100644 --- a/docs/api/cases/cases-api-create.asciidoc +++ b/docs/api/cases/cases-api-create.asciidoc @@ -140,6 +140,10 @@ An object that contains the case settings. (Required, boolean) Turns alert syncing on or off. ==== +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `tags`:: (Required, string array) The words and phrases that help categorize cases. It can be an empty array. @@ -206,6 +210,7 @@ the case identifier, version, and creation time. For example: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/docs/api/cases/cases-api-find-cases.asciidoc b/docs/api/cases/cases-api-find-cases.asciidoc index 3e94dd56ffa36..92b23a4aafb8d 100644 --- a/docs/api/cases/cases-api-find-cases.asciidoc +++ b/docs/api/cases/cases-api-find-cases.asciidoc @@ -62,6 +62,10 @@ filters the objects in the response. (Optional, string or array of strings) The fields to perform the `simple_query_string` parsed query against. +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `sortField`:: (Optional, string) Determines which field is used to sort the results, `createdAt` or `updatedAt`. Defaults to `createdAt`. @@ -126,6 +130,7 @@ The API returns a JSON object listing the retrieved cases. For example: }, "owner": "securitySolution", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-29T13:03:23.533Z", diff --git a/docs/api/cases/cases-api-get-case-activity.asciidoc b/docs/api/cases/cases-api-get-case-activity.asciidoc index 25d102dc11ee7..0f931965df248 100644 --- a/docs/api/cases/cases-api-get-case-activity.asciidoc +++ b/docs/api/cases/cases-api-get-case-activity.asciidoc @@ -51,362 +51,56 @@ The API returns a JSON object with all the activity for the case. For example: [source,json] -------------------------------------------------- [ - { - "action": "create", - "action_id": "5275af50-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:34:48.709Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": null, - "id": "none", - "name": "none", - "type": ".none" - }, - "description": "migrating user actions", - "settings": { - "syncAlerts": true - }, - "status": "open", - "tags": [ - "user", - "actions" - ], - "title": "User actions", - "owner": "securitySolution" - }, - "sub_case_id": "", - "type": "create_case" - }, - { - "action": "create", - "action_id": "72e73240-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "72a03e30-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:35:42.872Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "a comment", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - }, - { - "action": "update", - "action_id": "7685b5c0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:35:48.826Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "title": "User actions!" - }, - "sub_case_id": "", - "type": "title" - }, - { - "action": "update", - "action_id": "7a2d8810-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:35:55.421Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "description": "migrating user actions and update!" - }, - "sub_case_id": "", - "type": "description" - }, - { - "action": "update", - "action_id": "7f942160-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "72a03e30-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:36:04.120Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "a comment updated!", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - }, - { - "action": "add", - "action_id": "8591a380-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:13.840Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "tags": [ - "migration" - ] - }, - "sub_case_id": "", - "type": "tags" - }, - { - "action": "delete", - "action_id": "8591a381-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:13.840Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "tags": [ - "user" - ] - }, - "sub_case_id": "", - "type": "tags" + { + "created_at": "2022-12-16T14:34:48.709Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" }, - { - "action": "update", - "action_id": "87fadb50-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:17.764Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "settings": { - "syncAlerts": false - } - }, - "sub_case_id": "", - "type": "settings" - }, - { - "action": "update", - "action_id": "89ca4420-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:21.509Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "status": "in-progress" - }, - "sub_case_id": "", - "type": "status" - }, - { - "action": "update", - "action_id": "9060aae0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:32.716Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "issueType": "10001", - "parent": null, - "priority": "High" - }, - "id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "Jira", - "type": ".jira" - } - }, - "sub_case_id": "", - "type": "connector" - }, - { - "action": "push_to_service", - "action_id": "988579d0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:36:46.443Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "externalService": { - "connector_id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "connector_name": "Jira", - "external_id": "26225", - "external_title": "CASES-229", - "external_url": "https://example.com/browse/CASES-229", - "pushed_at": "2021-12-16T14:36:46.443Z", - "pushed_by": { - "email": "", - "full_name": "", - "username": "elastic" - } - } - }, - "sub_case_id": "", - "type": "pushed" + "owner": "securitySolution", + "action": "create", + "payload": { + "title": "User actions", + "tags": [ + "user", + "actions" + ], + "connector": { + "fields": null, + "id": "none", + "name": "none", + "type": ".none" + }, + "settings": { + "syncAlerts": true + }, + "owner": "cases", + "severity": "low", + "description": "migrating user actions", + "status": "open" }, - { - "action": "update", - "action_id": "bcb76020-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:37:46.863Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "incidentTypes": [ - "17", - "4" - ], - "severityCode": "5" - }, - "id": "b3214df0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "IBM", - "type": ".resilient" - } - }, - "sub_case_id": "", - "type": "connector" - }, - { - "action": "push_to_service", - "action_id": "c0338e90-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:37:53.016Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "externalService": { - "connector_id": "b3214df0-5e7d-11ec-9ee9-cd64f0b77b3c", - "connector_name": "IBM", - "external_id": "17574", - "external_title": "17574", - "external_url": "https://example.com/#incidents/17574", - "pushed_at": "2021-12-16T14:37:53.016Z", - "pushed_by": { - "email": "", - "full_name": "", - "username": "elastic" - } - } - }, - "sub_case_id": "", - "type": "pushed" + "type": "create_case", + "action_id": "5275af50-5e7d-11ec-9ee9-cd64f0b77b3c", + "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", + "comment_id": null + }, + { + "created_at": "2022-12-16T14:35:42.872Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" }, - { - "action": "update", - "action_id": "c5b6d7a0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": null, - "created_at": "2021-12-16T14:38:01.895Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "connector": { - "fields": { - "issueType": "10001", - "parent": null, - "priority": "Lowest" - }, - "id": "6773fba0-5e7d-11ec-9ee9-cd64f0b77b3c", - "name": "Jira", - "type": ".jira" - } - }, - "sub_case_id": "", - "type": "connector" + "owner": "cases", + "action": "add", + "payload": { + "tags": ["bubblegum"] }, - { - "action": "create", - "action_id": "ca8f61c0-5e7d-11ec-9ee9-cd64f0b77b3c", - "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", - "comment_id": "ca1d17f0-5e7d-11ec-9ee9-cd64f0b77b3c", - "created_at": "2021-12-16T14:38:09.649Z", - "created_by": { - "email": "", - "full_name": "", - "username": "elastic" - }, - "owner": "securitySolution", - "payload": { - "comment": { - "comment": "and another comment!", - "owner": "securitySolution", - "type": "user" - } - }, - "sub_case_id": "", - "type": "comment" - } - ] + "type": "tags", + "action_id": "72e73240-5e7d-11ec-9ee9-cd64f0b77b3c", + "case_id": "5257a000-5e7d-11ec-9ee9-cd64f0b77b3c", + "comment_id": null + } +] -------------------------------------------------- \ No newline at end of file diff --git a/docs/api/cases/cases-api-get-case.asciidoc b/docs/api/cases/cases-api-get-case.asciidoc index 42cf0672065e7..a3adc90fe09bf 100644 --- a/docs/api/cases/cases-api-get-case.asciidoc +++ b/docs/api/cases/cases-api-get-case.asciidoc @@ -91,6 +91,7 @@ The API returns a JSON object with the retrieved case. For example: "syncAlerts": true }, "owner": "securitySolution", + "severity": "low", "duration": null, "tags": [ "phishing", diff --git a/docs/api/cases/cases-api-push.asciidoc b/docs/api/cases/cases-api-push.asciidoc index 16c411104caed..46dbc1110d589 100644 --- a/docs/api/cases/cases-api-push.asciidoc +++ b/docs/api/cases/cases-api-push.asciidoc @@ -68,6 +68,7 @@ The API returns a JSON object representing the pushed case. For example: "syncAlerts": true }, "owner": "securitySolution", + "severity": "low", "duration": null, "closed_at": null, "closed_by": null, diff --git a/docs/api/cases/cases-api-update-comment.asciidoc b/docs/api/cases/cases-api-update-comment.asciidoc index d00d1eb66ea7c..a4ea53ec19468 100644 --- a/docs/api/cases/cases-api-update-comment.asciidoc +++ b/docs/api/cases/cases-api-update-comment.asciidoc @@ -135,6 +135,7 @@ The API returns details about the case and its comments. For example: "settings": {"syncAlerts":false}, "owner": "cases", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", diff --git a/docs/api/cases/cases-api-update.asciidoc b/docs/api/cases/cases-api-update.asciidoc index ebad2feaedff4..ea33394a6ee63 100644 --- a/docs/api/cases/cases-api-update.asciidoc +++ b/docs/api/cases/cases-api-update.asciidoc @@ -144,6 +144,10 @@ An object that contains the case settings. (Required, boolean) Turn on or off synching with alerts. ===== +`severity`:: +(Optional,string) The severity of the case. Valid values are: `critical`, `high`, +`low`, and `medium`. + `status`:: (Optional, string) The case status. Valid values are: `closed`, `in-progress`, and `open`. @@ -227,6 +231,7 @@ The API returns the updated case with a new `version` value. For example: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateoptions.md index b81a59c745e7b..7044f3007c382 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsupdateoptions.md @@ -18,6 +18,7 @@ export interface SavedObjectsUpdateOptions extends SavedOb | --- | --- | --- | | [references?](./kibana-plugin-core-server.savedobjectsupdateoptions.references.md) | SavedObjectReference\[\] | (Optional) A reference to another saved object. | | [refresh?](./kibana-plugin-core-server.savedobjectsupdateoptions.refresh.md) | MutatingOperationRefreshSetting | (Optional) The Elasticsearch Refresh setting for this operation | +| [retryOnConflict?](./kibana-plugin-core-server.savedobjectsupdateoptions.retryonconflict.md) | number | (Optional) The Elasticsearch retry_on_conflict setting for this operation. Defaults to 0 when version is provided, 3 otherwise. | | [upsert?](./kibana-plugin-core-server.savedobjectsupdateoptions.upsert.md) | Attributes | (Optional) If specified, will be used to perform an upsert if the document doesn't exist | | [version?](./kibana-plugin-core-server.savedobjectsupdateoptions.version.md) | string | (Optional) An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. | diff --git a/docs/management/cases/images/cases.png b/docs/management/cases/images/cases.png index 7b0c551cb6903..b244b3df16a20 100644 Binary files a/docs/management/cases/images/cases.png and b/docs/management/cases/images/cases.png differ diff --git a/docs/maps/asset-tracking-tutorial.asciidoc b/docs/maps/asset-tracking-tutorial.asciidoc index 46248c5280b20..85629e0e611f6 100644 --- a/docs/maps/asset-tracking-tutorial.asciidoc +++ b/docs/maps/asset-tracking-tutorial.asciidoc @@ -136,10 +136,10 @@ PUT _index_template/tri_met_tracks "type": "text" }, "lastLocID": { - "type": "integer" + "type": "keyword" }, "nextLocID": { - "type": "integer" + "type": "keyword" }, "locationInScheduleDay": { "type": "integer" @@ -163,13 +163,13 @@ PUT _index_template/tri_met_tracks "type": "keyword" }, "tripID": { - "type": "integer" + "type": "keyword" }, "delay": { "type": "integer" }, "extraBlockID": { - "type": "integer" + "type": "keyword" }, "messageCode": { "type": "integer" @@ -188,7 +188,7 @@ PUT _index_template/tri_met_tracks "doc_values": true }, "vehicleID": { - "type": "integer" + "type": "keyword" }, "offRoute": { "type": "boolean" diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 787efa64f0775..6f7ada651ad3a 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -112,34 +112,20 @@ In addition to <.credentials {ess-icon}:: -Credentials that {kib} should use internally to authenticate anonymous requests to {es}. Possible values are: username and password, API key, or the constant `elasticsearch_anonymous_user` if you want to leverage {ref}/anonymous-access.html[{es} anonymous access]. +Credentials that {kib} should use internally to authenticate anonymous requests to {es}. + For example: + [source,yaml] ---------------------------------------- -# Username and password credentials xpack.security.authc.providers.anonymous.anonymous1: credentials: username: "anonymous_service_account" password: "anonymous_service_account_password" - -# API key (concatenated and base64-encoded) -xpack.security.authc.providers.anonymous.anonymous1: - credentials: - apiKey: "VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==" - -# API key (as returned from Elasticsearch API) -xpack.security.authc.providers.anonymous.anonymous1: - credentials: - apiKey.id: "VuaCfGcBCdbkQm-e5aOx" - apiKey.key: "ui2lp2axTNmsyakw9tvNnw" - -# Elasticsearch anonymous access -xpack.security.authc.providers.anonymous.anonymous1: - credentials: "elasticsearch_anonymous_user" ---------------------------------------- +For more information, refer to <>. + [float] [[http-authentication-settings]] ==== HTTP authentication settings diff --git a/docs/user/alerting/alerting-setup.asciidoc b/docs/user/alerting/alerting-setup.asciidoc index 2b92e8caa7ef9..6643f8d0ec870 100644 --- a/docs/user/alerting/alerting-setup.asciidoc +++ b/docs/user/alerting/alerting-setup.asciidoc @@ -65,10 +65,9 @@ Rules and connectors are isolated to the {kib} space in which they were created. Rules are authorized using an <> associated with the last user to edit the rule. This API key captures a snapshot of the user's privileges at the time of edit and is subsequently used to run all background tasks associated with the rule, including condition checks like {es} queries and triggered actions. The following rule actions will re-generate the API key: * Creating a rule -* Enabling a disabled rule * Updating a rule [IMPORTANT] ============================================== -If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates, disables, or re-enables the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges. +If a rule requires certain privileges, such as index privileges, to run, and a user without those privileges updates the rule, the rule will no longer function. Conversely, if a user with greater or administrator privileges modifies the rule, it will begin running with increased privileges. ============================================== diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index 446de62326f8e..007d1af017df3 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -332,13 +332,11 @@ Anyone with access to the network {kib} is exposed to will be able to access {ki Anonymous authentication gives users access to {kib} without requiring them to provide credentials. This can be useful if you want your users to skip the login step when you embed dashboards in another application or set up a demo {kib} instance in your internal network, while still keeping other security features intact. -To enable anonymous authentication in {kib}, you must decide what credentials the anonymous service account {kib} should use internally to authenticate anonymous requests. +To enable anonymous authentication in {kib}, you must specify the credentials the anonymous service account {kib} should use internally to authenticate anonymous requests. NOTE: You can configure only one anonymous authentication provider per {kib} instance. -There are three ways to specify these credentials: - -If you have a user who can authenticate to {es} using username and password, for instance from the Native or LDAP security realms, you can also use these credentials to impersonate the anonymous users. Here is how your `kibana.yml` might look if you use username and password credentials: +You must have a user account that can authenticate to {es} using a username and password, for instance from the Native or LDAP security realms, so that you can use these credentials to impersonate the anonymous users. Here is how your `kibana.yml` might look: [source,yaml] ----------------------------------------------- @@ -350,45 +348,6 @@ xpack.security.authc.providers: password: "anonymous_service_account_password" ----------------------------------------------- -If using username and password credentials isn't desired or feasible, then you can create a dedicated <> for the anonymous service account. In this case, your `kibana.yml` might look like this: - -[source,yaml] ------------------------------------------------ -xpack.security.authc.providers: - anonymous.anonymous1: - order: 0 - credentials: - apiKey: "VnVhQ2ZHY0JDZGJrUW0tZTVhT3g6dWkybHAyYXhUTm1zeWFrdzl0dk5udw==" ------------------------------------------------ - -The previous configuration snippet uses an API key string that is the result of base64-encoding of the `id` and `api_key` fields returned from the {es} API, joined by a colon. You can also specify these fields separately, and {kib} will do the concatenation and base64-encoding for you: - -[source,yaml] ------------------------------------------------ -xpack.security.authc.providers: - anonymous.anonymous1: - order: 0 - credentials: - apiKey.id: "VuaCfGcBCdbkQm-e5aOx" - apiKey.key: "ui2lp2axTNmsyakw9tvNnw" ------------------------------------------------ - -It's also possible to use {kib} anonymous access in conjunction with the {es} anonymous access. - -Prior to configuring {kib}, ensure that anonymous access is enabled and properly configured in {es}. See {ref}/anonymous-access.html[Enabling anonymous access] for more information. - -Here is how your `kibana.yml` might look like if you want to use {es} anonymous access to impersonate anonymous users in {kib}: - -[source,yaml] ------------------------------------------------ -xpack.security.authc.providers: - anonymous.anonymous1: - order: 0 - credentials: "elasticsearch_anonymous_user" <1> ------------------------------------------------ - -<1> The `elasticsearch_anonymous_user` is a special constant that indicates you want to use the {es} anonymous user. - [float] ===== Anonymous access and other types of authentication diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 6075889f47889..4704430ba94b6 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -197,8 +197,10 @@ "label": "Utilities", "items": [ { "id": "kibDevDocsToolingLog" }, - { "id": "kibDevDocsOpsJestSerializers"}, - { "id": "kibDevDocsOpsExpect" } + { "id": "kibDevDocsOpsJestSerializers" }, + { "id": "kibDevDocsOpsExpect" }, + { "id": "kibDevDocsOpsAmbientStorybookTypes" }, + { "id": "kibDevDocsOpsAmbientUiTypes" } ] } ] diff --git a/package.json b/package.json index 560803e1a6d0d..84f9be547e7a1 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "puppeteer/node-fetch": "^2.6.7" }, "dependencies": { + "@appland/sql-parser": "^1.5.1", "@babel/runtime": "^7.17.9", "@dnd-kit/core": "^3.1.1", "@dnd-kit/sortable": "^4.0.0", @@ -107,7 +108,7 @@ "@elastic/charts": "46.0.1", "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.2", - "@elastic/ems-client": "8.3.0", + "@elastic/ems-client": "8.3.2", "@elastic/eui": "55.1.2", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", @@ -218,7 +219,8 @@ "@types/jsonwebtoken": "^8.5.6", "@types/mapbox__vector-tile": "1.3.0", "@types/moment-duration-format": "^2.2.3", - "@types/react-is": "^16.7.1", + "@types/react-is": "^16.7.2", + "@types/rrule": "^2.2.9", "JSONStream": "1.3.5", "abort-controller": "^3.0.0", "antlr4ts": "^0.5.0-alpha.3", @@ -297,7 +299,6 @@ "js-levenshtein": "^1.1.6", "js-search": "^1.4.3", "js-sha256": "^0.9.0", - "js-sql-parser": "^1.4.1", "js-yaml": "^3.14.1", "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", @@ -309,6 +310,7 @@ "loader-utils": "^1.2.3", "lodash": "^4.17.21", "lru-cache": "^4.1.5", + "luxon": "^2.3.2", "lz-string": "^1.4.4", "mapbox-gl-draw-rectangle-mode": "1.0.4", "maplibre-gl": "2.1.9", @@ -356,16 +358,16 @@ "rbush": "^3.0.1", "re-resizable": "^6.1.1", "re2": "1.17.4", - "react": "^16.12.0", + "react": "^16.14.0", "react-ace": "^7.0.5", "react-beautiful-dnd": "^13.1.0", "react-color": "^2.13.8", - "react-dom": "^16.12.0", + "react-dom": "^16.14.0", "react-dropzone": "^4.2.9", "react-fast-compare": "^2.0.4", "react-grid-layout": "^0.16.2", "react-intl": "^2.8.0", - "react-is": "^16.8.0", + "react-is": "^16.13.1", "react-markdown": "^4.3.1", "react-moment-proptypes": "^1.7.0", "react-monaco-editor": "^0.41.2", @@ -405,6 +407,7 @@ "reselect": "^4.0.0", "resize-observer-polyfill": "^1.5.1", "rison-node": "1.0.2", + "rrule": "2.6.4", "rxjs": "^7.5.5", "safe-squel": "^5.12.5", "seedrandom": "^3.0.5", @@ -524,25 +527,26 @@ "@microsoft/api-extractor": "7.18.19", "@octokit/rest": "^16.35.0", "@percy/agent": "^0.28.6", - "@storybook/addon-a11y": "^6.3.12", - "@storybook/addon-actions": "^6.3.12", - "@storybook/addon-docs": "^6.3.12", - "@storybook/addon-essentials": "^6.3.12", - "@storybook/addon-knobs": "^6.3.1", - "@storybook/addon-storyshots": "^6.3.12", - "@storybook/addons": "^6.3.12", - "@storybook/api": "^6.3.12", - "@storybook/components": "^6.3.12", - "@storybook/core": "^6.3.12", - "@storybook/core-common": "^6.3.12", - "@storybook/core-events": "^6.3.12", - "@storybook/node-logger": "^6.3.12", - "@storybook/react": "^6.3.12", - "@storybook/testing-react": "^0.0.22", - "@storybook/theming": "^6.3.12", + "@storybook/addon-a11y": "^6.4.22", + "@storybook/addon-actions": "^6.4.22", + "@storybook/addon-controls": "^6.4.22", + "@storybook/addon-docs": "^6.4.22", + "@storybook/addon-essentials": "^6.4.22", + "@storybook/addon-knobs": "^6.4.0", + "@storybook/addon-storyshots": "^6.4.22", + "@storybook/addons": "^6.4.22", + "@storybook/api": "^6.4.22", + "@storybook/components": "^6.4.22", + "@storybook/core": "^6.4.22", + "@storybook/core-common": "^6.4.22", + "@storybook/core-events": "^6.4.22", + "@storybook/node-logger": "^6.4.22", + "@storybook/react": "^6.4.22", + "@storybook/testing-react": "^1.2.4", + "@storybook/theming": "^6.4.22", "@testing-library/dom": "^8.12.0", "@testing-library/jest-dom": "^5.16.3", - "@testing-library/react": "^12.1.4", + "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", "@testing-library/user-event": "^13.5.0", "@types/apidoc": "^0.22.3", @@ -704,6 +708,7 @@ "@types/lz-string": "^1.3.34", "@types/markdown-it": "^12.2.3", "@types/md5": "^2.2.0", + "@types/micromatch": "^4.0.2", "@types/mime": "^2.0.1", "@types/mime-types": "^2.1.0", "@types/minimatch": "^2.0.29", @@ -731,10 +736,9 @@ "@types/pretty-ms": "^5.0.0", "@types/prop-types": "^15.7.3", "@types/rbush": "^3.0.0", - "@types/reach__router": "^1.2.6", - "@types/react": "^16.9.36", + "@types/react": "^16.14.25", "@types/react-beautiful-dnd": "^13.0.0", - "@types/react-dom": "^16.9.8", + "@types/react-dom": "^16.9.15", "@types/react-grid-layout": "^0.16.7", "@types/react-intl": "^2.3.15", "@types/react-redux": "^7.1.9", @@ -815,6 +819,7 @@ "cpy": "^8.1.1", "css-loader": "^3.4.2", "cssnano": "^4.1.11", + "csstype": "^3.0.2", "cypress": "^9.6.1", "cypress-axe": "^0.14.0", "cypress-file-upload": "^5.0.8", @@ -921,7 +926,7 @@ "prettier": "^2.6.2", "pretty-format": "^27.5.1", "q": "^1.5.1", - "react-test-renderer": "^16.12.0", + "react-test-renderer": "^16.14.0", "read-pkg": "^5.2.0", "regenerate": "^1.4.0", "resolve": "^1.22.0", diff --git a/packages/kbn-ambient-storybook-types/README.md b/packages/kbn-ambient-storybook-types/README.md deleted file mode 100644 index 865cf8d522d1b..0000000000000 --- a/packages/kbn-ambient-storybook-types/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @kbn/ambient-storybook-types - -Ambient types needed to use storybook. \ No newline at end of file diff --git a/packages/kbn-ambient-storybook-types/README.mdx b/packages/kbn-ambient-storybook-types/README.mdx new file mode 100644 index 0000000000000..f0db9b552d6ee --- /dev/null +++ b/packages/kbn-ambient-storybook-types/README.mdx @@ -0,0 +1,18 @@ +--- +id: kibDevDocsOpsAmbientStorybookTypes +slug: /kibana-dev-docs/ops/ambient-storybook-types +title: "@kbn/ambient-storybook-types" +description: A package holding ambient type definitions for storybooks +date: 2022-05-18 +tags: ['kibana', 'dev', 'contributor', 'operations', 'ambient', 'storybook', 'types'] +--- + +This package holds ambient typescript definitions needed to use storybooks. + +## Packages + +To include these types in a package: + +- add `"//packages/kbn-ambient-storybook-types"` to the `RUNTIME_DEPS` portion of the `BUILD.bazel` file. +- add `"//packages/kbn-ambient-storybook-types:npm_module_types"` to the `TYPES_DEPS` portion of the `BUILD.bazel` file. +- add `"@kbn/ambient-storybook-types"` to the `types` portion of the `tsconfig.json` file. diff --git a/packages/kbn-ambient-ui-types/README.mdx b/packages/kbn-ambient-ui-types/README.mdx index d63d8567afe07..dbff6fb8e18a2 100644 --- a/packages/kbn-ambient-ui-types/README.mdx +++ b/packages/kbn-ambient-ui-types/README.mdx @@ -1,7 +1,15 @@ -# @kbn/ambient-ui-types +--- +id: kibDevDocsOpsAmbientUiTypes +slug: /kibana-dev-docs/ops/ambient-ui-types +title: "@kbn/ambient-ui-types" +description: A package holding ambient type definitions for files +date: 2022-05-18 +tags: ['kibana', 'dev', 'contributor', 'operations', 'ambient', 'ui', 'types'] +--- -This is a package of Typescript types for files that might get imported by Webpack and therefore need definitions. +This package holds ambient typescript definitions for files with extensions like `.html, .png, .svg, .mdx` that might get imported by Webpack and therefore needed. +## Plugins These types will automatically be included for plugins. ## Packages @@ -9,4 +17,5 @@ These types will automatically be included for plugins. To include these types in a package: - add `"//packages/kbn-ambient-ui-types"` to the `RUNTIME_DEPS` portion of the `BUILD.bazel` file. +- add `"//packages/kbn-ambient-ui-types:npm_module_types"` to the `TYPES_DEPS` portion of the `BUILD.bazel` file. - add `"@kbn/ambient-ui-types"` to the `types` portion of the `tsconfig.json` file. \ No newline at end of file diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index eecaef06be453..5c410523d70ca 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -325,29 +325,41 @@ exports.Cluster = class Cluster { this._log.info(chalk.bold('Starting')); this._log.indent(4); - const esArgs = [ - 'action.destructive_requires_name=true', - 'ingest.geoip.downloader.enabled=false', - 'search.check_ccs_compatibility=true', - 'cluster.routing.allocation.disk.threshold_enabled=false', - ].concat(options.esArgs || []); + const esArgs = new Map([ + ['action.destructive_requires_name', 'true'], + ['cluster.routing.allocation.disk.threshold_enabled', 'false'], + ['ingest.geoip.downloader.enabled', 'false'], + ['search.check_ccs_compatibility', 'true'], + ]); + + // options.esArgs overrides the default esArg values + for (const arg of [].concat(options.esArgs || [])) { + const [key, ...value] = arg.split('='); + esArgs.set(key.trim(), value.join('=').trim()); + } // Add to esArgs if ssl is enabled if (this._ssl) { - esArgs.push('xpack.security.http.ssl.enabled=true'); - - // Include default keystore settings only if keystore isn't configured. - if (!esArgs.some((arg) => arg.startsWith('xpack.security.http.ssl.keystore'))) { - esArgs.push(`xpack.security.http.ssl.keystore.path=${ES_NOPASSWORD_P12_PATH}`); - esArgs.push(`xpack.security.http.ssl.keystore.type=PKCS12`); + esArgs.set('xpack.security.http.ssl.enabled', 'true'); + // Include default keystore settings only if ssl isn't disabled by esArgs and keystore isn't configured. + if (!esArgs.get('xpack.security.http.ssl.keystore.path')) { // We are explicitly using ES_NOPASSWORD_P12_PATH instead of ES_P12_PATH + ES_P12_PASSWORD. The reasoning for this is that setting // the keystore password using environment variables causes Elasticsearch to emit deprecation warnings. + esArgs.set(`xpack.security.http.ssl.keystore.path`, ES_NOPASSWORD_P12_PATH); + esArgs.set(`xpack.security.http.ssl.keystore.type`, `PKCS12`); } } - const args = parseSettings(extractConfigFiles(esArgs, installPath, { log: this._log }), { - filter: SettingsFilter.NonSecureOnly, - }).reduce( + const args = parseSettings( + extractConfigFiles( + Array.from(esArgs).map((e) => e.join('=')), + installPath, + { log: this._log } + ), + { + filter: SettingsFilter.NonSecureOnly, + } + ).reduce( (acc, [settingName, settingValue]) => acc.concat(['-E', `${settingName}=${settingValue}`]), [] ); diff --git a/packages/kbn-es/src/integration_tests/cluster.test.js b/packages/kbn-es/src/integration_tests/cluster.test.js index 250bc9ac883b3..1a871667bd7a9 100644 --- a/packages/kbn-es/src/integration_tests/cluster.test.js +++ b/packages/kbn-es/src/integration_tests/cluster.test.js @@ -304,9 +304,41 @@ describe('#start(installPath)', () => { Array [ Array [ "action.destructive_requires_name=true", + "cluster.routing.allocation.disk.threshold_enabled=false", "ingest.geoip.downloader.enabled=false", "search.check_ccs_compatibility=true", + ], + undefined, + Object { + "log": , + }, + ], + ] + `); + }); + + it(`allows overriding search.check_ccs_compatibility`, async () => { + mockEsBin({ start: true }); + + extractConfigFiles.mockReturnValueOnce([]); + + const cluster = new Cluster({ + log, + ssl: false, + }); + + await cluster.start(undefined, { + esArgs: ['search.check_ccs_compatibility=false'], + }); + + expect(extractConfigFiles.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Array [ + "action.destructive_requires_name=true", "cluster.routing.allocation.disk.threshold_enabled=false", + "ingest.geoip.downloader.enabled=false", + "search.check_ccs_compatibility=false", ], undefined, Object { @@ -384,9 +416,9 @@ describe('#run()', () => { Array [ Array [ "action.destructive_requires_name=true", + "cluster.routing.allocation.disk.threshold_enabled=false", "ingest.geoip.downloader.enabled=false", "search.check_ccs_compatibility=true", - "cluster.routing.allocation.disk.threshold_enabled=false", ], undefined, Object { diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 5045611c2ac2c..5699df6aa3666 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -45125,82 +45125,6 @@ exports.wrapOutput = (input, state = {}, options = {}) => { }; -/***/ }), - -/***/ "../../node_modules/pify/index.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -const processFn = (fn, options) => function (...args) { - const P = options.promiseModule; - - return new P((resolve, reject) => { - if (options.multiArgs) { - args.push((...result) => { - if (options.errorFirst) { - if (result[0]) { - reject(result); - } else { - result.shift(); - resolve(result); - } - } else { - resolve(result); - } - }); - } else if (options.errorFirst) { - args.push((error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - } else { - args.push(resolve); - } - - fn.apply(this, args); - }); -}; - -module.exports = (input, options) => { - options = Object.assign({ - exclude: [/.+(Sync|Stream)$/], - errorFirst: true, - promiseModule: Promise - }, options); - - const objType = typeof input; - if (!(input !== null && (objType === 'object' || objType === 'function'))) { - throw new TypeError(`Expected \`input\` to be a \`Function\` or \`Object\`, got \`${input === null ? 'null' : objType}\``); - } - - const filter = key => { - const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key); - return options.include ? options.include.some(match) : !options.exclude.some(match); - }; - - let ret; - if (objType === 'function') { - ret = function (...args) { - return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args); - }; - } else { - ret = Object.create(Object.getPrototypeOf(input)); - } - - for (const key in input) { // eslint-disable-line guard-for-in - const property = input[key]; - ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property; - } - - return ret; -}; - - /***/ }), /***/ "../../node_modules/pump/index.js": @@ -59599,7 +59523,7 @@ const fs = __webpack_require__("../../node_modules/graceful-fs/graceful-fs.js"); const writeFileAtomic = __webpack_require__("../../node_modules/write-json-file/node_modules/write-file-atomic/index.js"); const sortKeys = __webpack_require__("../../node_modules/sort-keys/index.js"); const makeDir = __webpack_require__("../../node_modules/write-json-file/node_modules/make-dir/index.js"); -const pify = __webpack_require__("../../node_modules/pify/index.js"); +const pify = __webpack_require__("../../node_modules/write-json-file/node_modules/pify/index.js"); const detectIndent = __webpack_require__("../../node_modules/write-json-file/node_modules/detect-indent/index.js"); const init = (fn, filePath, data, options) => { @@ -59810,7 +59734,7 @@ module.exports = str => { const fs = __webpack_require__("fs"); const path = __webpack_require__("path"); -const pify = __webpack_require__("../../node_modules/pify/index.js"); +const pify = __webpack_require__("../../node_modules/write-json-file/node_modules/pify/index.js"); const semver = __webpack_require__("../../node_modules/write-json-file/node_modules/semver/semver.js"); const defaults = { @@ -59948,6 +59872,82 @@ module.exports.sync = (input, options) => { }; +/***/ }), + +/***/ "../../node_modules/write-json-file/node_modules/pify/index.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +const processFn = (fn, options) => function (...args) { + const P = options.promiseModule; + + return new P((resolve, reject) => { + if (options.multiArgs) { + args.push((...result) => { + if (options.errorFirst) { + if (result[0]) { + reject(result); + } else { + result.shift(); + resolve(result); + } + } else { + resolve(result); + } + }); + } else if (options.errorFirst) { + args.push((error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + } else { + args.push(resolve); + } + + fn.apply(this, args); + }); +}; + +module.exports = (input, options) => { + options = Object.assign({ + exclude: [/.+(Sync|Stream)$/], + errorFirst: true, + promiseModule: Promise + }, options); + + const objType = typeof input; + if (!(input !== null && (objType === 'object' || objType === 'function'))) { + throw new TypeError(`Expected \`input\` to be a \`Function\` or \`Object\`, got \`${input === null ? 'null' : objType}\``); + } + + const filter = key => { + const match = pattern => typeof pattern === 'string' ? key === pattern : pattern.test(key); + return options.include ? options.include.some(match) : !options.exclude.some(match); + }; + + let ret; + if (objType === 'function') { + ret = function (...args) { + return options.excludeMain ? input(...args) : processFn(input, options).apply(this, args); + }; + } else { + ret = Object.create(Object.getPrototypeOf(input)); + } + + for (const key in input) { // eslint-disable-line guard-for-in + const property = input[key]; + ret[key] = typeof property === 'function' && filter(key) ? processFn(property, options) : property; + } + + return ret; +}; + + /***/ }), /***/ "../../node_modules/write-json-file/node_modules/semver/semver.js": diff --git a/packages/kbn-shared-ux-components/src/empty_state/assets/index.tsx b/packages/kbn-shared-ux-components/src/empty_state/assets/index.tsx index 001efa143b1cc..e42627da6983f 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/assets/index.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/assets/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; -import { EuiLoadingSpinner } from '@elastic/eui'; +import { EuiPanel } from '@elastic/eui'; import { withSuspense } from '@kbn/shared-ux-utility'; @@ -20,5 +20,5 @@ export const LazyDataViewIllustration = React.lazy(() => export const DataViewIllustration = withSuspense( LazyDataViewIllustration, - + ); diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.stories.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.stories.tsx index 552ffa555377d..f544f21c35387 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.stories.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.stories.tsx @@ -39,8 +39,9 @@ type Params = Pick & DataServiceFactoryCon export const PureComponent = (params: Params) => { const { solution, logo, hasESData, hasUserDataView } = params; + const serviceParams = { hasESData, hasUserDataView, hasDataViews: false }; - const services = servicesFactory(serviceParams); + const services = servicesFactory({ ...serviceParams, hasESData, hasUserDataView }); return ( { ); }; +export const PureComponentLoadingState = () => { + const dataCheck = () => new Promise((resolve, reject) => {}); + const services = { + ...servicesFactory({ hasESData: false, hasUserDataView: false, hasDataViews: false }), + data: { + hasESData: dataCheck, + hasUserDataView: dataCheck, + hasDataView: dataCheck, + }, + }; + return ( + + + + ); +}; + PureComponent.argTypes = { solution: { control: 'text', diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx index 82fbd222b3640..4f565e55ef52c 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; +import { EuiLoadingElastic } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { SharedUxServicesProvider, mockServicesFactory } from '@kbn/shared-ux-services'; @@ -68,4 +69,28 @@ describe('Kibana No Data Page', () => { expect(component.find(NoDataViews).length).toBe(1); expect(component.find(NoDataConfigPage).length).toBe(0); }); + + test('renders loading indicator', async () => { + const dataCheck = () => new Promise((resolve, reject) => {}); + const services = { + ...mockServicesFactory(), + data: { + hasESData: dataCheck, + hasUserDataView: dataCheck, + hasDataView: dataCheck, + }, + }; + const component = mountWithIntl( + + + + ); + + await act(() => new Promise(setImmediate)); + component.update(); + + expect(component.find(EuiLoadingElastic).length).toBe(1); + expect(component.find(NoDataViews).length).toBe(0); + expect(component.find(NoDataConfigPage).length).toBe(0); + }); }); diff --git a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx index 2e54d0d9f6a67..89ba915c07cfd 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/kibana_no_data_page.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import React, { useEffect, useState } from 'react'; +import { EuiLoadingElastic } from '@elastic/eui'; import { useData } from '@kbn/shared-ux-services'; import { NoDataConfigPage, NoDataPageProps } from '../page_template'; import { NoDataViews } from './no_data_views'; @@ -17,6 +18,7 @@ export interface Props { export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => { const { hasESData, hasUserDataView } = useData(); + const [isLoading, setIsLoading] = useState(true); const [dataExists, setDataExists] = useState(false); const [hasUserDataViews, setHasUserDataViews] = useState(false); @@ -24,12 +26,19 @@ export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: Props) => const checkData = async () => { setDataExists(await hasESData()); setHasUserDataViews(await hasUserDataView()); + setIsLoading(false); }; // TODO: add error handling // https://github.com/elastic/kibana/issues/130913 - checkData().catch(() => {}); + checkData().catch(() => { + setIsLoading(false); + }); }, [hasESData, hasUserDataView]); + if (isLoading) { + return ; + } + if (!dataExists) { return ; } diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx index 1d8028d4889a0..87dd68e202bc2 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { EuiButton, EuiPanel } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { NoDataViews } from './no_data_views.component'; import { DocumentationLink } from './documentation_link'; @@ -21,7 +21,7 @@ describe('', () => { dataViewsDocLink={'dummy'} /> ); - expect(component.find(EuiPanel).length).toBe(1); + expect(component.find(EuiEmptyPrompt).length).toBe(1); expect(component.find(EuiButton).length).toBe(1); expect(component.find(DocumentationLink).length).toBe(1); }); diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.component.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.component.test.tsx.snap index ab465d9a6c5b2..c871196b92282 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.component.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.component.test.tsx.snap @@ -4,7 +4,21 @@ exports[`ElasticAgentCardComponent props button 1`] = ` + } title="Add Elastic Agent" /> `; @@ -13,7 +27,21 @@ exports[`ElasticAgentCardComponent props href 1`] = ` + } title="Add Elastic Agent" /> `; @@ -21,7 +49,21 @@ exports[`ElasticAgentCardComponent props href 1`] = ` exports[`ElasticAgentCardComponent renders 1`] = ` + } title="Add Elastic Agent" /> `; @@ -35,7 +77,21 @@ exports[`ElasticAgentCardComponent renders with canAccessFleet false 1`] = ` This integration is not yet enabled. Your administrator has the required permissions to turn it on. } - image="test-file-stub" + image={ + + } isDisabled={true} title={ + } title="Add Elastic Agent" > } href="/app/integrations/browse" - image="test-file-stub" + image={ + + } paddingSize="l" title="Add Elastic Agent" > @@ -287,10 +315,37 @@ exports[`ElasticAgentCard renders 1`] = `
- + size="fullWidth" + style={ + Object { + "background": "aliceblue", + "height": 240, + "objectFit": "cover", + "width": "max(100%, 360px)", + } + } + url="test-file-stub" + > +
+ +
+
; + const image = ( + + ); + + return ; }; diff --git a/packages/kbn-storybook/src/index.ts b/packages/kbn-storybook/src/index.ts index b3258be91ed82..f986e35d1b4ed 100644 --- a/packages/kbn-storybook/src/index.ts +++ b/packages/kbn-storybook/src/index.ts @@ -6,6 +6,13 @@ * Side Public License, v 1. */ -export { defaultConfig, defaultConfigWebFinal, mergeWebpackFinal } from './lib/default_config'; +import { + defaultConfig, + defaultConfigWebFinal, + mergeWebpackFinal, + StorybookConfig, +} from './lib/default_config'; +export { defaultConfig, defaultConfigWebFinal, mergeWebpackFinal }; +export type { StorybookConfig }; export { runStorybookCli } from './lib/run_storybook_cli'; export { default as WebpackConfig } from './webpack.config'; diff --git a/packages/kbn-storybook/src/lib/default_config.ts b/packages/kbn-storybook/src/lib/default_config.ts index 0f0b8070ff8b0..a2712d3d6f24e 100644 --- a/packages/kbn-storybook/src/lib/default_config.ts +++ b/packages/kbn-storybook/src/lib/default_config.ts @@ -7,12 +7,14 @@ */ import * as path from 'path'; -import { StorybookConfig } from '@storybook/core-common'; +import type { StorybookConfig } from '@storybook/core-common'; import { Configuration } from 'webpack'; import webpackMerge from 'webpack-merge'; import { REPO_ROOT } from './constants'; import { default as WebpackConfig } from '../webpack.config'; +export type { StorybookConfig }; + const toPath = (_path: string) => path.join(REPO_ROOT, _path); // This ignore pattern excludes all of node_modules EXCEPT for `@kbn`. This allows for @@ -81,7 +83,7 @@ export const defaultConfig: StorybookConfig = { // an issue with storybook typescript setup see this issue for more details // https://github.com/storybookjs/storybook/issues/9610 -export const defaultConfigWebFinal = { +export const defaultConfigWebFinal: StorybookConfig = { ...defaultConfig, webpackFinal: (config: Configuration) => { return WebpackConfig({ config }); diff --git a/packages/kbn-storybook/templates/index.ejs b/packages/kbn-storybook/templates/index.ejs index 53dc0f5e55750..73367d44cd393 100644 --- a/packages/kbn-storybook/templates/index.ejs +++ b/packages/kbn-storybook/templates/index.ejs @@ -6,10 +6,10 @@ - <%= options.title || 'Storybook'%> + <%= htmlWebpackPlugin.options.title || 'Storybook'%> - <% if (files.favicon) { %> - + <% if (htmlWebpackPlugin.files.favicon) { %> + <% } %> @@ -26,7 +26,7 @@ <% if (typeof headHtmlSnippet !== 'undefined') { %> <%= headHtmlSnippet %> <% } %> <% - files.css.forEach(file => { %> + htmlWebpackPlugin.files.css.forEach(file => { %> <% }); %> @@ -58,7 +58,7 @@ <% } %> - <% files.js.forEach(file => { %> + <% htmlWebpackPlugin.files.js.forEach(file => { %> <% }); %> diff --git a/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts b/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts index 3ac4a64c1f3f7..6cf979eb46a26 100644 --- a/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts +++ b/packages/kbn-test/src/jest/ci_stats_jest_reporter.ts @@ -41,6 +41,7 @@ export default class CiStatsJestReporter extends BaseReporter { private startTime: number | undefined; private passCount = 0; private failCount = 0; + private testExecErrorCount = 0; private group: CiStatsReportTestsOptions['group'] | undefined; private readonly testRuns: CiStatsReportTestsOptions['testRuns'] = []; @@ -90,6 +91,10 @@ export default class CiStatsJestReporter extends BaseReporter { return; } + if (testResult.testExecError) { + this.testExecErrorCount += 1; + } + let elapsedTime = 0; for (const t of testResult.testResults) { const result = t.status === 'failed' ? 'fail' : t.status === 'passed' ? 'pass' : 'skip'; @@ -123,7 +128,8 @@ export default class CiStatsJestReporter extends BaseReporter { } this.group.durationMs = Date.now() - this.startTime; - this.group.result = this.failCount ? 'fail' : this.passCount ? 'pass' : 'skip'; + this.group.result = + this.failCount || this.testExecErrorCount ? 'fail' : this.passCount ? 'pass' : 'skip'; await this.reporter.reportTests({ group: this.group, diff --git a/renovate.json b/renovate.json index 4b9418311ced7..3d24e88d638b0 100644 --- a/renovate.json +++ b/renovate.json @@ -157,6 +157,14 @@ "matchBaseBranches": ["main"], "labels": ["Team:Operations", "release_note:skip"], "enabled": true + }, + { + "groupName": "@storybook", + "reviewers": ["team:kibana-operations"], + "matchBaseBranches": ["main"], + "matchPackagePatterns": ["^@storybook"], + "labels": ["Team:Operations", "release_note:skip"], + "enabled": true } ] } diff --git a/src/core/server/saved_objects/service/lib/repository.test.ts b/src/core/server/saved_objects/service/lib/repository.test.ts index 746ed0033a1d2..313ca2bd07e73 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.ts +++ b/src/core/server/saved_objects/service/lib/repository.test.ts @@ -1688,20 +1688,6 @@ describe('SavedObjectsRepository', () => { ); }); - it(`defaults to the version of the existing document for multi-namespace types`, async () => { - // only multi-namespace documents are obtained using a pre-flight mget request - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkUpdateSuccess(objects); - const overrides = { - if_seq_no: mockVersionProps._seq_no, - if_primary_term: mockVersionProps._primary_term, - }; - expectClientCallArgsAction(objects, { method: 'update', overrides }); - }); - it(`defaults to no version for types that are not multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; await bulkUpdateSuccess(objects); @@ -1759,12 +1745,6 @@ describe('SavedObjectsRepository', () => { it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - const overrides = { - // bulkUpdate uses a preflight `get` request for multi-namespace saved objects, and specifies that version on `update` - // we aren't testing for this here, but we need to include Jest assertions so this test doesn't fail - if_primary_term: expect.any(Number), - if_seq_no: expect.any(Number), - }; const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; @@ -1772,7 +1752,7 @@ describe('SavedObjectsRepository', () => { expectClientCallArgsAction([_obj1], { method: 'update', getId }); client.bulk.mockClear(); await bulkUpdateSuccess([_obj2], { namespace }); - expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }); + expectClientCallArgsAction([_obj2], { method: 'update', getId }); jest.clearAllMocks(); // test again with object namespace string that supersedes the operation's namespace ID @@ -1780,7 +1760,7 @@ describe('SavedObjectsRepository', () => { expectClientCallArgsAction([_obj1], { method: 'update', getId }); client.bulk.mockClear(); await bulkUpdateSuccess([{ ..._obj2, namespace }]); - expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }); + expectClientCallArgsAction([_obj2], { method: 'update', getId }); }); }); @@ -2723,14 +2703,14 @@ describe('SavedObjectsRepository', () => { expect(client.delete).toHaveBeenCalledTimes(1); }); - it(`includes the version of the existing document when using a multi-namespace type`, async () => { + it(`does not includes the version of the existing document when using a multi-namespace type`, async () => { await deleteSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; expect(client.delete).toHaveBeenCalledWith( - expect.objectContaining(versionProperties), + expect.not.objectContaining(versionProperties), expect.anything() ); }); @@ -4605,14 +4585,14 @@ describe('SavedObjectsRepository', () => { ); }); - it(`defaults to the version of the existing document when type is multi-namespace`, async () => { + it(`does not default to the version of the existing document when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes, { references }); const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; expect(client.update).toHaveBeenCalledWith( - expect.objectContaining(versionProperties), + expect.not.objectContaining(versionProperties), expect.anything() ); }); @@ -4627,6 +4607,35 @@ describe('SavedObjectsRepository', () => { ); }); + it('default to a `retry_on_conflict` setting of `3` when `version` is not provided', async () => { + await updateSuccess(type, id, attributes, {}); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ retry_on_conflict: 3 }), + expect.anything() + ); + }); + + it('default to a `retry_on_conflict` setting of `0` when `version` is provided', async () => { + await updateSuccess(type, id, attributes, { + version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), + }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ retry_on_conflict: 0, if_seq_no: 100, if_primary_term: 200 }), + expect.anything() + ); + }); + + it('accepts a `retryOnConflict` option', async () => { + await updateSuccess(type, id, attributes, { + version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), + retryOnConflict: 42, + }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ retry_on_conflict: 42, if_seq_no: 100, if_primary_term: 200 }), + expect.anything() + ); + }); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await updateSuccess(type, id, attributes, { namespace }); expect(client.update).toHaveBeenCalledWith( diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index db57e74bae138..287c78d3b2618 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -151,6 +151,7 @@ export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOp } export const DEFAULT_REFRESH_SETTING = 'wait_for'; +export const DEFAULT_RETRY_COUNT = 3; /** * See {@link SavedObjectsRepository} @@ -523,7 +524,7 @@ export class SavedObjectsRepository { } savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace, existingDocument); - versionProperties = getExpectedVersionProperties(version, existingDocument); + versionProperties = getExpectedVersionProperties(version); } else { if (this._registry.isSingleNamespace(object.type)) { savedObjectNamespace = initialNamespaces @@ -761,7 +762,7 @@ export class SavedObjectsRepository { { id: rawId, index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult?.rawDocSource), + ...getExpectedVersionProperties(undefined), refresh, }, { ignore: [404], meta: true } @@ -1312,7 +1313,13 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createBadRequestError('id cannot be empty'); // prevent potentially upserting a saved object with an empty ID } - const { version, references, upsert, refresh = DEFAULT_REFRESH_SETTING } = options; + const { + version, + references, + upsert, + refresh = DEFAULT_REFRESH_SETTING, + retryOnConflict = version ? 0 : DEFAULT_RETRY_COUNT, + } = options; const namespace = normalizeNamespace(options.namespace); let preflightResult: PreflightCheckNamespacesResult | undefined; @@ -1373,8 +1380,9 @@ export class SavedObjectsRepository { .update({ id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), - ...getExpectedVersionProperties(version, preflightResult?.rawDocSource), + ...getExpectedVersionProperties(version), refresh, + retry_on_conflict: retryOnConflict, body: { doc, ...(rawUpsert && { upsert: rawUpsert._source }), @@ -1608,8 +1616,7 @@ export class SavedObjectsRepository { // @ts-expect-error MultiGetHit is incorrectly missing _id, _source SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), ]; - // @ts-expect-error MultiGetHit is incorrectly missing _id, _source - versionProperties = getExpectedVersionProperties(version, actualResult!); + versionProperties = getExpectedVersionProperties(version); } else { if (this._registry.isSingleNamespace(type)) { // if `objectNamespace` is undefined, fall back to `options.namespace` diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 7ebea2d8ff26e..ba40127958aab 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -221,7 +221,10 @@ export interface SavedObjectsCheckConflictsResponse { * @public */ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { - /** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */ + /** + * An opaque version number which changes on each successful write operation. + * Can be used for implementing optimistic concurrency control. + */ version?: string; /** {@inheritdoc SavedObjectReference} */ references?: SavedObjectReference[]; @@ -229,6 +232,11 @@ export interface SavedObjectsUpdateOptions extends SavedOb refresh?: MutatingOperationRefreshSetting; /** If specified, will be used to perform an upsert if the document doesn't exist */ upsert?: Attributes; + /** + * The Elasticsearch `retry_on_conflict` setting for this operation. + * Defaults to `0` when `version` is provided, `3` otherwise. + */ + retryOnConflict?: number; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 4ff42f95b571a..3ff44c5b10fb1 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2939,6 +2939,7 @@ export interface SavedObjectsUpdateObjectsSpacesResponseObject { export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; refresh?: MutatingOperationRefreshSetting; + retryOnConflict?: number; upsert?: Attributes; version?: string; } diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 0ccab6fcf1b24..f10fb0231352d 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -76,7 +76,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint - '@elastic/ems-client@8.3.0': ['Elastic License 2.0'], + '@elastic/ems-client@8.3.2': ['Elastic License 2.0'], '@elastic/eui@55.1.2': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts index 1a18c905548d4..7b1b83429ef7b 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/__stories__/shared/arg_types.ts @@ -54,14 +54,14 @@ export const argTypes: ArgTypes = { palette: { name: `${visConfigName}.palette`, description: 'Palette', - type: { name: 'palette', required: false }, + type: { name: 'other', required: true, value: 'string' }, table: { type: { summary: 'object' } }, control: { type: 'object' }, }, labels: { name: `${visConfigName}.labels`, description: 'Labels configuration', - type: { name: 'object', required: false }, + type: { name: 'other', required: false, value: 'string' }, table: { type: { summary: 'object', @@ -81,7 +81,7 @@ export const argTypes: ArgTypes = { dimensions: { name: `${visConfigName}.dimensions`, description: 'dimensions configuration', - type: { name: 'object', required: false }, + type: { name: 'other', required: false, value: 'string' }, table: { type: { summary: 'object', diff --git a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts index b8969fd599765..76e524960b159 100644 --- a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts @@ -10,7 +10,7 @@ import { Position } from '@elastic/charts'; import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, DatatableRow } from '@kbn/expressions-plugin'; import { LayerTypes } from '../constants'; -import { DataLayerConfig, XYProps } from '../types'; +import { DataLayerConfig, ExtendedDataLayerConfig, XYProps } from '../types'; export const mockPaletteOutput: PaletteOutput = { type: 'palette', @@ -35,7 +35,7 @@ export const createSampleDatatableWithRows = (rows: DatatableRow[]): Datatable = id: 'c', name: 'c', meta: { - type: 'date', + type: 'string', field: 'order_date', sourceParams: { type: 'date-histogram', params: { interval: 'auto' } }, params: { id: 'string' }, @@ -61,6 +61,21 @@ export const sampleLayer: DataLayerConfig = { table: createSampleDatatableWithRows([]), }; +export const sampleExtendedLayer: ExtendedDataLayerConfig = { + layerId: 'first', + type: 'extendedDataLayer', + layerType: LayerTypes.DATA, + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + isHistogram: false, + palette: mockPaletteOutput, + table: createSampleDatatableWithRows([]), +}; + export const createArgsWithLayers = ( layers: DataLayerConfig | DataLayerConfig[] = sampleLayer ): XYProps => ({ diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap new file mode 100644 index 0000000000000..68262f8a4f3de --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/extended_data_layer.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`extendedDataLayerConfig throws the error if markSizeAccessor doesn't have the corresponding column in the table 1`] = `"Provided column name or index is invalid: nonsense"`; + +exports[`extendedDataLayerConfig throws the error if markSizeAccessor is provided to the not line/area chart 1`] = `"\`markSizeAccessor\` can't be used. Dots are applied only for line or area charts"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap new file mode 100644 index 0000000000000..b8e7cb8c05d3f --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/layered_xy_vis.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`layeredXyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 1`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`layeredXyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 2`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`layeredXyVis it should throw error if markSizeRatio is specified if no markSizeAccessor is present 1`] = `"Mark size ratio can be applied only with \`markSizeAccessor\`"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap index 3a33797bc0cbf..05109cc65446b 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap @@ -1,5 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 1`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`xyVis it should throw error if markSizeRatio is lower then 1 or greater then 100 2`] = `"Mark size ratio must be greater or equal to 1 and less or equal to 100"`; + +exports[`xyVis it should throw error if markSizeRatio is specified while markSizeAccessor is not 1`] = `"Mark size ratio can be applied only with \`markSizeAccessor\`"`; + exports[`xyVis it should throw error if minTimeBarInterval applied for not time bar chart 1`] = `"\`minTimeBarInterval\` argument is applicable only for time bar charts."`; exports[`xyVis it should throw error if minTimeBarInterval is invalid 1`] = `"Provided x-axis interval is invalid. The interval should include quantity and unit names. Examples: 1d, 24h, 1w."`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts index a09212d59cce3..0921760f9f676 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_xy_args.ts @@ -128,6 +128,10 @@ export const commonXYArgs: CommonXYFn['args'] = { types: ['string'], help: strings.getAriaLabelHelp(), }, + markSizeRatio: { + types: ['number'], + help: strings.getMarkSizeRatioHelp(), + }, minTimeBarInterval: { types: ['string'], help: strings.getMinTimeBarIntervalHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts new file mode 100644 index 0000000000000..5b943b0790313 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ExtendedDataLayerArgs } from '../types'; +import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { mockPaletteOutput, sampleArgs } from '../__mocks__'; +import { LayerTypes } from '../constants'; +import { extendedDataLayerFunction } from './extended_data_layer'; + +describe('extendedDataLayerConfig', () => { + test('produces the correct arguments', async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'b', + }; + + const result = await extendedDataLayerFunction.fn(data, args, createMockExecutionContext()); + + expect(result).toEqual({ + type: 'extendedDataLayer', + layerType: LayerTypes.DATA, + ...args, + table: data, + }); + }); + + test('throws the error if markSizeAccessor is provided to the not line/area chart', async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'bar', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'b', + }; + + expect( + extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test("throws the error if markSizeAccessor doesn't have the corresponding column in the table", async () => { + const { data } = sampleArgs(); + const args: ExtendedDataLayerArgs = { + seriesType: 'line', + xAccessor: 'c', + accessors: ['a', 'b'], + splitAccessor: 'd', + xScaleType: 'linear', + isHistogram: false, + palette: mockPaletteOutput, + markSizeAccessor: 'nonsense', + }; + + expect( + extendedDataLayerFunction.fn(data, args, createMockExecutionContext()) + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts index a7aa63645d119..58da88a8d4b25 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer.ts @@ -32,6 +32,10 @@ export const extendedDataLayerFunction: ExtendedDataLayerFn = { help: strings.getAccessorsHelp(), multi: true, }, + markSizeAccessor: { + types: ['string'], + help: strings.getMarkSizeAccessorHelp(), + }, table: { types: ['datatable'], help: strings.getTableHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts index 47e62f9ccae4a..8e5019e065133 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -10,6 +10,7 @@ import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; import { getAccessors, normalizeTable } from '../helpers'; +import { validateMarkSizeForChartType } from './validate'; export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, context) => { const table = args.table ?? data; @@ -18,6 +19,8 @@ export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, validateAccessor(accessors.xAccessor, table.columns); validateAccessor(accessors.splitAccessor, table.columns); accessors.accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + validateMarkSizeForChartType(args.markSizeAccessor, args.seriesType); + validateAccessor(args.markSizeAccessor, table.columns); const normalizedTable = normalizeTable(table, accessors.xAccessor); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts new file mode 100644 index 0000000000000..79427cbe4d3cc --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { layeredXyVisFunction } from '.'; +import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; +import { sampleArgs, sampleExtendedLayer } from '../__mocks__'; +import { XY_VIS } from '../constants'; + +describe('layeredXyVis', () => { + test('it renders with the specified data and args', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const result = await layeredXyVisFunction.fn( + data, + { ...rest, layers: [sampleExtendedLayer] }, + createMockExecutionContext() + ); + + expect(result).toEqual({ + type: 'render', + as: XY_VIS, + value: { args: { ...rest, layers: [sampleExtendedLayer] } }, + }); + }); + + test('it should throw error if markSizeRatio is lower then 1 or greater then 100', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 0, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 101, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('it should throw error if markSizeRatio is specified if no markSizeAccessor is present', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + + expect( + layeredXyVisFunction.fn( + data, + { + ...rest, + markSizeRatio: 10, + layers: [sampleExtendedLayer], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts index c4e2decb3279d..29624d8037393 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts @@ -10,7 +10,12 @@ import { XY_VIS_RENDERER } from '../constants'; import { appendLayerIds, getDataLayers } from '../helpers'; import { LayeredXyVisFn } from '../types'; import { logDatatables } from '../utils'; -import { validateMinTimeBarInterval, hasBarLayer } from './validate'; +import { + validateMarkSizeRatioLimits, + validateMinTimeBarInterval, + hasBarLayer, + errors, +} from './validate'; export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) => { const layers = appendLayerIds(args.layers ?? [], 'layers'); @@ -19,7 +24,14 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) const dataLayers = getDataLayers(layers); const hasBar = hasBarLayer(dataLayers); + validateMarkSizeRatioLimits(args.markSizeRatio); validateMinTimeBarInterval(dataLayers, hasBar, args.minTimeBarInterval); + const hasMarkSizeAccessors = + dataLayers.filter((dataLayer) => dataLayer.markSizeAccessor !== undefined).length > 0; + + if (!hasMarkSizeAccessors && args.markSizeRatio !== undefined) { + throw new Error(errors.markSizeRatioWithoutAccessor()); + } return { type: 'render', @@ -28,6 +40,7 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) args: { ...args, layers, + markSizeRatio: hasMarkSizeAccessors && !args.markSizeRatio ? 10 : args.markSizeRatio, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts index 2d1ecb2840c0a..60e590b0f8cca 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/validate.ts @@ -8,8 +8,10 @@ import { i18n } from '@kbn/i18n'; import { isValidInterval } from '@kbn/data-plugin/common'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { AxisExtentModes, ValueLabelModes } from '../constants'; import { + SeriesType, AxisExtentConfigResult, DataLayerConfigResult, CommonXYDataLayerConfigResult, @@ -18,7 +20,23 @@ import { } from '../types'; import { isTimeChart } from '../helpers'; -const errors = { +export const errors = { + markSizeAccessorForNonLineOrAreaChartsError: () => + i18n.translate( + 'expressionXY.reusable.function.dataLayer.errors.markSizeAccessorForNonLineOrAreaChartsError', + { + defaultMessage: + "`markSizeAccessor` can't be used. Dots are applied only for line or area charts", + } + ), + markSizeRatioLimitsError: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeLimitsError', { + defaultMessage: 'Mark size ratio must be greater or equal to 1 and less or equal to 100', + }), + markSizeRatioWithoutAccessor: () => + i18n.translate('expressionXY.reusable.function.xyVis.errors.markSizeRatioWithoutAccessor', { + defaultMessage: 'Mark size ratio can be applied only with `markSizeAccessor`', + }), extendBoundsAreInvalidError: () => i18n.translate('expressionXY.reusable.function.xyVis.errors.extendBoundsAreInvalidError', { defaultMessage: @@ -117,6 +135,30 @@ export const validateValueLabels = ( } }; +export const validateMarkSizeForChartType = ( + markSizeAccessor: ExpressionValueVisDimension | string | undefined, + seriesType: SeriesType +) => { + if (markSizeAccessor && !seriesType.includes('line') && !seriesType.includes('area')) { + throw new Error(errors.markSizeAccessorForNonLineOrAreaChartsError()); + } +}; + +export const validateMarkSizeRatioLimits = (markSizeRatio?: number) => { + if (markSizeRatio !== undefined && (markSizeRatio < 1 || markSizeRatio > 100)) { + throw new Error(errors.markSizeRatioLimitsError()); + } +}; + +export const validateMarkSizeRatioWithAccessor = ( + markSizeRatio: number | undefined, + markSizeAccessor: ExpressionValueVisDimension | string | undefined +) => { + if (markSizeRatio !== undefined && !markSizeAccessor) { + throw new Error(errors.markSizeRatioWithoutAccessor()); + } +}; + export const validateMinTimeBarInterval = ( dataLayers: CommonXYDataLayerConfigResult[], hasBar: boolean, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 9348e489ab391..8ec1961416638 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -50,6 +50,37 @@ describe('xyVis', () => { }); }); + test('it should throw error if markSizeRatio is lower then 1 or greater then 100', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...{ ...sampleLayer, markSizeAccessor: 'b' }, + markSizeRatio: 0, + referenceLineLayers: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...{ ...sampleLayer, markSizeAccessor: 'b' }, + markSizeRatio: 101, + referenceLineLayers: [], + annotationLayers: [], + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); test('it should throw error if minTimeBarInterval is invalid', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; @@ -129,4 +160,24 @@ describe('xyVis', () => { ) ).rejects.toThrowErrorMatchingSnapshot(); }); + + test('it should throw error if markSizeRatio is specified while markSizeAccessor is not', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + referenceLineLayers: [], + annotationLayers: [], + markSizeRatio: 5, + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts index e4e519b0a7433..37baf028178cc 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.ts @@ -51,6 +51,10 @@ export const xyVisFunction: XyVisFn = { types: ['vis_dimension', 'string'], help: strings.getSplitRowAccessorHelp(), }, + markSizeAccessor: { + types: ['vis_dimension', 'string'], + help: strings.getMarkSizeAccessorHelp(), + }, }, async fn(data, args, handlers) { const { xyVisFn } = await import('./xy_vis_fn'); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 292e69988c37e..e879f33b76548 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -23,8 +23,11 @@ import { hasHistogramBarLayer, validateExtent, validateFillOpacity, + validateMarkSizeRatioLimits, validateValueLabels, validateMinTimeBarInterval, + validateMarkSizeForChartType, + validateMarkSizeRatioWithAccessor, } from './validate'; const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => { @@ -63,6 +66,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { isHistogram, yConfig, palette, + markSizeAccessor, ...restArgs } = args; @@ -72,6 +76,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { validateAccessor(dataLayers[0].splitAccessor, data.columns); dataLayers[0].accessors.forEach((accessor) => validateAccessor(accessor, data.columns)); + validateMarkSizeForChartType(dataLayers[0].markSizeAccessor, args.seriesType); + validateAccessor(dataLayers[0].markSizeAccessor, data.columns); + const layers: XYLayerConfig[] = [ ...appendLayerIds(dataLayers, 'dataLayers'), ...appendLayerIds(referenceLineLayers, 'referenceLineLayers'), @@ -105,6 +112,8 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { const hasNotHistogramBars = !hasHistogramBarLayer(dataLayers); validateValueLabels(args.valueLabels, hasBar, hasNotHistogramBars); + validateMarkSizeRatioWithAccessor(args.markSizeRatio, dataLayers[0].markSizeAccessor); + validateMarkSizeRatioLimits(args.markSizeRatio); return { type: 'render', @@ -113,6 +122,8 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { args: { ...restArgs, layers, + markSizeRatio: + dataLayers[0].markSizeAccessor && !args.markSizeRatio ? 10 : args.markSizeRatio, ariaLabel: args.ariaLabel ?? (handlers.variables?.embeddableTitle as string) ?? diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts index 23aa8bd3218d2..b70211e4b0682 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts @@ -35,19 +35,24 @@ export function getDataLayers(layers: XYExtendedLayerConfigResult[]) { ); } -export function getAccessors( - args: U, - table: Datatable -) { +export function getAccessors< + T, + U extends { splitAccessor?: T; xAccessor?: T; accessors: T[]; markSizeAccessor?: T } +>(args: U, table: Datatable) { let splitAccessor: T | string | undefined = args.splitAccessor; let xAccessor: T | string | undefined = args.xAccessor; let accessors: Array = args.accessors ?? []; - if (!splitAccessor && !xAccessor && !(accessors && accessors.length)) { + let markSizeAccessor: T | string | undefined = args.markSizeAccessor; + + if (!splitAccessor && !xAccessor && !(accessors && accessors.length) && !markSizeAccessor) { const y = table.columns.find((column) => column.id === PointSeriesColumnNames.Y)?.id; xAccessor = table.columns.find((column) => column.id === PointSeriesColumnNames.X)?.id; splitAccessor = table.columns.find((column) => column.id === PointSeriesColumnNames.COLOR)?.id; accessors = y ? [y] : []; + markSizeAccessor = table.columns.find( + (column) => column.id === PointSeriesColumnNames.SIZE + )?.id; } - return { splitAccessor, xAccessor, accessors }; + return { splitAccessor, xAccessor, accessors, markSizeAccessor }; } diff --git a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx index 21230643fe078..f3425ec2db625 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -121,6 +121,10 @@ export const strings = { i18n.translate('expressionXY.xyVis.ariaLabel.help', { defaultMessage: 'Specifies the aria label of the xy chart', }), + getMarkSizeRatioHelp: () => + i18n.translate('expressionXY.xyVis.markSizeRatio.help', { + defaultMessage: 'Specifies the ratio of the dots at the line and area charts', + }), getMinTimeBarIntervalHelp: () => i18n.translate('expressionXY.xyVis.xAxisInterval.help', { defaultMessage: 'Specifies the min interval for time bar chart', @@ -169,6 +173,10 @@ export const strings = { i18n.translate('expressionXY.dataLayer.accessors.help', { defaultMessage: 'The columns to display on the y axis.', }), + getMarkSizeAccessorHelp: () => + i18n.translate('expressionXY.dataLayer.markSizeAccessor.help', { + defaultMessage: 'Mark size accessor', + }), getYConfigHelp: () => i18n.translate('expressionXY.dataLayer.yConfig.help', { defaultMessage: 'Additional configuration for y axes', diff --git a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts index a9910032699e0..0e10f680811ec 100644 --- a/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_xy/common/types/expression_functions.ts @@ -100,6 +100,7 @@ export interface DataLayerArgs { xAccessor?: string | ExpressionValueVisDimension; hide?: boolean; splitAccessor?: string | ExpressionValueVisDimension; + markSizeAccessor?: string | ExpressionValueVisDimension; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; @@ -118,10 +119,12 @@ export interface ExtendedDataLayerArgs { xAccessor?: string; hide?: boolean; splitAccessor?: string; + markSizeAccessor?: string; columnToLabel?: string; // Actually a JSON key-value pair xScaleType: XScaleType; isHistogram: boolean; palette: PaletteOutput; + // palette will always be set on the expression yConfig?: YConfigResult[]; table?: Datatable; } @@ -203,6 +206,7 @@ export interface XYArgs extends DataLayerArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; splitColumnAccessor?: ExpressionValueVisDimension | string; @@ -231,6 +235,7 @@ export interface LayeredXYArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; } @@ -257,6 +262,7 @@ export interface XYProps { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + markSizeRatio?: number; minTimeBarInterval?: string; splitRowAccessor?: ExpressionValueVisDimension | string; splitColumnAccessor?: ExpressionValueVisDimension | string; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 0bc41100012de..e7a26ec20bbfc 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -324,6 +324,7 @@ exports[`XYChart component it renders area 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -645,7 +646,7 @@ exports[`XYChart component it renders area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -735,7 +736,7 @@ exports[`XYChart component it renders area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -868,6 +869,7 @@ exports[`XYChart component it renders bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -1189,7 +1191,7 @@ exports[`XYChart component it renders bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1279,7 +1281,7 @@ exports[`XYChart component it renders bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1412,6 +1414,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -1733,7 +1736,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1823,7 +1826,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -1956,6 +1959,7 @@ exports[`XYChart component it renders line 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -2277,7 +2281,7 @@ exports[`XYChart component it renders line 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2367,7 +2371,7 @@ exports[`XYChart component it renders line 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2500,6 +2504,7 @@ exports[`XYChart component it renders stacked area 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -2821,7 +2826,7 @@ exports[`XYChart component it renders stacked area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -2911,7 +2916,7 @@ exports[`XYChart component it renders stacked area 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3044,6 +3049,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -3365,7 +3371,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3455,7 +3461,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3588,6 +3594,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -3909,7 +3916,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -3999,7 +4006,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4132,6 +4139,7 @@ exports[`XYChart component split chart should render split chart if both, splitR "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -4210,7 +4218,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4708,7 +4716,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4798,7 +4806,7 @@ exports[`XYChart component split chart should render split chart if both, splitR }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -4931,6 +4939,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -5009,7 +5018,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5506,7 +5515,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5596,7 +5605,7 @@ exports[`XYChart component split chart should render split chart if splitColumnA }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -5729,6 +5738,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce "maxLines": 0, }, }, + "markSizeRatio": undefined, } } tooltip={ @@ -5807,7 +5817,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -6304,7 +6314,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, @@ -6394,7 +6404,7 @@ exports[`XYChart component split chart should render split chart if splitRowAcce }, "type": "date-histogram", }, - "type": "date", + "type": "string", }, "name": "c", }, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index 62f23ba86a166..d03a5e648f366 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -13,6 +13,7 @@ import { AreaSeries, Axis, BarSeries, + ColorVariant, Fit, GeometryValue, GroupBy, @@ -687,6 +688,40 @@ describe('XYChart component', () => { expect(component.find(Settings).at(0).prop('showLegendExtra')).toEqual(true); }); + test('applies the mark size ratio', () => { + const { args } = sampleArgs(); + const markSizeRatioArg = { markSizeRatio: 50 }; + const component = shallow( + + ); + expect(component.find(Settings).at(0).prop('theme')).toEqual( + expect.objectContaining(markSizeRatioArg) + ); + }); + + test('applies the mark size accessor', () => { + const { args } = sampleArgs(); + const markSizeAccessorArg = { markSizeAccessor: 'b' }; + const component = shallow( + + ); + const dataLayers = component.find(DataLayers).dive(); + const lineArea = dataLayers.find(LineSeries).at(0); + expect(lineArea.prop('markSizeAccessor')).toEqual(markSizeAccessorArg.markSizeAccessor); + const expectedSeriesStyle = expect.objectContaining({ + point: expect.objectContaining({ + visible: true, + fill: ColorVariant.Series, + }), + }); + + expect(lineArea.prop('areaSeriesStyle')).toEqual(expectedSeriesStyle); + expect(lineArea.prop('lineSeriesStyle')).toEqual(expectedSeriesStyle); + }); + test('it renders bar', () => { const { args } = sampleArgs(); const component = shallow( @@ -2132,6 +2167,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, layers: [ { layerId: 'first', @@ -2219,6 +2255,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, yLeftScale: 'linear', yRightScale: 'linear', layers: [ @@ -2292,6 +2329,7 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + markSizeRatio: 1, yLeftScale: 'linear', yRightScale: 'linear', layers: [ diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 7b31112c4b9ed..9bb3ea4f498e4 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -48,17 +48,15 @@ import { getAnnotationsLayers, getDataLayers, Series, - getFormattedTablesByLayers, - validateExtent, getFormat, -} from '../helpers'; -import { + getFormattedTablesByLayers, getFilteredLayers, getReferenceLayers, isDataLayer, getAxesConfiguration, GroupsConfiguration, getLinesCausedPaddings, + validateExtent, } from '../helpers'; import { getXDomain, XyEndzones } from './x_domain'; import { getLegendAction } from './legend_action'; @@ -571,6 +569,7 @@ export function XYChart({ shouldRotate ), }, + markSizeRatio: args.markSizeRatio, }} baseTheme={chartBaseTheme} tooltip={{ diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx index c2a7c847e150b..7ac661ed9709d 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/data_layers.tsx @@ -226,9 +226,14 @@ const getSeriesName: GetSeriesNameFn = ( return splitColumnId ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null; }; -const getPointConfig = (xAccessor?: string, emphasizeFitting?: boolean) => ({ - visible: !xAccessor, +const getPointConfig = ( + xAccessor: string | undefined, + markSizeAccessor: string | undefined, + emphasizeFitting?: boolean +) => ({ + visible: !xAccessor || markSizeAccessor !== undefined, radius: xAccessor && !emphasizeFitting ? 5 : 0, + fill: markSizeAccessor ? ColorVariant.Series : undefined, }); const getLineConfig = () => ({ visible: true, stroke: ColorVariant.Series, opacity: 1, dash: [] }); @@ -276,7 +281,7 @@ export const getSeriesProps: GetSeriesPropsFn = ({ fillOpacity, formattedDatatableInfo, }): SeriesSpec => { - const { table } = layer; + const { table, markSizeAccessor } = layer; const isStacked = layer.seriesType.includes('stacked'); const isPercentage = layer.seriesType.includes('percentage'); const isBarChart = layer.seriesType.includes('bar'); @@ -294,6 +299,14 @@ export const getSeriesProps: GetSeriesPropsFn = ({ : undefined; const splitFormatter = formatFactory(splitHint); + const markSizeColumnId = markSizeAccessor + ? getAccessorByDimension(markSizeAccessor, table.columns) + : undefined; + + const markFormatter = formatFactory( + markSizeAccessor ? getFormat(table.columns, markSizeAccessor) : undefined + ); + // what if row values are not primitive? That is the case of, for instance, Ranges // remaps them to their serialized version with the formatHint metadata // In order to do it we need to make a copy of the table as the raw one is required for more features (filters, etc...) later on @@ -326,6 +339,8 @@ export const getSeriesProps: GetSeriesPropsFn = ({ id: splitColumnId ? `${splitColumnId}-${accessor}` : accessor, xAccessor: xColumnId || 'unifiedX', yAccessors: [accessor], + markSizeAccessor: markSizeColumnId, + markFormat: (value) => markFormatter.convert(value), data: rows, xScaleType: xColumnId ? layer.xScaleType : 'ordinal', yScaleType: @@ -346,14 +361,14 @@ export const getSeriesProps: GetSeriesPropsFn = ({ stackMode: isPercentage ? StackMode.Percentage : undefined, timeZone, areaSeriesStyle: { - point: getPointConfig(xColumnId, emphasizeFitting), + point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), ...(fillOpacity && { area: { opacity: fillOpacity } }), ...(emphasizeFitting && { fit: { area: { opacity: fillOpacity || 0.5 }, line: getLineConfig() }, }), }, lineSeriesStyle: { - point: getPointConfig(xColumnId, emphasizeFitting), + point: getPointConfig(xColumnId, markSizeColumnId, emphasizeFitting), ...(emphasizeFitting && { fit: { line: getLineConfig() } }), }, name(d) { diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx index b410e240151d7..fe88d651c12f1 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.test.mock.tsx @@ -16,10 +16,6 @@ jest.mock('../../../../contexts/editor_context/editor_registry', () => ({ }, })); jest.mock('../../../../components/editor_example', () => {}); -jest.mock('../../../../../lib/mappings/mappings', () => ({ - retrieveAutoCompleteInfo: () => {}, - clearSubscriptions: () => {}, -})); jest.mock('../../../../models/sense_editor', () => { return { create: () => ({ diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index d01a40bdd44b3..9219c6e076ca0 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -20,8 +20,6 @@ import { decompressFromEncodedURIComponent } from 'lz-string'; import { parse } from 'query-string'; import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import { ace } from '@kbn/es-ui-shared-plugin/public'; -// @ts-ignore -import { retrieveAutoCompleteInfo, clearSubscriptions } from '../../../../../lib/mappings/mappings'; import { ConsoleMenu } from '../../../../components'; import { useEditorReadContext, useServicesContext } from '../../../../contexts'; import { @@ -66,7 +64,14 @@ const inputId = 'ConAppInputTextarea'; function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { const { - services: { history, notifications, settings: settingsService, esHostService, http }, + services: { + history, + notifications, + settings: settingsService, + esHostService, + http, + autocompleteInfo, + }, docLinkVersion, } = useServicesContext(); @@ -196,14 +201,14 @@ function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { setInputEditor(editor); setTextArea(editorRef.current!.querySelector('textarea')); - retrieveAutoCompleteInfo(http, settingsService, settingsService.getAutocomplete()); + autocompleteInfo.retrieve(settingsService, settingsService.getAutocomplete()); const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor); setupAutosave(); return () => { unsubscribeResizer(); - clearSubscriptions(); + autocompleteInfo.clearSubscriptions(); window.removeEventListener('hashchange', onHashChange); if (editorInstanceRef.current) { editorInstanceRef.current.getCoreEditor().destroy(); @@ -217,6 +222,7 @@ function EditorUI({ initialTextValue, setEditorInstance }: EditorProps) { setInputEditor, settingsService, http, + autocompleteInfo, ]); useEffect(() => { diff --git a/src/plugins/console/public/application/containers/settings.tsx b/src/plugins/console/public/application/containers/settings.tsx index b4cbea5833f32..b9a9d68294e6d 100644 --- a/src/plugins/console/public/application/containers/settings.tsx +++ b/src/plugins/console/public/application/containers/settings.tsx @@ -8,11 +8,8 @@ import React from 'react'; -import type { HttpSetup } from '@kbn/core/public'; import { AutocompleteOptions, DevToolsSettingsModal } from '../components'; -// @ts-ignore -import { retrieveAutoCompleteInfo } from '../../lib/mappings/mappings'; import { useServicesContext, useEditorActionContext } from '../contexts'; import { DevToolsSettings, Settings as SettingsService } from '../../services'; import type { SenseEditor } from '../models'; @@ -27,48 +24,6 @@ const getAutocompleteDiff = ( }) as AutocompleteOptions[]; }; -const refreshAutocompleteSettings = ( - http: HttpSetup, - settings: SettingsService, - selectedSettings: DevToolsSettings['autocomplete'] -) => { - retrieveAutoCompleteInfo(http, settings, selectedSettings); -}; - -const fetchAutocompleteSettingsIfNeeded = ( - http: HttpSetup, - settings: SettingsService, - newSettings: DevToolsSettings, - prevSettings: DevToolsSettings -) => { - // We'll only retrieve settings if polling is on. The expectation here is that if the user - // disables polling it's because they want manual control over the fetch request (possibly - // because it's a very expensive request given their cluster and bandwidth). In that case, - // they would be unhappy with any request that's sent automatically. - if (newSettings.polling) { - const autocompleteDiff = getAutocompleteDiff(newSettings, prevSettings); - - const isSettingsChanged = autocompleteDiff.length > 0; - const isPollingChanged = prevSettings.polling !== newSettings.polling; - - if (isSettingsChanged) { - // If the user has changed one of the autocomplete settings, then we'll fetch just the - // ones which have changed. - const changedSettings: DevToolsSettings['autocomplete'] = autocompleteDiff.reduce( - (changedSettingsAccum, setting) => { - changedSettingsAccum[setting] = newSettings.autocomplete[setting]; - return changedSettingsAccum; - }, - {} as DevToolsSettings['autocomplete'] - ); - retrieveAutoCompleteInfo(http, settings, changedSettings); - } else if (isPollingChanged && newSettings.polling) { - // If the user has turned polling on, then we'll fetch all selected autocomplete settings. - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); - } - } -}; - export interface Props { onClose: () => void; editorInstance: SenseEditor | null; @@ -76,14 +31,57 @@ export interface Props { export function Settings({ onClose, editorInstance }: Props) { const { - services: { settings, http }, + services: { settings, autocompleteInfo }, } = useServicesContext(); const dispatch = useEditorActionContext(); + const refreshAutocompleteSettings = ( + settingsService: SettingsService, + selectedSettings: DevToolsSettings['autocomplete'] + ) => { + autocompleteInfo.retrieve(settingsService, selectedSettings); + }; + + const fetchAutocompleteSettingsIfNeeded = ( + settingsService: SettingsService, + newSettings: DevToolsSettings, + prevSettings: DevToolsSettings + ) => { + // We'll only retrieve settings if polling is on. The expectation here is that if the user + // disables polling it's because they want manual control over the fetch request (possibly + // because it's a very expensive request given their cluster and bandwidth). In that case, + // they would be unhappy with any request that's sent automatically. + if (newSettings.polling) { + const autocompleteDiff = getAutocompleteDiff(newSettings, prevSettings); + + const isSettingsChanged = autocompleteDiff.length > 0; + const isPollingChanged = prevSettings.polling !== newSettings.polling; + + if (isSettingsChanged) { + // If the user has changed one of the autocomplete settings, then we'll fetch just the + // ones which have changed. + const changedSettings: DevToolsSettings['autocomplete'] = autocompleteDiff.reduce( + (changedSettingsAccum, setting) => { + changedSettingsAccum[setting] = newSettings.autocomplete[setting]; + return changedSettingsAccum; + }, + {} as DevToolsSettings['autocomplete'] + ); + autocompleteInfo.retrieve(settingsService, { + ...settingsService.getAutocomplete(), + ...changedSettings, + }); + } else if (isPollingChanged && newSettings.polling) { + // If the user has turned polling on, then we'll fetch all selected autocomplete settings. + autocompleteInfo.retrieve(settingsService, settingsService.getAutocomplete()); + } + } + }; + const onSaveSettings = (newSettings: DevToolsSettings) => { const prevSettings = settings.toJSON(); - fetchAutocompleteSettingsIfNeeded(http, settings, newSettings, prevSettings); + fetchAutocompleteSettingsIfNeeded(settings, newSettings, prevSettings); // Update the new settings in localStorage settings.updateSettings(newSettings); @@ -101,7 +99,7 @@ export function Settings({ onClose, editorInstance }: Props) { onClose={onClose} onSaveSettings={onSaveSettings} refreshAutocompleteSettings={(selectedSettings) => - refreshAutocompleteSettings(http, settings, selectedSettings) + refreshAutocompleteSettings(settings, selectedSettings) } settings={settings.toJSON()} editorInstance={editorInstance} diff --git a/src/plugins/console/public/application/contexts/services_context.mock.ts b/src/plugins/console/public/application/contexts/services_context.mock.ts index 5ede7f58d4bdc..5d3c7ea6e172d 100644 --- a/src/plugins/console/public/application/contexts/services_context.mock.ts +++ b/src/plugins/console/public/application/contexts/services_context.mock.ts @@ -17,6 +17,7 @@ import type { ObjectStorageClient } from '../../../common/types'; import { HistoryMock } from '../../services/history.mock'; import { SettingsMock } from '../../services/settings.mock'; import { StorageMock } from '../../services/storage.mock'; +import { AutocompleteInfoMock } from '../../services/autocomplete.mock'; import { createApi, createEsHostService } from '../lib'; import { ContextValue } from './services_context'; @@ -38,6 +39,7 @@ export const serviceContextMock = { notifications: notificationServiceMock.createSetupContract(), objectStorageClient: {} as unknown as ObjectStorageClient, http, + autocompleteInfo: new AutocompleteInfoMock(), }, docLinkVersion: 'NA', theme$: themeServiceMock.create().start().theme$, diff --git a/src/plugins/console/public/application/contexts/services_context.tsx b/src/plugins/console/public/application/contexts/services_context.tsx index c60e41d8f14bb..f133a49ca1fe1 100644 --- a/src/plugins/console/public/application/contexts/services_context.tsx +++ b/src/plugins/console/public/application/contexts/services_context.tsx @@ -10,7 +10,7 @@ import React, { createContext, useContext, useEffect } from 'react'; import { Observable } from 'rxjs'; import type { NotificationsSetup, CoreTheme, DocLinksStart, HttpSetup } from '@kbn/core/public'; -import { History, Settings, Storage } from '../../services'; +import { AutocompleteInfo, History, Settings, Storage } from '../../services'; import { ObjectStorageClient } from '../../../common/types'; import { MetricsTracker } from '../../types'; import { EsHostService } from '../lib'; @@ -24,6 +24,7 @@ interface ContextServices { trackUiMetric: MetricsTracker; esHostService: EsHostService; http: HttpSetup; + autocompleteInfo: AutocompleteInfo; } export interface ContextValue { diff --git a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts b/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts index ed08304d8d660..6cd1eaddc3583 100644 --- a/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts +++ b/src/plugins/console/public/application/hooks/use_send_current_request/use_send_current_request.ts @@ -11,8 +11,6 @@ import { useCallback } from 'react'; import { toMountPoint } from '../../../shared_imports'; import { isQuotaExceededError } from '../../../services/history'; -// @ts-ignore -import { retrieveAutoCompleteInfo } from '../../../lib/mappings/mappings'; import { instance as registry } from '../../contexts/editor_context/editor_registry'; import { useRequestActionContext, useServicesContext } from '../../contexts'; import { StorageQuotaError } from '../../components/storage_quota_error'; @@ -21,7 +19,7 @@ import { track } from './track'; export const useSendCurrentRequest = () => { const { - services: { history, settings, notifications, trackUiMetric, http }, + services: { history, settings, notifications, trackUiMetric, http, autocompleteInfo }, theme$, } = useServicesContext(); @@ -102,7 +100,7 @@ export const useSendCurrentRequest = () => { // or templates may have changed, so we'll need to update this data. Assume that if // the user disables polling they're trying to optimize performance or otherwise // preserve resources, so they won't want this request sent either. - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); + autocompleteInfo.retrieve(settings, settings.getAutocomplete()); } dispatch({ @@ -129,5 +127,14 @@ export const useSendCurrentRequest = () => { }); } } - }, [dispatch, http, settings, notifications.toasts, trackUiMetric, history, theme$]); + }, [ + dispatch, + http, + settings, + notifications.toasts, + trackUiMetric, + history, + theme$, + autocompleteInfo, + ]); }; diff --git a/src/plugins/console/public/application/index.tsx b/src/plugins/console/public/application/index.tsx index 1950ab0c37951..e9f37c232eeaa 100644 --- a/src/plugins/console/public/application/index.tsx +++ b/src/plugins/console/public/application/index.tsx @@ -19,7 +19,7 @@ import { import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { KibanaThemeProvider } from '../shared_imports'; -import { createStorage, createHistory, createSettings } from '../services'; +import { createStorage, createHistory, createSettings, AutocompleteInfo } from '../services'; import { createUsageTracker } from '../services/tracker'; import * as localStorageObjectClient from '../lib/local_storage_object_client'; import { Main } from './containers'; @@ -35,6 +35,7 @@ export interface BootDependencies { element: HTMLElement; theme$: Observable; docLinks: DocLinksStart['links']; + autocompleteInfo: AutocompleteInfo; } export function renderApp({ @@ -46,6 +47,7 @@ export function renderApp({ http, theme$, docLinks, + autocompleteInfo, }: BootDependencies) { const trackUiMetric = createUsageTracker(usageCollection); trackUiMetric.load('opened_app'); @@ -76,6 +78,7 @@ export function renderApp({ trackUiMetric, objectStorageClient, http, + autocompleteInfo, }, theme$, }} diff --git a/src/plugins/console/public/application/models/sense_editor/integration.test.js b/src/plugins/console/public/application/models/sense_editor/integration.test.js index e60b4175f668f..9159e0d08740e 100644 --- a/src/plugins/console/public/application/models/sense_editor/integration.test.js +++ b/src/plugins/console/public/application/models/sense_editor/integration.test.js @@ -12,10 +12,12 @@ import _ from 'lodash'; import $ from 'jquery'; import * as kb from '../../../lib/kb/kb'; -import * as mappings from '../../../lib/mappings/mappings'; +import { AutocompleteInfo, setAutocompleteInfo } from '../../../services'; describe('Integration', () => { let senseEditor; + let autocompleteInfo; + beforeEach(() => { // Set up our document body document.body.innerHTML = @@ -24,10 +26,14 @@ describe('Integration', () => { senseEditor = create(document.querySelector('#ConAppEditor')); $(senseEditor.getCoreEditor().getContainer()).show(); senseEditor.autocomplete._test.removeChangeListener(); + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); }); afterEach(() => { $(senseEditor.getCoreEditor().getContainer()).hide(); senseEditor.autocomplete._test.addChangeListener(); + autocompleteInfo = null; + setAutocompleteInfo(null); }); function processContextTest(data, mapping, kbSchemes, requestLine, testToRun) { @@ -45,8 +51,8 @@ describe('Integration', () => { testToRun.cursor.lineNumber += lineOffset; - mappings.clear(); - mappings.loadMappings(mapping); + autocompleteInfo.clear(); + autocompleteInfo.mapping.loadMappings(mapping); const json = {}; json[test.name] = kbSchemes || {}; const testApi = kb._test.loadApisFromJson(json); diff --git a/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js index ca59e077116e4..2b547d698415c 100644 --- a/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getComponentTemplates } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; export class ComponentTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getComponentTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('componentTemplates'), parent, true, true); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js index 015136b7670f5..0b043410c3b25 100644 --- a/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/data_stream_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getDataStreams } from '../../mappings/mappings'; import { ListComponent } from './list_component'; +import { getAutocompleteInfo } from '../../../services'; export class DataStreamAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getDataStreams, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('dataStreams'), parent, multiValued); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js index 76cd37b7e8d99..e3257b2bd86b8 100644 --- a/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/field_autocomplete_component.js @@ -7,11 +7,11 @@ */ import _ from 'lodash'; -import { getFields } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; function FieldGenerator(context) { - return _.map(getFields(context.indices, context.types), function (field) { + return _.map(getAutocompleteInfo().getEntityProvider('fields', context), function (field) { return { name: field.name, meta: field.type }; }); } diff --git a/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js index 0ec53be7e56af..c2a7e2fb14286 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_autocomplete_component.js @@ -7,14 +7,16 @@ */ import _ from 'lodash'; -import { getIndices } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; + function nonValidIndexType(token) { return !(token === '_all' || token[0] !== '_'); } + export class IndexAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getIndices, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('indices'), parent, multiValued); } validateTokens(tokens) { if (!this.multiValued && tokens.length > 1) { diff --git a/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js index 444e40e756f7b..7bb3c32239751 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getIndexTemplates } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; export class IndexTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getIndexTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('indexTemplates'), parent, true, true); } getContextKey() { diff --git a/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js index b68ae952702f5..73a9e3ea65c17 100644 --- a/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { getLegacyTemplates } from '../../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../../services'; import { ListComponent } from '../list_component'; export class LegacyTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getLegacyTemplates, parent, true, true); + super(name, getAutocompleteInfo().getEntityProvider('legacyTemplates'), parent, true, true); } getContextKey() { return 'template'; diff --git a/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js index bab45f28710e0..f7caf05e5805f 100644 --- a/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/type_autocomplete_component.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { ListComponent } from './list_component'; -import { getTypes } from '../../mappings/mappings'; +import { getTypes } from '../../autocomplete_entities'; function TypeGenerator(context) { return getTypes(context.indices); } diff --git a/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js index 78b24f26444d6..c505f66a68b0c 100644 --- a/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/username_autocomplete_component.js @@ -7,14 +7,16 @@ */ import _ from 'lodash'; -import { getIndices } from '../../mappings/mappings'; +import { getAutocompleteInfo } from '../../../services'; import { ListComponent } from './list_component'; + function nonValidUsernameType(token) { return token[0] === '_'; } + export class UsernameAutocompleteComponent extends ListComponent { constructor(name, parent, multiValued) { - super(name, getIndices, parent, multiValued); + super(name, getAutocompleteInfo().getEntityProvider('indices'), parent, multiValued); } validateTokens(tokens) { if (!this.multiValued && tokens.length > 1) { diff --git a/src/plugins/console/public/lib/autocomplete_entities/alias.ts b/src/plugins/console/public/lib/autocomplete_entities/alias.ts new file mode 100644 index 0000000000000..9bce35ab510c0 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/alias.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetAliasResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { BaseMapping } from './mapping'; + +interface BaseAlias { + getIndices(includeAliases: boolean, collaborator: BaseMapping): string[]; + loadAliases(aliases: IndicesGetAliasResponse, collaborator: BaseMapping): void; + clearAliases(): void; +} + +export class Alias implements BaseAlias { + public perAliasIndexes: Record = {}; + + getIndices = (includeAliases: boolean, collaborator: BaseMapping): string[] => { + const ret: string[] = []; + const perIndexTypes = collaborator.perIndexTypes; + Object.keys(perIndexTypes).forEach((index) => { + // ignore .ds* indices in the suggested indices list. + if (!index.startsWith('.ds')) { + ret.push(index); + } + }); + + if (typeof includeAliases === 'undefined' ? true : includeAliases) { + Object.keys(this.perAliasIndexes).forEach((alias) => { + ret.push(alias); + }); + } + return ret; + }; + + loadAliases = (aliases: IndicesGetAliasResponse, collaborator: BaseMapping) => { + this.perAliasIndexes = {}; + const perIndexTypes = collaborator.perIndexTypes; + + Object.entries(aliases).forEach(([index, indexAliases]) => { + // verify we have an index defined. useful when mapping loading is disabled + perIndexTypes[index] = perIndexTypes[index] || {}; + Object.keys(indexAliases.aliases || {}).forEach((alias) => { + if (alias === index) { + return; + } // alias which is identical to index means no index. + let curAliases = this.perAliasIndexes[alias]; + if (!curAliases) { + curAliases = []; + this.perAliasIndexes[alias] = curAliases; + } + curAliases.push(index); + }); + }); + const includeAliases = false; + this.perAliasIndexes._all = this.getIndices(includeAliases, collaborator); + }; + + clearAliases = () => { + this.perAliasIndexes = {}; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js new file mode 100644 index 0000000000000..5349538799d9b --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/autocomplete_entities.test.js @@ -0,0 +1,315 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import '../../application/models/sense_editor/sense_editor.test.mocks'; +import { setAutocompleteInfo, AutocompleteInfo } from '../../services'; +import { expandAliases } from './expand_aliases'; + +function fc(f1, f2) { + if (f1.name < f2.name) { + return -1; + } + if (f1.name > f2.name) { + return 1; + } + return 0; +} + +function f(name, type) { + return { name, type: type || 'string' }; +} + +describe('Autocomplete entities', () => { + let mapping; + let alias; + let legacyTemplate; + let indexTemplate; + let componentTemplate; + let dataStream; + let autocompleteInfo; + beforeEach(() => { + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); + mapping = autocompleteInfo.mapping; + alias = autocompleteInfo.alias; + legacyTemplate = autocompleteInfo.legacyTemplate; + indexTemplate = autocompleteInfo.indexTemplate; + componentTemplate = autocompleteInfo.componentTemplate; + dataStream = autocompleteInfo.dataStream; + }); + afterEach(() => { + autocompleteInfo.clear(); + autocompleteInfo = null; + }); + + describe('Mappings', function () { + test('Multi fields 1.0 style', function () { + mapping.loadMappings({ + index: { + properties: { + first_name: { + type: 'string', + index: 'analyzed', + path: 'just_name', + fields: { + any_name: { type: 'string', index: 'analyzed' }, + }, + }, + last_name: { + type: 'string', + index: 'no', + fields: { + raw: { type: 'string', index: 'analyzed' }, + }, + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('any_name', 'string'), + f('first_name', 'string'), + f('last_name', 'string'), + f('last_name.raw', 'string'), + ]); + }); + + test('Simple fields', function () { + mapping.loadMappings({ + index: { + properties: { + str: { + type: 'string', + }, + number: { + type: 'int', + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('number', 'int'), + f('str', 'string'), + ]); + }); + + test('Simple fields - 1.0 style', function () { + mapping.loadMappings({ + index: { + mappings: { + properties: { + str: { + type: 'string', + }, + number: { + type: 'int', + }, + }, + }, + }, + }); + + expect(mapping.getMappings('index').sort(fc)).toEqual([ + f('number', 'int'), + f('str', 'string'), + ]); + }); + + test('Nested fields', function () { + mapping.loadMappings({ + index: { + properties: { + person: { + type: 'object', + properties: { + name: { + properties: { + first_name: { type: 'string' }, + last_name: { type: 'string' }, + }, + }, + sid: { type: 'string', index: 'not_analyzed' }, + }, + }, + message: { type: 'string' }, + }, + }, + }); + + expect(mapping.getMappings('index', []).sort(fc)).toEqual([ + f('message'), + f('person.name.first_name'), + f('person.name.last_name'), + f('person.sid'), + ]); + }); + + test('Enabled fields', function () { + mapping.loadMappings({ + index: { + properties: { + person: { + type: 'object', + properties: { + name: { + type: 'object', + enabled: false, + }, + sid: { type: 'string', index: 'not_analyzed' }, + }, + }, + message: { type: 'string' }, + }, + }, + }); + + expect(mapping.getMappings('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]); + }); + + test('Path tests', function () { + mapping.loadMappings({ + index: { + properties: { + name1: { + type: 'object', + path: 'just_name', + properties: { + first1: { type: 'string' }, + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + name2: { + type: 'object', + path: 'full', + properties: { + first2: { type: 'string' }, + last2: { type: 'string', index_name: 'i_last_2' }, + }, + }, + }, + }, + }); + + expect(mapping.getMappings().sort(fc)).toEqual([ + f('first1'), + f('i_last_1'), + f('name2.first2'), + f('name2.i_last_2'), + ]); + }); + + test('Use index_name tests', function () { + mapping.loadMappings({ + index: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + }); + + expect(mapping.getMappings().sort(fc)).toEqual([f('i_last_1')]); + }); + }); + + describe('Aliases', function () { + test('Aliases', function () { + alias.loadAliases( + { + test_index1: { + aliases: { + alias1: {}, + }, + }, + test_index2: { + aliases: { + alias2: { + filter: { + term: { + FIELD: 'VALUE', + }, + }, + }, + alias1: {}, + }, + }, + }, + mapping + ); + mapping.loadMappings({ + test_index1: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + test_index2: { + properties: { + last1: { type: 'string', index_name: 'i_last_1' }, + }, + }, + }); + + expect(alias.getIndices(true, mapping).sort()).toEqual([ + '_all', + 'alias1', + 'alias2', + 'test_index1', + 'test_index2', + ]); + expect(alias.getIndices(false, mapping).sort()).toEqual(['test_index1', 'test_index2']); + expect(expandAliases(['alias1', 'test_index2']).sort()).toEqual([ + 'test_index1', + 'test_index2', + ]); + expect(expandAliases('alias2')).toEqual('test_index2'); + }); + }); + + describe('Templates', function () { + test('legacy templates, index templates, component templates', function () { + legacyTemplate.loadTemplates({ + test_index1: { order: 0 }, + test_index2: { order: 0 }, + test_index3: { order: 0 }, + }); + + indexTemplate.loadTemplates({ + index_templates: [ + { name: 'test_index1' }, + { name: 'test_index2' }, + { name: 'test_index3' }, + ], + }); + + componentTemplate.loadTemplates({ + component_templates: [ + { name: 'test_index1' }, + { name: 'test_index2' }, + { name: 'test_index3' }, + ], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + + expect(legacyTemplate.getTemplates()).toEqual(expectedResult); + expect(indexTemplate.getTemplates()).toEqual(expectedResult); + expect(componentTemplate.getTemplates()).toEqual(expectedResult); + }); + }); + + describe('Data streams', function () { + test('data streams', function () { + dataStream.loadDataStreams({ + data_streams: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + expect(dataStream.getDataStreams()).toEqual(expectedResult); + }); + }); +}); diff --git a/src/plugins/console/public/lib/autocomplete_entities/base_template.ts b/src/plugins/console/public/lib/autocomplete_entities/base_template.ts new file mode 100644 index 0000000000000..2304150d94e77 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/base_template.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export abstract class BaseTemplate { + protected templates: string[] = []; + + public abstract loadTemplates(templates: T): void; + + public getTemplates = (): string[] => { + return [...this.templates]; + }; + + public clearTemplates = () => { + this.templates = []; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/component_template.ts b/src/plugins/console/public/lib/autocomplete_entities/component_template.ts new file mode 100644 index 0000000000000..b6699438de011 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/component_template.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ClusterGetComponentTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from './base_template'; + +export class ComponentTemplate extends BaseTemplate { + loadTemplates = (templates: ClusterGetComponentTemplateResponse) => { + this.templates = (templates.component_templates ?? []).map(({ name }) => name).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/data_stream.ts b/src/plugins/console/public/lib/autocomplete_entities/data_stream.ts new file mode 100644 index 0000000000000..2b65d086aeb13 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/data_stream.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetDataStreamResponse } from '@elastic/elasticsearch/lib/api/types'; + +export class DataStream { + private dataStreams: string[] = []; + + getDataStreams = (): string[] => { + return [...this.dataStreams]; + }; + + loadDataStreams = (dataStreams: IndicesGetDataStreamResponse) => { + this.dataStreams = (dataStreams.data_streams ?? []).map(({ name }) => name).sort(); + }; + + clearDataStreams = () => { + this.dataStreams = []; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts b/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts new file mode 100644 index 0000000000000..27f8211f533a9 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/expand_aliases.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getAutocompleteInfo } from '../../services'; + +export function expandAliases(indicesOrAliases: string | string[]) { + // takes a list of indices or aliases or a string which may be either and returns a list of indices + // returns a list for multiple values or a string for a single. + const perAliasIndexes = getAutocompleteInfo().alias.perAliasIndexes; + if (!indicesOrAliases) { + return indicesOrAliases; + } + + if (typeof indicesOrAliases === 'string') { + indicesOrAliases = [indicesOrAliases]; + } + + indicesOrAliases = indicesOrAliases.flatMap((iOrA) => { + if (perAliasIndexes[iOrA]) { + return perAliasIndexes[iOrA]; + } + return [iOrA]; + }); + + let ret = ([] as string[]).concat.apply([], indicesOrAliases); + ret.sort(); + ret = ret.reduce((result, value, index, array) => { + const last = array[index - 1]; + if (last !== value) { + result.push(value); + } + return result; + }, [] as string[]); + + return ret.length > 1 ? ret : ret[0]; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/index.ts b/src/plugins/console/public/lib/autocomplete_entities/index.ts new file mode 100644 index 0000000000000..e523ce42ddc79 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { Alias } from './alias'; +export { Mapping } from './mapping'; +export { DataStream } from './data_stream'; +export { LegacyTemplate } from './legacy'; +export { IndexTemplate } from './index_template'; +export { ComponentTemplate } from './component_template'; +export { getTypes } from './type'; diff --git a/src/plugins/console/public/lib/autocomplete_entities/index_template.ts b/src/plugins/console/public/lib/autocomplete_entities/index_template.ts new file mode 100644 index 0000000000000..ab3081841f0d4 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/index_template.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetIndexTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from './base_template'; + +export class IndexTemplate extends BaseTemplate { + loadTemplates = (templates: IndicesGetIndexTemplateResponse) => { + this.templates = (templates.index_templates ?? []).map(({ name }) => name).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts b/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts new file mode 100644 index 0000000000000..9f0c06ad6a518 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/legacy/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { LegacyTemplate } from './legacy_template'; diff --git a/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts b/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts new file mode 100644 index 0000000000000..73d17745702a8 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/legacy/legacy_template.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IndicesGetTemplateResponse } from '@elastic/elasticsearch/lib/api/types'; +import { BaseTemplate } from '../base_template'; + +export class LegacyTemplate extends BaseTemplate { + loadTemplates = (templates: IndicesGetTemplateResponse) => { + this.templates = Object.keys(templates).sort(); + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/mapping.ts b/src/plugins/console/public/lib/autocomplete_entities/mapping.ts new file mode 100644 index 0000000000000..ddb6905fa6e53 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/mapping.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import _ from 'lodash'; +import type { IndicesGetMappingResponse } from '@elastic/elasticsearch/lib/api/types'; +import { expandAliases } from './expand_aliases'; +import type { Field, FieldMapping } from './types'; + +function getFieldNamesFromProperties(properties: Record = {}) { + const fieldList = Object.entries(properties).flatMap(([fieldName, fieldMapping]) => { + return getFieldNamesFromFieldMapping(fieldName, fieldMapping); + }); + + // deduping + return _.uniqBy(fieldList, function (f) { + return f.name + ':' + f.type; + }); +} + +function getFieldNamesFromFieldMapping( + fieldName: string, + fieldMapping: FieldMapping +): Array<{ name: string; type: string | undefined }> { + if (fieldMapping.enabled === false) { + return []; + } + let nestedFields; + + function applyPathSettings(nestedFieldNames: Array<{ name: string; type: string | undefined }>) { + const pathType = fieldMapping.path || 'full'; + if (pathType === 'full') { + return nestedFieldNames.map((f) => { + f.name = fieldName + '.' + f.name; + return f; + }); + } + return nestedFieldNames; + } + + if (fieldMapping.properties) { + // derived object type + nestedFields = getFieldNamesFromProperties(fieldMapping.properties); + return applyPathSettings(nestedFields); + } + + const fieldType = fieldMapping.type; + + const ret = { name: fieldName, type: fieldType }; + + if (fieldMapping.index_name) { + ret.name = fieldMapping.index_name; + } + + if (fieldMapping.fields) { + nestedFields = Object.entries(fieldMapping.fields).flatMap(([name, mapping]) => { + return getFieldNamesFromFieldMapping(name, mapping); + }); + nestedFields = applyPathSettings(nestedFields); + nestedFields.unshift(ret); + return nestedFields; + } + + return [ret]; +} + +export interface BaseMapping { + perIndexTypes: Record; + getMappings(indices: string | string[], types?: string | string[]): Field[]; + loadMappings(mappings: IndicesGetMappingResponse): void; + clearMappings(): void; +} + +export class Mapping implements BaseMapping { + public perIndexTypes: Record = {}; + + getMappings = (indices: string | string[], types?: string | string[]) => { + // get fields for indices and types. Both can be a list, a string or null (meaning all). + let ret: Field[] = []; + indices = expandAliases(indices); + + if (typeof indices === 'string') { + const typeDict = this.perIndexTypes[indices] as Record; + if (!typeDict) { + return []; + } + + if (typeof types === 'string') { + const f = typeDict[types]; + if (Array.isArray(f)) { + ret = f; + } + } else { + // filter what we need + Object.entries(typeDict).forEach(([type, fields]) => { + if (!types || types.length === 0 || types.includes(type)) { + ret.push(fields as Field); + } + }); + + ret = ([] as Field[]).concat.apply([], ret); + } + } else { + // multi index mode. + Object.keys(this.perIndexTypes).forEach((index) => { + if (!indices || indices.length === 0 || indices.includes(index)) { + ret.push(this.getMappings(index, types) as unknown as Field); + } + }); + + ret = ([] as Field[]).concat.apply([], ret); + } + + return _.uniqBy(ret, function (f) { + return f.name + ':' + f.type; + }); + }; + + loadMappings = (mappings: IndicesGetMappingResponse) => { + const maxMappingSize = Object.keys(mappings).length > 10 * 1024 * 1024; + let mappingsResponse; + if (maxMappingSize) { + // eslint-disable-next-line no-console + console.warn( + `Mapping size is larger than 10MB (${ + Object.keys(mappings).length / 1024 / 1024 + } MB). Ignoring...` + ); + mappingsResponse = {}; + } else { + mappingsResponse = mappings; + } + + this.perIndexTypes = {}; + + Object.entries(mappingsResponse).forEach(([index, indexMapping]) => { + const normalizedIndexMappings: Record = {}; + let transformedMapping: Record = indexMapping; + + // Migrate 1.0.0 mappings. This format has changed, so we need to extract the underlying mapping. + if (indexMapping.mappings && Object.keys(indexMapping).length === 1) { + transformedMapping = indexMapping.mappings; + } + + Object.entries(transformedMapping).forEach(([typeName, typeMapping]) => { + if (typeName === 'properties') { + const fieldList = getFieldNamesFromProperties(typeMapping); + normalizedIndexMappings[typeName] = fieldList; + } else { + normalizedIndexMappings[typeName] = []; + } + }); + this.perIndexTypes[index] = normalizedIndexMappings; + }); + }; + + clearMappings = () => { + this.perIndexTypes = {}; + }; +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/type.ts b/src/plugins/console/public/lib/autocomplete_entities/type.ts new file mode 100644 index 0000000000000..5f1d8b1308d77 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/type.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import _ from 'lodash'; +import { getAutocompleteInfo } from '../../services'; +import { expandAliases } from './expand_aliases'; + +export function getTypes(indices: string | string[]) { + let ret: string[] = []; + const perIndexTypes = getAutocompleteInfo().mapping.perIndexTypes; + indices = expandAliases(indices); + if (typeof indices === 'string') { + const typeDict = perIndexTypes[indices]; + if (!typeDict) { + return []; + } + + // filter what we need + if (Array.isArray(typeDict)) { + typeDict.forEach((type) => { + ret.push(type); + }); + } else if (typeof typeDict === 'object') { + Object.keys(typeDict).forEach((type) => { + ret.push(type); + }); + } + } else { + // multi index mode. + Object.keys(perIndexTypes).forEach((index) => { + if (!indices || indices.includes(index)) { + ret.push(getTypes(index) as unknown as string); + } + }); + ret = ([] as string[]).concat.apply([], ret); + } + + return _.uniq(ret); +} diff --git a/src/plugins/console/public/lib/autocomplete_entities/types.ts b/src/plugins/console/public/lib/autocomplete_entities/types.ts new file mode 100644 index 0000000000000..e49f8f106f37a --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete_entities/types.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + ClusterGetComponentTemplateResponse, + IndicesGetAliasResponse, + IndicesGetDataStreamResponse, + IndicesGetIndexTemplateResponse, + IndicesGetMappingResponse, + IndicesGetTemplateResponse, +} from '@elastic/elasticsearch/lib/api/types'; + +export interface Field { + name: string; + type: string; +} + +export interface FieldMapping { + enabled?: boolean; + path?: string; + properties?: Record; + type?: string; + index_name?: string; + fields?: FieldMapping[]; +} + +export interface MappingsApiResponse { + mappings: IndicesGetMappingResponse; + aliases: IndicesGetAliasResponse; + dataStreams: IndicesGetDataStreamResponse; + legacyTemplates: IndicesGetTemplateResponse; + indexTemplates: IndicesGetIndexTemplateResponse; + componentTemplates: ClusterGetComponentTemplateResponse; +} diff --git a/src/plugins/console/public/lib/kb/kb.test.js b/src/plugins/console/public/lib/kb/kb.test.js index ff0ddba37281a..8b1af7103c40b 100644 --- a/src/plugins/console/public/lib/kb/kb.test.js +++ b/src/plugins/console/public/lib/kb/kb.test.js @@ -11,16 +11,20 @@ import { populateContext } from '../autocomplete/engine'; import '../../application/models/sense_editor/sense_editor.test.mocks'; import * as kb from '.'; -import * as mappings from '../mappings/mappings'; +import { AutocompleteInfo, setAutocompleteInfo } from '../../services'; describe('Knowledge base', () => { + let autocompleteInfo; beforeEach(() => { - mappings.clear(); kb.setActiveApi(kb._test.loadApisFromJson({})); + autocompleteInfo = new AutocompleteInfo(); + setAutocompleteInfo(autocompleteInfo); + autocompleteInfo.mapping.clearMappings(); }); afterEach(() => { - mappings.clear(); kb.setActiveApi(kb._test.loadApisFromJson({})); + autocompleteInfo = null; + setAutocompleteInfo(null); }); const MAPPING = { @@ -122,7 +126,7 @@ describe('Knowledge base', () => { kb.setActiveApi(testApi); - mappings.loadMappings(MAPPING); + autocompleteInfo.mapping.loadMappings(MAPPING); testUrlContext(tokenPath, otherTokenValues, expectedContext); }); } @@ -165,7 +169,7 @@ describe('Knowledge base', () => { ); kb.setActiveApi(testApi); - mappings.loadMappings(MAPPING); + autocompleteInfo.mapping.loadMappings(MAPPING); testUrlContext(tokenPath, otherTokenValues, expectedContext); }); diff --git a/src/plugins/console/public/lib/mappings/mapping.test.js b/src/plugins/console/public/lib/mappings/mapping.test.js deleted file mode 100644 index e2def74e892cc..0000000000000 --- a/src/plugins/console/public/lib/mappings/mapping.test.js +++ /dev/null @@ -1,278 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import '../../application/models/sense_editor/sense_editor.test.mocks'; -import * as mappings from './mappings'; - -describe('Mappings', () => { - beforeEach(() => { - mappings.clear(); - }); - afterEach(() => { - mappings.clear(); - }); - - function fc(f1, f2) { - if (f1.name < f2.name) { - return -1; - } - if (f1.name > f2.name) { - return 1; - } - return 0; - } - - function f(name, type) { - return { name: name, type: type || 'string' }; - } - - test('Multi fields 1.0 style', function () { - mappings.loadMappings({ - index: { - properties: { - first_name: { - type: 'string', - index: 'analyzed', - path: 'just_name', - fields: { - any_name: { type: 'string', index: 'analyzed' }, - }, - }, - last_name: { - type: 'string', - index: 'no', - fields: { - raw: { type: 'string', index: 'analyzed' }, - }, - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([ - f('any_name', 'string'), - f('first_name', 'string'), - f('last_name', 'string'), - f('last_name.raw', 'string'), - ]); - }); - - test('Simple fields', function () { - mappings.loadMappings({ - index: { - properties: { - str: { - type: 'string', - }, - number: { - type: 'int', - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([f('number', 'int'), f('str', 'string')]); - }); - - test('Simple fields - 1.0 style', function () { - mappings.loadMappings({ - index: { - mappings: { - properties: { - str: { - type: 'string', - }, - number: { - type: 'int', - }, - }, - }, - }, - }); - - expect(mappings.getFields('index').sort(fc)).toEqual([f('number', 'int'), f('str', 'string')]); - }); - - test('Nested fields', function () { - mappings.loadMappings({ - index: { - properties: { - person: { - type: 'object', - properties: { - name: { - properties: { - first_name: { type: 'string' }, - last_name: { type: 'string' }, - }, - }, - sid: { type: 'string', index: 'not_analyzed' }, - }, - }, - message: { type: 'string' }, - }, - }, - }); - - expect(mappings.getFields('index', []).sort(fc)).toEqual([ - f('message'), - f('person.name.first_name'), - f('person.name.last_name'), - f('person.sid'), - ]); - }); - - test('Enabled fields', function () { - mappings.loadMappings({ - index: { - properties: { - person: { - type: 'object', - properties: { - name: { - type: 'object', - enabled: false, - }, - sid: { type: 'string', index: 'not_analyzed' }, - }, - }, - message: { type: 'string' }, - }, - }, - }); - - expect(mappings.getFields('index', []).sort(fc)).toEqual([f('message'), f('person.sid')]); - }); - - test('Path tests', function () { - mappings.loadMappings({ - index: { - properties: { - name1: { - type: 'object', - path: 'just_name', - properties: { - first1: { type: 'string' }, - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - name2: { - type: 'object', - path: 'full', - properties: { - first2: { type: 'string' }, - last2: { type: 'string', index_name: 'i_last_2' }, - }, - }, - }, - }, - }); - - expect(mappings.getFields().sort(fc)).toEqual([ - f('first1'), - f('i_last_1'), - f('name2.first2'), - f('name2.i_last_2'), - ]); - }); - - test('Use index_name tests', function () { - mappings.loadMappings({ - index: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - }); - - expect(mappings.getFields().sort(fc)).toEqual([f('i_last_1')]); - }); - - test('Aliases', function () { - mappings.loadAliases({ - test_index1: { - aliases: { - alias1: {}, - }, - }, - test_index2: { - aliases: { - alias2: { - filter: { - term: { - FIELD: 'VALUE', - }, - }, - }, - alias1: {}, - }, - }, - }); - mappings.loadMappings({ - test_index1: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - test_index2: { - properties: { - last1: { type: 'string', index_name: 'i_last_1' }, - }, - }, - }); - - expect(mappings.getIndices().sort()).toEqual([ - '_all', - 'alias1', - 'alias2', - 'test_index1', - 'test_index2', - ]); - expect(mappings.getIndices(false).sort()).toEqual(['test_index1', 'test_index2']); - expect(mappings.expandAliases(['alias1', 'test_index2']).sort()).toEqual([ - 'test_index1', - 'test_index2', - ]); - expect(mappings.expandAliases('alias2')).toEqual('test_index2'); - }); - - test('Templates', function () { - mappings.loadLegacyTemplates({ - test_index1: { order: 0 }, - test_index2: { order: 0 }, - test_index3: { order: 0 }, - }); - - mappings.loadIndexTemplates({ - index_templates: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], - }); - - mappings.loadComponentTemplates({ - component_templates: [ - { name: 'test_index1' }, - { name: 'test_index2' }, - { name: 'test_index3' }, - ], - }); - - const expectedResult = ['test_index1', 'test_index2', 'test_index3']; - - expect(mappings.getLegacyTemplates()).toEqual(expectedResult); - expect(mappings.getIndexTemplates()).toEqual(expectedResult); - expect(mappings.getComponentTemplates()).toEqual(expectedResult); - }); - - test('Data streams', function () { - mappings.loadDataStreams({ - data_streams: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], - }); - - const expectedResult = ['test_index1', 'test_index2', 'test_index3']; - expect(mappings.getDataStreams()).toEqual(expectedResult); - }); -}); diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js deleted file mode 100644 index 289bfb9aa17bb..0000000000000 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ /dev/null @@ -1,410 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import _ from 'lodash'; -import * as es from '../es/es'; - -let pollTimeoutId; - -let perIndexTypes = {}; -let perAliasIndexes = {}; -let legacyTemplates = []; -let indexTemplates = []; -let componentTemplates = []; -let dataStreams = []; - -export function expandAliases(indicesOrAliases) { - // takes a list of indices or aliases or a string which may be either and returns a list of indices - // returns a list for multiple values or a string for a single. - - if (!indicesOrAliases) { - return indicesOrAliases; - } - - if (typeof indicesOrAliases === 'string') { - indicesOrAliases = [indicesOrAliases]; - } - - indicesOrAliases = indicesOrAliases.map((iOrA) => { - if (perAliasIndexes[iOrA]) { - return perAliasIndexes[iOrA]; - } - return [iOrA]; - }); - let ret = [].concat.apply([], indicesOrAliases); - ret.sort(); - ret = ret.reduce((result, value, index, array) => { - const last = array[index - 1]; - if (last !== value) { - result.push(value); - } - return result; - }, []); - - return ret.length > 1 ? ret : ret[0]; -} - -export function getLegacyTemplates() { - return [...legacyTemplates]; -} - -export function getIndexTemplates() { - return [...indexTemplates]; -} - -export function getComponentTemplates() { - return [...componentTemplates]; -} - -export function getDataStreams() { - return [...dataStreams]; -} - -export function getFields(indices, types) { - // get fields for indices and types. Both can be a list, a string or null (meaning all). - let ret = []; - indices = expandAliases(indices); - - if (typeof indices === 'string') { - const typeDict = perIndexTypes[indices]; - if (!typeDict) { - return []; - } - - if (typeof types === 'string') { - const f = typeDict[types]; - ret = f ? f : []; - } else { - // filter what we need - Object.entries(typeDict).forEach(([type, fields]) => { - if (!types || types.length === 0 || types.includes(type)) { - ret.push(fields); - } - }); - - ret = [].concat.apply([], ret); - } - } else { - // multi index mode. - Object.keys(perIndexTypes).forEach((index) => { - if (!indices || indices.length === 0 || indices.includes(index)) { - ret.push(getFields(index, types)); - } - }); - - ret = [].concat.apply([], ret); - } - - return _.uniqBy(ret, function (f) { - return f.name + ':' + f.type; - }); -} - -export function getTypes(indices) { - let ret = []; - indices = expandAliases(indices); - if (typeof indices === 'string') { - const typeDict = perIndexTypes[indices]; - if (!typeDict) { - return []; - } - - // filter what we need - if (Array.isArray(typeDict)) { - typeDict.forEach((type) => { - ret.push(type); - }); - } else if (typeof typeDict === 'object') { - Object.keys(typeDict).forEach((type) => { - ret.push(type); - }); - } - } else { - // multi index mode. - Object.keys(perIndexTypes).forEach((index) => { - if (!indices || indices.includes(index)) { - ret.push(getTypes(index)); - } - }); - ret = [].concat.apply([], ret); - } - - return _.uniq(ret); -} - -export function getIndices(includeAliases) { - const ret = []; - Object.keys(perIndexTypes).forEach((index) => { - // ignore .ds* indices in the suggested indices list. - if (!index.startsWith('.ds')) { - ret.push(index); - } - }); - - if (typeof includeAliases === 'undefined' ? true : includeAliases) { - Object.keys(perAliasIndexes).forEach((alias) => { - ret.push(alias); - }); - } - return ret; -} - -function getFieldNamesFromFieldMapping(fieldName, fieldMapping) { - if (fieldMapping.enabled === false) { - return []; - } - let nestedFields; - - function applyPathSettings(nestedFieldNames) { - const pathType = fieldMapping.path || 'full'; - if (pathType === 'full') { - return nestedFieldNames.map((f) => { - f.name = fieldName + '.' + f.name; - return f; - }); - } - return nestedFieldNames; - } - - if (fieldMapping.properties) { - // derived object type - nestedFields = getFieldNamesFromProperties(fieldMapping.properties); - return applyPathSettings(nestedFields); - } - - const fieldType = fieldMapping.type; - - const ret = { name: fieldName, type: fieldType }; - - if (fieldMapping.index_name) { - ret.name = fieldMapping.index_name; - } - - if (fieldMapping.fields) { - nestedFields = Object.entries(fieldMapping.fields).flatMap(([fieldName, fieldMapping]) => { - return getFieldNamesFromFieldMapping(fieldName, fieldMapping); - }); - nestedFields = applyPathSettings(nestedFields); - nestedFields.unshift(ret); - return nestedFields; - } - - return [ret]; -} - -function getFieldNamesFromProperties(properties = {}) { - const fieldList = Object.entries(properties).flatMap(([fieldName, fieldMapping]) => { - return getFieldNamesFromFieldMapping(fieldName, fieldMapping); - }); - - // deduping - return _.uniqBy(fieldList, function (f) { - return f.name + ':' + f.type; - }); -} - -export function loadLegacyTemplates(templatesObject = {}) { - legacyTemplates = Object.keys(templatesObject); -} - -export function loadIndexTemplates(data) { - indexTemplates = (data.index_templates ?? []).map(({ name }) => name); -} - -export function loadComponentTemplates(data) { - componentTemplates = (data.component_templates ?? []).map(({ name }) => name); -} - -export function loadDataStreams(data) { - dataStreams = (data.data_streams ?? []).map(({ name }) => name); -} - -export function loadMappings(mappings) { - perIndexTypes = {}; - - Object.entries(mappings).forEach(([index, indexMapping]) => { - const normalizedIndexMappings = {}; - - // Migrate 1.0.0 mappings. This format has changed, so we need to extract the underlying mapping. - if (indexMapping.mappings && Object.keys(indexMapping).length === 1) { - indexMapping = indexMapping.mappings; - } - - Object.entries(indexMapping).forEach(([typeName, typeMapping]) => { - if (typeName === 'properties') { - const fieldList = getFieldNamesFromProperties(typeMapping); - normalizedIndexMappings[typeName] = fieldList; - } else { - normalizedIndexMappings[typeName] = []; - } - }); - perIndexTypes[index] = normalizedIndexMappings; - }); -} - -export function loadAliases(aliases) { - perAliasIndexes = {}; - Object.entries(aliases).forEach(([index, omdexAliases]) => { - // verify we have an index defined. useful when mapping loading is disabled - perIndexTypes[index] = perIndexTypes[index] || {}; - - Object.keys(omdexAliases.aliases || {}).forEach((alias) => { - if (alias === index) { - return; - } // alias which is identical to index means no index. - let curAliases = perAliasIndexes[alias]; - if (!curAliases) { - curAliases = []; - perAliasIndexes[alias] = curAliases; - } - curAliases.push(index); - }); - }); - - perAliasIndexes._all = getIndices(false); -} - -export function clear() { - perIndexTypes = {}; - perAliasIndexes = {}; - legacyTemplates = []; - indexTemplates = []; - componentTemplates = []; -} - -function retrieveSettings(http, settingsKey, settingsToRetrieve) { - const settingKeyToPathMap = { - fields: '_mapping', - indices: '_aliases', - legacyTemplates: '_template', - indexTemplates: '_index_template', - componentTemplates: '_component_template', - dataStreams: '_data_stream', - }; - // Fetch autocomplete info if setting is set to true, and if user has made changes. - if (settingsToRetrieve[settingsKey] === true) { - // Use pretty=false in these request in order to compress the response by removing whitespace - const path = `${settingKeyToPathMap[settingsKey]}?pretty=false`; - const method = 'GET'; - const asSystemRequest = true; - const withProductOrigin = true; - - return es.send({ http, method, path, asSystemRequest, withProductOrigin }); - } else { - if (settingsToRetrieve[settingsKey] === false) { - // If the user doesn't want autocomplete suggestions, then clear any that exist - return Promise.resolve({}); - // return settingsPromise.resolveWith(this, [{}]); - } else { - // If the user doesn't want autocomplete suggestions, then clear any that exist - return Promise.resolve(); - } - } -} - -// Retrieve all selected settings by default. -// TODO: We should refactor this to be easier to consume. Ideally this function should retrieve -// whatever settings are specified, otherwise just use the saved settings. This requires changing -// the behavior to not *clear* whatever settings have been unselected, but it's hard to tell if -// this is possible without altering the autocomplete behavior. These are the scenarios we need to -// support: -// 1. Manual refresh. Specify what we want. Fetch specified, leave unspecified alone. -// 2. Changed selection and saved: Specify what we want. Fetch changed and selected, leave -// unchanged alone (both selected and unselected). -// 3. Poll: Use saved. Fetch selected. Ignore unselected. - -export function clearSubscriptions() { - if (pollTimeoutId) { - clearTimeout(pollTimeoutId); - } -} - -const retrieveMappings = async (http, settingsToRetrieve) => { - const mappings = await retrieveSettings(http, 'fields', settingsToRetrieve); - - if (mappings) { - const maxMappingSize = Object.keys(mappings).length > 10 * 1024 * 1024; - let mappingsResponse; - if (maxMappingSize) { - console.warn( - `Mapping size is larger than 10MB (${ - Object.keys(mappings).length / 1024 / 1024 - } MB). Ignoring...` - ); - mappingsResponse = '{}'; - } else { - mappingsResponse = mappings; - } - loadMappings(mappingsResponse); - } -}; - -const retrieveAliases = async (http, settingsToRetrieve) => { - const aliases = await retrieveSettings(http, 'indices', settingsToRetrieve); - - if (aliases) { - loadAliases(aliases); - } -}; - -const retrieveTemplates = async (http, settingsToRetrieve) => { - const legacyTemplates = await retrieveSettings(http, 'legacyTemplates', settingsToRetrieve); - const indexTemplates = await retrieveSettings(http, 'indexTemplates', settingsToRetrieve); - const componentTemplates = await retrieveSettings(http, 'componentTemplates', settingsToRetrieve); - - if (legacyTemplates) { - loadLegacyTemplates(legacyTemplates); - } - - if (indexTemplates) { - loadIndexTemplates(indexTemplates); - } - - if (componentTemplates) { - loadComponentTemplates(componentTemplates); - } -}; - -const retrieveDataStreams = async (http, settingsToRetrieve) => { - const dataStreams = await retrieveSettings(http, 'dataStreams', settingsToRetrieve); - - if (dataStreams) { - loadDataStreams(dataStreams); - } -}; -/** - * - * @param settings Settings A way to retrieve the current settings - * @param settingsToRetrieve any - */ -export function retrieveAutoCompleteInfo(http, settings, settingsToRetrieve) { - clearSubscriptions(); - - const templatesSettingToRetrieve = { - ...settingsToRetrieve, - legacyTemplates: settingsToRetrieve.templates, - indexTemplates: settingsToRetrieve.templates, - componentTemplates: settingsToRetrieve.templates, - }; - - Promise.allSettled([ - retrieveMappings(http, settingsToRetrieve), - retrieveAliases(http, settingsToRetrieve), - retrieveTemplates(http, templatesSettingToRetrieve), - retrieveDataStreams(http, settingsToRetrieve), - ]).then(() => { - // Schedule next request. - pollTimeoutId = setTimeout(() => { - // This looks strange/inefficient, but it ensures correct behavior because we don't want to send - // a scheduled request if the user turns off polling. - if (settings.getPolling()) { - retrieveAutoCompleteInfo(http, settings, settings.getAutocomplete()); - } - }, settings.getPollInterval()); - }); -} diff --git a/src/plugins/console/public/plugin.ts b/src/plugins/console/public/plugin.ts index e6a4d7fff61b0..33ee5446dc268 100644 --- a/src/plugins/console/public/plugin.ts +++ b/src/plugins/console/public/plugin.ts @@ -15,8 +15,10 @@ import { ConsolePluginSetup, ConsoleUILocatorParams, } from './types'; +import { AutocompleteInfo, setAutocompleteInfo } from './services'; export class ConsoleUIPlugin implements Plugin { + private readonly autocompleteInfo = new AutocompleteInfo(); constructor(private ctx: PluginInitializerContext) {} public setup( @@ -27,6 +29,9 @@ export class ConsoleUIPlugin implements Plugin(); + this.autocompleteInfo.setup(http); + setAutocompleteInfo(this.autocompleteInfo); + if (isConsoleUiEnabled) { if (home) { home.featureCatalogue.register({ @@ -70,6 +75,7 @@ export class ConsoleUIPlugin implements Plugin | undefined; + + public setup(http: HttpSetup) { + this.http = http; + } + + public getEntityProvider( + type: string, + context: { indices: string[]; types: string[] } = { indices: [], types: [] } + ) { + switch (type) { + case 'indices': + const includeAliases = true; + const collaborator = this.mapping; + return () => this.alias.getIndices(includeAliases, collaborator); + case 'fields': + return this.mapping.getMappings(context.indices, context.types); + case 'indexTemplates': + return () => this.indexTemplate.getTemplates(); + case 'componentTemplates': + return () => this.componentTemplate.getTemplates(); + case 'legacyTemplates': + return () => this.legacyTemplate.getTemplates(); + case 'dataStreams': + return () => this.dataStream.getDataStreams(); + default: + throw new Error(`Unsupported type: ${type}`); + } + } + + public retrieve(settings: Settings, settingsToRetrieve: DevToolsSettings['autocomplete']) { + this.clearSubscriptions(); + this.http + .get(`${API_BASE_PATH}/autocomplete_entities`, { + query: { ...settingsToRetrieve }, + }) + .then((data) => { + this.load(data); + // Schedule next request. + this.pollTimeoutId = setTimeout(() => { + // This looks strange/inefficient, but it ensures correct behavior because we don't want to send + // a scheduled request if the user turns off polling. + if (settings.getPolling()) { + this.retrieve(settings, settings.getAutocomplete()); + } + }, settings.getPollInterval()); + }); + } + + public clearSubscriptions() { + if (this.pollTimeoutId) { + clearTimeout(this.pollTimeoutId); + } + } + + private load(data: MappingsApiResponse) { + this.mapping.loadMappings(data.mappings); + const collaborator = this.mapping; + this.alias.loadAliases(data.aliases, collaborator); + this.indexTemplate.loadTemplates(data.indexTemplates); + this.componentTemplate.loadTemplates(data.componentTemplates); + this.legacyTemplate.loadTemplates(data.legacyTemplates); + this.dataStream.loadDataStreams(data.dataStreams); + } + + public clear() { + this.alias.clearAliases(); + this.mapping.clearMappings(); + this.dataStream.clearDataStreams(); + this.legacyTemplate.clearTemplates(); + this.indexTemplate.clearTemplates(); + this.componentTemplate.clearTemplates(); + } +} + +export const [getAutocompleteInfo, setAutocompleteInfo] = + createGetterSetter('AutocompleteInfo'); diff --git a/src/plugins/console/public/services/index.ts b/src/plugins/console/public/services/index.ts index c37c9d9359a16..2447ab1438ba4 100644 --- a/src/plugins/console/public/services/index.ts +++ b/src/plugins/console/public/services/index.ts @@ -10,3 +10,4 @@ export { createHistory, History } from './history'; export { createStorage, Storage, StorageKeys } from './storage'; export type { DevToolsSettings } from './settings'; export { createSettings, Settings, DEFAULT_SETTINGS } from './settings'; +export { AutocompleteInfo, getAutocompleteInfo, setAutocompleteInfo } from './autocomplete'; diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index c1ae53bbaabc6..2ab87d4e9fcc5 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -16,6 +16,7 @@ import { ConsoleConfig, ConsoleConfig7x } from './config'; import { registerRoutes } from './routes'; import { ESConfigForProxy, ConsoleSetup, ConsoleStart } from './types'; +import { handleEsError } from './shared_imports'; export class ConsoleServerPlugin implements Plugin { log: Logger; @@ -58,6 +59,9 @@ export class ConsoleServerPlugin implements Plugin { esLegacyConfigService: this.esLegacyConfigService, specDefinitionService: this.specDefinitionsService, }, + lib: { + handleEsError, + }, proxy: { readLegacyESConfig: async (): Promise => { const legacyConfig = await this.esLegacyConfigService.readConfig(); diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts new file mode 100644 index 0000000000000..796451b2721f3 --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { registerMappingsRoute } from './register_mappings_route'; diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts new file mode 100644 index 0000000000000..9d5778f0a9b0f --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_get_route.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { IScopedClusterClient } from '@kbn/core/server'; +import { parse } from 'query-string'; +import type { RouteDependencies } from '../../..'; +import { API_BASE_PATH } from '../../../../../common/constants'; + +interface Settings { + indices: boolean; + fields: boolean; + templates: boolean; + dataStreams: boolean; +} + +async function getMappings(esClient: IScopedClusterClient, settings: Settings) { + if (settings.fields) { + return esClient.asInternalUser.indices.getMapping(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getAliases(esClient: IScopedClusterClient, settings: Settings) { + if (settings.indices) { + return esClient.asInternalUser.indices.getAlias(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getDataStreams(esClient: IScopedClusterClient, settings: Settings) { + if (settings.dataStreams) { + return esClient.asInternalUser.indices.getDataStream(); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve({}); +} + +async function getTemplates(esClient: IScopedClusterClient, settings: Settings) { + if (settings.templates) { + return Promise.all([ + esClient.asInternalUser.indices.getTemplate(), + esClient.asInternalUser.indices.getIndexTemplate(), + esClient.asInternalUser.cluster.getComponentTemplate(), + ]); + } + // If the user doesn't want autocomplete suggestions, then clear any that exist. + return Promise.resolve([]); +} + +export function registerGetRoute({ router, lib: { handleEsError } }: RouteDependencies) { + router.get( + { + path: `${API_BASE_PATH}/autocomplete_entities`, + validate: false, + }, + async (ctx, request, response) => { + try { + const settings = parse(request.url.search, { parseBooleans: true }) as unknown as Settings; + + // If no settings are provided return 400 + if (Object.keys(settings).length === 0) { + return response.badRequest({ + body: 'Request must contain a query param of autocomplete settings', + }); + } + + const esClient = (await ctx.core).elasticsearch.client; + const mappings = await getMappings(esClient, settings); + const aliases = await getAliases(esClient, settings); + const dataStreams = await getDataStreams(esClient, settings); + const [legacyTemplates = {}, indexTemplates = {}, componentTemplates = {}] = + await getTemplates(esClient, settings); + + return response.ok({ + body: { + mappings, + aliases, + dataStreams, + legacyTemplates, + indexTemplates, + componentTemplates, + }, + }); + } catch (e) { + return handleEsError({ error: e, response }); + } + } + ); +} diff --git a/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.ts new file mode 100644 index 0000000000000..53d12f69d30e5 --- /dev/null +++ b/src/plugins/console/server/routes/api/console/autocomplete_entities/register_mappings_route.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { RouteDependencies } from '../../..'; +import { registerGetRoute } from './register_get_route'; + +export function registerMappingsRoute(deps: RouteDependencies) { + registerGetRoute(deps); +} diff --git a/src/plugins/console/server/routes/api/console/proxy/mocks.ts b/src/plugins/console/server/routes/api/console/proxy/mocks.ts index cef9ea34a11ca..61f8e510f9735 100644 --- a/src/plugins/console/server/routes/api/console/proxy/mocks.ts +++ b/src/plugins/console/server/routes/api/console/proxy/mocks.ts @@ -17,6 +17,7 @@ import { MAJOR_VERSION } from '../../../../../common/constants'; import { ProxyConfigCollection } from '../../../../lib'; import { RouteDependencies, ProxyDependencies } from '../../..'; import { EsLegacyConfigService, SpecDefinitionsService } from '../../../../services'; +import { handleEsError } from '../../../../shared_imports'; const kibanaVersion = new SemVer(MAJOR_VERSION); @@ -65,5 +66,6 @@ export const getProxyRouteHandlerDeps = ({ : defaultProxyValue, log, kibanaVersion, + lib: { handleEsError }, }; }; diff --git a/src/plugins/console/server/routes/index.ts b/src/plugins/console/server/routes/index.ts index a3263fff2e435..b82b2ffbffa8e 100644 --- a/src/plugins/console/server/routes/index.ts +++ b/src/plugins/console/server/routes/index.ts @@ -12,10 +12,12 @@ import { SemVer } from 'semver'; import { EsLegacyConfigService, SpecDefinitionsService } from '../services'; import { ESConfigForProxy } from '../types'; import { ProxyConfigCollection } from '../lib'; +import { handleEsError } from '../shared_imports'; import { registerEsConfigRoute } from './api/console/es_config'; import { registerProxyRoute } from './api/console/proxy'; import { registerSpecDefinitionsRoute } from './api/console/spec_definitions'; +import { registerMappingsRoute } from './api/console/autocomplete_entities'; export interface ProxyDependencies { readLegacyESConfig: () => Promise; @@ -31,6 +33,9 @@ export interface RouteDependencies { esLegacyConfigService: EsLegacyConfigService; specDefinitionService: SpecDefinitionsService; }; + lib: { + handleEsError: typeof handleEsError; + }; kibanaVersion: SemVer; } @@ -38,4 +43,5 @@ export const registerRoutes = (dependencies: RouteDependencies) => { registerEsConfigRoute(dependencies); registerProxyRoute(dependencies); registerSpecDefinitionsRoute(dependencies); + registerMappingsRoute(dependencies); }; diff --git a/src/plugins/console/server/shared_imports.ts b/src/plugins/console/server/shared_imports.ts new file mode 100644 index 0000000000000..f709280aa013b --- /dev/null +++ b/src/plugins/console/server/shared_imports.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { handleEsError } from '@kbn/es-ui-shared-plugin/server'; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx index 259b6bd7f66a1..54b53f25da89f 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider.component.tsx @@ -20,6 +20,7 @@ import './range_slider.scss'; interface Props { componentStateSubject: BehaviorSubject; + ignoreValidation: boolean; } // Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. export interface RangeSliderComponentState { @@ -28,9 +29,10 @@ export interface RangeSliderComponentState { min: string; max: string; loading: boolean; + isInvalid?: boolean; } -export const RangeSliderComponent: FC = ({ componentStateSubject }) => { +export const RangeSliderComponent: FC = ({ componentStateSubject, ignoreValidation }) => { // Redux embeddable Context to get state from Embeddable input const { useEmbeddableDispatch, @@ -40,10 +42,11 @@ export const RangeSliderComponent: FC = ({ componentStateSubject }) => { const dispatch = useEmbeddableDispatch(); // useStateObservable to get component state from Embeddable - const { loading, min, max, fieldFormatter } = useStateObservable( - componentStateSubject, - componentStateSubject.getValue() - ); + const { loading, min, max, fieldFormatter, isInvalid } = + useStateObservable( + componentStateSubject, + componentStateSubject.getValue() + ); const { value, id, title } = useEmbeddableSelector((state) => state); @@ -64,6 +67,7 @@ export const RangeSliderComponent: FC = ({ componentStateSubject }) => { value={value ?? ['', '']} onChange={onChangeComplete} fieldFormatter={fieldFormatter} + isInvalid={!ignoreValidation && isInvalid} /> ); }; diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx index 1ad34fd361ac6..d7e1984b7c54c 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_embeddable.tsx @@ -12,12 +12,14 @@ import { buildRangeFilter, COMPARE_ALL_OPTIONS, RangeFilterParams, + Filter, + Query, } from '@kbn/es-query'; import React from 'react'; import ReactDOM from 'react-dom'; import { get, isEqual } from 'lodash'; import deepEqual from 'fast-deep-equal'; -import { Subscription, BehaviorSubject } from 'rxjs'; +import { Subscription, BehaviorSubject, lastValueFrom } from 'rxjs'; import { debounceTime, distinctUntilChanged, skip, map } from 'rxjs/operators'; import { @@ -59,6 +61,7 @@ interface RangeSliderDataFetchProps { dataViewId: string; query?: ControlInput['query']; filters?: ControlInput['filters']; + validate?: boolean; } const fieldMissingError = (fieldName: string) => @@ -99,6 +102,7 @@ export class RangeSliderEmbeddable extends Embeddable value, + isInvalid: false, }; this.updateComponentState(this.componentState); @@ -111,7 +115,7 @@ export class RangeSliderEmbeddable extends Embeddable { + this.runRangeSliderQuery().then(async () => { if (initialValue) { this.setInitializationFinished(); } @@ -122,6 +126,7 @@ export class RangeSliderEmbeddable extends Embeddable { const dataFetchPipe = this.getInput$().pipe( map((newInput) => ({ + validate: !Boolean(newInput.ignoreParentSettings?.ignoreValidations), lastReloadRequestTime: newInput.lastReloadRequestTime, dataViewId: newInput.dataViewId, fieldName: newInput.fieldName, @@ -134,7 +139,7 @@ export class RangeSliderEmbeddable extends Embeddable { - const aggBody: any = {}; - if (field) { - if (field.scripted) { - aggBody.script = { - source: field.script, - lang: field.lang, - }; - } else { - aggBody.field = field.name; - } - } - - return { - maxAgg: { - max: aggBody, - }, - minAgg: { - min: aggBody, - }, - }; - }; - - private fetchMinMax = async () => { + private runRangeSliderQuery = async () => { this.updateComponentState({ loading: true }); this.updateOutput({ loading: true }); const { dataView, field } = await this.getCurrentDataViewAndField(); @@ -220,7 +202,7 @@ export class RangeSliderEmbeddable extends Embeddable { const searchSource = await this.dataService.searchSource.create(); searchSource.setField('size', 0); searchSource.setField('index', dataView); - const aggs = this.minMaxAgg(field); - searchSource.setField('aggs', aggs); - searchSource.setField('filter', filters); - if (!ignoreParentSettings?.ignoreQuery) { + if (query) { searchSource.setField('query', query); } - const resp = await searchSource.fetch$().toPromise(); + const aggBody: any = {}; + + if (field) { + if (field.scripted) { + aggBody.script = { + source: field.script, + lang: field.lang, + }; + } else { + aggBody.field = field.name; + } + } + + const aggs = { + maxAgg: { + max: aggBody, + }, + minAgg: { + min: aggBody, + }, + }; + + searchSource.setField('aggs', aggs); + + const resp = await lastValueFrom(searchSource.fetch$()); const min = get(resp, 'rawResponse.aggregations.minAgg.value', ''); const max = get(resp, 'rawResponse.aggregations.maxAgg.value', ''); - this.updateComponentState({ - min: `${min ?? ''}`, - max: `${max ?? ''}`, - }); - - // build filter with new min/max - await this.buildFilter(); + return { min, max }; }; private buildFilter = async () => { - const { value: [selectedMin, selectedMax] = ['', ''], ignoreParentSettings } = this.getInput(); + const { + value: [selectedMin, selectedMax] = ['', ''], + query, + timeRange, + filters = [], + ignoreParentSettings, + } = this.getInput(); + const availableMin = this.componentState.min; const availableMax = this.componentState.max; @@ -271,22 +302,14 @@ export class RangeSliderEmbeddable extends Embeddable parseFloat(selectedMax); - const isLowerSelectionOutOfRange = - hasLowerSelection && parseFloat(selectedMin) > parseFloat(availableMax); - const isUpperSelectionOutOfRange = - hasUpperSelection && parseFloat(selectedMax) < parseFloat(availableMin); - const isSelectionOutOfRange = - (!ignoreParentSettings?.ignoreValidations && hasData && isLowerSelectionOutOfRange) || - isUpperSelectionOutOfRange; + const { dataView, field } = await this.getCurrentDataViewAndField(); - if (!hasData || !hasEitherSelection || hasInvalidSelection || isSelectionOutOfRange) { - this.updateComponentState({ loading: false }); + if (!hasData || !hasEitherSelection) { + this.updateComponentState({ + loading: false, + isInvalid: !ignoreParentSettings?.ignoreValidations && hasEitherSelection, + }); this.updateOutput({ filters: [], dataViews: [dataView], loading: false }); return; } @@ -307,12 +330,52 @@ export class RangeSliderEmbeddable extends Embeddable { - this.fetchMinMax(); + this.runRangeSliderQuery(); }; public destroy = () => { @@ -327,7 +390,14 @@ export class RangeSliderEmbeddable extends Embeddable - + , node ); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx index 1bb7501f7104f..fce3dbdfe7009 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_popover.tsx @@ -23,8 +23,11 @@ import { import { RangeSliderStrings } from './range_slider_strings'; import { RangeValue } from './types'; +const INVALID_CLASS = 'rangeSliderAnchor__fieldNumber--invalid'; + export interface Props { id: string; + isInvalid?: boolean; isLoading?: boolean; min: string; max: string; @@ -36,6 +39,7 @@ export interface Props { export const RangeSliderPopover: FC = ({ id, + isInvalid, isLoading, min, max, @@ -52,6 +56,13 @@ export const RangeSliderPopover: FC = ({ let helpText = ''; const hasAvailableRange = min !== '' && max !== ''; + + if (!hasAvailableRange) { + helpText = RangeSliderStrings.popover.getNoAvailableDataHelpText(); + } else if (isInvalid) { + helpText = RangeSliderStrings.popover.getNoDataHelpText(); + } + const hasLowerBoundSelection = value[0] !== ''; const hasUpperBoundSelection = value[1] !== ''; @@ -60,23 +71,10 @@ export const RangeSliderPopover: FC = ({ const minValue = parseFloat(min); const maxValue = parseFloat(max); - if (!hasAvailableRange) { - helpText = 'There is no data to display. Adjust the time range and filters.'; - } - // EuiDualRange can only handle integers as min/max const roundedMin = hasAvailableRange ? Math.floor(minValue) : minValue; const roundedMax = hasAvailableRange ? Math.ceil(maxValue) : maxValue; - const isLowerSelectionInvalid = hasLowerBoundSelection && lowerBoundValue > roundedMax; - const isUpperSelectionInvalid = hasUpperBoundSelection && upperBoundValue < roundedMin; - const isSelectionInvalid = - hasAvailableRange && (isLowerSelectionInvalid || isUpperSelectionInvalid); - - if (isSelectionInvalid) { - helpText = RangeSliderStrings.popover.getNoDataHelpText(); - } - if (lowerBoundValue > upperBoundValue) { errorMessage = RangeSliderStrings.errors.getUpperLessThanLowerErrorMessage(); } @@ -89,7 +87,7 @@ export const RangeSliderPopover: FC = ({ const ticks = []; const levels = []; - if (hasAvailableRange) { + if (hasAvailableRange && isPopoverOpen) { ticks.push({ value: rangeSliderMin, label: fieldFormatter(String(rangeSliderMin)) }); ticks.push({ value: rangeSliderMax, label: fieldFormatter(String(rangeSliderMax)) }); levels.push({ min: roundedMin, max: roundedMax, color: 'success' }); @@ -127,17 +125,15 @@ export const RangeSliderPopover: FC = ({ controlOnly fullWidth className={`rangeSliderAnchor__fieldNumber ${ - hasLowerBoundSelection && isSelectionInvalid - ? 'rangeSliderAnchor__fieldNumber--invalid' - : '' + hasLowerBoundSelection && isInvalid ? INVALID_CLASS : '' }`} value={hasLowerBoundSelection ? lowerBoundValue : ''} onChange={(event) => { onChange([event.target.value, isNaN(upperBoundValue) ? '' : String(upperBoundValue)]); }} - disabled={!hasAvailableRange || isLoading} + disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMin : ''}`} - isInvalid={isLowerSelectionInvalid} + isInvalid={isInvalid} data-test-subj="rangeSlider__lowerBoundFieldNumber" /> @@ -151,17 +147,15 @@ export const RangeSliderPopover: FC = ({ controlOnly fullWidth className={`rangeSliderAnchor__fieldNumber ${ - hasUpperBoundSelection && isSelectionInvalid - ? 'rangeSliderAnchor__fieldNumber--invalid' - : '' + hasUpperBoundSelection && isInvalid ? INVALID_CLASS : '' }`} value={hasUpperBoundSelection ? upperBoundValue : ''} onChange={(event) => { onChange([isNaN(lowerBoundValue) ? '' : String(lowerBoundValue), event.target.value]); }} - disabled={!hasAvailableRange || isLoading} + disabled={isLoading} placeholder={`${hasAvailableRange ? roundedMax : ''}`} - isInvalid={isUpperSelectionInvalid} + isInvalid={isInvalid} data-test-subj="rangeSlider__upperBoundFieldNumber" /> @@ -234,19 +228,17 @@ export const RangeSliderPopover: FC = ({ {errorMessage || helpText} - {hasAvailableRange ? ( - - - onChange(['', ''])} - aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} - data-test-subj="rangeSlider__clearRangeButton" - /> - - - ) : null} + + + onChange(['', ''])} + aria-label={RangeSliderStrings.popover.getClearRangeButtonTitle()} + data-test-subj="rangeSlider__clearRangeButton" + /> + + ); diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts b/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts index a901f79ba20f5..53d614fd54a2e 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_strings.ts @@ -42,7 +42,11 @@ export const RangeSliderStrings = { }), getNoDataHelpText: () => i18n.translate('controls.rangeSlider.popover.noDataHelpText', { - defaultMessage: 'Selected range is outside of available data. No filter was applied.', + defaultMessage: 'Selected range resulted in no data. No filter was applied.', + }), + getNoAvailableDataHelpText: () => + i18n.translate('controls.rangeSlider.popover.noAvailableDataHelpText', { + defaultMessage: 'There is no data to display. Adjust the time range and filters.', }), }, errors: { diff --git a/src/plugins/controls/public/services/kibana/data.ts b/src/plugins/controls/public/services/kibana/data.ts index 29a96a98c7e76..0dc702542633b 100644 --- a/src/plugins/controls/public/services/kibana/data.ts +++ b/src/plugins/controls/public/services/kibana/data.ts @@ -8,7 +8,7 @@ import { DataViewField } from '@kbn/data-views-plugin/common'; import { get } from 'lodash'; -import { from } from 'rxjs'; +import { from, lastValueFrom } from 'rxjs'; import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public'; import { ControlsDataService } from '../data'; import { ControlsPluginStartDeps } from '../../types'; @@ -78,7 +78,7 @@ export const dataServiceFactory: DataServiceFactory = ({ startPlugins }) => { searchSource.setField('filter', ignoreParentSettings?.ignoreFilters ? [] : filters); searchSource.setField('query', ignoreParentSettings?.ignoreQuery ? undefined : query); - const resp = await searchSource.fetch$().toPromise(); + const resp = await lastValueFrom(searchSource.fetch$()); const min = get(resp, 'rawResponse.aggregations.minAgg.value', undefined); const max = get(resp, 'rawResponse.aggregations.maxAgg.value', undefined); diff --git a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx index 9b5e1248d1938..8115872749c3e 100644 --- a/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx +++ b/src/plugins/custom_integrations/public/components/replacement_card/replacement_card.component.tsx @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +/** @jsxRuntime classic */ /** @jsx jsx */ import { css, jsx } from '@emotion/react'; diff --git a/src/plugins/discover/.storybook/discover.webpack.ts b/src/plugins/discover/.storybook/discover.webpack.ts index 7b978a4e7110e..c548162f7730c 100644 --- a/src/plugins/discover/.storybook/discover.webpack.ts +++ b/src/plugins/discover/.storybook/discover.webpack.ts @@ -5,9 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { defaultConfig } from '@kbn/storybook'; +import { defaultConfig, StorybookConfig } from '@kbn/storybook'; -export const discoverStorybookConfig = { +export const discoverStorybookConfig: StorybookConfig = { ...defaultConfig, stories: ['../**/*.stories.tsx'], addons: [...(defaultConfig.addons || []), './addon/target/register'], diff --git a/src/plugins/discover/public/application/main/discover_main_route.tsx b/src/plugins/discover/public/application/main/discover_main_route.tsx index 3a964aafbbc19..c69cb448a195c 100644 --- a/src/plugins/discover/public/application/main/discover_main_route.tsx +++ b/src/plugins/discover/public/application/main/discover_main_route.tsx @@ -194,7 +194,7 @@ export function DiscoverMainRoute() { }; return ( - ; + ); } diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx index da9892f343d70..64cbab5c1511b 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx @@ -6,12 +6,11 @@ * Side Public License, v 1. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import './discover_grid.scss'; import { EuiDataGridSorting, - EuiDataGridProps, EuiDataGrid, EuiScreenReaderOnly, EuiSpacer, @@ -19,6 +18,7 @@ import { htmlIdGenerator, EuiLoadingSpinner, EuiIcon, + EuiDataGridRefProps, } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/public'; import { flattenHit } from '@kbn/data-plugin/public'; @@ -165,9 +165,7 @@ export interface DiscoverGridProps { onUpdateRowHeight?: (rowHeight: number) => void; } -export const EuiDataGridMemoized = React.memo((props: EuiDataGridProps) => { - return ; -}); +export const EuiDataGridMemoized = React.memo(EuiDataGrid); const CONTROL_COLUMN_IDS_DEFAULT = ['openDetails', 'select']; @@ -199,6 +197,7 @@ export const DiscoverGrid = ({ rowHeightState, onUpdateRowHeight, }: DiscoverGridProps) => { + const dataGridRef = useRef(null); const services = useDiscoverServices(); const [selectedDocs, setSelectedDocs] = useState([]); const [isFilterActive, setIsFilterActive] = useState(false); @@ -232,6 +231,12 @@ export const DiscoverGrid = ({ return rowsFiltered; }, [rows, usedSelectedDocs, isFilterActive]); + const displayedRowsFlattened = useMemo(() => { + return displayedRows.map((hit) => { + return flattenHit(hit, indexPattern, { includeIgnoredValues: true }); + }); + }, [displayedRows, indexPattern]); + /** * Pagination */ @@ -290,16 +295,20 @@ export const DiscoverGrid = ({ getRenderCellValueFn( indexPattern, displayedRows, - displayedRows - ? displayedRows.map((hit) => - flattenHit(hit, indexPattern, { includeIgnoredValues: true }) - ) - : [], + displayedRowsFlattened, useNewFieldsApi, fieldsToShow, - services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED) + services.uiSettings.get(MAX_DOC_FIELDS_DISPLAYED), + () => dataGridRef.current?.closeCellPopover() ), - [indexPattern, displayedRows, useNewFieldsApi, fieldsToShow, services.uiSettings] + [ + indexPattern, + displayedRowsFlattened, + displayedRows, + useNewFieldsApi, + fieldsToShow, + services.uiSettings, + ] ); /** @@ -432,6 +441,7 @@ export const DiscoverGrid = ({ expanded: expandedDoc, setExpanded: setExpandedDoc, rows: displayedRows, + rowsFlattened: displayedRowsFlattened, onFilter, indexPattern, isDarkMode: services.uiSettings.get('theme:darkMode'), @@ -463,6 +473,7 @@ export const DiscoverGrid = ({ onColumnResize={onResize} pagination={paginationObj} renderCellValue={renderCellValue} + ref={dataGridRef} rowCount={rowCount} schemaDetectors={schemaDetectors} sorting={sorting as EuiDataGridSorting} diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.test.tsx index 9a75a74396ff0..5ce0befcf9305 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.test.tsx @@ -5,17 +5,50 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +const mockCopyToClipboard = jest.fn(); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + copyToClipboard: (value: string) => mockCopyToClipboard(value), + }; +}); + +jest.mock('../../utils/use_discover_services', () => { + const services = { + toastNotifications: { + addInfo: jest.fn(), + }, + }; + const originalModule = jest.requireActual('../../utils/use_discover_services'); + return { + ...originalModule, + useDiscoverServices: () => services, + }; +}); import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; -import { FilterInBtn, FilterOutBtn, buildCellActions } from './discover_grid_cell_actions'; +import { FilterInBtn, FilterOutBtn, buildCellActions, CopyBtn } from './discover_grid_cell_actions'; import { DiscoverGridContext } from './discover_grid_context'; - +import { EuiButton } from '@elastic/eui'; import { indexPatternMock } from '../../__mocks__/index_pattern'; import { esHits } from '../../__mocks__/es_hits'; -import { EuiButton } from '@elastic/eui'; import { DataViewField } from '@kbn/data-views-plugin/public'; +import { flattenHit } from '@kbn/data-plugin/common'; + +const contextMock = { + expanded: undefined, + setExpanded: jest.fn(), + rows: esHits, + rowsFlattened: esHits.map((hit) => flattenHit(hit, indexPatternMock)), + onFilter: jest.fn(), + indexPattern: indexPatternMock, + isDarkMode: false, + selectedDocs: [], + setSelectedDocs: jest.fn(), +}; describe('Discover cell actions ', function () { it('should not show cell actions for unfilterable fields', async () => { @@ -23,17 +56,6 @@ describe('Discover cell actions ', function () { }); it('triggers filter function when FilterInBtn is clicked', async () => { - const contextMock = { - expanded: undefined, - setExpanded: jest.fn(), - rows: esHits, - onFilter: jest.fn(), - indexPattern: indexPatternMock, - isDarkMode: false, - selectedDocs: [], - setSelectedDocs: jest.fn(), - }; - const component = mountWithIntl( { - const contextMock = { - expanded: undefined, - setExpanded: jest.fn(), - rows: esHits, - onFilter: jest.fn(), - indexPattern: indexPatternMock, - isDarkMode: false, - selectedDocs: [], - setSelectedDocs: jest.fn(), - }; - const component = mountWithIntl( { + const component = mountWithIntl( + + } + rowIndex={1} + colIndex={1} + columnId="extension" + isExpanded={false} + /> + + ); + const button = findTestSubject(component, 'copyClipboardButton'); + await button.simulate('click'); + expect(mockCopyToClipboard).toHaveBeenCalledWith('jpg'); + }); }); diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx index 318e1719c08f8..df07478dae5c6 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_cell_actions.tsx @@ -7,11 +7,12 @@ */ import React, { useContext } from 'react'; -import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import { copyToClipboard, EuiDataGridColumnCellActionProps } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DataViewField } from '@kbn/data-views-plugin/public'; -import { flattenHit } from '@kbn/data-plugin/public'; import { DiscoverGridContext, GridContext } from './discover_grid_context'; +import { useDiscoverServices } from '../../utils/use_discover_services'; +import { formatFieldValue } from '../../utils/format_value'; function onFilterCell( context: GridContext, @@ -19,12 +20,12 @@ function onFilterCell( columnId: EuiDataGridColumnCellActionProps['columnId'], mode: '+' | '-' ) { - const row = context.rows[rowIndex]; - const flattened = flattenHit(row, context.indexPattern); + const row = context.rowsFlattened[rowIndex]; + const value = String(row[columnId]); const field = context.indexPattern.fields.getByName(columnId); - if (flattened && field) { - context.onFilter(field, flattened[columnId], mode); + if (value && field) { + context.onFilter(field, value, mode); } } @@ -84,8 +85,52 @@ export const FilterOutBtn = ({ ); }; +export const CopyBtn = ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => { + const { indexPattern: dataView, rowsFlattened, rows } = useContext(DiscoverGridContext); + const { fieldFormats, toastNotifications } = useDiscoverServices(); + + const buttonTitle = i18n.translate('discover.grid.copyClipboardButtonTitle', { + defaultMessage: 'Copy value of {column}', + values: { column: columnId }, + }); + + return ( + { + const rowFlattened = rowsFlattened[rowIndex]; + const field = dataView.fields.getByName(columnId); + const value = rowFlattened[columnId]; + + const valueFormatted = + field?.type === '_source' + ? JSON.stringify(rowFlattened, null, 2) + : formatFieldValue(value, rows[rowIndex], fieldFormats, dataView, field, 'text'); + copyToClipboard(valueFormatted); + const infoTitle = i18n.translate('discover.grid.copyClipboardToastTitle', { + defaultMessage: 'Copied value of {column} to clipboard.', + values: { column: columnId }, + }); + + toastNotifications.addInfo({ + title: infoTitle, + }); + }} + iconType="copyClipboard" + aria-label={buttonTitle} + title={buttonTitle} + data-test-subj="copyClipboardButton" + > + {i18n.translate('discover.grid.copyClipboardButton', { + defaultMessage: 'Copy to clipboard', + })} + + ); +}; + export function buildCellActions(field: DataViewField) { - if (!field.filterable) { + if (field?.type === '_source') { + return [CopyBtn]; + } else if (!field.filterable) { return undefined; } diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx index 41d58cf213336..f1b21dabab86e 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_context.tsx @@ -15,6 +15,7 @@ export interface GridContext { expanded?: ElasticSearchHit; setExpanded: (hit?: ElasticSearchHit) => void; rows: ElasticSearchHit[]; + rowsFlattened: Array>; onFilter: DocViewFilterFn; indexPattern: DataView; isDarkMode: boolean; diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx index d416372ac183f..f1d8ab9fcb86d 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_document_selection.test.tsx @@ -21,6 +21,7 @@ const baseContextMock = { expanded: undefined, setExpanded: jest.fn(), rows: esHits, + rowsFlattened: esHits, onFilter: jest.fn(), indexPattern: indexPatternMock, isDarkMode: false, diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx index 27ee307d746eb..903d0bc4bedcd 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_expand_button.test.tsx @@ -18,6 +18,7 @@ const baseContextMock = { expanded: undefined, setExpanded: jest.fn(), rows: esHits, + rowsFlattened: esHits, onFilter: jest.fn(), indexPattern: indexPatternMock, isDarkMode: false, diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx index 53e5c23cb47d5..62b37225372dc 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.test.tsx @@ -8,24 +8,28 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; import { getRenderCellValueFn } from './get_render_cell_value'; import { indexPatternMock } from '../../__mocks__/index_pattern'; import { flattenHit } from '@kbn/data-plugin/public'; import { ElasticSearchHit } from '../../types'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +const mockServices = { + uiSettings: { + get: (key: string) => key === 'discover:maxDocFieldsDisplayed' && 200, + }, + fieldFormats: { + getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => (value ? value : '-') })), + }, +}; jest.mock('../../utils/use_discover_services', () => { - const services = { - uiSettings: { - get: (key: string) => key === 'discover:maxDocFieldsDisplayed' && 200, - }, - fieldFormats: { - getDefaultInstance: jest.fn(() => ({ convert: (value: unknown) => (value ? value : '-') })), - }, - }; const originalModule = jest.requireActual('../../utils/use_discover_services'); return { ...originalModule, - useDiscoverServices: () => services, + useDiscoverServices: () => mockServices, }; }); @@ -79,7 +83,8 @@ describe('Discover grid cell rendering', function () { rowsSource.map(flatten), false, [], - 100 + 100, + jest.fn() ); const component = shallow( ); expect(component.html()).toMatchInlineSnapshot( - `"100"` + `"
100
"` ); }); it('renders bytes column correctly using fields when details is true', () => { + const closePopoverMockFn = jest.fn(); const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, rowsFields, rowsFields.map(flatten), false, [], - 100 + 100, + closePopoverMockFn ); - const component = shallow( + const component = mountWithIntl( ); expect(component.html()).toMatchInlineSnapshot( - `"100"` + `"
100
"` ); + findTestSubject(component, 'docTableClosePopover').simulate('click'); + expect(closePopoverMockFn).toHaveBeenCalledTimes(1); }); it('renders _source column correctly', () => { @@ -154,7 +164,8 @@ describe('Discover grid cell rendering', function () { rowsSource.map(flatten), false, ['extension', 'bytes'], - 100 + 100, + jest.fn() ); const component = shallow( ); expect(component).toMatchInlineSnapshot(` - + + + + + + + + + + + + `); }); @@ -271,7 +313,8 @@ describe('Discover grid cell rendering', function () { rowsFields.map(flatten), true, ['extension', 'bytes'], - 100 + 100, + jest.fn() ); const component = shallow( ); expect(component).toMatchInlineSnapshot(` - + + + + + + + + + + + + `); }); @@ -476,7 +551,8 @@ describe('Discover grid cell rendering', function () { rowsFieldsWithTopLevelObject.map(flatten), true, ['object.value', 'extension', 'bytes'], - 100 + 100, + jest.fn() ); const component = shallow( { + const closePopoverMockFn = jest.fn(); const DiscoverGridCellValue = getRenderCellValueFn( indexPatternMock, rowsFieldsWithTopLevelObject, rowsFieldsWithTopLevelObject.map(flatten), true, [], - 100 + 100, + closePopoverMockFn ); const component = shallow( ); expect(component).toMatchInlineSnapshot(` - + + + + + + + + + + + + `); }); + it('renders a functional close button when CodeEditor is rendered', () => { + const closePopoverMockFn = jest.fn(); + const DiscoverGridCellValue = getRenderCellValueFn( + indexPatternMock, + rowsFieldsWithTopLevelObject, + rowsFieldsWithTopLevelObject.map(flatten), + true, + [], + 100, + closePopoverMockFn + ); + const component = mountWithIntl( + + + + ); + const gridSelectionBtn = findTestSubject(component, 'docTableClosePopover'); + gridSelectionBtn.simulate('click'); + expect(closePopoverMockFn).toHaveBeenCalledTimes(1); + }); + it('does not collect subfields when the the column is unmapped but part of fields response', () => { (indexPatternMock.getFieldByName as jest.Mock).mockReturnValueOnce(undefined); const DiscoverGridCellValue = getRenderCellValueFn( @@ -594,7 +732,8 @@ describe('Discover grid cell rendering', function () { rowsFieldsWithTopLevelObject.map(flatten), true, [], - 100 + 100, + jest.fn() ); const component = shallow( ); expect(componentWithDetails).toMatchInlineSnapshot(` - + + + + + + + + `); }); }); diff --git a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx index b6a63d47b7a0f..4175ff1bdd7b5 100644 --- a/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx +++ b/src/plugins/discover/public/components/discover_grid/get_render_cell_value.tsx @@ -8,6 +8,7 @@ import React, { Fragment, useContext, useEffect, useMemo } from 'react'; import classnames from 'classnames'; +import { i18n } from '@kbn/i18n'; import { euiLightVars as themeLight, euiDarkVars as themeDark } from '@kbn/ui-theme'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import { @@ -15,6 +16,9 @@ import { EuiDescriptionList, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { DiscoverGridContext } from './discover_grid_context'; @@ -36,7 +40,8 @@ export const getRenderCellValueFn = rowsFlattened: Array>, useNewFieldsApi: boolean, fieldsToShow: string[], - maxDocFieldsDisplayed: number + maxDocFieldsDisplayed: number, + closePopover: () => void ) => ({ rowIndex, columnId, isDetails, setCellProps }: EuiDataGridCellValueElementProps) => { const { uiSettings, fieldFormats } = useDiscoverServices(); @@ -93,6 +98,7 @@ export const getRenderCellValueFn = dataView, useTopLevelObjectColumns, fieldFormats, + closePopover, }); } @@ -147,6 +153,13 @@ function getInnerColumns(fields: Record, columnId: string) { ); } +function getJSON(columnId: string, rowRaw: ElasticSearchHit, useTopLevelObjectColumns: boolean) { + const json = useTopLevelObjectColumns + ? getInnerColumns(rowRaw.fields as Record, columnId) + : rowRaw; + return json as Record; +} + /** * Helper function for the cell popover */ @@ -158,6 +171,7 @@ function renderPopoverContent({ dataView, useTopLevelObjectColumns, fieldFormats, + closePopover, }: { rowRaw: ElasticSearchHit; rowFlattened: Record; @@ -166,25 +180,53 @@ function renderPopoverContent({ dataView: DataView; useTopLevelObjectColumns: boolean; fieldFormats: FieldFormatsStart; + closePopover: () => void; }) { + const closeButton = ( + + ); if (useTopLevelObjectColumns || field?.type === '_source') { - const json = useTopLevelObjectColumns - ? getInnerColumns(rowRaw.fields as Record, columnId) - : rowRaw; return ( - } width={defaultMonacoEditorWidth} /> + + + + {closeButton} + + + + + + ); } return ( - + + + + + {closeButton} + ); } /** diff --git a/src/plugins/discover/public/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap b/src/plugins/discover/public/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap index 31dd6347218b5..7af546298e0d8 100644 --- a/src/plugins/discover/public/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap +++ b/src/plugins/discover/public/components/json_code_editor/__snapshots__/json_code_editor.test.tsx.snap @@ -2,6 +2,7 @@ exports[`returns the \`JsonCodeEditor\` component 1`] = ` ; width?: string | number; + height?: string | number; hasLineNumbers?: boolean; } -export const JsonCodeEditor = ({ json, width, hasLineNumbers }: JsonCodeEditorProps) => { +export const JsonCodeEditor = ({ json, width, height, hasLineNumbers }: JsonCodeEditorProps) => { const jsonValue = JSON.stringify(json, null, 2); - // setting editor height based on lines height and count to stretch and fit its content - const setEditorCalculatedHeight = useCallback((editor) => { - const editorElement = editor.getDomNode(); - - if (!editorElement) { - return; - } - - const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight); - const lineCount = editor.getModel()?.getLineCount() || 1; - const height = editor.getTopForLineNumber(lineCount + 1) + lineHeight; - - editorElement.style.height = `${height}px`; - editor.layout(); - }, []); - return ( void 0} + hideCopyButton={true} /> ); }; diff --git a/src/plugins/discover/public/components/json_code_editor/json_code_editor_common.tsx b/src/plugins/discover/public/components/json_code_editor/json_code_editor_common.tsx index 5f6faa8ac0e9d..777240fe2f5bb 100644 --- a/src/plugins/discover/public/components/json_code_editor/json_code_editor_common.tsx +++ b/src/plugins/discover/public/components/json_code_editor/json_code_editor_common.tsx @@ -27,6 +27,7 @@ interface JsonCodeEditorCommonProps { width?: string | number; height?: string | number; hasLineNumbers?: boolean; + hideCopyButton?: boolean; } export const JsonCodeEditorCommon = ({ @@ -35,10 +36,40 @@ export const JsonCodeEditorCommon = ({ height, hasLineNumbers, onEditorDidMount, + hideCopyButton, }: JsonCodeEditorCommonProps) => { if (jsonValue === '') { return null; } + const codeEditor = ( + + ); + if (hideCopyButton) { + return codeEditor; + } return ( @@ -53,32 +84,7 @@ export const JsonCodeEditorCommon = ({
- - - + {codeEditor} ); }; diff --git a/src/plugins/discover/public/utils/format_value.ts b/src/plugins/discover/public/utils/format_value.ts index 74331e946682e..b7ee9af7f6873 100644 --- a/src/plugins/discover/public/utils/format_value.ts +++ b/src/plugins/discover/public/utils/format_value.ts @@ -10,6 +10,7 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { KBN_FIELD_TYPES } from '@kbn/data-plugin/public'; import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; +import { FieldFormatsContentType } from '@kbn/field-formats-plugin/common/types'; /** * Formats the value of a specific field using the appropriate field formatter if available @@ -26,16 +27,18 @@ export function formatFieldValue( hit: estypes.SearchHit, fieldFormats: FieldFormatsStart, dataView?: DataView, - field?: DataViewField + field?: DataViewField, + contentType?: FieldFormatsContentType | undefined ): string { + const usedContentType = contentType ?? 'html'; if (!dataView || !field) { // If either no field is available or no data view, we'll use the default // string formatter to format that field. return fieldFormats .getDefaultInstance(KBN_FIELD_TYPES.STRING) - .convert(value, 'html', { hit, field }); + .convert(value, usedContentType, { hit, field }); } // If we have a data view and field we use that fields field formatter - return dataView.getFormatterForField(field).convert(value, 'html', { hit, field }); + return dataView.getFormatterForField(field).convert(value, usedContentType, { hit, field }); } diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap index f6dcf46b27d8c..c11c1a11e7369 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap @@ -27,7 +27,21 @@ exports[`ElasticAgentCard props button 1`] = ` } href="/app/integrations/browse" - image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + image={ + + } paddingSize="l" title={ @@ -67,7 +81,21 @@ exports[`ElasticAgentCard props category 1`] = ` } href="/app/integrations/browse/custom" - image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + image={ + + } paddingSize="l" title={ @@ -107,7 +135,21 @@ exports[`ElasticAgentCard props href 1`] = ` } href="#" - image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + image={ + + } paddingSize="l" title={ @@ -147,7 +189,21 @@ exports[`ElasticAgentCard props recommended 1`] = ` } href="/app/integrations/browse" - image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + image={ + + } paddingSize="l" title={ @@ -187,7 +243,21 @@ exports[`ElasticAgentCard renders 1`] = ` } href="/app/integrations/browse" - image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + image={ + + } paddingSize="l" title={ diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx index 2cec3045c31c1..a8701fb7b7a34 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx @@ -9,7 +9,7 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { CoreStart } from '@kbn/core/public'; -import { EuiButton, EuiCard, EuiTextColor, EuiScreenReaderOnly } from '@elastic/eui'; +import { EuiButton, EuiCard, EuiTextColor, EuiScreenReaderOnly, EuiImage } from '@elastic/eui'; import { useKibana } from '../../../context'; import { NoDataPageActions, NO_DATA_RECOMMENDED } from '../no_data_page'; import { RedirectAppLinks } from '../../../app_links'; @@ -35,10 +35,24 @@ export const ElasticAgentCard: FunctionComponent = ({ services: { http, application }, } = useKibana(); const addBasePath = http.basePath.prepend; - const image = addBasePath(`/plugins/kibanaReact/assets/elastic_agent_card.svg`); + const imageUrl = addBasePath(`/plugins/kibanaReact/assets/elastic_agent_card.svg`); const canAccessFleet = application.capabilities.navLinks.integrations; const hasCategory = category ? `/${category}` : ''; + const image = ( + + ); + if (!canAccessFleet) { return ( (false); + + useEffect(() => { + function handleResize() { + setIsXXLarge(window.innerWidth >= 1440); + } + + window.removeEventListener('resize', handleResize); + window.addEventListener('resize', handleResize); + handleResize(); + + return () => window.removeEventListener('resize', handleResize); + }, []); + const { showQueryInput = true, showDatePicker = true, @@ -367,7 +381,7 @@ export const QueryBarTopRow = React.memo( { * in case you the date range (from/to) */ private shouldRenderTimeFilterInSavedQueryForm() { - const { dateRangeFrom, dateRangeTo, showDatePicker } = this.props; - return ( - showDatePicker || - (!showDatePicker && dateRangeFrom !== undefined && dateRangeTo !== undefined) - ); + const { dateRangeFrom, dateRangeTo, showDatePicker, indexPatterns } = this.props; + + if (!showDatePicker && dateRangeFrom !== undefined && dateRangeTo !== undefined) { + return false; + } + + if (indexPatterns?.length) { + // return true if at least one of the DateView has timeFieldName + return indexPatterns.some((dataView) => Boolean(dataView.timeFieldName)); + } + + return true; } public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => { diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 2588a3e9854e6..c468662fb1431 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -14,7 +14,8 @@ "savedObjects", "screenshotMode", "presentationUtil", - "dataViews" + "dataViews", + "dataViewEditor" ], "optionalPlugins": ["home", "share", "usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover", "kibanaReact"], diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 35cc96daf4f7d..96f4d48f12837 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -13,6 +13,7 @@ import { embeddablePluginMock } from '@kbn/embeddable-plugin/public/mocks'; import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { indexPatternEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/public/mocks'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks'; @@ -58,6 +59,7 @@ const createInstance = async () => { plugin.start(coreMock.createStart(), { data: dataPluginMock.createStartContract(), dataViews: dataViewPluginMocks.createStartContract(), + dataViewEditor: indexPatternEditorPluginMock.createStartContract(), expressions: expressionsPluginMock.createStartContract(), inspector: inspectorPluginMock.createStartContract(), uiActions: uiActionsPluginMock.createStartContract(), diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 7cba4ef19b254..40c408605b7b8 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -53,6 +53,7 @@ import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; import type { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { TypesSetup, TypesStart } from './vis_types'; import type { VisualizeServices } from './visualize_app/types'; import { visualizeEditorTrigger } from './triggers'; @@ -118,6 +119,7 @@ export interface VisualizationsSetupDeps { export interface VisualizationsStartDeps { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; + dataViewEditor: DataViewEditorStart; expressions: ExpressionsStart; embeddable: EmbeddableStart; inspector: InspectorStart; @@ -239,9 +241,6 @@ export class VisualizationsPlugin // make sure the index pattern list is up to date pluginsStart.dataViews.clearCache(); - // make sure a default index pattern exists - // if not, the page will be redirected to management and visualize won't be rendered - await pluginsStart.dataViews.ensureDefaultDataView(); appMounted(); @@ -269,6 +268,8 @@ export class VisualizationsPlugin pluginInitializerContext: this.initializerContext, chrome: coreStart.chrome, data: pluginsStart.data, + core: coreStart, + dataViewEditor: pluginsStart.dataViewEditor, dataViews: pluginsStart.dataViews, localStorage: new Storage(localStorage), navigation: pluginsStart.navigation, diff --git a/src/plugins/visualizations/public/visualize_app/app.scss b/src/plugins/visualizations/public/visualize_app/app.scss index f7f68fbc2c359..c22ba129dbf50 100644 --- a/src/plugins/visualizations/public/visualize_app/app.scss +++ b/src/plugins/visualizations/public/visualize_app/app.scss @@ -10,3 +10,10 @@ flex-direction: column; flex-grow: 1; } + +.visAppLoadingWrapper { + align-items: center; + justify-content: center; + display: flex; + flex-grow: 1; +} diff --git a/src/plugins/visualizations/public/visualize_app/app.tsx b/src/plugins/visualizations/public/visualize_app/app.tsx index 156cc9b99b16e..66cbac5dcc4e0 100644 --- a/src/plugins/visualizations/public/visualize_app/app.tsx +++ b/src/plugins/visualizations/public/visualize_app/app.tsx @@ -7,12 +7,18 @@ */ import './app.scss'; -import React, { useEffect } from 'react'; +import React, { useEffect, useCallback, useState } from 'react'; import { Route, Switch, useLocation } from 'react-router-dom'; - -import { AppMountParameters } from '@kbn/core/public'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import { syncQueryStateWithUrl } from '@kbn/data-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { + AnalyticsNoDataPageKibanaProvider, + AnalyticsNoDataPage, +} from '@kbn/shared-ux-page-analytics-no-data'; +import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import { VisualizeServices } from './types'; import { VisualizeEditor, @@ -26,14 +32,49 @@ export interface VisualizeAppProps { onAppLeave: AppMountParameters['onAppLeave']; } +interface NoDataComponentProps { + core: CoreStart; + dataViews: DataViewsContract; + dataViewEditor: DataViewEditorStart; + onDataViewCreated: (dataView: unknown) => void; +} + +const NoDataComponent = ({ + core, + dataViews, + dataViewEditor, + onDataViewCreated, +}: NoDataComponentProps) => { + const analyticsServices = { + coreStart: core, + dataViews, + dataViewEditor, + }; + return ( + + ; + + ); +}; + export const VisualizeApp = ({ onAppLeave }: VisualizeAppProps) => { const { services: { - data: { query }, + data: { query, dataViews }, + core, kbnUrlStateStorage, + dataViewEditor, }, } = useKibana(); const { pathname } = useLocation(); + const [showNoDataPage, setShowNoDataPage] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const onDataViewCreated = useCallback((dataView: unknown) => { + if (dataView) { + setShowNoDataPage(false); + } + }, []); useEffect(() => { // syncs `_g` portion of url with query services @@ -45,6 +86,46 @@ export const VisualizeApp = ({ onAppLeave }: VisualizeAppProps) => { // so the global state is always preserved }, [query, kbnUrlStateStorage, pathname]); + useEffect(() => { + const checkESOrDataViewExist = async () => { + // check if there is any data view or data source + const hasUserDataView = await dataViews.hasData.hasUserDataView().catch(() => false); + const hasEsData = await dataViews.hasData.hasESData().catch(() => false); + if (!hasUserDataView || !hasEsData) { + setShowNoDataPage(true); + } + // Adding this check as TSVB asks for the default dataview on initialization + const defaultDataView = await dataViews.getDefaultDataView(); + if (!defaultDataView) { + setShowNoDataPage(true); + } + setIsLoading(false); + }; + + // call the function + checkESOrDataViewExist(); + }, [dataViews]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + // Visualize app should return the noData component if there is no data view or data source + if (showNoDataPage) { + return ( + + ); + } + return ( diff --git a/src/plugins/visualizations/public/visualize_app/types.ts b/src/plugins/visualizations/public/visualize_app/types.ts index caa39d8bf9308..a940063067e89 100644 --- a/src/plugins/visualizations/public/visualize_app/types.ts +++ b/src/plugins/visualizations/public/visualize_app/types.ts @@ -25,6 +25,7 @@ import type { IKbnUrlStateStorage, ReduxLikeStateContainer, } from '@kbn/kibana-utils-plugin/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { NavigationPublicPluginStart as NavigationStart } from '@kbn/navigation-plugin/public'; import type { Filter } from '@kbn/es-query'; @@ -85,7 +86,9 @@ export interface VisualizeServices extends CoreStart { stateTransferService: EmbeddableStateTransfer; embeddable: EmbeddableStart; history: History; + dataViewEditor: DataViewEditorStart; kbnUrlStateStorage: IKbnUrlStateStorage; + core: CoreStart; urlForwarding: UrlForwardingStart; pluginInitializerContext: PluginInitializerContext; chrome: ChromeStart; diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index ce38bbf55ebdf..9a9cb97d63764 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -30,6 +30,7 @@ { "path": "../navigation/tsconfig.json" }, { "path": "../home/tsconfig.json" }, { "path": "../share/tsconfig.json" }, + { "path": "../data_view_editor/tsconfig.json" }, { "path": "../presentation_util/tsconfig.json" }, { "path": "../screenshot_mode/tsconfig.json" }, { "path": "../../../x-pack/plugins/spaces/tsconfig.json" } diff --git a/test/api_integration/apis/console/autocomplete_entities.ts b/test/api_integration/apis/console/autocomplete_entities.ts new file mode 100644 index 0000000000000..7f74156f379a0 --- /dev/null +++ b/test/api_integration/apis/console/autocomplete_entities.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import type { Response } from 'superagent'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + + function utilTest(name: string, query: object, test: (response: Response) => void) { + it(name, async () => { + const response = await supertest.get('/api/console/autocomplete_entities').query(query); + test(response); + }); + } + + describe('/api/console/autocomplete_entities', () => { + utilTest('should not succeed if no settings are provided in query params', {}, (response) => { + const { status } = response; + expect(status).to.be(400); + }); + + utilTest( + 'should return an object with properties of "mappings", "aliases", "dataStreams", "legacyTemplates", "indexTemplates", "componentTemplates"', + { + indices: true, + fields: true, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(Object.keys(body).sort()).to.eql([ + 'aliases', + 'componentTemplates', + 'dataStreams', + 'indexTemplates', + 'legacyTemplates', + 'mappings', + ]); + } + ); + + utilTest( + 'should return empty payload with all settings are set to false', + { + indices: false, + fields: false, + templates: false, + dataStreams: false, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.legacyTemplates).to.eql({}); + expect(body.indexTemplates).to.eql({}); + expect(body.componentTemplates).to.eql({}); + expect(body.aliases).to.eql({}); + expect(body.mappings).to.eql({}); + expect(body.dataStreams).to.eql({}); + } + ); + + utilTest( + 'should return empty templates with templates setting is set to false', + { + indices: true, + fields: true, + templates: false, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.legacyTemplates).to.eql({}); + expect(body.indexTemplates).to.eql({}); + expect(body.componentTemplates).to.eql({}); + } + ); + + utilTest( + 'should return empty data streams with dataStreams setting is set to false', + { + indices: true, + fields: true, + templates: true, + dataStreams: false, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.dataStreams).to.eql({}); + } + ); + + utilTest( + 'should return empty aliases with indices setting is set to false', + { + indices: false, + fields: true, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.aliases).to.eql({}); + } + ); + + utilTest( + 'should return empty mappings with fields setting is set to false', + { + indices: true, + fields: false, + templates: true, + dataStreams: true, + }, + (response) => { + const { body, status } = response; + expect(status).to.be(200); + expect(body.mappings).to.eql({}); + } + ); + }); +}; diff --git a/test/api_integration/apis/console/index.ts b/test/api_integration/apis/console/index.ts index ad4f8256f97ad..81f6f17f77b87 100644 --- a/test/api_integration/apis/console/index.ts +++ b/test/api_integration/apis/console/index.ts @@ -11,5 +11,6 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('core', () => { loadTestFile(require.resolve('./proxy_route')); + loadTestFile(require.resolve('./autocomplete_entities')); }); } diff --git a/test/functional/apps/console/_autocomplete.ts b/test/functional/apps/console/_autocomplete.ts index 7bf872373c6c7..85be77d9522a7 100644 --- a/test/functional/apps/console/_autocomplete.ts +++ b/test/functional/apps/console/_autocomplete.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const log = getService('log'); const retry = getService('retry'); const PageObjects = getPageObjects(['common', 'console']); + const find = getService('find'); describe('console autocomplete feature', function describeIndexTests() { this.tags('includeFirefox'); @@ -34,14 +35,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(PageObjects.console.isAutocompleteVisible()).to.be.eql(true); }); - // FLAKY: https://github.com/elastic/kibana/issues/126414 - describe.skip('with a missing comma in query', () => { + describe('with a missing comma in query', () => { const LINE_NUMBER = 4; beforeEach(async () => { await PageObjects.console.clearTextArea(); await PageObjects.console.enterRequest(); await PageObjects.console.pressEnter(); }); + it('should add a comma after previous non empty line', async () => { await PageObjects.console.enterText(`{\n\t"query": {\n\t\t"match": {}`); await PageObjects.console.pressEnter(); @@ -49,7 +50,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.pressEnter(); await PageObjects.console.promptAutocomplete(); await PageObjects.console.pressEnter(); - + await retry.try(async () => { + let conApp = await find.byCssSelector('.conApp'); + const firstInnerHtml = await conApp.getAttribute('innerHTML'); + await PageObjects.common.sleep(500); + conApp = await find.byCssSelector('.conApp'); + const secondInnerHtml = await conApp.getAttribute('innerHTML'); + return firstInnerHtml === secondInnerHtml; + }); + const textAreaString = await PageObjects.console.getAllVisibleText(); + log.debug('Text Area String Value==================\n'); + log.debug(textAreaString); + expect(textAreaString).to.contain(','); const text = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); const lastChar = text.charAt(text.length - 1); expect(lastChar).to.be.eql(','); @@ -61,6 +73,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.promptAutocomplete(); await PageObjects.console.pressEnter(); + await retry.waitForWithTimeout('text area to contain comma', 25000, async () => { + const textAreaString = await PageObjects.console.getAllVisibleText(); + return textAreaString.includes(','); + }); + const text = await PageObjects.console.getVisibleTextAt(LINE_NUMBER); const lastChar = text.charAt(text.length - 1); expect(lastChar).to.be.eql(','); diff --git a/test/functional/apps/dashboard/group4/dashboard_save.ts b/test/functional/apps/dashboard/group4/dashboard_save.ts index f20817c65d25d..4272d95fd68d4 100644 --- a/test/functional/apps/dashboard/group4/dashboard_save.ts +++ b/test/functional/apps/dashboard/group4/dashboard_save.ts @@ -13,6 +13,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); + const esArchiver = getService('esArchiver'); describe('dashboard save', function describeIndexTests() { this.tags('includeFirefox'); @@ -125,6 +126,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('Does not show dashboard save modal when on quick save', async function () { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.dashboard.saveDashboard('test quick save'); diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index b2d07e7a49489..a4b84206bde84 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -206,7 +206,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.rangeSliderSetUpperBound(firstId, '400'); }); - it('disables inputs when no data available', async () => { + it('disables range slider when no data available', async () => { await dashboardControls.createControl({ controlType: RANGE_SLIDER_CONTROL, dataViewTitle: 'logstash-*', @@ -214,12 +214,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { width: 'small', }); const secondId = (await dashboardControls.getAllControlIds())[1]; - expect( - await dashboardControls.rangeSliderGetLowerBoundAttribute(secondId, 'disabled') - ).to.be('true'); - expect( - await dashboardControls.rangeSliderGetUpperBoundAttribute(secondId, 'disabled') - ).to.be('true'); await dashboardControls.rangeSliderOpenPopover(secondId); await dashboardControls.rangeSliderPopoverAssertOpen(); expect( diff --git a/test/functional/apps/visualize/group1/_no_data.ts b/test/functional/apps/visualize/group1/_no_data.ts new file mode 100644 index 0000000000000..9b86ea3aae675 --- /dev/null +++ b/test/functional/apps/visualize/group1/_no_data.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['visualize', 'header', 'common']); + const esArchiver = getService('esArchiver'); + const find = getService('find'); + const kibanaServer = getService('kibanaServer'); + + const createDataView = async (dataViewName: string) => { + await testSubjects.setValue('createIndexPatternNameInput', dataViewName, { + clearWithKeyboard: true, + typeCharByChar: true, + }); + await testSubjects.click('saveIndexPatternButton'); + }; + + describe('no data in visualize', function () { + it('should show the integrations component if there is no data', async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.unload('test/functional/fixtures/es_archiver/long_window_logstash'); + await kibanaServer.savedObjects.clean({ types: ['search', 'index-pattern'] }); + await PageObjects.common.navigateToApp('visualize'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + const addIntegrations = await testSubjects.find('kbnOverviewAddIntegrations'); + await addIntegrations.click(); + await PageObjects.common.waitUntilUrlIncludes('integrations/browse'); + }); + + it('should show the no dataview component if no dataviews exist', async function () { + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/long_window_logstash'); + await kibanaServer.savedObjects.clean({ types: ['search', 'index-pattern'] }); + await PageObjects.common.navigateToApp('visualize'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const button = await testSubjects.find('createDataViewButtonFlyout'); + button.click(); + await retry.waitForWithTimeout('index pattern editor form to be visible', 15000, async () => { + return await (await find.byClassName('indexPatternEditor__form')).isDisplayed(); + }); + + const dataViewToCreate = 'logstash'; + await createDataView(dataViewToCreate); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await retry.waitForWithTimeout( + 'data view selector to include a newly created dataview', + 5000, + async () => { + const addNewVizButton = await testSubjects.exists('newItemButton'); + expect(addNewVizButton).to.be(true); + return addNewVizButton; + } + ); + }); + }); +} diff --git a/test/functional/apps/visualize/group1/index.ts b/test/functional/apps/visualize/group1/index.ts index fa3379b632cc1..aee4595d8f0a0 100644 --- a/test/functional/apps/visualize/group1/index.ts +++ b/test/functional/apps/visualize/group1/index.ts @@ -22,11 +22,11 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/long_window_logstash'); }); - loadTestFile(require.resolve('./_embedding_chart')); loadTestFile(require.resolve('./_data_table')); loadTestFile(require.resolve('./_data_table_nontimeindex')); loadTestFile(require.resolve('./_data_table_notimeindex_filters')); loadTestFile(require.resolve('./_chart_types')); + loadTestFile(require.resolve('./_no_data')); }); } diff --git a/test/functional/page_objects/console_page.ts b/test/functional/page_objects/console_page.ts index 7aaf842f28d14..218a1077d63ef 100644 --- a/test/functional/page_objects/console_page.ts +++ b/test/functional/page_objects/console_page.ts @@ -119,10 +119,22 @@ export class ConsolePageObject extends FtrService { return await this.testSubjects.find('console-textarea'); } - public async getVisibleTextAt(lineIndex: number) { + public async getAllTextLines() { const editor = await this.getEditor(); - const lines = await editor.findAllByClassName('ace_line_group'); + return await editor.findAllByClassName('ace_line_group'); + } + public async getAllVisibleText() { + let textString = ''; + const textLineElements = await this.getAllTextLines(); + for (let i = 0; i < textLineElements.length; i++) { + textString = textString.concat(await textLineElements[i].getVisibleText()); + } + return textString; + } + + public async getVisibleTextAt(lineIndex: number) { + const lines = await this.getAllTextLines(); if (lines.length < lineIndex) { throw new Error(`No line with index: ${lineIndex}`); } diff --git a/typings/js_sql_parser.d.ts b/typings/@appland/sql_parser/index.d.ts similarity index 90% rename from typings/js_sql_parser.d.ts rename to typings/@appland/sql_parser/index.d.ts index b58091d0117e3..8f78182005eac 100644 --- a/typings/js_sql_parser.d.ts +++ b/typings/@appland/sql_parser/index.d.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -declare module 'js-sql-parser'; +declare module '@appland/sql-parser'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index ff3a92e935818..63cb0195a14f4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -245,7 +245,6 @@ export interface ImportSetApiResponseError { export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError; export interface GetApplicationInfoResponse { - id: string; name: string; scope: string; version: string; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 6b0070af0b022..3b9869be91413 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -55,6 +55,10 @@ export { ACTION_SAVED_OBJECT_TYPE } from './constants/saved_objects'; export const plugin = (initContext: PluginInitializerContext) => new ActionsPlugin(initContext); +export { SubActionConnector } from './sub_action_framework/sub_action_connector'; +export { CaseConnector } from './sub_action_framework/case'; +export type { ServiceParams } from './sub_action_framework/types'; + export const config: PluginConfigDescriptor = { schema: configSchema, exposeToBrowser: { diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 00cca942fe14b..c6e5d7979c55f 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -24,7 +24,10 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const createSetupMock = () => { const mock: jest.Mocked = { registerType: jest.fn(), + registerSubActionConnectorType: jest.fn(), isPreconfiguredConnector: jest.fn(), + getSubActionConnectorClass: jest.fn(), + getCaseConnectorClass: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 131563fd3e731..4bbdb26b8e6a1 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -97,6 +97,10 @@ import { isConnectorDeprecated, ConnectorWithOptionalDeprecation, } from './lib/is_conector_deprecated'; +import { createSubActionConnectorFramework } from './sub_action_framework'; +import { IServiceAbstract, SubActionConnectorType } from './sub_action_framework/types'; +import { SubActionConnector } from './sub_action_framework/sub_action_connector'; +import { CaseConnector } from './sub_action_framework/case'; export interface PluginSetupContract { registerType< @@ -107,8 +111,15 @@ export interface PluginSetupContract { >( actionType: ActionType ): void; - + registerSubActionConnectorType< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets + >( + connector: SubActionConnectorType + ): void; isPreconfiguredConnector(connectorId: string): boolean; + getSubActionConnectorClass: () => IServiceAbstract; + getCaseConnectorClass: () => IServiceAbstract; } export interface PluginStartContract { @@ -310,6 +321,12 @@ export class ActionsPlugin implements Plugin(), @@ -342,11 +359,21 @@ export class ActionsPlugin implements Plugin( + connector: SubActionConnectorType + ) => { + subActionFramework.registerConnector(connector); + }, isPreconfiguredConnector: (connectorId: string): boolean => { return !!this.preconfiguredActions.find( (preconfigured) => preconfigured.id === connectorId ); }, + getSubActionConnectorClass: () => SubActionConnector, + getCaseConnectorClass: () => CaseConnector, }; } diff --git a/x-pack/plugins/actions/server/sub_action_framework/README.md b/x-pack/plugins/actions/server/sub_action_framework/README.md new file mode 100644 index 0000000000000..90951692f5457 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/README.md @@ -0,0 +1,356 @@ +# Sub actions framework + +## Summary + +The Kibana actions plugin provides a framework to create executable actions that supports sub actions. That means you can execute different flows (sub actions) when you execute an action. The framework provides tools to aid you to focus only on the business logic of your connector. You can: + +- Register a sub action and map it to a function of your choice. +- Define a schema for the parameters of your sub action. +- Define a response schema for responses from external services. +- Create connectors that are supported by the Cases management system. + +The framework is built on top of the current actions framework and it is not a replacement of it. All practices described on the plugin's main [README](../../README.md#developing-new-action-types) applies to this framework also. + +## Classes + +The framework provides two classes. The `SubActionConnector` class and the `CaseConnector` class. When registering your connector you should provide a class that implements the business logic of your connector. The class must extend one of the two classes provided by the framework. The classes provides utility functions to register sub actions and make requests to external services. + + +If you extend the `SubActionConnector`, you should implement the following abstract methods: +- `getResponseErrorMessage(error: AxiosError): string;` + + +If you extend the `CaseConnector`, you should implement the following abstract methods: + +- `getResponseErrorMessage(error: AxiosError): string;` +- `addComment({ incidentId, comment }): Promise` +- `createIncident(incident): Promise` +- `updateIncident({ incidentId, incident }): Promise` +- `getIncident({ id }): Promise` + +where + +``` +interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} +``` + +The `CaseConnector` class registers automatically the `pushToService` sub action and implements the corresponding method that is needed by Cases. + + +### Class Diagrams + +```mermaid +classDiagram + SubActionConnector <|-- CaseConnector + + class SubActionConnector{ + -subActions + #config + #secrets + #registerSubAction(subAction) + +getResponseErrorMessage(error)* + +getSubActions() + +registerSubAction(subAction) + } + + class CaseConnector{ + +addComment(comment)* + +createIncident(incident)* + +updateIncident(incidentId, incident)* + +getIncident(incidentId)* + +pushToService(params) + } +``` + +### Examples of extending the classes + +```mermaid +classDiagram + SubActionConnector <|-- CaseConnector + SubActionConnector <|-- Tines + CaseConnector <|-- ServiceNow + + class SubActionConnector{ + -subActions + #config + #secrets + #registerSubAction(subAction) + +getSubActions() + +register(params) + } + + class CaseConnector{ + +addComment(comment)* + +createIncident(incident)* + +updateIncident(incidentId, incident)* + +getIncident(incidentId)* + +pushToService(params) + } + + class ServiceNow{ + +getFields() + +getChoices() + } + + class Tines{ + +getStories() + +getWebooks(storyId) + +runAction(actionId) + } +``` + +## Usage + +This guide assumes that you created a class that extends one of the two classes provided by the framework. + +### Register a sub action + +To register a sub action use the `registerSubAction` method provided by the base classes. It expects the name of the sub action, the name of the method of the class that will be called when the sub action is triggered, and a validation schema for the sub action parameters. Example: + +``` +this.registerSubAction({ name: 'fields', method: 'getFields', schema: schema.object({ incidentId: schema.string() }) }) +``` + +If your method does not accepts any arguments pass `null` to the schema property. Example: + +``` +this.registerSubAction({ name: 'noParams', method: 'noParams', schema: null }) +``` + +### Request to an external service + +To make a request to an external you should use the `request` method provided by the base classes. It accepts all attributes of the [request configuration object](https://github.com/axios/axios#request-config) of axios plus the expected response schema. Example: + +``` +const res = await this.request({ + auth: this.getBasicAuth(), + url: 'https://example/com/api/incident/1', + method: 'get', + responseSchema: schema.object({ id: schema.string(), name: schema.string() }) }, + }); +``` + +The message returned by the `getResponseErrorMessage` method will be used by the framework as an argument to the constructor of the `Error` class. Then the framework will thrown the `error`. + +The request method does the following: + +- Logs the request URL and method for debugging purposes. +- Asserts the URL. +- Normalizes the URL. +- Ensures that the URL is in the allow list. +- Configures proxies. +- Validates the response. + +### Error messages from external services + +Each external service has a different response schema for errors. For that reason, you have to implement the abstract method `getResponseErrorMessage` which returns a string representing the error message of the response. Example: + +``` +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } +``` + +### Remove null or undefined values from data + +There is a possibility that an external service would throw an error for fields with `null` values. For that reason, the base classes provide the `removeNullOrUndefinedFields` utility function to remove or `null` or `undefined` values from an object. Example: + +``` +// Returns { foo: 'foo' } +this.removeNullOrUndefinedFields({ toBeRemoved: null, foo: 'foo' }) +``` + +## Example: Sub action connector + +``` +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './basic'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestBasicConnector extends SubActionConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'mySubAction', + method: 'triggerSubAction', + schema: schema.object({ id: schema.string() }), + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async triggerSubAction({ id }: { id: string; }) { + const res = await this.request({ + url, + data, + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } +} +``` + +## Example: Case connector + +``` +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './basic'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestCaseConnector extends CaseConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'categories', + method: 'getCategories', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async createIncident(incident: { + incident: Record + }): Promise { + const res = await this.request({ + method: 'post', + url: 'https://example.com/api/incident', + data: { incident }, + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise { + const res = await this.request({ + url: `https://example.com/api/incident/${incidentId}/comment`, + data: { comment }, + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: { category: string }; + }): Promise { + const res = await this.request({ + method: 'put', + url: `https://example.com/api/incident/${incidentId}`', + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getIncident({ id }: { id: string }): Promise { + const res = await this.request({ + url: 'https://example.com/api/incident/1', + responseSchema: schema.object({ id: schema.string(), title: schema.string() }), + }); + + return { + id: res.data.id, + title: res.data.title, + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getCategories() { + const res = await this.request({ + url: 'https://example.com/api/categories', + responseSchema: schema.object({ categories: schema.array(schema.string()) }), + }); + + return res; + } +``` + +### Example: Register sub action connector + +The actions framework exports the `registerSubActionConnectorType` to register sub action connectors. Example: + +``` +plugins.actions.registerSubActionConnectorType({ + id: '.test-sub-action-connector', + name: 'Test: Sub action connector', + minimumLicenseRequired: 'platinum' as const, + schema: { config: TestConfigSchema, secrets: TestSecretsSchema }, + Service: TestSubActionConnector, +}); +``` + +You can see a full example in [x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts](../../../../test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts) \ No newline at end of file diff --git a/x-pack/plugins/actions/server/sub_action_framework/case.test.ts b/x-pack/plugins/actions/server/sub_action_framework/case.test.ts new file mode 100644 index 0000000000000..7de7e4f903e0d --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/case.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { TestCaseConnector } from './mocks'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +describe('CaseConnector', () => { + const pushToServiceParams = { externalId: null, comments: [] }; + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + let service: TestCaseConnector; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + + mockedActionsConfig.getResponseSettings.mockReturnValue({ + maxContentLength: 1000000, + timeout: 360000, + }); + + service = new TestCaseConnector({ + configurationUtilities: mockedActionsConfig, + logger, + connector: { id: 'test-id', type: '.test' }, + config: { url: 'https://example.com' }, + secrets: { username: 'elastic', password: 'changeme' }, + services, + }); + }); + + describe('Sub actions', () => { + it('registers the pushToService sub action correctly', async () => { + const subActions = service.getSubActions(); + expect(subActions.get('pushToService')).toEqual({ + method: 'pushToService', + name: 'pushToService', + schema: expect.anything(), + }); + }); + + it('should validate the schema of pushToService correctly', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect( + subAction?.schema?.validate({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + }) + ).toEqual({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + }); + }); + + it('should accept null for externalId', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(subAction?.schema?.validate({ externalId: null, comments: [] })); + }); + + it.each([[undefined], [1], [false], [{ test: 'hello' }], [['test']], [{ test: 'hello' }]])( + 'should throw if externalId is %p', + async (externalId) => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(() => subAction?.schema?.validate({ externalId, comments: [] })); + } + ); + + it('should accept null for comments', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(subAction?.schema?.validate({ externalId: 'test', comments: null })); + }); + + it.each([ + [undefined], + [1], + [false], + [{ test: 'hello' }], + [['test']], + [{ test: 'hello' }], + [{ comment: 'comment', commentId: 'comment-id', foo: 'foo' }], + ])('should throw if comments %p', async (comments) => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect(() => subAction?.schema?.validate({ externalId: 'test', comments })); + }); + + it('should allow any field in the params', async () => { + const subActions = service.getSubActions(); + const subAction = subActions.get('pushToService'); + expect( + subAction?.schema?.validate({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }) + ).toEqual({ + externalId: 'test', + comments: [{ comment: 'comment', commentId: 'comment-id' }], + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }); + }); + }); + + describe('pushToService', () => { + it('should create an incident if externalId is null', async () => { + const res = await service.pushToService(pushToServiceParams); + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should update an incident if externalId is not null', async () => { + const res = await service.pushToService({ ...pushToServiceParams, externalId: 'test-id' }); + expect(res).toEqual({ + id: 'update-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should add comments', async () => { + const res = await service.pushToService({ + ...pushToServiceParams, + comments: [ + { comment: 'comment-1', commentId: 'comment-id-1' }, + { comment: 'comment-2', commentId: 'comment-id-2' }, + ], + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + comments: [ + { + commentId: 'comment-id-1', + pushedDate: '2022-05-06T09:41:00.401Z', + }, + { + commentId: 'comment-id-2', + pushedDate: '2022-05-06T09:41:00.401Z', + }, + ], + }); + }); + + it.each([[undefined], [null]])('should throw if externalId is %p', async (comments) => { + const res = await service.pushToService({ + ...pushToServiceParams, + // @ts-expect-error + comments, + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + + it('should not add comments if comments are an empty array', async () => { + const res = await service.pushToService({ + ...pushToServiceParams, + comments: [], + }); + + expect(res).toEqual({ + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/case.ts b/x-pack/plugins/actions/server/sub_action_framework/case.ts new file mode 100644 index 0000000000000..49e6586926645 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/case.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { + ExternalServiceIncidentResponse, + PushToServiceParams, + PushToServiceResponse, +} from './types'; +import { SubActionConnector } from './sub_action_connector'; +import { ServiceParams } from './types'; + +export interface CaseConnectorInterface { + addComment: ({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }) => Promise; + createIncident: (incident: Record) => Promise; + updateIncident: ({ + incidentId, + incident, + }: { + incidentId: string; + incident: Record; + }) => Promise; + getIncident: ({ id }: { id: string }) => Promise; + pushToService: (params: PushToServiceParams) => Promise; +} + +export abstract class CaseConnector + extends SubActionConnector + implements CaseConnectorInterface +{ + constructor(params: ServiceParams) { + super(params); + + this.registerSubAction({ + name: 'pushToService', + method: 'pushToService', + schema: schema.object( + { + externalId: schema.nullable(schema.string()), + comments: schema.nullable( + schema.arrayOf( + schema.object({ + comment: schema.string(), + commentId: schema.string(), + }) + ) + ), + }, + { unknowns: 'allow' } + ), + }); + } + + public abstract addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise; + + public abstract createIncident( + incident: Record + ): Promise; + public abstract updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: Record; + }): Promise; + public abstract getIncident({ id }: { id: string }): Promise; + + public async pushToService(params: PushToServiceParams) { + const { externalId, comments, ...rest } = params; + + let res: PushToServiceResponse; + + if (externalId != null) { + res = await this.updateIncident({ + incidentId: externalId, + incident: rest, + }); + } else { + res = await this.createIncident(rest); + } + + if (comments && Array.isArray(comments) && comments.length > 0) { + res.comments = []; + + for (const currentComment of comments) { + await this.addComment({ + incidentId: res.id, + comment: currentComment.comment, + }); + + res.comments = [ + ...(res.comments ?? []), + { + commentId: currentComment.commentId, + pushedDate: res.pushedDate, + }, + ]; + } + } + + return res; + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts new file mode 100644 index 0000000000000..410bcda0f30d7 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.test.ts @@ -0,0 +1,198 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { buildExecutor } from './executor'; +import { + TestSecretsSchema, + TestConfigSchema, + TestNoSubActions, + TestConfig, + TestSecrets, + TestExecutor, +} from './mocks'; +import { IService } from './types'; + +describe('Executor', () => { + const actionId = 'test-action-id'; + const config = { url: 'https://example.com' }; + const secrets = { username: 'elastic', password: 'changeme' }; + const params = { subAction: 'testUrl', subActionParams: { url: 'https://example.com' } }; + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + + const createExecutor = (Service: IService) => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service, + }; + + return buildExecutor({ configurationUtilities: mockedActionsConfig, logger, connector }); + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + }); + + it('should execute correctly', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { subAction: 'echo', subActionParams: { id: 'test-id' } }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: { + id: 'test-id', + }, + status: 'ok', + }); + }); + + it('should execute correctly without schema validation', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { subAction: 'noSchema', subActionParams: { id: 'test-id' } }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: { + id: 'test-id', + }, + status: 'ok', + }); + }); + + it('should return an empty object if the func returns undefined', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { ...params, subAction: 'noData' }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: {}, + status: 'ok', + }); + }); + + it('should execute a non async function', async () => { + const executor = createExecutor(TestExecutor); + + const res = await executor({ + actionId, + params: { ...params, subAction: 'noAsync' }, + config, + secrets, + services, + }); + + expect(res).toEqual({ + actionId: 'test-action-id', + data: {}, + status: 'ok', + }); + }); + + it('throws if the are not sub actions registered', async () => { + const executor = createExecutor(TestNoSubActions); + + await expect(async () => + executor({ actionId, params, config, secrets, services }) + ).rejects.toThrowError('You should register at least one subAction for your connector type'); + }); + + it('throws if the sub action is not registered', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params: { subAction: 'not-exist', subActionParams: {} }, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Sub action "not-exist" is not registered. Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the method does not exists', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Method "not-exist" does not exists in service. Sub action: "testUrl". Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the registered method is not a function', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ + actionId, + params: { ...params, subAction: 'notAFunction' }, + config, + secrets, + services, + }) + ).rejects.toThrowError( + 'Method "notAFunction" must be a function. Connector id: test-action-id. Connector name: Test. Connector type: .test' + ); + }); + + it('throws if the sub actions params are not valid', async () => { + const executor = createExecutor(TestExecutor); + + await expect(async () => + executor({ actionId, params: { ...params, subAction: 'echo' }, config, secrets, services }) + ).rejects.toThrowError( + 'Request validation failed (Error: [id]: expected value of type [string] but got [undefined])' + ); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/executor.ts b/x-pack/plugins/actions/server/sub_action_framework/executor.ts new file mode 100644 index 0000000000000..469cc383e3d93 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/executor.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ExecutorType } from '../types'; +import { ExecutorParams, SubActionConnectorType } from './types'; + +const isFunction = (v: unknown): v is Function => { + return typeof v === 'function'; +}; + +const getConnectorErrorMsg = (actionId: string, connector: { id: string; name: string }) => + `Connector id: ${actionId}. Connector name: ${connector.name}. Connector type: ${connector.id}`; + +export const buildExecutor = ({ + configurationUtilities, + connector, + logger, +}: { + connector: SubActionConnectorType; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; +}): ExecutorType => { + return async ({ actionId, params, config, secrets, services }) => { + const subAction = params.subAction; + const subActionParams = params.subActionParams; + + const service = new connector.Service({ + connector: { id: actionId, type: connector.id }, + config, + secrets, + configurationUtilities, + logger, + services, + }); + + const subActions = service.getSubActions(); + + if (subActions.size === 0) { + throw new Error('You should register at least one subAction for your connector type'); + } + + const action = subActions.get(subAction); + + if (!action) { + throw new Error( + `Sub action "${subAction}" is not registered. ${getConnectorErrorMsg(actionId, connector)}` + ); + } + + const method = action.method; + + if (!service[method]) { + throw new Error( + `Method "${method}" does not exists in service. Sub action: "${subAction}". ${getConnectorErrorMsg( + actionId, + connector + )}` + ); + } + + const func = service[method]; + + if (!isFunction(func)) { + throw new Error( + `Method "${method}" must be a function. ${getConnectorErrorMsg(actionId, connector)}` + ); + } + + if (action.schema) { + try { + action.schema.validate(subActionParams); + } catch (reqValidationError) { + throw new Error(`Request validation failed (${reqValidationError})`); + } + } + + const data = await func.call(service, subActionParams); + return { status: 'ok', data: data ?? {}, actionId }; + }; +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/index.ts b/x-pack/plugins/actions/server/sub_action_framework/index.ts new file mode 100644 index 0000000000000..02eb281fa6e1b --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +import { ActionTypeRegistry } from '../action_type_registry'; +import { register } from './register'; +import { SubActionConnectorType } from './types'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; + +export const createSubActionConnectorFramework = ({ + actionsConfigUtils: configurationUtilities, + actionTypeRegistry, + logger, +}: { + actionTypeRegistry: PublicMethodsOf; + logger: Logger; + actionsConfigUtils: ActionsConfigurationUtilities; +}) => { + return { + registerConnector: ( + connector: SubActionConnectorType + ) => { + register({ actionTypeRegistry, logger, connector, configurationUtilities }); + }, + }; +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/mocks.ts b/x-pack/plugins/actions/server/sub_action_framework/mocks.ts new file mode 100644 index 0000000000000..274662bb7a35f --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/mocks.ts @@ -0,0 +1,194 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +/* eslint-disable max-classes-per-file */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { AxiosError } from 'axios'; +import { SubActionConnector } from './sub_action_connector'; +import { CaseConnector } from './case'; +import { ExternalServiceIncidentResponse, ServiceParams } from './types'; + +export const TestConfigSchema = schema.object({ url: schema.string() }); +export const TestSecretsSchema = schema.object({ + username: schema.string(), + password: schema.string(), +}); +export type TestConfig = TypeOf; +export type TestSecrets = TypeOf; + +interface ErrorSchema { + errorMessage: string; + errorCode: number; +} + +export class TestSubActionConnector extends SubActionConnector { + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'testUrl', + method: 'testUrl', + schema: schema.object({ url: schema.string() }), + }); + + this.registerSubAction({ + name: 'testData', + method: 'testData', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async testUrl({ url, data = {} }: { url: string; data?: Record | null }) { + const res = await this.request({ + url, + data, + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } + + public async testData({ data }: { data: Record }) { + const res = await this.request({ + url: 'https://example.com', + data: this.removeNullOrUndefinedFields(data), + headers: { 'X-Test-Header': 'test' }, + responseSchema: schema.object({ status: schema.string() }), + }); + + return res; + } +} + +export class TestNoSubActions extends SubActionConnector { + protected getResponseErrorMessage(error: AxiosError) { + return `Error`; + } +} + +export class TestExecutor extends SubActionConnector { + public notAFunction: string = 'notAFunction'; + + constructor(params: ServiceParams) { + super(params); + this.registerSubAction({ + name: 'testUrl', + method: 'not-exist', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'notAFunction', + method: 'notAFunction', + schema: schema.object({}), + }); + + this.registerSubAction({ + name: 'echo', + method: 'echo', + schema: schema.object({ id: schema.string() }), + }); + + this.registerSubAction({ + name: 'noSchema', + method: 'noSchema', + schema: null, + }); + + this.registerSubAction({ + name: 'noData', + method: 'noData', + schema: null, + }); + + this.registerSubAction({ + name: 'noAsync', + method: 'noAsync', + schema: null, + }); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Error`; + } + + public async echo({ id }: { id: string }) { + return Promise.resolve({ id }); + } + + public async noSchema({ id }: { id: string }) { + return { id }; + } + + public async noData() {} + + public noAsync() {} +} + +export class TestCaseConnector extends CaseConnector { + constructor(params: ServiceParams) { + super(params); + } + + protected getResponseErrorMessage(error: AxiosError) { + return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`; + } + + public async createIncident(incident: { + category: string; + }): Promise { + return { + id: 'create-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async addComment({ + incidentId, + comment, + }: { + incidentId: string; + comment: string; + }): Promise { + return { + id: 'add-comment', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async updateIncident({ + incidentId, + incident, + }: { + incidentId: string; + incident: { category: string }; + }): Promise { + return { + id: 'update-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } + + public async getIncident({ id }: { id: string }): Promise { + return { + id: 'get-incident', + title: 'Test incident', + url: 'https://example.com', + pushedDate: '2022-05-06T09:41:00.401Z', + }; + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.test.ts b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts new file mode 100644 index 0000000000000..85d630736a3b1 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/register.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionTypeRegistryMock } from '../action_type_registry.mock'; +import { + TestSecretsSchema, + TestConfigSchema, + TestConfig, + TestSecrets, + TestSubActionConnector, +} from './mocks'; +import { register } from './register'; + +describe('Registration', () => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service: TestSubActionConnector, + }; + + const actionTypeRegistry = actionTypeRegistryMock.create(); + const mockedActionsConfig = actionsConfigMock.create(); + const logger = loggingSystemMock.createLogger(); + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + it('registers the connector correctly', async () => { + register({ + actionTypeRegistry, + connector, + configurationUtilities: mockedActionsConfig, + logger, + }); + + expect(actionTypeRegistry.register).toHaveBeenCalledTimes(1); + expect(actionTypeRegistry.register).toHaveBeenCalledWith({ + id: connector.id, + name: connector.name, + minimumLicenseRequired: connector.minimumLicenseRequired, + validate: expect.anything(), + executor: expect.anything(), + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/register.ts b/x-pack/plugins/actions/server/sub_action_framework/register.ts new file mode 100644 index 0000000000000..ff9cf50e514cd --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/register.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { Logger } from '@kbn/core/server'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeRegistry } from '../action_type_registry'; +import { SubActionConnector } from './sub_action_connector'; +import { CaseConnector } from './case'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; +import { buildExecutor } from './executor'; +import { ExecutorParams, SubActionConnectorType, IService } from './types'; +import { buildValidators } from './validators'; + +const validateService = (Service: IService) => { + if ( + !(Service.prototype instanceof CaseConnector) && + !(Service.prototype instanceof SubActionConnector) + ) { + throw new Error( + 'Service must be extend one of the abstract classes: SubActionConnector or CaseConnector' + ); + } +}; + +export const register = ({ + actionTypeRegistry, + connector, + logger, + configurationUtilities, +}: { + configurationUtilities: ActionsConfigurationUtilities; + actionTypeRegistry: PublicMethodsOf; + connector: SubActionConnectorType; + logger: Logger; +}) => { + validateService(connector.Service); + + const validators = buildValidators({ connector, configurationUtilities }); + const executor = buildExecutor({ + connector, + logger, + configurationUtilities, + }); + + actionTypeRegistry.register({ + id: connector.id, + name: connector.name, + minimumLicenseRequired: connector.minimumLicenseRequired, + validate: validators, + executor, + }); +}; diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts new file mode 100644 index 0000000000000..957d8875547c2 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.test.ts @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Agent as HttpsAgent } from 'https'; +import HttpProxyAgent from 'http-proxy-agent'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { MockedLogger } from '@kbn/logging-mocks'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsMock } from '../mocks'; +import { TestSubActionConnector } from './mocks'; +import { getCustomAgents } from '../builtin_action_types/lib/get_custom_agents'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +jest.mock('axios'); +const axiosMock = axios as jest.Mocked; + +const createAxiosError = (): AxiosError => { + const error = new Error() as AxiosError; + error.isAxiosError = true; + error.config = { method: 'get', url: 'https://example.com' }; + error.response = { + data: { errorMessage: 'An error occurred', errorCode: 500 }, + } as AxiosResponse; + + return error; +}; + +describe('SubActionConnector', () => { + const axiosInstanceMock = jest.fn(); + let logger: MockedLogger; + let services: ReturnType; + let mockedActionsConfig: jest.Mocked; + let service: TestSubActionConnector; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + axiosInstanceMock.mockReturnValue({ data: { status: 'ok' } }); + axiosMock.create.mockImplementation(() => { + return axiosInstanceMock as unknown as AxiosInstance; + }); + + logger = loggingSystemMock.createLogger(); + services = actionsMock.createServices(); + mockedActionsConfig = actionsConfigMock.create(); + + mockedActionsConfig.getResponseSettings.mockReturnValue({ + maxContentLength: 1000000, + timeout: 360000, + }); + + service = new TestSubActionConnector({ + configurationUtilities: mockedActionsConfig, + logger, + connector: { id: 'test-id', type: '.test' }, + config: { url: 'https://example.com' }, + secrets: { username: 'elastic', password: 'changeme' }, + services, + }); + }); + + describe('Sub actions', () => { + it('gets the sub actions correctly', async () => { + const subActions = service.getSubActions(); + expect(subActions.get('testUrl')).toEqual({ + method: 'testUrl', + name: 'testUrl', + schema: expect.anything(), + }); + }); + }); + + describe('URL validation', () => { + it('removes double slashes correctly', async () => { + await service.testUrl({ url: 'https://example.com//api///test-endpoint' }); + expect(axiosInstanceMock.mock.calls[0][0]).toBe('https://example.com/api/test-endpoint'); + }); + + it('removes the ending slash correctly', async () => { + await service.testUrl({ url: 'https://example.com/' }); + expect(axiosInstanceMock.mock.calls[0][0]).toBe('https://example.com'); + }); + + it('throws an error if the url is invalid', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: 'invalid-url' })).rejects.toThrow( + 'URL Error: Invalid URL: invalid-url' + ); + }); + + it('throws an error if the url starts with backslashes', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: '//example.com/foo' })).rejects.toThrow( + 'URL Error: Invalid URL: //example.com/foo' + ); + }); + + it('throws an error if the protocol is not supported', async () => { + expect.assertions(1); + await expect(async () => service.testUrl({ url: 'ftp://example.com' })).rejects.toThrow( + 'URL Error: Invalid protocol' + ); + }); + + it('throws if the host is the URI is not allowed', async () => { + expect.assertions(1); + + mockedActionsConfig.ensureUriAllowed.mockImplementation(() => { + throw new Error('URI is not allowed'); + }); + + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'error configuring connector action: URI is not allowed' + ); + }); + }); + + describe('Data', () => { + it('sets data to an empty object if the data are null', async () => { + await service.testUrl({ url: 'https://example.com', data: null }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({}); + }); + + it('pass data to axios correctly if not null', async () => { + await service.testUrl({ url: 'https://example.com', data: { foo: 'foo' } }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({ foo: 'foo' }); + }); + + it('removeNullOrUndefinedFields: removes null values and undefined values correctly', async () => { + await service.testData({ data: { foo: 'foo', bar: null, baz: undefined } }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({ foo: 'foo' }); + }); + + it.each([[null], [undefined], [[]], [() => {}], [new Date()]])( + 'removeNullOrUndefinedFields: returns data if it is not an object', + async (dataToTest) => { + // @ts-expect-error + await service.testData({ data: dataToTest }); + + const { data } = axiosInstanceMock.mock.calls[0][1]; + expect(data).toEqual({}); + } + ); + }); + + describe('Fetching', () => { + it('fetch correctly', async () => { + const res = await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'Content-Type': 'application/json', + 'X-Test-Header': 'test', + }, + httpAgent: undefined, + httpsAgent: expect.any(HttpsAgent), + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + + expect(logger.debug).toBeCalledWith( + 'Request to external service. Connector Id: test-id. Connector type: .test Method: get. URL: https://example.com' + ); + + expect(res).toEqual({ data: { status: 'ok' } }); + }); + + it('validates the response correctly', async () => { + axiosInstanceMock.mockReturnValue({ data: { invalidField: 'test' } }); + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'Response validation failed (Error: [status]: expected value of type [string] but got [undefined])' + ); + }); + + it('formats the response error correctly', async () => { + axiosInstanceMock.mockImplementation(() => { + throw createAxiosError(); + }); + + await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow( + 'Message: An error occurred. Code: 500' + ); + + expect(logger.debug).toHaveBeenLastCalledWith( + 'Request to external service failed. Connector Id: test-id. Connector type: .test. Method: get. URL: https://example.com' + ); + }); + }); + + describe('Proxy', () => { + it('have been called with proper proxy agent for a valid url', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://localhost:1212', + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + const { httpAgent, httpsAgent } = getCustomAgents( + mockedActionsConfig, + logger, + 'https://example.com' + ); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'X-Test-Header': 'test', + 'Content-Type': 'application/json', + }, + httpAgent, + httpsAgent, + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + }); + + it('have been called with proper proxy agent for an invalid url', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxyUrl: ':nope:', + proxySSLSettings: { + verificationMode: 'none', + }, + proxyBypassHosts: undefined, + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toBeCalledWith('https://example.com', { + method: 'get', + data: {}, + headers: { + 'X-Test-Header': 'test', + 'Content-Type': 'application/json', + }, + httpAgent: undefined, + httpsAgent: expect.any(HttpsAgent), + proxy: false, + maxContentLength: 1000000, + timeout: 360000, + }); + }); + + it('bypasses with proxyBypassHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: new Set(['example.com']), + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(false); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(false); + }); + + it('does not bypass with proxyBypassHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: new Set(['not-example.com']), + proxyOnlyHosts: undefined, + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(true); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(true); + }); + + it('proxies with proxyOnlyHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['example.com']), + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(true); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(true); + }); + + it('does not proxy with proxyOnlyHosts when expected', async () => { + mockedActionsConfig.getProxySettings.mockReturnValue({ + proxySSLSettings: { + verificationMode: 'full', + }, + proxyUrl: 'https://elastic.proxy.co', + proxyBypassHosts: undefined, + proxyOnlyHosts: new Set(['not-example.com']), + }); + + await service.testUrl({ url: 'https://example.com' }); + + expect(axiosInstanceMock).toHaveBeenCalledTimes(1); + const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1]; + expect(httpAgent instanceof HttpProxyAgent).toBe(false); + expect(httpsAgent instanceof HttpsProxyAgent).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts new file mode 100644 index 0000000000000..4e2be22a6834e --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isPlainObject, isEmpty } from 'lodash'; +import { Type } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import axios, { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + Method, + AxiosError, + AxiosRequestHeaders, +} from 'axios'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { getCustomAgents } from '../builtin_action_types/lib/get_custom_agents'; +import { SubAction } from './types'; +import { ServiceParams } from './types'; +import * as i18n from './translations'; + +const isObject = (value: unknown): value is Record => { + return isPlainObject(value); +}; + +const isAxiosError = (error: unknown): error is AxiosError => (error as AxiosError).isAxiosError; + +export abstract class SubActionConnector { + [k: string]: ((params: unknown) => unknown) | unknown; + private axiosInstance: AxiosInstance; + private validProtocols: string[] = ['http:', 'https:']; + private subActions: Map = new Map(); + private configurationUtilities: ActionsConfigurationUtilities; + protected logger: Logger; + protected connector: ServiceParams['connector']; + protected config: Config; + protected secrets: Secrets; + + constructor(params: ServiceParams) { + this.connector = params.connector; + this.logger = params.logger; + this.config = params.config; + this.secrets = params.secrets; + this.configurationUtilities = params.configurationUtilities; + this.axiosInstance = axios.create(); + } + + private normalizeURL(url: string) { + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const replaceDoubleSlashesRegex = new RegExp('([^:]/)/+', 'g'); + return urlWithoutTrailingSlash.replace(replaceDoubleSlashesRegex, '$1'); + } + + private normalizeData(data: unknown | undefined | null) { + if (isEmpty(data)) { + return {}; + } + + return data; + } + + private assertURL(url: string) { + try { + const parsedUrl = new URL(url); + + if (!parsedUrl.hostname) { + throw new Error('URL must contain hostname'); + } + + if (!this.validProtocols.includes(parsedUrl.protocol)) { + throw new Error('Invalid protocol'); + } + } catch (error) { + throw new Error(`URL Error: ${error.message}`); + } + } + + private ensureUriAllowed(url: string) { + try { + this.configurationUtilities.ensureUriAllowed(url); + } catch (allowedListError) { + throw new Error(i18n.ALLOWED_HOSTS_ERROR(allowedListError.message)); + } + } + + private getHeaders(headers?: AxiosRequestHeaders) { + return { ...headers, 'Content-Type': 'application/json' }; + } + + private validateResponse(responseSchema: Type, data: unknown) { + try { + responseSchema.validate(data); + } catch (resValidationError) { + throw new Error(`Response validation failed (${resValidationError})`); + } + } + + protected registerSubAction(subAction: SubAction) { + this.subActions.set(subAction.name, subAction); + } + + protected removeNullOrUndefinedFields(data: unknown | undefined) { + if (isObject(data)) { + return Object.fromEntries(Object.entries(data).filter(([_, value]) => value != null)); + } + + return data; + } + + public getSubActions() { + return this.subActions; + } + + protected abstract getResponseErrorMessage(error: AxiosError): string; + + protected async request({ + url, + data, + method = 'get', + responseSchema, + headers, + ...config + }: { + url: string; + responseSchema: Type; + method?: Method; + } & AxiosRequestConfig): Promise> { + try { + this.assertURL(url); + this.ensureUriAllowed(url); + const normalizedURL = this.normalizeURL(url); + + const { httpAgent, httpsAgent } = getCustomAgents( + this.configurationUtilities, + this.logger, + url + ); + const { maxContentLength, timeout } = this.configurationUtilities.getResponseSettings(); + + this.logger.debug( + `Request to external service. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type} Method: ${method}. URL: ${normalizedURL}` + ); + const res = await this.axiosInstance(normalizedURL, { + ...config, + method, + headers: this.getHeaders(headers), + data: this.normalizeData(data), + // use httpAgent and httpsAgent and set axios proxy: false, to be able to handle fail on invalid certs + httpAgent, + httpsAgent, + proxy: false, + maxContentLength, + timeout, + }); + + this.validateResponse(responseSchema, res.data); + + return res; + } catch (error) { + if (isAxiosError(error)) { + this.logger.debug( + `Request to external service failed. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type}. Method: ${error.config.method}. URL: ${error.config.url}` + ); + + const errorMessage = this.getResponseErrorMessage(error); + throw new Error(errorMessage); + } + + throw error; + } + } +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/translations.ts b/x-pack/plugins/actions/server/sub_action_framework/translations.ts new file mode 100644 index 0000000000000..3ffaa230cf23b --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/translations.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.actions.builtin.cases.jiraTitle', { + defaultMessage: 'Jira', +}); + +export const ALLOWED_HOSTS_ERROR = (message: string) => + i18n.translate('xpack.actions.apiAllowedHostsError', { + defaultMessage: 'error configuring connector action: {message}', + values: { + message, + }, + }); diff --git a/x-pack/plugins/actions/server/sub_action_framework/types.ts b/x-pack/plugins/actions/server/sub_action_framework/types.ts new file mode 100644 index 0000000000000..f3080310b1fc0 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/types.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Type } from '@kbn/config-schema'; +import { Logger } from '@kbn/logging'; +import type { LicenseType } from '@kbn/licensing-plugin/common/types'; + +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeParams, Services } from '../types'; +import { SubActionConnector } from './sub_action_connector'; + +export interface ServiceParams { + /** + * The type is the connector type id. For example ".servicenow" + * The id is the connector's SavedObject UUID. + */ + connector: { id: string; type: string }; + config: Config; + configurationUtilities: ActionsConfigurationUtilities; + logger: Logger; + secrets: Secrets; + services: Services; +} + +export type IService = new ( + params: ServiceParams +) => SubActionConnector; + +export type IServiceAbstract = abstract new ( + params: ServiceParams +) => SubActionConnector; + +export interface SubActionConnectorType { + id: string; + name: string; + minimumLicenseRequired: LicenseType; + schema: { + config: Type; + secrets: Type; + }; + Service: IService; +} + +export interface ExecutorParams extends ActionTypeParams { + subAction: string; + subActionParams: Record; +} + +export type ExtractFunctionKeys = { + [P in keyof T]-?: T[P] extends Function ? P : never; +}[keyof T]; + +export interface SubAction { + name: string; + method: string; + schema: Type | null; +} + +export interface PushToServiceParams { + externalId: string | null; + comments: Array<{ commentId: string; comment: string }>; + [x: string]: unknown; +} + +export interface ExternalServiceIncidentResponse { + id: string; + title: string; + url: string; + pushedDate: string; +} + +export interface ExternalServiceCommentResponse { + commentId: string; + pushedDate: string; + externalCommentId?: string; +} +export interface PushToServiceResponse extends ExternalServiceIncidentResponse { + comments?: ExternalServiceCommentResponse[]; +} diff --git a/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts b/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts new file mode 100644 index 0000000000000..78c3f042efce6 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/validators.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionsConfigurationUtilities } from '../actions_config'; +import { actionsConfigMock } from '../actions_config.mock'; +import { + TestSecretsSchema, + TestConfigSchema, + TestConfig, + TestSecrets, + TestSubActionConnector, +} from './mocks'; +import { IService } from './types'; +import { buildValidators } from './validators'; + +describe('Validators', () => { + let mockedActionsConfig: jest.Mocked; + + const createValidator = (Service: IService) => { + const connector = { + id: '.test', + name: 'Test', + minimumLicenseRequired: 'basic' as const, + schema: { + config: TestConfigSchema, + secrets: TestSecretsSchema, + }, + Service, + }; + + return buildValidators({ configurationUtilities: mockedActionsConfig, connector }); + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + + mockedActionsConfig = actionsConfigMock.create(); + }); + + it('should create the config and secrets validators correctly', async () => { + const validator = createValidator(TestSubActionConnector); + const { config, secrets } = validator; + + expect(config).toEqual(TestConfigSchema); + expect(secrets).toEqual(TestSecretsSchema); + }); + + it('should validate the params correctly', async () => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect(params.validate({ subAction: 'test', subActionParams: {} })); + }); + + it('should allow any field in subActionParams', async () => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect( + params.validate({ + subAction: 'test', + subActionParams: { + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }, + }) + ).toEqual({ + subAction: 'test', + subActionParams: { + foo: 'foo', + bar: 1, + baz: [{ test: 'hello' }, 1, 'test', false], + isValid: false, + val: null, + }, + }); + }); + + it.each([ + [undefined], + [null], + [1], + [false], + [{ test: 'hello' }], + [['test']], + [{ test: 'hello' }], + ])('should throw if the subAction is %p', async (subAction) => { + const validator = createValidator(TestSubActionConnector); + const { params } = validator; + expect(() => params.validate({ subAction, subActionParams: {} })).toThrow(); + }); +}); diff --git a/x-pack/plugins/actions/server/sub_action_framework/validators.ts b/x-pack/plugins/actions/server/sub_action_framework/validators.ts new file mode 100644 index 0000000000000..2c272a7d858d6 --- /dev/null +++ b/x-pack/plugins/actions/server/sub_action_framework/validators.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { ActionTypeConfig, ActionTypeSecrets } from '../types'; +import { SubActionConnectorType } from './types'; + +export const buildValidators = < + Config extends ActionTypeConfig, + Secrets extends ActionTypeSecrets +>({ + connector, + configurationUtilities, +}: { + configurationUtilities: ActionsConfigurationUtilities; + connector: SubActionConnectorType; +}) => { + return { + config: connector.schema.config, + secrets: connector.schema.secrets, + params: schema.object({ + subAction: schema.string(), + /** + * With this validation we enforce the subActionParams to be an object. + * Each sub action has different parameters and they are validated inside the executor + * (x-pack/plugins/actions/server/sub_action_framework/executor.ts). For that reason, + * we allow all unknowns at this level of validation as they are not known at this + * time of execution. + */ + subActionParams: schema.object({}, { unknowns: 'allow' }), + }), + }; +}; diff --git a/x-pack/plugins/alerting/common/execution_log_types.ts b/x-pack/plugins/alerting/common/execution_log_types.ts index 4fff1f14ca5bd..cdfc7601190dd 100644 --- a/x-pack/plugins/alerting/common/execution_log_types.ts +++ b/x-pack/plugins/alerting/common/execution_log_types.ts @@ -13,6 +13,9 @@ export const executionLogSortableColumns = [ 'schedule_delay', 'num_triggered_actions', 'num_generated_actions', + 'num_active_alerts', + 'num_recovered_alerts', + 'num_new_alerts', ] as const; export type ExecutionLogSortFields = typeof executionLogSortableColumns[number]; @@ -23,6 +26,7 @@ export interface IExecutionLog { duration_ms: number; status: string; message: string; + version: string; num_active_alerts: number; num_new_alerts: number; num_recovered_alerts: number; diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index f056ad7e0e4b7..eeb3db0be0066 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -21,6 +21,7 @@ export * from './disabled_action_groups'; export * from './rule_notify_when_type'; export * from './parse_duration'; export * from './execution_log_types'; +export * from './rule_snooze_type'; export interface AlertingFrameworkHealth { isSufficientlySecure: boolean; diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 4509a004c6e58..f690e1b603359 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -12,6 +12,7 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '@kbn/core/server'; import { RuleNotifyWhenType } from './rule_notify_when_type'; +import { RuleSnooze } from './rule_snooze_type'; export type RuleTypeState = Record; export type RuleTypeParams = Record; @@ -104,12 +105,13 @@ export interface Rule { apiKey: string | null; apiKeyOwner: string | null; throttle: string | null; - notifyWhen: RuleNotifyWhenType | null; muteAll: boolean; + notifyWhen: RuleNotifyWhenType | null; mutedInstanceIds: string[]; executionStatus: RuleExecutionStatus; monitoring?: RuleMonitoring; - snoozeEndTime?: Date | null; // Remove ? when this parameter is made available in the public API + snoozeSchedule?: RuleSnooze; // Remove ? when this parameter is made available in the public API + isSnoozedUntil?: Date | null; } export type SanitizedRule = Omit, 'apiKey'>; diff --git a/x-pack/plugins/alerting/common/rule_snooze_type.ts b/x-pack/plugins/alerting/common/rule_snooze_type.ts new file mode 100644 index 0000000000000..405cbef357242 --- /dev/null +++ b/x-pack/plugins/alerting/common/rule_snooze_type.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { WeekdayStr } from 'rrule'; + +export type RuleSnooze = Array<{ + duration: number; + rRule: Partial & Pick; + // For scheduled/recurring snoozes, `id` uniquely identifies them so that they can be displayed, modified, and deleted individually + id?: string; +}>; + +// An iCal RRULE to define a recurrence schedule, see https://github.com/jakubroztocil/rrule for the spec +export interface RRuleRecord { + dtstart: string; + tzid: string; + freq?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + until?: string; + count?: number; + interval?: number; + wkst?: WeekdayStr; + byweekday?: Array; + bymonth?: number[]; + bysetpos?: number[]; + bymonthday: number; + byyearday: number[]; + byweekno: number[]; + byhour: number[]; + byminute: number[]; + bysecond: number[]; +} diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts index 6927ef86dd47c..f5be4f0fcd34e 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.test.ts @@ -83,7 +83,7 @@ describe('getExecutionLogAggregation', () => { sort: [{ notsortable: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]"` ); }); @@ -95,7 +95,7 @@ describe('getExecutionLogAggregation', () => { sort: [{ notsortable: { order: 'asc' } }, { timestamp: { order: 'asc' } }], }); }).toThrowErrorMatchingInlineSnapshot( - `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]"` + `"Invalid sort field \\"notsortable\\" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]"` ); }); @@ -164,15 +164,6 @@ describe('getExecutionLogAggregation', () => { gap_policy: 'insert_zeros', }, }, - alertCounts: { - filters: { - filters: { - newAlerts: { match: { 'event.action': 'new-instance' } }, - activeAlerts: { match: { 'event.action': 'active-instance' } }, - recoveredAlerts: { match: { 'event.action': 'recovered-instance' } }, - }, - }, - }, actionExecution: { filter: { bool: { @@ -216,11 +207,28 @@ describe('getExecutionLogAggregation', () => { field: 'kibana.alert.rule.execution.metrics.number_of_generated_actions', }, }, + numActiveAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.active', + }, + }, + numRecoveredAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.recovered', + }, + }, + numNewAlerts: { + max: { + field: 'kibana.alert.rule.execution.metrics.alert_counts.new', + }, + }, executionDuration: { max: { field: 'event.duration' } }, outcomeAndMessage: { top_hits: { size: 1, - _source: { includes: ['event.outcome', 'message', 'error.message'] }, + _source: { + includes: ['event.outcome', 'message', 'error.message', 'kibana.version'], + }, }, }, }, @@ -278,20 +286,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -301,6 +295,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -317,6 +320,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -363,20 +369,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -386,6 +378,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -402,6 +403,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -459,6 +463,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -478,6 +483,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -512,20 +518,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -535,6 +527,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -551,6 +552,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'failure', }, + kibana: { + version: '8.2.0', + }, message: "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", error: { @@ -600,20 +604,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -623,6 +613,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -639,6 +638,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -696,6 +698,7 @@ describe('formatExecutionLogResult', () => { status: 'failure', message: "rule execution failure: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule' - I am erroring in rule execution!!", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -715,6 +718,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -749,20 +753,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 1, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 0, - }, - newAlerts: { - doc_count: 0, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -772,6 +762,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 0.0, }, + numActiveAlerts: { + value: 0.0, + }, + numNewAlerts: { + value: 0.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -788,6 +787,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -829,20 +831,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -852,6 +840,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -868,6 +865,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -925,6 +925,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 0, num_new_alerts: 0, num_recovered_alerts: 0, @@ -944,6 +945,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -978,20 +980,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -1001,6 +989,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -1017,6 +1014,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -1063,20 +1063,6 @@ describe('formatExecutionLogResult', () => { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -1086,6 +1072,15 @@ describe('formatExecutionLogResult', () => { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -1102,6 +1097,9 @@ describe('formatExecutionLogResult', () => { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -1159,6 +1157,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -1178,6 +1177,7 @@ describe('formatExecutionLogResult', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, diff --git a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts index 03e1077b02eda..aa8a7f6de88cf 100644 --- a/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts +++ b/x-pack/plugins/alerting/server/lib/get_execution_log_aggregation.ts @@ -20,6 +20,7 @@ const ACTION_FIELD = 'event.action'; const OUTCOME_FIELD = 'event.outcome'; const DURATION_FIELD = 'event.duration'; const MESSAGE_FIELD = 'message'; +const VERSION_FIELD = 'kibana.version'; const ERROR_MESSAGE_FIELD = 'error.message'; const SCHEDULE_DELAY_FIELD = 'kibana.task.schedule_delay'; const ES_SEARCH_DURATION_FIELD = 'kibana.alert.rule.execution.metrics.es_search_duration_ms'; @@ -28,6 +29,10 @@ const NUMBER_OF_TRIGGERED_ACTIONS_FIELD = 'kibana.alert.rule.execution.metrics.number_of_triggered_actions'; const NUMBER_OF_GENERATED_ACTIONS_FIELD = 'kibana.alert.rule.execution.metrics.number_of_generated_actions'; +const NUMBER_OF_ACTIVE_ALERTS_FIELD = 'kibana.alert.rule.execution.metrics.alert_counts.active'; +const NUMBER_OF_NEW_ALERTS_FIELD = 'kibana.alert.rule.execution.metrics.alert_counts.new'; +const NUMBER_OF_RECOVERED_ALERTS_FIELD = + 'kibana.alert.rule.execution.metrics.alert_counts.recovered'; const EXECUTION_UUID_FIELD = 'kibana.alert.rule.execution.uuid'; const Millis2Nanos = 1000 * 1000; @@ -37,14 +42,6 @@ export const EMPTY_EXECUTION_LOG_RESULT = { data: [], }; -interface IAlertCounts extends estypes.AggregationsMultiBucketAggregateBase { - buckets: { - activeAlerts: estypes.AggregationsSingleBucketAggregateBase; - newAlerts: estypes.AggregationsSingleBucketAggregateBase; - recoveredAlerts: estypes.AggregationsSingleBucketAggregateBase; - }; -} - interface IActionExecution extends estypes.AggregationsTermsAggregateBase<{ key: string; doc_count: number }> { buckets: Array<{ key: string; doc_count: number }>; @@ -60,9 +57,11 @@ interface IExecutionUuidAggBucket extends estypes.AggregationsStringTermsBucketK totalSearchDuration: estypes.AggregationsMaxAggregate; numTriggeredActions: estypes.AggregationsMaxAggregate; numGeneratedActions: estypes.AggregationsMaxAggregate; + numActiveAlerts: estypes.AggregationsMaxAggregate; + numRecoveredAlerts: estypes.AggregationsMaxAggregate; + numNewAlerts: estypes.AggregationsMaxAggregate; outcomeAndMessage: estypes.AggregationsTopHitsAggregate; }; - alertCounts: IAlertCounts; actionExecution: { actionOutcomes: IActionExecution; }; @@ -91,6 +90,9 @@ const ExecutionLogSortFields: Record = { schedule_delay: 'ruleExecution>scheduleDelay', num_triggered_actions: 'ruleExecution>numTriggeredActions', num_generated_actions: 'ruleExecution>numGeneratedActions', + num_active_alerts: 'ruleExecution>numActiveAlerts', + num_recovered_alerts: 'ruleExecution>numRecoveredAlerts', + num_new_alerts: 'ruleExecution>numNewAlerts', }; export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLogAggOptions) { @@ -153,16 +155,6 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo gap_policy: 'insert_zeros' as estypes.AggregationsGapPolicy, }, }, - // Get counts for types of alerts and whether there was an execution timeout - alertCounts: { - filters: { - filters: { - newAlerts: { match: { [ACTION_FIELD]: 'new-instance' } }, - activeAlerts: { match: { [ACTION_FIELD]: 'active-instance' } }, - recoveredAlerts: { match: { [ACTION_FIELD]: 'recovered-instance' } }, - }, - }, - }, // Filter by action execute doc and get information from this event actionExecution: { filter: getProviderAndActionFilter('actions', 'execute'), @@ -209,6 +201,21 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo field: NUMBER_OF_GENERATED_ACTIONS_FIELD, }, }, + numActiveAlerts: { + max: { + field: NUMBER_OF_ACTIVE_ALERTS_FIELD, + }, + }, + numRecoveredAlerts: { + max: { + field: NUMBER_OF_RECOVERED_ALERTS_FIELD, + }, + }, + numNewAlerts: { + max: { + field: NUMBER_OF_NEW_ALERTS_FIELD, + }, + }, executionDuration: { max: { field: DURATION_FIELD, @@ -218,7 +225,7 @@ export function getExecutionLogAggregation({ page, perPage, sort }: IExecutionLo top_hits: { size: 1, _source: { - includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD], + includes: [OUTCOME_FIELD, MESSAGE_FIELD, ERROR_MESSAGE_FIELD, VERSION_FIELD], }, }, }, @@ -275,15 +282,17 @@ function formatExecutionLogAggBucket(bucket: IExecutionUuidAggBucket): IExecutio status === 'failure' ? `${outcomeAndMessage?.message ?? ''} - ${outcomeAndMessage?.error?.message ?? ''}` : outcomeAndMessage?.message ?? ''; + const version = outcomeAndMessage ? outcomeAndMessage?.kibana?.version ?? '' : ''; return { id: bucket?.key ?? '', timestamp: bucket?.ruleExecution?.executeStartTime.value_as_string ?? '', duration_ms: durationUs / Millis2Nanos, status, message, - num_active_alerts: bucket?.alertCounts?.buckets?.activeAlerts?.doc_count ?? 0, - num_new_alerts: bucket?.alertCounts?.buckets?.newAlerts?.doc_count ?? 0, - num_recovered_alerts: bucket?.alertCounts?.buckets?.recoveredAlerts?.doc_count ?? 0, + version, + num_active_alerts: bucket?.ruleExecution?.numActiveAlerts?.value ?? 0, + num_new_alerts: bucket?.ruleExecution?.numNewAlerts?.value ?? 0, + num_recovered_alerts: bucket?.ruleExecution?.numRecoveredAlerts?.value ?? 0, num_triggered_actions: bucket?.ruleExecution?.numTriggeredActions?.value ?? 0, num_generated_actions: bucket?.ruleExecution?.numGeneratedActions?.value ?? 0, num_succeeded_actions: actionExecutionSuccess, diff --git a/x-pack/plugins/alerting/server/lib/index.ts b/x-pack/plugins/alerting/server/lib/index.ts index 31528c0d50683..4c0d4a00b05de 100644 --- a/x-pack/plugins/alerting/server/lib/index.ts +++ b/x-pack/plugins/alerting/server/lib/index.ts @@ -27,4 +27,5 @@ export { } from './rule_execution_status'; export { getRecoveredAlerts } from './get_recovered_alerts'; export { createWrappedScopedClusterClientFactory } from './wrap_scoped_cluster_client'; +export { isRuleSnoozed, getRuleSnoozeEndTime } from './is_rule_snoozed'; export { convertRuleIdsToKueryNode } from './convert_rule_ids_to_kuery_node'; diff --git a/x-pack/plugins/alerting/server/lib/is_rule_snoozed.test.ts b/x-pack/plugins/alerting/server/lib/is_rule_snoozed.test.ts new file mode 100644 index 0000000000000..14ad981a5e903 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/is_rule_snoozed.test.ts @@ -0,0 +1,319 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import sinon from 'sinon'; +import { RRule } from 'rrule'; +import { isRuleSnoozed } from './is_rule_snoozed'; +import { RRuleRecord } from '../types'; + +const DATE_9999 = '9999-12-31T12:34:56.789Z'; +const DATE_1970 = '1970-01-01T00:00:00.000Z'; +const DATE_2019 = '2019-01-01T00:00:00.000Z'; +const DATE_2019_PLUS_6_HOURS = '2019-01-01T06:00:00.000Z'; +const DATE_2020 = '2020-01-01T00:00:00.000Z'; +const DATE_2020_MINUS_1_HOUR = '2019-12-31T23:00:00.000Z'; +const DATE_2020_MINUS_1_MONTH = '2019-12-01T00:00:00.000Z'; + +const NOW = DATE_2020; + +let fakeTimer: sinon.SinonFakeTimers; + +describe('isRuleSnoozed', () => { + beforeAll(() => { + fakeTimer = sinon.useFakeTimers(new Date(NOW)); + }); + + afterAll(() => fakeTimer.restore()); + + test('returns false when snooze has not yet started', () => { + const snoozeSchedule = [ + { + duration: 100000000, + rRule: { + dtstart: DATE_9999, + tzid: 'UTC', + count: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule, muteAll: false })).toBe(false); + }); + + test('returns true when snooze has started', () => { + const snoozeSchedule = [ + { + duration: 100000000, + rRule: { + dtstart: NOW, + tzid: 'UTC', + count: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule, muteAll: false })).toBe(true); + }); + + test('returns false when snooze has ended', () => { + const snoozeSchedule = [ + { + duration: 100000000, + + rRule: { + dtstart: DATE_2019, + tzid: 'UTC', + count: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule, muteAll: false })).toBe(false); + }); + + test('returns true when snooze is indefinite', () => { + const snoozeSchedule = [ + { + duration: 100000000, + + rRule: { + dtstart: DATE_9999, + tzid: 'UTC', + count: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule, muteAll: true })).toBe(true); + }); + + test('returns as expected for an indefinitely recurring snooze', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + dtstart: DATE_2019, + tzid: 'UTC', + freq: RRule.DAILY, + interval: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + dtstart: DATE_2019_PLUS_6_HOURS, + tzid: 'UTC', + freq: RRule.DAILY, + interval: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + const snoozeScheduleC = [ + { + duration: 60 * 1000, + rRule: { + dtstart: DATE_2020_MINUS_1_HOUR, + tzid: 'UTC', + freq: RRule.HOURLY, + interval: 1, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleC, muteAll: false })).toBe(true); + }); + + test('returns as expected for a recurring snooze with limited occurrences', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.HOURLY, + interval: 1, + tzid: 'UTC', + count: 8761, + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + + rRule: { + freq: RRule.HOURLY, + interval: 1, + tzid: 'UTC', + count: 25, + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + const snoozeScheduleC = [ + { + duration: 60 * 1000, + + rRule: { + freq: RRule.YEARLY, + interval: 1, + tzid: 'UTC', + count: 60, + dtstart: DATE_1970, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleC, muteAll: false })).toBe(true); + }); + + test('returns as expected for a recurring snooze with an end date', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.HOURLY, + interval: 1, + tzid: 'UTC', + until: DATE_9999, + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.HOURLY, + interval: 1, + tzid: 'UTC', + until: DATE_2020_MINUS_1_HOUR, + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + }); + + test('returns as expected for a recurring snooze on a day of the week', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + tzid: 'UTC', + byweekday: ['MO', 'WE', 'FR'], // Jan 1 2020 was a Wednesday + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + tzid: 'UTC', + byweekday: ['TU', 'TH', 'SA', 'SU'], + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + const snoozeScheduleC = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + tzid: 'UTC', + byweekday: ['MO', 'WE', 'FR'], + count: 12, + dtstart: DATE_2020_MINUS_1_MONTH, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleC, muteAll: false })).toBe(false); + const snoozeScheduleD = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + tzid: 'UTC', + byweekday: ['MO', 'WE', 'FR'], + count: 15, + dtstart: DATE_2020_MINUS_1_MONTH, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleD, muteAll: false })).toBe(true); + }); + + test('returns as expected for a recurring snooze on an nth day of the week of a month', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.MONTHLY, + interval: 1, + tzid: 'UTC', + byweekday: ['+1WE'], // Jan 1 2020 was the first Wednesday of the month + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(true); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.MONTHLY, + interval: 1, + tzid: 'UTC', + byweekday: ['+2WE'], + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(false); + }); + + test('using a timezone, returns as expected for a recurring snooze on a day of the week', () => { + const snoozeScheduleA = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + byweekday: ['WE'], + tzid: 'Asia/Taipei', + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleA, muteAll: false })).toBe(false); + const snoozeScheduleB = [ + { + duration: 60 * 1000, + rRule: { + freq: RRule.WEEKLY, + interval: 1, + byweekday: ['WE'], + byhour: [0], + byminute: [0], + tzid: 'UTC', + dtstart: DATE_2019, + } as RRuleRecord, + }, + ]; + expect(isRuleSnoozed({ snoozeSchedule: snoozeScheduleB, muteAll: false })).toBe(true); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/is_rule_snoozed.ts b/x-pack/plugins/alerting/server/lib/is_rule_snoozed.ts new file mode 100644 index 0000000000000..7ae4b99e4df75 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/is_rule_snoozed.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RRule, ByWeekday, Weekday, rrulestr } from 'rrule'; +import { SanitizedRule, RuleTypeParams } from '../../common/rule'; + +type RuleSnoozeProps = Pick, 'muteAll' | 'snoozeSchedule'>; + +export function getRuleSnoozeEndTime(rule: RuleSnoozeProps): Date | null { + if (rule.snoozeSchedule == null) { + return null; + } + + const now = Date.now(); + for (const snooze of rule.snoozeSchedule) { + const { duration, rRule } = snooze; + const startTimeMS = Date.parse(rRule.dtstart); + const initialEndTime = startTimeMS + duration; + // If now is during the first occurrence of the snooze + + if (now >= startTimeMS && now < initialEndTime) return new Date(initialEndTime); + + // Check to see if now is during a recurrence of the snooze + if (rRule) { + try { + const rRuleOptions = { + ...rRule, + dtstart: new Date(rRule.dtstart), + until: rRule.until ? new Date(rRule.until) : null, + wkst: rRule.wkst ? Weekday.fromStr(rRule.wkst) : null, + byweekday: rRule.byweekday ? parseByWeekday(rRule.byweekday) : null, + }; + + const recurrenceRule = new RRule(rRuleOptions); + const lastOccurrence = recurrenceRule.before(new Date(now), true); + if (!lastOccurrence) continue; + const lastOccurrenceEndTime = lastOccurrence.getTime() + duration; + if (now < lastOccurrenceEndTime) return new Date(lastOccurrenceEndTime); + } catch (e) { + throw new Error(`Failed to process RRule ${rRule}: ${e}`); + } + } + } + + return null; +} + +export function isRuleSnoozed(rule: RuleSnoozeProps) { + if (rule.muteAll) { + return true; + } + return Boolean(getRuleSnoozeEndTime(rule)); +} + +function parseByWeekday(byweekday: Array): ByWeekday[] { + const rRuleString = `RRULE:BYDAY=${byweekday.join(',')}`; + const parsedRRule = rrulestr(rRuleString); + return parsedRRule.origOptions.byweekday as ByWeekday[]; +} diff --git a/x-pack/plugins/alerting/server/lib/types.ts b/x-pack/plugins/alerting/server/lib/types.ts index 701ac32e6974e..173ba1119a72a 100644 --- a/x-pack/plugins/alerting/server/lib/types.ts +++ b/x-pack/plugins/alerting/server/lib/types.ts @@ -7,6 +7,9 @@ import * as t from 'io-ts'; import { either } from 'fp-ts/lib/Either'; +import { Rule } from '../types'; +import { RuleRunMetrics } from './rule_run_metrics_store'; + // represents a Date from an ISO string export const DateFromString = new t.Type( 'DateFromString', @@ -24,3 +27,15 @@ export const DateFromString = new t.Type( ), (valueToEncode) => valueToEncode.toISOString() ); + +export type RuleInfo = Pick & { spaceId: string }; + +export interface LogSearchMetricsOpts { + esSearchDuration: number; + totalSearchDuration: number; +} + +export type SearchMetrics = Pick< + RuleRunMetrics, + 'numSearches' | 'totalSearchDurationMs' | 'esSearchDurationMs' +>; diff --git a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts index 28c5301e9a8b9..e1156d177116c 100644 --- a/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts +++ b/x-pack/plugins/alerting/server/lib/wrap_scoped_cluster_client.ts @@ -20,15 +20,8 @@ import type { SearchRequest as SearchRequestWithBody, AggregationsAggregate, } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { IScopedClusterClient, ElasticsearchClient, Logger } from '@kbn/core/server'; -import { Rule } from '../types'; -import { RuleRunMetrics } from './rule_run_metrics_store'; - -type RuleInfo = Pick & { spaceId: string }; -type SearchMetrics = Pick< - RuleRunMetrics, - 'numSearches' | 'totalSearchDurationMs' | 'esSearchDurationMs' ->; +import type { IScopedClusterClient, ElasticsearchClient, Logger } from '@kbn/core/server'; +import { SearchMetrics, RuleInfo } from './types'; interface WrapScopedClusterClientFactoryOpts { scopedClusterClient: IScopedClusterClient; diff --git a/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts new file mode 100644 index 0000000000000..9c10e619e3ebb --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; +import { of, throwError } from 'rxjs'; +import { wrapSearchSourceClient } from './wrap_search_source_client'; + +const logger = loggingSystemMock.create().get(); + +const rule = { + name: 'test-rule', + alertTypeId: '.test-rule-type', + id: 'abcdefg', + spaceId: 'my-space', +}; + +const createSearchSourceClientMock = () => { + const searchSourceMock = createSearchSourceMock(); + searchSourceMock.fetch$ = jest.fn().mockImplementation(() => of({ rawResponse: { took: 5 } })); + + return { + searchSourceMock, + searchSourceClientMock: { + create: jest.fn().mockReturnValue(searchSourceMock), + createEmpty: jest.fn().mockReturnValue(searchSourceMock), + } as unknown as ISearchStartSearchSource, + }; +}; + +describe('wrapSearchSourceClient', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('searches with provided abort controller', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.createEmpty(); + await wrappedSearchSource.fetch(); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + abortSignal: abortController.signal, + }); + }); + + test('uses search options when specified', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + await wrappedSearchSource.fetch({ isStored: true }); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + isStored: true, + abortSignal: abortController.signal, + }); + }); + + test('keeps track of number of queries', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockImplementation(() => of({ rawResponse: { took: 333 } })); + + const { searchSourceClient, getMetrics } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + await wrappedSearchSource.fetch(); + await wrappedSearchSource.fetch(); + await wrappedSearchSource.fetch(); + + expect(searchSourceMock.fetch$).toHaveBeenCalledWith({ + abortSignal: abortController.signal, + }); + + const stats = getMetrics(); + expect(stats.numSearches).toEqual(3); + expect(stats.esSearchDurationMs).toEqual(999); + + expect(logger.debug).toHaveBeenCalledWith( + `executing query for rule .test-rule-type:abcdefg in space my-space - with options {}` + ); + }); + + test('re-throws error when search throws error', async () => { + const abortController = new AbortController(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('something went wrong!'))); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot('"something went wrong!"'); + }); + + test('throws error when search throws abort error', async () => { + const abortController = new AbortController(); + abortController.abort(); + const { searchSourceMock, searchSourceClientMock } = createSearchSourceClientMock(); + searchSourceMock.fetch$ = jest + .fn() + .mockReturnValue(throwError(new Error('Request has been aborted by the user'))); + + const { searchSourceClient } = wrapSearchSourceClient({ + logger, + rule, + searchSourceClient: searchSourceClientMock, + abortController, + }); + const wrappedSearchSource = await searchSourceClient.create(); + const fetch = wrappedSearchSource.fetch(); + + await expect(fetch).rejects.toThrowErrorMatchingInlineSnapshot( + '"Search has been aborted due to cancelled execution"' + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts new file mode 100644 index 0000000000000..442f0c3e292bf --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/wrap_search_source_client.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger } from '@kbn/core/server'; +import { + ISearchOptions, + ISearchSource, + ISearchStartSearchSource, + SearchSource, + SerializedSearchSourceFields, +} from '@kbn/data-plugin/common'; +import { catchError, tap, throwError } from 'rxjs'; +import { LogSearchMetricsOpts, RuleInfo, SearchMetrics } from './types'; + +interface Props { + logger: Logger; + rule: RuleInfo; + abortController: AbortController; + searchSourceClient: ISearchStartSearchSource; +} + +interface WrapParams { + logger: Logger; + rule: RuleInfo; + abortController: AbortController; + pureSearchSource: T; + logMetrics: (metrics: LogSearchMetricsOpts) => void; +} + +export function wrapSearchSourceClient({ + logger, + rule, + abortController, + searchSourceClient: pureSearchSourceClient, +}: Props) { + let numSearches: number = 0; + let esSearchDurationMs: number = 0; + let totalSearchDurationMs: number = 0; + + function logMetrics(metrics: LogSearchMetricsOpts) { + numSearches++; + esSearchDurationMs += metrics.esSearchDuration; + totalSearchDurationMs += metrics.totalSearchDuration; + } + + const wrapParams = { + logMetrics, + logger, + rule, + abortController, + }; + + const wrappedSearchSourceClient: ISearchStartSearchSource = Object.create(pureSearchSourceClient); + + wrappedSearchSourceClient.createEmpty = () => { + const pureSearchSource = pureSearchSourceClient.createEmpty(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource, + }); + }; + + wrappedSearchSourceClient.create = async (fields?: SerializedSearchSourceFields) => { + const pureSearchSource = await pureSearchSourceClient.create(fields); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource, + }); + }; + + return { + searchSourceClient: wrappedSearchSourceClient, + getMetrics: (): SearchMetrics => ({ + esSearchDurationMs, + totalSearchDurationMs, + numSearches, + }), + }; +} + +function wrapSearchSource({ + pureSearchSource, + ...wrapParams +}: WrapParams): T { + const wrappedSearchSource = Object.create(pureSearchSource); + + wrappedSearchSource.createChild = wrapCreateChild({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.createCopy = wrapCreateCopy({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.create = wrapCreate({ ...wrapParams, pureSearchSource }); + wrappedSearchSource.fetch$ = wrapFetch$({ ...wrapParams, pureSearchSource }); + + return wrappedSearchSource; +} + +function wrapCreate({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureCreatedSearchSource = pureSearchSource.create(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureCreatedSearchSource, + }); + }; +} + +function wrapCreateChild({ pureSearchSource, ...wrapParams }: WrapParams) { + return function (options?: {}) { + const pureSearchSourceChild = pureSearchSource.createChild(options); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }); + }; +} + +function wrapCreateCopy({ pureSearchSource, ...wrapParams }: WrapParams) { + return function () { + const pureSearchSourceChild = pureSearchSource.createCopy(); + + return wrapSearchSource({ + ...wrapParams, + pureSearchSource: pureSearchSourceChild, + }) as SearchSource; + }; +} + +function wrapFetch$({ + logger, + rule, + abortController, + pureSearchSource, + logMetrics, +}: WrapParams) { + return (options?: ISearchOptions) => { + const searchOptions = options ?? {}; + const start = Date.now(); + + logger.debug( + `executing query for rule ${rule.alertTypeId}:${rule.id} in space ${ + rule.spaceId + } - with options ${JSON.stringify(searchOptions)}` + ); + + return pureSearchSource + .fetch$({ + ...searchOptions, + abortSignal: abortController.signal, + }) + .pipe( + catchError((error) => { + if (abortController.signal.aborted) { + return throwError( + () => new Error('Search has been aborted due to cancelled execution') + ); + } + return throwError(() => error); + }), + tap((result) => { + const durationMs = Date.now() - start; + logMetrics({ + esSearchDuration: result.rawResponse.took ?? 0, + totalSearchDuration: durationMs, + }); + }) + ); + }; +} diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index f7525c2c5f570..fd554783111d2 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -9,9 +9,8 @@ import { elasticsearchServiceMock, savedObjectsClientMock, uiSettingsServiceMock, - httpServerMock, } from '@kbn/core/server/mocks'; -import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { searchSourceCommonMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { rulesClientMock } from './rules_client.mock'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { Alert, AlertFactoryDoneUtils } from './alert'; @@ -113,11 +112,7 @@ const createRuleExecutorServicesMock = < shouldWriteAlerts: () => true, shouldStopExecution: () => true, search: createAbortableSearchServiceMock(), - searchSourceClient: Promise.resolve( - dataPluginMock - .createStartContract() - .search.searchSource.asScoped(httpServerMock.createKibanaRequest()) - ), + searchSourceClient: searchSourceCommonMock, }; }; export type RuleExecutorServicesMock = ReturnType; diff --git a/x-pack/plugins/alerting/server/routes/create_rule.ts b/x-pack/plugins/alerting/server/routes/create_rule.ts index cf044c94f2529..442162ae21cbb 100644 --- a/x-pack/plugins/alerting/server/routes/create_rule.ts +++ b/x-pack/plugins/alerting/server/routes/create_rule.ts @@ -67,12 +67,14 @@ const rewriteBodyRes: RewriteResponseCase> = ({ notifyWhen, muteAll, mutedInstanceIds, + snoozeSchedule, executionStatus: { lastExecutionDate, lastDuration, ...executionStatus }, ...rest }) => ({ ...rest, rule_type_id: alertTypeId, scheduled_task_id: scheduledTaskId, + snooze_schedule: snoozeSchedule, created_by: createdBy, updated_by: updatedBy, created_at: createdAt, diff --git a/x-pack/plugins/alerting/server/routes/get_rule.ts b/x-pack/plugins/alerting/server/routes/get_rule.ts index f4414b0364dcb..c735d68f83bbe 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule.ts @@ -35,7 +35,8 @@ const rewriteBodyRes: RewriteResponseCase> = ({ executionStatus, actions, scheduledTaskId, - snoozeEndTime, + snoozeSchedule, + isSnoozedUntil, ...rest }) => ({ ...rest, @@ -46,10 +47,10 @@ const rewriteBodyRes: RewriteResponseCase> = ({ updated_at: updatedAt, api_key_owner: apiKeyOwner, notify_when: notifyWhen, - mute_all: muteAll, muted_alert_ids: mutedInstanceIds, - // Remove this object spread boolean check after snoozeEndTime is added to the public API - ...(snoozeEndTime !== undefined ? { snooze_end_time: snoozeEndTime } : {}), + mute_all: muteAll, + ...(isSnoozedUntil !== undefined ? { is_snoozed_until: isSnoozedUntil } : {}), + snooze_schedule: snoozeSchedule, scheduled_task_id: scheduledTaskId, execution_status: executionStatus && { ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts index cbcff65cdbdca..4a67404ab232e 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.test.ts @@ -34,6 +34,7 @@ describe('getRuleExecutionLogRoute', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -53,6 +54,7 @@ describe('getRuleExecutionLogRoute', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, diff --git a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts index 650bdd83a0a83..4a8a91089203d 100644 --- a/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts +++ b/x-pack/plugins/alerting/server/routes/get_rule_execution_log.ts @@ -26,6 +26,9 @@ const sortFieldSchema = schema.oneOf([ schema.object({ schedule_delay: schema.object({ order: sortOrderSchema }) }), schema.object({ num_triggered_actions: schema.object({ order: sortOrderSchema }) }), schema.object({ num_generated_actions: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_active_alerts: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_recovered_alerts: schema.object({ order: sortOrderSchema }) }), + schema.object({ num_new_alerts: schema.object({ order: sortOrderSchema }) }), ]); const sortFieldsSchema = schema.arrayOf(sortFieldSchema, { diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts index 537d42bbc4f47..162177d695e0a 100644 --- a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts @@ -21,7 +21,8 @@ export const rewriteRule = ({ executionStatus, actions, scheduledTaskId, - snoozeEndTime, + snoozeSchedule, + isSnoozedUntil, ...rest }: SanitizedRule) => ({ ...rest, @@ -35,8 +36,8 @@ export const rewriteRule = ({ mute_all: muteAll, muted_alert_ids: mutedInstanceIds, scheduled_task_id: scheduledTaskId, - // Remove this object spread boolean check after snoozeEndTime is added to the public API - ...(snoozeEndTime !== undefined ? { snooze_end_time: snoozeEndTime } : {}), + snooze_schedule: snoozeSchedule, + ...(isSnoozedUntil != null ? { is_snoozed_until: isSnoozedUntil } : {}), execution_status: executionStatus && { ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), last_execution_date: executionStatus.lastExecutionDate, diff --git a/x-pack/plugins/alerting/server/routes/update_rule.ts b/x-pack/plugins/alerting/server/routes/update_rule.ts index d2130e1f33541..1faddd66c8f0e 100644 --- a/x-pack/plugins/alerting/server/routes/update_rule.ts +++ b/x-pack/plugins/alerting/server/routes/update_rule.ts @@ -70,12 +70,16 @@ const rewriteBodyRes: RewriteResponseCase> = ({ muteAll, mutedInstanceIds, executionStatus, + snoozeSchedule, + isSnoozedUntil, ...rest }) => ({ ...rest, api_key_owner: apiKeyOwner, created_by: createdBy, updated_by: updatedBy, + snooze_schedule: snoozeSchedule, + ...(isSnoozedUntil ? { is_snoozed_until: isSnoozedUntil } : {}), ...(alertTypeId ? { rule_type_id: alertTypeId } : {}), ...(scheduledTaskId ? { scheduled_task_id: scheduledTaskId } : {}), ...(createdAt ? { created_at: createdAt } : {}), diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index 302824221ded8..44914e3e3bce8 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -35,6 +35,7 @@ const createRulesClientMock = () => { bulkEdit: jest.fn(), snooze: jest.fn(), unsnooze: jest.fn(), + updateSnoozedUntilTime: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 75398a6668755..4e248412eae15 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -54,6 +54,7 @@ import { RuleWithLegacyId, SanitizedRuleWithLegacyId, PartialRuleWithLegacyId, + RuleSnooze, RawAlertInstance as RawAlert, } from '../types'; import { @@ -62,6 +63,7 @@ import { getRuleNotifyWhenType, validateMutatedRuleTypeParams, convertRuleIdsToKueryNode, + getRuleSnoozeEndTime, } from '../lib'; import { taskInstanceToAlertTaskInstance } from '../task_runner/alert_task_instance'; import { RegistryRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; @@ -310,7 +312,8 @@ export interface CreateOptions { | 'mutedInstanceIds' | 'actions' | 'executionStatus' - | 'snoozeEndTime' + | 'snoozeSchedule' + | 'isSnoozedUntil' > & { actions: NormalizedAlertAction[] }; options?: { id?: string; @@ -391,7 +394,7 @@ export class RulesClient { private readonly fieldsToExcludeFromPublicApi: Array = [ 'monitoring', 'mapped_params', - 'snoozeEndTime', + 'snoozeSchedule', ]; constructor({ @@ -504,7 +507,8 @@ export class RulesClient { updatedBy: username, createdAt: new Date(createTime).toISOString(), updatedAt: new Date(createTime).toISOString(), - snoozeEndTime: null, + isSnoozedUntil: null, + snoozeSchedule: [], params: updatedParams as RawRule['params'], muteAll: false, mutedInstanceIds: [], @@ -1018,7 +1022,7 @@ export class RulesClient { }, snoozed: { date_range: { - field: 'alert.attributes.snoozeEndTime', + field: 'alert.attributes.snoozeSchedule.rRule.dtstart', format: 'strict_date_time', ranges: [{ from: 'now' }], }, @@ -1828,7 +1832,7 @@ export class RulesClient { } private async enableWithOCC({ id }: { id: string }) { - let apiKeyToInvalidate: string | null = null; + let existingApiKey: string | null = null; let attributes: RawRule; let version: string | undefined; @@ -1837,14 +1841,11 @@ export class RulesClient { await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); - apiKeyToInvalidate = decryptedAlert.attributes.apiKey; + existingApiKey = decryptedAlert.attributes.apiKey; attributes = decryptedAlert.attributes; version = decryptedAlert.version; } catch (e) { - // We'll skip invalidating the API key since we failed to load the decrypted saved object - this.logger.error( - `enable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` - ); + this.logger.error(`enable(): Failed to load API key of alert ${id}: ${e.message}`); // Still attempt to load the attributes and version using SOC const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; @@ -1886,19 +1887,10 @@ export class RulesClient { if (attributes.enabled === false) { const username = await this.getUserName(); - let createdAPIKey = null; - try { - createdAPIKey = await this.createAPIKey( - this.generateAPIKeyName(attributes.alertTypeId, attributes.name) - ); - } catch (error) { - throw Boom.badRequest(`Error enabling rule: could not create API key - ${error.message}`); - } - const updateAttributes = this.updateMeta({ ...attributes, + ...(!existingApiKey && (await this.createNewAPIKeySet({ attributes, username }))), enabled: true, - ...this.apiKeyAsAlertAttributes(createdAPIKey, username), updatedBy: username, updatedAt: new Date().toISOString(), executionStatus: { @@ -1909,15 +1901,10 @@ export class RulesClient { warning: null, }, }); + try { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { - // Avoid unused API key - await bulkMarkApiKeysForInvalidation( - { apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] }, - this.logger, - this.unsecuredSavedObjectsClient - ); throw e; } const scheduledTask = await this.scheduleRule({ @@ -1930,16 +1917,28 @@ export class RulesClient { await this.unsecuredSavedObjectsClient.update('alert', id, { scheduledTaskId: scheduledTask.id, }); - if (apiKeyToInvalidate) { - await bulkMarkApiKeysForInvalidation( - { apiKeys: [apiKeyToInvalidate] }, - this.logger, - this.unsecuredSavedObjectsClient - ); - } } } + private async createNewAPIKeySet({ + attributes, + username, + }: { + attributes: RawRule; + username: string | null; + }): Promise> { + let createdAPIKey = null; + try { + createdAPIKey = await this.createAPIKey( + this.generateAPIKeyName(attributes.alertTypeId, attributes.name) + ); + } catch (error) { + throw Boom.badRequest(`Error creating API key for rule: ${error.message}`); + } + + return this.apiKeyAsAlertAttributes(createdAPIKey, username); + } + public async disable({ id }: { id: string }): Promise { return await retryIfConflicts( this.logger, @@ -1949,7 +1948,6 @@ export class RulesClient { } private async disableWithOCC({ id }: { id: string }) { - let apiKeyToInvalidate: string | null = null; let attributes: RawRule; let version: string | undefined; @@ -1958,14 +1956,10 @@ export class RulesClient { await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser('alert', id, { namespace: this.namespace, }); - apiKeyToInvalidate = decryptedAlert.attributes.apiKey; attributes = decryptedAlert.attributes; version = decryptedAlert.version; } catch (e) { - // We'll skip invalidating the API key since we failed to load the decrypted saved object - this.logger.error( - `disable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` - ); + this.logger.error(`disable(): Failed to load API key of alert ${id}: ${e.message}`); // Still attempt to load the attributes and version using SOC const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; @@ -2058,26 +2052,14 @@ export class RulesClient { ...attributes, enabled: false, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }), { version } ); - - await Promise.all([ - attributes.scheduledTaskId - ? this.taskManager.removeIfExists(attributes.scheduledTaskId) - : null, - apiKeyToInvalidate - ? await bulkMarkApiKeysForInvalidation( - { apiKeys: [apiKeyToInvalidate] }, - this.logger, - this.unsecuredSavedObjectsClient - ) - : null, - ]); + if (attributes.scheduledTaskId) { + await this.taskManager.removeIfExists(attributes.scheduledTaskId); + } } } @@ -2142,8 +2124,21 @@ export class RulesClient { // If snoozeEndTime is -1, instead mute all const newAttrs = snoozeEndTime === -1 - ? { muteAll: true, snoozeEndTime: null } - : { snoozeEndTime: new Date(snoozeEndTime).toISOString(), muteAll: false }; + ? { + muteAll: true, + snoozeSchedule: clearUnscheduledSnooze(attributes), + } + : { + snoozeSchedule: clearUnscheduledSnooze(attributes).concat({ + duration: Date.parse(snoozeEndTime) - Date.now(), + rRule: { + dtstart: new Date().toISOString(), + tzid: 'UTC', + count: 1, + }, + }), + muteAll: false, + }; const updateAttributes = this.updateMeta({ ...newAttrs, @@ -2157,7 +2152,7 @@ export class RulesClient { id, updateAttributes, updateOptions - ); + ).then(() => this.updateSnoozedUntilTime({ id })); } public async unsnooze({ id }: { id: string }): Promise { @@ -2207,7 +2202,7 @@ export class RulesClient { this.ruleTypeRegistry.ensureRuleTypeEnabled(attributes.alertTypeId); const updateAttributes = this.updateMeta({ - snoozeEndTime: null, + snoozeSchedule: clearUnscheduledSnooze(attributes), muteAll: false, updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), @@ -2222,6 +2217,30 @@ export class RulesClient { ); } + public async updateSnoozedUntilTime({ id }: { id: string }): Promise { + const { attributes, version } = await this.unsecuredSavedObjectsClient.get( + 'alert', + id + ); + + const isSnoozedUntil = getRuleSnoozeEndTime(attributes); + if (!isSnoozedUntil) return; + + const updateAttributes = this.updateMeta({ + isSnoozedUntil: isSnoozedUntil.toISOString(), + updatedBy: await this.getUserName(), + updatedAt: new Date().toISOString(), + }); + const updateOptions = { version }; + + await partiallyUpdateAlert( + this.unsecuredSavedObjectsClient, + id, + updateAttributes, + updateOptions + ); + } + public async muteAll({ id }: { id: string }): Promise { return await retryIfConflicts( this.logger, @@ -2271,7 +2290,7 @@ export class RulesClient { const updateAttributes = this.updateMeta({ muteAll: true, mutedInstanceIds: [], - snoozeEndTime: null, + snoozeSchedule: clearUnscheduledSnooze(attributes), updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }); @@ -2334,7 +2353,7 @@ export class RulesClient { const updateAttributes = this.updateMeta({ muteAll: false, mutedInstanceIds: [], - snoozeEndTime: null, + snoozeSchedule: clearUnscheduledSnooze(attributes), updatedBy: await this.getUserName(), updatedAt: new Date().toISOString(), }); @@ -2582,15 +2601,23 @@ export class RulesClient { executionStatus, schedule, actions, - snoozeEndTime, + snoozeSchedule, + isSnoozedUntil, ...partialRawRule }: Partial, references: SavedObjectReference[] | undefined, includeLegacyId: boolean = false, excludeFromPublicApi: boolean = false ): PartialRule | PartialRuleWithLegacyId { - const snoozeEndTimeDate = snoozeEndTime != null ? new Date(snoozeEndTime) : snoozeEndTime; - const includeSnoozeEndTime = snoozeEndTimeDate !== undefined && !excludeFromPublicApi; + const snoozeScheduleDates = snoozeSchedule?.map((s) => ({ + ...s, + rRule: { + ...s.rRule, + dtstart: new Date(s.rRule.dtstart), + ...(s.rRule.until ? { until: new Date(s.rRule.until) } : {}), + }, + })); + const includeSnoozeSchedule = snoozeSchedule !== undefined; const rule = { id, notifyWhen, @@ -2600,9 +2627,10 @@ export class RulesClient { schedule: schedule as IntervalSchedule, actions: actions ? this.injectReferencesIntoActions(id, actions, references || []) : [], params: this.injectReferencesIntoParams(id, ruleType, params, references || []) as Params, - ...(includeSnoozeEndTime ? { snoozeEndTime: snoozeEndTimeDate } : {}), + ...(includeSnoozeSchedule ? { snoozeSchedule: snoozeScheduleDates } : {}), ...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}), ...(createdAt ? { createdAt: new Date(createdAt) } : {}), + ...(isSnoozedUntil ? { isSnoozedUntil: new Date(isSnoozedUntil) } : {}), ...(scheduledTaskId ? { scheduledTaskId } : {}), ...(executionStatus ? { executionStatus: ruleExecutionStatusFromRaw(this.logger, id, executionStatus) } @@ -2817,3 +2845,9 @@ function parseDate(dateString: string | undefined, propertyName: string, default return parsedDate; } + +function clearUnscheduledSnooze(attributes: { snoozeSchedule?: RuleSnooze }) { + return attributes.snoozeSchedule + ? attributes.snoozeSchedule.filter((s) => typeof s.id !== 'undefined') + : []; +} diff --git a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts index 1a3d203162bd6..bc1c8d276aedd 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/aggregate.test.ts @@ -203,7 +203,7 @@ describe('aggregate()', () => { }, snoozed: { date_range: { - field: 'alert.attributes.snoozeEndTime', + field: 'alert.attributes.snoozeSchedule.rRule.dtstart', format: 'strict_date_time', ranges: [{ from: 'now' }], }, @@ -240,7 +240,7 @@ describe('aggregate()', () => { }, snoozed: { date_range: { - field: 'alert.attributes.snoozeEndTime', + field: 'alert.attributes.snoozeSchedule.rRule.dtstart', format: 'strict_date_time', ranges: [{ from: 'now' }], }, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts index 8e24b7c183262..f5c839c5006fd 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/create.test.ts @@ -300,7 +300,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], actions: [ { @@ -376,6 +376,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -412,6 +413,7 @@ describe('create()', () => { "status": "pending", "warning": null, }, + "isSnoozedUntil": null, "legacyId": null, "meta": Object { "versionApiKeyLastmodified": "v8.0.0", @@ -434,7 +436,7 @@ describe('create()', () => { "schedule": Object { "interval": "1m", }, - "snoozeEndTime": null, + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -506,7 +508,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], actions: [ { @@ -566,7 +568,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], actions: [ { @@ -618,6 +620,7 @@ describe('create()', () => { "status": "pending", "warning": null, }, + "isSnoozedUntil": null, "legacyId": "123", "meta": Object { "versionApiKeyLastmodified": "v7.10.0", @@ -640,7 +643,7 @@ describe('create()', () => { "schedule": Object { "interval": "1m", }, - "snoozeEndTime": null, + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -1044,6 +1047,7 @@ describe('create()', () => { createdAt: '2019-02-12T21:01:22.479Z', createdBy: 'elastic', enabled: true, + isSnoozedUntil: null, legacyId: null, executionStatus: { error: null, @@ -1054,7 +1058,7 @@ describe('create()', () => { monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], name: 'abc', notifyWhen: 'onActiveAlert', @@ -1243,6 +1247,7 @@ describe('create()', () => { createdAt: '2019-02-12T21:01:22.479Z', createdBy: 'elastic', enabled: true, + isSnoozedUntil: null, legacyId: null, executionStatus: { error: null, @@ -1253,7 +1258,7 @@ describe('create()', () => { monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], name: 'abc', notifyWhen: 'onActiveAlert', @@ -1407,6 +1412,7 @@ describe('create()', () => { alertTypeId: '123', apiKey: null, apiKeyOwner: null, + isSnoozedUntil: null, legacyId: null, consumer: 'bar', createdAt: '2019-02-12T21:01:22.479Z', @@ -1421,7 +1427,7 @@ describe('create()', () => { monitoring: getDefaultRuleMonitoring(), meta: { versionApiKeyLastmodified: kibanaVersion }, muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], name: 'abc', notifyWhen: 'onActiveAlert', @@ -1530,7 +1536,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], notifyWhen: 'onActionGroupChange', actions: [ @@ -1571,6 +1577,7 @@ describe('create()', () => { alertTypeId: '123', consumer: 'bar', name: 'abc', + isSnoozedUntil: null, legacyId: null, params: { bar: true }, apiKey: null, @@ -1587,7 +1594,7 @@ describe('create()', () => { throttle: '10m', notifyWhen: 'onActionGroupChange', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { @@ -1638,6 +1645,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -1662,7 +1670,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], notifyWhen: 'onThrottleInterval', actions: [ @@ -1700,6 +1708,7 @@ describe('create()', () => { params: { foo: true }, }, ], + isSnoozedUntil: null, legacyId: null, alertTypeId: '123', consumer: 'bar', @@ -1719,7 +1728,7 @@ describe('create()', () => { throttle: '10m', notifyWhen: 'onThrottleInterval', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { @@ -1770,6 +1779,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -1794,7 +1804,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], notifyWhen: 'onActiveAlert', actions: [ @@ -1832,6 +1842,7 @@ describe('create()', () => { params: { foo: true }, }, ], + isSnoozedUntil: null, legacyId: null, alertTypeId: '123', consumer: 'bar', @@ -1851,7 +1862,7 @@ describe('create()', () => { throttle: null, notifyWhen: 'onActiveAlert', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { @@ -1902,6 +1913,7 @@ describe('create()', () => { "interval": "1m", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -1935,7 +1947,7 @@ describe('create()', () => { updatedBy: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], actions: [ { @@ -1993,13 +2005,14 @@ describe('create()', () => { ], apiKeyOwner: null, apiKey: null, + isSnoozedUntil: null, legacyId: null, createdBy: 'elastic', updatedBy: 'elastic', createdAt: '2019-02-12T21:01:22.479Z', updatedAt: '2019-02-12T21:01:22.479Z', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], executionStatus: { status: 'pending', @@ -2066,6 +2079,7 @@ describe('create()', () => { "interval": "10s", }, "scheduledTaskId": "task-123", + "snoozeSchedule": Array [], "tags": Array [ "foo", ], @@ -2345,6 +2359,7 @@ describe('create()', () => { alertTypeId: '123', consumer: 'bar', name: 'abc', + isSnoozedUntil: null, legacyId: null, params: { bar: true }, apiKey: Buffer.from('123:abc').toString('base64'), @@ -2361,7 +2376,7 @@ describe('create()', () => { throttle: null, notifyWhen: 'onActiveAlert', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { @@ -2444,6 +2459,7 @@ describe('create()', () => { params: { foo: true }, }, ], + isSnoozedUntil: null, legacyId: null, alertTypeId: '123', consumer: 'bar', @@ -2463,7 +2479,7 @@ describe('create()', () => { throttle: null, notifyWhen: 'onActiveAlert', muteAll: false, - snoozeEndTime: null, + snoozeSchedule: [], mutedInstanceIds: [], tags: ['foo'], executionStatus: { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 02f2c66a491ad..a193733aff26f 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -18,7 +18,6 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; -import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -111,6 +110,7 @@ describe('disable()', () => { attributes: { ...existingAlert.attributes, apiKey: Buffer.from('123:abc').toString('base64'), + apiKeyOwner: 'elastic', }, version: '123', references: [], @@ -206,11 +206,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -230,12 +230,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); }); test('disables the rule with calling event log to "recover" the alert instances from the task state', async () => { @@ -282,11 +276,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -306,12 +300,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent.mock.calls[0][0]).toStrictEqual({ @@ -369,11 +357,11 @@ describe('disable()', () => { alertTypeId: 'myType', enabled: false, meta: { - versionApiKeyLastmodified: kibanaVersion, + versionApiKeyLastmodified: 'v7.10.0', }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -393,12 +381,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(eventLogger.logEvent).toHaveBeenCalledTimes(0); expect(rulesClientParams.logger.warn).toHaveBeenCalledWith( @@ -408,7 +390,6 @@ describe('disable()', () => { test('falls back when getDecryptedAsInternalUser throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -422,12 +403,7 @@ describe('disable()', () => { schedule: { interval: '10s' }, alertTypeId: 'myType', enabled: false, - meta: { - versionApiKeyLastmodified: kibanaVersion, - }, scheduledTaskId: null, - apiKey: null, - apiKeyOwner: null, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', actions: [ @@ -447,7 +423,6 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test(`doesn't disable already disabled alerts`, async () => { @@ -463,14 +438,6 @@ describe('disable()', () => { await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); expect(taskManager.removeIfExists).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); - }); - - test(`doesn't invalidate when no API key is used`, async () => { - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce(existingAlert); - - await rulesClient.disable({ id: '1' }); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('swallows error when failing to load decrypted saved object', async () => { @@ -479,9 +446,8 @@ describe('disable()', () => { await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); expect(taskManager.removeIfExists).toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'disable(): Failed to load API key to invalidate on alert 1: Fail' + 'disable(): Failed to load API key of alert 1: Fail' ); }); @@ -493,17 +459,6 @@ describe('disable()', () => { ); }); - test('swallows error when invalidate API key throws', async () => { - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); - await rulesClient.disable({ id: '1' }); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); - }); - test('throws when failing to remove task from task manager', async () => { taskManager.removeIfExists.mockRejectedValueOnce(new Error('Failed to remove task')); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts index d823e0aaafdb8..8923031ab6b87 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/enable.test.ts @@ -17,7 +17,6 @@ import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; -import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ bulkMarkApiKeysForInvalidation: jest.fn(), @@ -51,23 +50,22 @@ const rulesClientParams: jest.Mocked = { auditLogger, }; -beforeEach(() => { - getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); - (auditLogger.log as jest.Mock).mockClear(); -}); - setGlobalDate(); describe('enable()', () => { let rulesClient: RulesClient; - const existingAlert = { + + const existingRule = { id: '1', type: 'alert', attributes: { + name: 'name', consumer: 'myApp', schedule: { interval: '10s' }, alertTypeId: 'myType', enabled: false, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', @@ -84,23 +82,24 @@ describe('enable()', () => { references: [], }; + const existingRuleWithoutApiKey = { + ...existingRule, + attributes: { + ...existingRule.attributes, + apiKey: null, + apiKeyOwner: null, + }, + }; + beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); rulesClient = new RulesClient(rulesClientParams); - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingAlert); - unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRule); + unsecuredSavedObjectsClient.get.mockResolvedValue(existingRule); rulesClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - enabled: true, - apiKey: null, - apiKeyOwner: null, - updatedBy: 'elastic', - }, - }); taskManager.schedule.mockResolvedValue({ id: '1', scheduledAt: new Date(), @@ -187,27 +186,17 @@ describe('enable()', () => { }); test('enables a rule', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - enabled: true, - apiKey: null, - apiKeyOwner: null, - updatedBy: 'elastic', - }, - }); - await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -217,8 +206,8 @@ describe('enable()', () => { }, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', @@ -265,33 +254,65 @@ describe('enable()', () => { }); }); - test('invalidates API key if ever one existed prior to updating', async () => { - encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ - ...existingAlert, - attributes: { - ...existingAlert.attributes, - apiKey: Buffer.from('123:abc').toString('base64'), - }, + test('enables a rule that does not have an apiKey', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRuleWithoutApiKey); + rulesClientParams.createAPIKey.mockResolvedValueOnce({ + apiKeysEnabled: true, + result: { id: '123', name: '123', api_key: 'abc' }, }); - await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); + expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/name'); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( + 'alert', + '1', + { + name: 'name', + schedule: { interval: '10s' }, + alertTypeId: 'myType', + consumer: 'myApp', + enabled: true, + meta: { + versionApiKeyLastmodified: kibanaVersion, + }, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + executionStatus: { + status: 'pending', + lastDuration: 0, + lastExecutionDate: '2019-02-12T21:01:22.479Z', + error: null, + warning: null, + }, + }, + { + version: '123', + } ); }); test(`doesn't enable already enabled alerts`, async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, }, }); @@ -314,6 +335,7 @@ describe('enable()', () => { 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -351,14 +373,14 @@ describe('enable()', () => { }); test('throws an error if API key creation throws', async () => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingRuleWithoutApiKey); + rulesClientParams.createAPIKey.mockImplementation(() => { throw new Error('no'); }); - expect( + await expect( async () => await rulesClient.enable({ id: '1' }) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Error enabling rule: could not create API key - no"` - ); + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Error creating API key for rule: no"`); }); test('falls back when failing to getDecryptedAsInternalUser', async () => { @@ -367,7 +389,7 @@ describe('enable()', () => { await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'enable(): Failed to load API key to invalidate on alert 1: Fail' + 'enable(): Failed to load API key of alert 1: Fail' ); }); @@ -396,13 +418,6 @@ describe('enable()', () => { `"Fail to update"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); - expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( - { apiKeys: ['MTIzOmFiYw=='] }, - expect.any(Object), - expect.any(Object) - ); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); expect(taskManager.schedule).not.toHaveBeenCalled(); }); @@ -410,9 +425,9 @@ describe('enable()', () => { test('throws error when failing to update the second time', async () => { unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, }, }); @@ -424,7 +439,6 @@ describe('enable()', () => { `"Fail to update second time"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(2); expect(taskManager.schedule).toHaveBeenCalled(); }); @@ -436,15 +450,14 @@ describe('enable()', () => { `"Fail to schedule"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); }); test('enables a rule if conflict errors received when scheduling a task', async () => { unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - ...existingAlert, + ...existingRuleWithoutApiKey, attributes: { - ...existingAlert.attributes, + ...existingRuleWithoutApiKey.attributes, enabled: true, apiKey: null, apiKeyOwner: null, @@ -460,11 +473,12 @@ describe('enable()', () => { expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { + name: 'name', schedule: { interval: '10s' }, alertTypeId: 'myType', consumer: 'myApp', @@ -474,8 +488,8 @@ describe('enable()', () => { }, updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', - apiKey: null, - apiKeyOwner: null, + apiKey: 'MTIzOmFiYw==', + apiKeyOwner: 'elastic', actions: [ { group: 'default', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts index 541e55f5c8d90..04653d491f28b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_execution_log.test.ts @@ -111,20 +111,6 @@ const aggregateResults = { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 0, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -134,6 +120,15 @@ const aggregateResults = { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 0.0, + }, outcomeAndMessage: { hits: { total: { @@ -150,6 +145,9 @@ const aggregateResults = { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -196,20 +194,6 @@ const aggregateResults = { meta: {}, doc_count: 0, }, - alertCounts: { - meta: {}, - buckets: { - activeAlerts: { - doc_count: 5, - }, - newAlerts: { - doc_count: 5, - }, - recoveredAlerts: { - doc_count: 5, - }, - }, - }, ruleExecution: { meta: {}, doc_count: 1, @@ -219,6 +203,15 @@ const aggregateResults = { numGeneratedActions: { value: 5.0, }, + numActiveAlerts: { + value: 5.0, + }, + numNewAlerts: { + value: 5.0, + }, + numRecoveredAlerts: { + value: 5.0, + }, outcomeAndMessage: { hits: { total: { @@ -235,6 +228,9 @@ const aggregateResults = { event: { outcome: 'success', }, + kibana: { + version: '8.2.0', + }, message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", }, @@ -631,6 +627,7 @@ describe('getExecutionLogForRule()', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 0, @@ -650,6 +647,7 @@ describe('getExecutionLogForRule()', () => { status: 'success', message: "rule executed: example.always-firing:a348a740-9e2c-11ec-bd64-774ed95c43ef: 'test rule'", + version: '8.2.0', num_active_alerts: 5, num_new_alerts: 5, num_recovered_alerts: 5, @@ -929,7 +927,7 @@ describe('getExecutionLogForRule()', () => { getExecutionLogByIdParams({ sort: [{ foo: { order: 'desc' } }] }) ) ).rejects.toMatchInlineSnapshot( - `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions]]` + `[Error: Invalid sort field "foo" - must be one of [timestamp,execution_duration,total_search_duration,es_search_duration,schedule_delay,num_triggered_actions,num_generated_actions,num_active_alerts,num_recovered_alerts,num_new_alerts]]` ); }); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts index 7f8ae28a20c6e..e2625be88482c 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/mute_all.test.ts @@ -82,7 +82,7 @@ describe('muteAll()', () => { { muteAll: true, mutedInstanceIds: [], - snoozeEndTime: null, + snoozeSchedule: [], updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', }, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts index cf063eea07862..f5d4cb372f867 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/unmute_all.test.ts @@ -82,7 +82,7 @@ describe('unmuteAll()', () => { { muteAll: false, mutedInstanceIds: [], - snoozeEndTime: null, + snoozeSchedule: [], updatedAt: '2019-02-12T21:01:22.479Z', updatedBy: 'elastic', }, diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 6566fee15d4a8..f4f23cced722c 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -30,7 +30,8 @@ export const AlertAttributesExcludedFromAAD = [ 'updatedAt', 'executionStatus', 'monitoring', - 'snoozeEndTime', + 'snoozeSchedule', + 'isSnoozedUntil', ]; // useful for Pick which is a @@ -45,7 +46,8 @@ export type AlertAttributesExcludedFromAADType = | 'updatedAt' | 'executionStatus' | 'monitoring' - | 'snoozeEndTime'; + | 'snoozeSchedule' + | 'isSnoozedUntil'; export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, diff --git a/x-pack/plugins/alerting/server/saved_objects/mappings.ts b/x-pack/plugins/alerting/server/saved_objects/mappings.ts index 5e2803222ecba..31ad40117a7ec 100644 --- a/x-pack/plugins/alerting/server/saved_objects/mappings.ts +++ b/x-pack/plugins/alerting/server/saved_objects/mappings.ts @@ -185,7 +185,73 @@ export const alertMappings: SavedObjectsTypeMappingDefinition = { }, }, }, - snoozeEndTime: { + snoozeSchedule: { + type: 'nested', + properties: { + id: { + type: 'keyword', + }, + duration: { + type: 'long', + }, + rRule: { + type: 'nested', + properties: { + freq: { + type: 'keyword', + }, + dtstart: { + type: 'date', + format: 'strict_date_time', + }, + tzid: { + type: 'keyword', + }, + until: { + type: 'date', + format: 'strict_date_time', + }, + count: { + type: 'long', + }, + interval: { + type: 'long', + }, + wkst: { + type: 'keyword', + }, + byweekday: { + type: 'keyword', + }, + bymonth: { + type: 'short', + }, + bysetpos: { + type: 'long', + }, + bymonthday: { + type: 'short', + }, + byyearday: { + type: 'short', + }, + byweekno: { + type: 'short', + }, + byhour: { + type: 'long', + }, + byminute: { + type: 'long', + }, + bysecond: { + type: 'long', + }, + }, + }, + }, + }, + isSnoozedUntil: { type: 'date', format: 'strict_date_time', }, diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts index c83d0a95dfdcb..bbf93f85450cb 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import sinon from 'sinon'; import uuid from 'uuid'; import { getMigrations, isAnyActionSupportIncidents } from './migrations'; import { RawRule } from '../types'; @@ -2318,6 +2319,27 @@ describe('successful migrations', () => { }); describe('8.3.0', () => { + test('migrates snoozed rules to the new data model', () => { + const fakeTimer = sinon.useFakeTimers(); + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.3.0' + ]; + const mutedAlert = getMockData( + { + snoozeEndTime: '1970-01-02T00:00:00.000Z', + }, + true + ); + const migratedMutedAlert830 = migration830(mutedAlert, migrationContext); + + expect(migratedMutedAlert830.attributes.snoozeSchedule.length).toEqual(1); + expect(migratedMutedAlert830.attributes.snoozeSchedule[0].rRule.dtstart).toEqual( + '1970-01-01T00:00:00.000Z' + ); + expect(migratedMutedAlert830.attributes.snoozeSchedule[0].duration).toEqual(86400000); + fakeTimer.restore(); + }); + test('migrates es_query alert params', () => { const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ '8.3.0' diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index b3f8d873d8ef0..ddae200ae8fa6 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -7,6 +7,8 @@ import { isRuleType, ruleTypeMappings } from '@kbn/securitysolution-rules'; import { isString } from 'lodash/fp'; +import { omit } from 'lodash'; +import moment from 'moment-timezone'; import { gte } from 'semver'; import { LogMeta, @@ -164,7 +166,7 @@ export function getMigrations( const migrationRules830 = createEsoMigration( encryptedSavedObjects, (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(addSearchType, removeInternalTags) + pipeMigrations(addSearchType, removeInternalTags, convertSnoozes) ); return mergeSavedObjectMigrationMaps( @@ -888,6 +890,33 @@ function addMappedParams( return doc; } +function convertSnoozes( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const { + attributes: { snoozeEndTime }, + } = doc; + + return { + ...doc, + attributes: { + ...(omit(doc.attributes, ['snoozeEndTime']) as RawRule), + snoozeSchedule: snoozeEndTime + ? [ + { + duration: Date.parse(snoozeEndTime as string) - Date.now(), + rRule: { + dtstart: new Date().toISOString(), + tzid: moment.tz.guess(), + count: 1, + }, + }, + ] + : [], + }, + }; +} + function getCorrespondingAction( actions: SavedObjectAttribute, connectorRef: string diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 7d95f63f3c43c..f3d2c7039585b 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -470,17 +470,42 @@ describe('Task Runner', () => { const snoozeTestParams: SnoozeTestParams[] = [ [false, null, false], [false, undefined, false], - [false, DATE_1970, false], - [false, DATE_9999, true], + // Stringify the snooze schedules for better failure reporting + [ + false, + JSON.stringify([ + { rRule: { dtstart: DATE_9999, tzid: 'UTC', count: 1 }, duration: 100000000 }, + ]), + false, + ], + [ + false, + JSON.stringify([ + { rRule: { dtstart: DATE_1970, tzid: 'UTC', count: 1 }, duration: 100000000 }, + ]), + true, + ], [true, null, true], [true, undefined, true], - [true, DATE_1970, true], - [true, DATE_9999, true], + [ + true, + JSON.stringify([ + { rRule: { dtstart: DATE_9999, tzid: 'UTC', count: 1 }, duration: 100000000 }, + ]), + true, + ], + [ + true, + JSON.stringify([ + { rRule: { dtstart: DATE_1970, tzid: 'UTC', count: 1 }, duration: 100000000 }, + ]), + true, + ], ]; test.each(snoozeTestParams)( - 'snoozing works as expected with muteAll: %s; snoozeEndTime: %s', - async (muteAll, snoozeEndTime, shouldBeSnoozed) => { + 'snoozing works as expected with muteAll: %s; snoozeSchedule: %s', + async (muteAll, snoozeSchedule, shouldBeSnoozed) => { taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); ruleType.executor.mockImplementation( @@ -507,7 +532,7 @@ describe('Task Runner', () => { rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, muteAll, - snoozeEndTime: snoozeEndTime != null ? new Date(snoozeEndTime) : snoozeEndTime, + snoozeSchedule: snoozeSchedule != null ? JSON.parse(snoozeSchedule) : [], }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 6cd6b73b9539e..bd83b269ce10d 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -17,7 +17,6 @@ import { TaskRunnerContext } from './task_runner_factory'; import { createExecutionHandler, ExecutionHandler } from './create_execution_handler'; import { Alert, createAlertFactory } from '../alert'; import { - createWrappedScopedClusterClientFactory, ElasticsearchError, ErrorWithReason, executionStatusFromError, @@ -25,6 +24,7 @@ import { getRecoveredAlerts, ruleExecutionStatusToRaw, validateRuleTypeParams, + isRuleSnoozed, } from '../lib'; import { Rule, @@ -68,9 +68,12 @@ import { RuleRunResult, RuleTaskStateAndMetrics, } from './types'; +import { createWrappedScopedClusterClientFactory } from '../lib/wrap_scoped_cluster_client'; import { IExecutionStatusAndMetrics } from '../lib/rule_execution_status'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { wrapSearchSourceClient } from '../lib/wrap_search_source_client'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { SearchMetrics } from '../lib/types'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -247,18 +250,6 @@ export class TaskRunner< } } - private isRuleSnoozed(rule: SanitizedRule): boolean { - if (rule.muteAll) { - return true; - } - - if (rule.snoozeEndTime == null) { - return false; - } - - return Date.now() < rule.snoozeEndTime.getTime(); - } - private shouldLogAndScheduleActionsForAlerts() { // if execution hasn't been cancelled, return true if (!this.cancelled) { @@ -348,9 +339,7 @@ export class TaskRunner< const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; - const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); - const wrappedScopedClusterClient = createWrappedScopedClusterClientFactory({ - scopedClusterClient, + const wrappedClientOptions = { rule: { name: rule.name, alertTypeId: rule.alertTypeId, @@ -359,6 +348,16 @@ export class TaskRunner< }, logger: this.logger, abortController: this.searchAbortController, + }; + const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); + const wrappedScopedClusterClient = createWrappedScopedClusterClientFactory({ + ...wrappedClientOptions, + scopedClusterClient, + }); + const searchSourceClient = await this.context.data.search.searchSource.asScoped(fakeRequest); + const wrappedSearchSourceClient = wrapSearchSourceClient({ + ...wrappedClientOptions, + searchSourceClient, }); let updatedRuleTypeState: void | Record; @@ -382,9 +381,9 @@ export class TaskRunner< executionId: this.executionId, services: { savedObjectsClient, + searchSourceClient: wrappedSearchSourceClient.searchSourceClient, uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), - searchSourceClient: this.context.data.search.searchSource.asScoped(fakeRequest), alertFactory: createAlertFactory< InstanceState, InstanceContext, @@ -437,9 +436,19 @@ export class TaskRunner< this.alertingEventLogger.setExecutionSucceeded(`rule executed: ${ruleLabel}`); + const scopedClusterClientMetrics = wrappedScopedClusterClient.getMetrics(); + const searchSourceClientMetrics = wrappedSearchSourceClient.getMetrics(); + const searchMetrics: SearchMetrics = { + numSearches: scopedClusterClientMetrics.numSearches + searchSourceClientMetrics.numSearches, + totalSearchDurationMs: + scopedClusterClientMetrics.totalSearchDurationMs + + searchSourceClientMetrics.totalSearchDurationMs, + esSearchDurationMs: + scopedClusterClientMetrics.esSearchDurationMs + + searchSourceClientMetrics.esSearchDurationMs, + }; const ruleRunMetricsStore = new RuleRunMetricsStore(); - const searchMetrics = wrappedScopedClusterClient.getMetrics(); ruleRunMetricsStore.setNumSearches(searchMetrics.numSearches); ruleRunMetricsStore.setTotalSearchDurationMs(searchMetrics.totalSearchDurationMs); ruleRunMetricsStore.setEsSearchDurationMs(searchMetrics.esSearchDurationMs); @@ -477,7 +486,10 @@ export class TaskRunner< }); } - const ruleIsSnoozed = this.isRuleSnoozed(rule); + const ruleIsSnoozed = isRuleSnoozed(rule); + if (ruleIsSnoozed) { + this.markRuleAsSnoozed(rule.id); + } if (!ruleIsSnoozed && this.shouldLogAndScheduleActionsForAlerts()) { const mutedAlertIdsSet = new Set(mutedInstanceIds); @@ -580,6 +592,23 @@ export class TaskRunner< return this.executeRule(fakeRequest, rule, validatedParams, executionHandler, spaceId); } + private async markRuleAsSnoozed(id: string) { + let apiKey: string | null; + + const { + params: { alertId: ruleId, spaceId }, + } = this.taskInstance; + try { + const decryptedAttributes = await this.getDecryptedAttributes(ruleId, spaceId); + apiKey = decryptedAttributes.apiKey; + } catch (err) { + throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Decrypt, err); + } + const fakeRequest = this.getFakeKibanaRequest(spaceId, apiKey); + const rulesClient = this.context.getRulesClientWithRequest(fakeRequest); + await rulesClient.updateSnoozedUntilTime({ id }); + } + private async loadRuleAttributesAndRun(): Promise> { const { params: { alertId: ruleId, spaceId }, diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 1c453df386e24..b7e06aa602f27 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -10,13 +10,15 @@ import type { CustomRequestHandlerContext, SavedObjectReference, IUiSettingsClient, +} from '@kbn/core/server'; +import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; +import { LicenseType } from '@kbn/licensing-plugin/server'; +import { IScopedClusterClient, SavedObjectAttributes, SavedObjectsClientContract, } from '@kbn/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { ISearchStartSearchSource } from '@kbn/data-plugin/common'; -import { LicenseType } from '@kbn/licensing-plugin/server'; import { AlertFactoryDoneUtils, PublicAlert } from './alert'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; @@ -40,6 +42,7 @@ import { SanitizedRuleConfig, RuleMonitoring, MappedParams, + RuleSnooze, } from '../common'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; @@ -71,7 +74,7 @@ export interface RuleExecutorServices< InstanceContext extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = never > { - searchSourceClient: Promise; + searchSourceClient: ISearchStartSearchSource; savedObjectsClient: SavedObjectsClientContract; uiSettingsClient: IUiSettingsClient; scopedClusterClient: IScopedClusterClient; @@ -249,7 +252,8 @@ export interface RawRule extends SavedObjectAttributes { meta?: RuleMeta; executionStatus: RawRuleExecutionStatus; monitoring?: RuleMonitoring; - snoozeEndTime?: string | null; // Remove ? when this parameter is made available in the public API + snoozeSchedule?: RuleSnooze; // Remove ? when this parameter is made available in the public API + isSnoozedUntil?: string | null; } export interface AlertingPlugin { diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx index 695ebfd9a8976..804a27481422e 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/accordion_waterfall.tsx @@ -13,7 +13,7 @@ import { EuiIcon, EuiText, } from '@elastic/eui'; -import React, { Dispatch, SetStateAction, useState } from 'react'; +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { Margins } from '../../../../../shared/charts/timeline'; import { @@ -76,8 +76,6 @@ const StyledAccordion = euiStyled(EuiAccordion).withConfig({ `; export function AccordionWaterfall(props: AccordionWaterfallProps) { - const [isOpen, setIsOpen] = useState(props.isOpen); - const { item, level, @@ -89,8 +87,12 @@ export function AccordionWaterfall(props: AccordionWaterfallProps) { onClickWaterfallItem, } = props; - const nextLevel = level + 1; - setMaxLevel(nextLevel); + const [isOpen, setIsOpen] = useState(props.isOpen); + const [nextLevel] = useState(level + 1); + + useEffect(() => { + setMaxLevel(nextLevel); + }, [nextLevel, setMaxLevel]); const children = waterfall.childrenByParentId[item.id] || []; const errorCount = waterfall.getErrorCount(item.id); diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx index 9245302539efb..2708c46b52960 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.stories.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import type { Story, StoryContext } from '@storybook/react'; -import React, { ComponentType } from 'react'; +import type { Story, DecoratorFn } from '@storybook/react'; +import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import { CoreStart } from '@kbn/core/public'; import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; @@ -26,7 +26,7 @@ export default { title: 'routing/templates/ApmServiceTemplate/AnalyzeDataButton', component: AnalyzeDataButton, decorators: [ - (StoryComponent: ComponentType, { args }: StoryContext) => { + (StoryComponent, { args }) => { const { agentName, canShowDashboard, environment, serviceName } = args; const KibanaContext = createKibanaReactContext({ @@ -61,7 +61,7 @@ export default { ); }, - ], + ] as DecoratorFn[], }; export const Example: Story = () => { diff --git a/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts b/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts index dfc33c0f10ffc..980c7986d098a 100644 --- a/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts +++ b/x-pack/plugins/apm/public/context/breadcrumbs/use_breadcrumb.ts @@ -21,17 +21,17 @@ export function useBreadcrumb(breadcrumb: Breadcrumb | Breadcrumb[]) { const matchedRoute = useRef(match?.route); - if (matchedRoute.current && matchedRoute.current !== match?.route) { - api.unset(matchedRoute.current); - } + useEffect(() => { + if (matchedRoute.current && matchedRoute.current !== match?.route) { + api.unset(matchedRoute.current); + } - matchedRoute.current = match?.route; + matchedRoute.current = match?.route; - if (matchedRoute.current) { - api.set(matchedRoute.current, castArray(breadcrumb)); - } + if (matchedRoute.current) { + api.set(matchedRoute.current, castArray(breadcrumb)); + } - useEffect(() => { return () => { if (matchedRoute.current) { api.unset(matchedRoute.current); diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot index 118f300ccab09..0b9358714e71c 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/simple_template.stories.storyshot @@ -11,38 +11,28 @@ exports[`Storyshots arguments/AxisConfig simple 1`] = ` } >
-
- -
+ className="euiSwitch__thumb" + /> + + +
`; @@ -58,38 +48,28 @@ exports[`Storyshots arguments/AxisConfig/components simple template 1`] = ` } >
-
- -
+ className="euiSwitch__thumb" + /> + + +
`; diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot index af099aefbc0e5..10a5c634da162 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/container_style/__stories__/__snapshots__/simple_template.stories.storyshot @@ -13,55 +13,45 @@ exports[`Storyshots arguments/ContainerStyle simple 1`] = `
-
- -
+ } + /> +
+
@@ -81,55 +71,45 @@ exports[`Storyshots arguments/ContainerStyle/components simple template 1`] = `
-
- -
+ } + /> +
+
diff --git a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot index f5298c1d1a908..f444266239314 100644 --- a/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot +++ b/x-pack/plugins/canvas/public/expression_types/arg_types/series_style/__stories__/__snapshots__/simple_template.stories.storyshot @@ -11,42 +11,32 @@ exports[`Storyshots arguments/SeriesStyle simple 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -64,42 +54,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: defaults 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -117,42 +97,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: no labels 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
@@ -170,62 +140,52 @@ exports[`Storyshots arguments/SeriesStyle/components simple: no series 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
-
+
+ - - Info - + Info -
+
@@ -242,42 +202,32 @@ exports[`Storyshots arguments/SeriesStyle/components simple: with series 1`] = ` } >
-
- Color -
+ Color
+
+
-
- -
+ Auto +
diff --git a/x-pack/plugins/canvas/storybook/canvas_webpack.ts b/x-pack/plugins/canvas/storybook/canvas_webpack.ts index db59af20440e2..e8ce5ff03b812 100644 --- a/x-pack/plugins/canvas/storybook/canvas_webpack.ts +++ b/x-pack/plugins/canvas/storybook/canvas_webpack.ts @@ -7,6 +7,7 @@ import { resolve } from 'path'; import { defaultConfig, mergeWebpackFinal } from '@kbn/storybook'; +import type { StorybookConfig } from '@kbn/storybook'; import { KIBANA_ROOT } from './constants'; export const canvasWebpack = { @@ -61,7 +62,7 @@ export const canvasWebpack = { }, }; -export const canvasStorybookConfig = { +export const canvasStorybookConfig: StorybookConfig = { ...defaultConfig, addons: [...(defaultConfig.addons || []), './addon/target/register'], ...mergeWebpackFinal(canvasWebpack), diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index 0cb084b5beb7c..d673f470de740 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -157,6 +157,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "tags": { "description": "The words and phrases that help categorize cases. It can be an empty array.", "type": "array", @@ -402,6 +405,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -636,6 +642,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -887,6 +896,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1093,6 +1105,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "tags": { "description": "The words and phrases that help categorize cases. It can be an empty array.", "type": "array", @@ -1338,6 +1353,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1578,6 +1596,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1829,6 +1850,9 @@ } } }, + "severity": { + "$ref": "#/components/schemas/severity" + }, "status": { "$ref": "#/components/schemas/status" }, @@ -1959,6 +1983,17 @@ "securitySolution" ] }, + "severity": { + "type": "string", + "description": "The severity of the case.", + "enum": [ + "critical", + "high", + "low", + "medium" + ], + "default": "low" + }, "status": { "type": "string", "description": "The status of the case.", @@ -2015,6 +2050,7 @@ "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", @@ -2090,6 +2126,7 @@ "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index 083aef3c25ad2..6dcde228ebd7c 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -147,6 +147,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' tags: description: >- The words and phrases that help categorize cases. It can be @@ -363,6 +365,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -569,6 +573,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -784,6 +790,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -960,6 +968,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' tags: description: >- The words and phrases that help categorize cases. It can be @@ -1176,6 +1186,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1384,6 +1396,8 @@ paths: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1599,6 +1613,8 @@ paths: syncAlerts: type: boolean example: true + severity: + $ref: '#/components/schemas/severity' status: $ref: '#/components/schemas/status' tags: @@ -1686,6 +1702,15 @@ components: - cases - observability - securitySolution + severity: + type: string + description: The severity of the case. + enum: + - critical + - high + - low + - medium + default: low status: type: string description: The status of the case. @@ -1738,6 +1763,7 @@ components: cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active duration: null + severity: low closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' @@ -1804,6 +1830,7 @@ components: cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active! duration: null + severity: low closed_at: null closed_by: null created_at: '2022-05-13T09:16:17.416Z' diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml index bc5fa1f5bc049..9646425bca0fe 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml @@ -18,6 +18,7 @@ value: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml index 114669b893651..c7b02cd47deaa 100644 --- a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml @@ -19,6 +19,7 @@ value: "owner": "securitySolution", "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants. Operation bubblegum is active. Repeat - operation bubblegum is now active!", "duration": null, + "severity": "low", "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml index 6a2c3c3963c3c..53f1fd3910224 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml @@ -84,6 +84,8 @@ settings: syncAlerts: type: boolean example: true +severity: + $ref: 'severity.yaml' status: $ref: 'status.yaml' tags: diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml new file mode 100644 index 0000000000000..cf5967f8f012e --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/severity.yaml @@ -0,0 +1,8 @@ +type: string +description: The severity of the case. +enum: + - critical + - high + - low + - medium +default: low \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml index c37bb3ecef645..62816ae2767cc 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml @@ -30,6 +30,8 @@ post: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' tags: description: The words and phrases that help categorize cases. It can be an empty array. type: array @@ -123,6 +125,8 @@ patch: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' status: $ref: '../components/schemas/status.yaml' tags: diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml index c03ea64a53538..b2c2a8e4e11f1 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml @@ -31,6 +31,8 @@ post: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' tags: description: The words and phrases that help categorize cases. It can be an empty array. type: array @@ -126,6 +128,8 @@ patch: syncAlerts: description: Turns alert syncing on or off. type: boolean + severity: + $ref: '../components/schemas/severity.yaml' status: $ref: '../components/schemas/status.yaml' tags: diff --git a/x-pack/plugins/cases/public/client/helpers/group_alerts_by_rule.ts b/x-pack/plugins/cases/public/client/helpers/group_alerts_by_rule.ts new file mode 100644 index 0000000000000..0c1d73ba03b71 --- /dev/null +++ b/x-pack/plugins/cases/public/client/helpers/group_alerts_by_rule.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CommentRequestAlertType } from '../../../common/api'; +import { CommentType, Ecs } from '../../../common'; +import { getRuleIdFromEvent } from './get_rule_id_from_event'; +import { CaseAttachments } from '../../types'; + +type Maybe = T | null; +interface Event { + data: EventNonEcsData[]; + ecs: Ecs; +} +interface EventNonEcsData { + field: string; + value?: Maybe; +} + +export const groupAlertsByRule = (items: Event[], owner: string): CaseAttachments => { + const attachmentsByRule = items.reduce>((acc, item) => { + const rule = getRuleIdFromEvent(item); + if (!acc[rule.id]) { + acc[rule.id] = { + alertId: [], + index: [], + owner, + type: CommentType.alert as const, + rule, + }; + } + const alerts = acc[rule.id].alertId; + const indexes = acc[rule.id].index; + if (Array.isArray(alerts) && Array.isArray(indexes)) { + alerts.push(item.ecs._id ?? ''); + indexes.push(item.ecs._index ?? ''); + } + return acc; + }, {}); + return Object.values(attachmentsByRule); +}; diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 908a0dd5d52df..9f81b8a7e98ee 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -264,10 +264,10 @@ export const CASE_SUCCESS_TOAST = (title: string) => defaultMessage: '{title} has been updated', }); -export const CASE_ALERT_SUCCESS_TOAST = (title: string) => +export const CASE_ALERT_SUCCESS_TOAST = (title: string, quantity: number = 1) => i18n.translate('xpack.cases.actions.caseAlertSuccessToast', { - values: { title }, - defaultMessage: 'An alert was added to "{title}"', + values: { quantity, title }, + defaultMessage: '{quantity, plural, =1 {An alert was} other {Alerts were}} added to "{title}"', }); export const CASE_ALERT_SUCCESS_SYNC_TEXT = i18n.translate( diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx index e788f7b399bb4..c764df4d6661d 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -72,7 +72,7 @@ describe('Use cases toast hook', () => { validateTitle('Custom title'); }); - it('should display the alert sync title when called with an alert attachment ', () => { + it('should display the alert sync title when called with an alert attachment (1 alert)', () => { const { result } = renderHook( () => { return useCasesToast(); @@ -86,6 +86,25 @@ describe('Use cases toast hook', () => { validateTitle('An alert was added to "Another horrible breach!!'); }); + it('should display the alert sync title when called with an alert attachment (multiple alerts)', () => { + const { result } = renderHook( + () => { + return useCasesToast(); + }, + { wrapper: TestProviders } + ); + const alert = { + ...alertComment, + alertId: ['1234', '54321'], + } as SupportedCaseAttachment; + + result.current.showSuccessAttach({ + theCase: mockCase, + attachments: [alert], + }); + validateTitle('Alerts were added to "Another horrible breach!!'); + }); + it('should display a generic title when called with a non-alert attachament', () => { const { result } = renderHook( () => { diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.tsx index 8abb18d3099f3..36ca8340036a5 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.tsx @@ -34,6 +34,22 @@ const EuiTextStyled = styled(EuiText)` `} `; +function getAlertsCount(attachments: CaseAttachments): number { + let alertsCount = 0; + for (const attachment of attachments) { + if (attachment.type === CommentType.alert) { + // alertId might be an array + if (Array.isArray(attachment.alertId) && attachment.alertId.length > 1) { + alertsCount += attachment.alertId.length; + } else { + // or might be a single string + alertsCount++; + } + } + } + return alertsCount; +} + function getToastTitle({ theCase, title, @@ -47,10 +63,9 @@ function getToastTitle({ return title; } if (attachments !== undefined) { - for (const attachment of attachments) { - if (attachment.type === CommentType.alert) { - return CASE_ALERT_SUCCESS_TOAST(theCase.title); - } + const alertsCount = getAlertsCount(attachments); + if (alertsCount > 0) { + return CASE_ALERT_SUCCESS_TOAST(theCase.title, alertsCount); } } return CASE_SUCCESS_TOAST(theCase.title); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 853a32eaabbaf..b8f77dac79920 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -205,7 +205,7 @@ describe('AllCasesListGeneric', () => { wrapper.find(`span[data-test-subj="case-table-column-tags-coke"]`).first().prop('title') ).toEqual(useGetCasesMockState.data.cases[0].tags[0]); expect(wrapper.find(`[data-test-subj="case-table-column-createdBy"]`).first().text()).toEqual( - useGetCasesMockState.data.cases[0].createdBy.username + 'LK' ); expect( wrapper @@ -225,6 +225,27 @@ describe('AllCasesListGeneric', () => { }); }); + it('should show a tooltip with the reporter username when hover over the reporter avatar', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, + }); + const result = render( + + + + ); + + userEvent.hover(result.queryAllByTestId('case-table-column-createdBy')[0]); + + await waitFor(() => { + expect(result.getByTestId('case-table-column-createdBy-tooltip')).toBeTruthy(); + expect(result.getByTestId('case-table-column-createdBy-tooltip').textContent).toEqual( + 'lknope' + ); + }); + }); + it('should show a tooltip with all tags when hovered', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index c895dfdc11f3f..05345fb05d009 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -53,10 +53,6 @@ const MediumShadeText = styled.p` color: ${({ theme }) => theme.eui.euiColorMediumShade}; `; -const Spacer = styled.span` - margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; -`; - const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); @@ -182,16 +178,18 @@ export const useCasesColumns = ({ render: (createdBy: Case['createdBy']) => { if (createdBy != null) { return ( - <> + - - {createdBy.username ?? i18n.UNKNOWN} - - + ); } return getEmptyTagValue(); diff --git a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx index f0eea39d551a7..822bbdee80a94 100644 --- a/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/selector_modal/use_cases_add_to_existing_case_modal.test.tsx @@ -15,7 +15,6 @@ import { AppMockRenderer, createAppMockRenderer } from '../../../common/mock'; import { useCasesToast } from '../../../common/use_cases_toast'; import { alertComment } from '../../../containers/mock'; import { useCreateAttachments } from '../../../containers/use_create_attachments'; -import { SupportedCaseAttachment } from '../../../types'; import { CasesContext } from '../../cases_context'; import { CasesContextStoreActionsList } from '../../cases_context/cases_context_reducer'; import { useCasesAddToExistingCaseModal } from './use_cases_add_to_existing_case_modal'; @@ -35,12 +34,10 @@ const AllCasesSelectorModalMock = AllCasesSelectorModal as unknown as jest.Mock; // test component to test the hook integration const TestComponent: React.FC = () => { - const hook = useCasesAddToExistingCaseModal({ - attachments: [alertComment as SupportedCaseAttachment], - }); + const hook = useCasesAddToExistingCaseModal(); const onClick = () => { - hook.open(); + hook.open({ attachments: [alertComment] }); }; return